From 4ba11d1bb5c2a6d42bdf7c1dae3f1cbc33e79802 Mon Sep 17 00:00:00 2001 From: dingo Date: Mon, 10 Aug 2026 16:01:04 +0700 Subject: [PATCH] Accept Hyprland's input:follow_mouse, and turn focus-follows-mouse on Focus follows mouse was already there and already off. cosmic-comp has supported it for as long as this fork has existed -- focus_follows_cursor and focus_follows_cursor_delay, both live-watched -- and the schema already exposed them under general. What was missing was the Hyprland spelling: there was no input section at all, so a config written the way a Hyprland user would write it named a setting that did not exist. So this is an alias, not a new setting. input.follow_mouse and input.follow_mouse_delay resolve to exactly the same cosmic-config keys as general.focus_follows_cursor and its delay, and both spellings stay. Setting both in one file is not an error; the last assignment wins, which is the rule the rest of the file already follows. A test pins the two pairs to the same targets, because a rebase that renames a target key would otherwise leave one spelling working and the other quietly dead. follow_mouse needs its own type. Hyprland writes it as a number and COSMIC stores a bool, so Ty::FollowMouse maps 0 to false and 1 to true -- the same trick Ty::Mode already plays for dark/light. Hyprland's 2 and 3 split pointer focus from keyboard focus, which cosmic-comp cannot express: it has one focus and either moves it or does not. They are rejected with a diagnostic that says why, rather than rounded up to 1, because silently handing click-to-focus to someone who asked for the opposite is worse than telling them the mode does not exist here. Anything else gets the ordinary "expected 0 or 1" error. There is no autoraise key because autoraise is not a separate feature. raise_with_children runs inside update_active, which is what the focus-follows-cursor timer ends up calling, so a floating window under the pointer comes to the front as part of being focused. Tiled windows do not overlap, so raising one is a no-op. The delay is left at COSMIC's 250ms rather than shortened. It is what stops focus from skating across every window the pointer crosses on its way somewhere else, and that failure is more irritating than the wait. Installing the binary before editing the config is the required order, not a preference: resolution is transactional, so the old binary meeting an unknown input section would refuse to write the whole file, not just that block. --- config/cosmic.conf | 26 +++++++++++ cosmic-conf/src/resolve.rs | 96 ++++++++++++++++++++++++++++++++++++++ cosmic-conf/src/schema.rs | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+) 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 {