diff --git a/config/cosmic.conf b/config/cosmic.conf index 5a95f92..f3efca5 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -50,6 +50,32 @@ general { gaps_out = $gap * 2 } +# --- Input --------------------------------------------------------------- +# +# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland +# spells it `input:follow_mouse`, and that spelling is what this file accepts; +# `general:focus_follows_cursor` is the same setting under COSMIC's own name, +# and setting both is not an error -- whichever comes last in the file wins. +# +# Autoraise comes with it and is not a separate key. cosmic-comp raises a +# window as part of focusing it, so a floating window under the pointer comes +# to the front on its own. Tiled windows do not overlap, so there is nothing +# there to raise. +# +# The delay is what stops the focus from skating across every window between +# where the pointer started and where it stopped -- moving the mouse to a menu +# on the far side of the screen should not hand focus to whatever it crossed on +# the way. 250ms is COSMIC's own default and is kept rather than shortened, +# because the failure it prevents is more annoying than the wait. +# +# Only 0 and 1 mean anything here. Hyprland's 2 and 3 separate pointer focus +# from keyboard focus, which cosmic-comp cannot do -- it has one focus. Those +# values are rejected with an explanation rather than rounded to 1. +input { + follow_mouse = 1 + follow_mouse_delay = 250 +} + # --- Launcher ----------------------------------------------------------- # # The hyprcosmic profile does not start cosmic-launcher or cosmic-app-library, diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs index 516a8cf..5010d41 100644 --- a/cosmic-conf/src/resolve.rs +++ b/cosmic-conf/src/resolve.rs @@ -211,6 +211,23 @@ fn coerce(raw: &str, ty: Ty, span: Span) -> Result { "light" => Ok(Value::Bool(false)), _ => Err(bad("`dark` or `light`")), }, + Ty::FollowMouse => match raw { + "0" => Ok(Value::Bool(false)), + "1" => Ok(Value::Bool(true)), + // Named separately from the catch-all so the message can say why a + // value that is valid in Hyprland does not work here. + "2" | "3" => Err(Diagnostic { + message: format!("`follow_mouse = {raw}` has no COSMIC equivalent"), + span, + help: Some( + "cosmic-comp has a single focus rather than separate pointer and \ + keyboard focus, so it cannot detach them. Use `1` for focus follows \ + mouse or `0` for click to focus." + .into(), + ), + }), + _ => Err(bad("`0` (click to focus) or `1` (focus follows mouse)")), + }, } } @@ -662,6 +679,85 @@ mod tests { ); } + /// The alias has to land on the same cosmic-config key as COSMIC's own + /// spelling, or the two would be separate settings that merely look alike. + #[test] + fn follow_mouse_is_the_same_write_as_focus_follows_cursor() { + let hypr = resolved("input {\n follow_mouse = 1\n}\n"); + let cosmic = resolved("general {\n focus_follows_cursor = true\n}\n"); + assert_eq!(hypr.writes, cosmic.writes); + + assert_eq!(hypr.writes.len(), 1); + assert_eq!(hypr.writes[0].target.key, "focus_follows_cursor"); + assert_eq!(hypr.writes[0].kind, WriteKind::Whole(Value::Bool(true))); + } + + #[test] + fn follow_mouse_zero_is_click_to_focus() { + let r = resolved("input {\n follow_mouse = 0\n}\n"); + assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false))); + } + + /// Hyprland accepts 2 and 3, which detach pointer focus from keyboard + /// focus. cosmic-comp has one focus and cannot, so the values are refused. + /// Rounding them to 1 would hand click-to-focus users the opposite of what + /// they asked for and never say so. + #[test] + fn follow_mouse_rejects_the_modes_cosmic_cannot_express() { + for v in ["2", "3"] { + let d = errors(&format!("input {{\n follow_mouse = {v}\n}}\n")); + assert_eq!(d.len(), 1, "{v}: {d:?}"); + assert!( + d[0].message.contains("no COSMIC equivalent"), + "{v}: {}", + d[0].message + ); + assert!( + d[0].help.as_deref().unwrap_or_default().contains("Use `1`"), + "{v}: help should say what to write instead, got {:?}", + d[0].help + ); + } + } + + /// It is an integer setting in Hyprland, so `true` is not one of its + /// spellings even though the value it resolves to is a boolean. + #[test] + fn follow_mouse_does_not_quietly_accept_boolean_spellings() { + let d = errors("input {\n follow_mouse = true\n}\n"); + assert_eq!(d.len(), 1); + assert!( + d[0].message.contains("`0`") && d[0].message.contains("`1`"), + "{}", + d[0].message + ); + } + + #[test] + fn follow_mouse_delay_shares_the_delay_key_and_its_range() { + let r = resolved("input {\n follow_mouse_delay = 400\n}\n"); + assert_eq!(r.writes[0].target.key, "focus_follows_cursor_delay"); + assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::U32(400))); + + let d = errors("input {\n follow_mouse_delay = 9001\n}\n"); + assert!( + d[0].message.contains("outside the allowed range"), + "{}", + d[0].message + ); + } + + /// Both spellings in one file is not an error: the file's own rule is that + /// the last assignment wins, and these are two names for one target. + #[test] + fn the_last_spelling_in_the_file_wins() { + let r = resolved( + "general {\n focus_follows_cursor = true\n}\ninput {\n follow_mouse = 0\n}\n", + ); + assert_eq!(r.writes.len(), 1, "one target, not two: {:?}", r.writes); + assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false))); + } + #[test] fn out_of_range_values_are_rejected() { let d = errors("general {\n gaps_in = 9999\n}\n"); diff --git a/cosmic-conf/src/schema.rs b/cosmic-conf/src/schema.rs index 3135798..e39200b 100644 --- a/cosmic-conf/src/schema.rs +++ b/cosmic-conf/src/schema.rs @@ -18,6 +18,15 @@ pub enum Ty { Rgba, /// `dark`/`light` -> the `is_dark` boolean. Mode, + /// Hyprland's `input:follow_mouse`, `0`-`3` -> `focus_follows_cursor`. + /// + /// COSMIC's setting is a plain boolean, so only `0` and `1` have a meaning + /// here. Hyprland's `2` and `3` split pointer focus from keyboard focus, + /// which cosmic-comp cannot express -- it has one focus and moves it or + /// does not. They are rejected rather than rounded to `1`: silently + /// granting click-to-focus to someone who asked for the opposite is worse + /// than telling them the setting does not exist here. + FollowMouse, } /// Where a conf key's value lands in the cosmic-config tree. @@ -244,6 +253,46 @@ pub const REGISTRY: &[Entry] = &[ }), doc: "Window corner radius in px (maps to the theme's radius_m)", }, + // ---- input ----------------------------------------------------------- + // + // Aliases, not new settings: both of these land on the same cosmic-config + // keys as `general.focus_follows_cursor` and its delay, which stay for + // anyone who prefers COSMIC's own naming. They exist because Hyprland puts + // this in `input` under a different name, and accepting the Hyprland + // spelling is the point of the fork. + // + // Two spellings writing one target is safe here only because the last + // assignment wins: setting both in one file is not an error, it just means + // whichever comes last is what the compositor gets. That is the same rule + // the rest of the file follows, so it needs no special handling. + Entry { + conf: "input.follow_mouse", + targets: &[Target::Direct { + component: COMP, + version: 1, + key: "focus_follows_cursor", + }], + ty: Ty::FollowMouse, + // No `Range`: `check_range` only inspects numeric values and this + // resolves to a bool, so a range here would be silently ignored. The + // accepted values are enforced by `Ty::FollowMouse` itself. + validate: None, + doc: "1 for focus follows mouse, 0 for click to focus", + }, + Entry { + conf: "input.follow_mouse_delay", + targets: &[Target::Direct { + component: COMP, + version: 1, + key: "focus_follows_cursor_delay", + }], + ty: Ty::U32, + validate: Some(Range { + min: 0.0, + max: 5000.0, + }), + doc: "Delay in ms before focus follows the mouse", + }, // ---- theme ----------------------------------------------------------- Entry { conf: "theme.mode", @@ -382,6 +431,39 @@ mod tests { assert!(matches!(e.ty, Ty::Bool)); } + /// `input.*` is an alias layer, so what matters is that it points at the + /// same place COSMIC's own naming does. If a rebase renames either target + /// key, one spelling would keep working and the other would go quietly + /// dead; pinning them together here makes that a test failure instead. + #[test] + fn the_input_section_aliases_the_general_focus_keys() { + for (hypr, cosmic) in [ + ("input.follow_mouse", "general.focus_follows_cursor"), + ( + "input.follow_mouse_delay", + "general.focus_follows_cursor_delay", + ), + ] { + let a = lookup(hypr).unwrap(); + let b = lookup(cosmic).unwrap(); + assert_eq!(a.targets, b.targets, "{hypr} and {cosmic} have drifted"); + } + } + + /// The alias is not a plain bool: Hyprland writes it as a number, and + /// `Ty::FollowMouse` is what turns the accepted numbers into one. + #[test] + fn follow_mouse_uses_the_hyprland_numeric_type() { + assert!(matches!( + lookup("input.follow_mouse").unwrap().ty, + Ty::FollowMouse + )); + assert!(matches!( + lookup("general.focus_follows_cursor").unwrap().ty, + Ty::Bool + )); + } + #[test] fn every_entry_has_at_least_one_target() { for e in REGISTRY {