From 19927bc00b1ee7070bbcb6d14a306a970eea308b Mon Sep 17 00:00:00 2001 From: dingo Date: Mon, 10 Aug 2026 08:51:16 +0700 Subject: [PATCH] cosmic-conf: translate Hyprland `bind` lines into COSMIC shortcuts `bind = SUPER, D, exec, rofi -show drun` is the most recognisable line in a hyprland.conf, and the hyprcosmic profile makes it necessary rather than just idiomatic: with cosmic-launcher and cosmic-app-library not running, COSMIC's stock Super, Super+/ and Super+A bindings point at nothing. Binds are the one repeatable key in the language -- many lines fold into a single map instead of the last one winning -- so they bypass the schema, which is built around one conf key naming one value. They land in the Shortcuts `custom` key, which cosmic-comp merges over `defaults`, so the system file is untouched and reverting means deleting the lines and re-applying. Actions are rendered as RON text rather than modelled as an enum: COSMIC's Action has forty-odd variants, this crate deliberately does not link the cosmic crates, and the mapping table only ever needs a handful. Dispatchers without a genuine equivalent are refused rather than approximated, since a keybinding that silently does the wrong thing is worse than one that fails to compile. Verified the emitted file deserializes into cosmic-settings-config's own `Shortcuts` type: five bindings, keysyms XK_a/XK_slash/XK_Return, Spawn actions. --- config/autostart | 4 +- config/cosmic.conf | 28 +++ cosmic-conf/src/bind.rs | 435 +++++++++++++++++++++++++++++++++++++ cosmic-conf/src/emit.rs | 4 + cosmic-conf/src/lib.rs | 2 + cosmic-conf/src/resolve.rs | 123 +++++++++++ 6 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 config/cosmic.conf create mode 100644 cosmic-conf/src/bind.rs diff --git a/config/autostart b/config/autostart index 39a2a5a..9dbcebf 100644 --- a/config/autostart +++ b/config/autostart @@ -13,8 +13,8 @@ waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s /usr/share/hyprcosmic/waybar/style.css # rofi is not a daemon. It is launched on demand by a keybinding, which COSMIC -# stores in com.system76.CosmicSettings.Shortcuts rather than here. Until that -# binding is set, run `rofi -show drun` from a terminal. +# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those +# with `bind` lines in cosmic.conf and `cosmic-conf apply`; see config/cosmic.conf. # HyDE drives wallpaper through swww, which is not packaged for Fedora. Once # it is available, uncomment: diff --git a/config/cosmic.conf b/config/cosmic.conf new file mode 100644 index 0000000..4eb81d8 --- /dev/null +++ b/config/cosmic.conf @@ -0,0 +1,28 @@ +# HyprCosmic configuration, in Hyprland's idiom. +# +# Compiled into COSMIC's config tree by `cosmic-conf apply`. The file wins: +# every key here overwrites whatever COSMIC's own settings UI last stored, so +# edit this rather than the GUI for anything it covers. + +$mainMod = SUPER + +# --- Launcher ----------------------------------------------------------- +# +# The hyprcosmic profile does not start cosmic-launcher or cosmic-app-library, +# which leaves COSMIC's stock Super, Super+/ and Super+A bindings pointing at +# nothing. These take them over with rofi. Written to the Shortcuts `custom` +# key, which cosmic-comp merges over `defaults`, so the system file is not +# touched and reverting is a matter of deleting these lines and re-applying. + +# Bare Super, exactly where stock COSMIC puts its launcher. +bind = $mainMod, , exec, rofi -show drun +bind = $mainMod, slash, exec, rofi -show drun +bind = $mainMod, A, exec, rofi -show drun + +# Was System(WorkspaceOverview); cosmic-workspaces is not running either. +# rofi's window mode is the nearest thing that still shows every open window. +bind = $mainMod, W, exec, rofi -show window + +# Terminal, in the Hyprland idiom. Super+T also still works — cosmic-comp +# handles System(Terminal) itself, so that binding never went dead. +bind = $mainMod, Return, exec, cosmic-term diff --git a/cosmic-conf/src/bind.rs b/cosmic-conf/src/bind.rs new file mode 100644 index 0000000..812112f --- /dev/null +++ b/cosmic-conf/src/bind.rs @@ -0,0 +1,435 @@ +//! Hyprland `bind` lines -> COSMIC shortcut bindings. +//! +//! `bind = SUPER, D, exec, rofi -show drun` is the single most recognisable +//! line in a hyprland.conf, so it is the one piece of the idiom that has to +//! feel native rather than translated. +//! +//! The target is the `custom` key of `com.system76.CosmicSettings.Shortcuts`, +//! which the compositor merges over `defaults`, letting a bind here override a +//! stock COSMIC shortcut without touching the system file +//! (cosmic-settings-daemon `config/src/shortcuts/mod.rs`: `shortcuts()` reads +//! `defaults`, then extends with `custom`). +//! +//! Actions are rendered as RON text rather than modelled as an enum. COSMIC's +//! `Action` has forty-odd variants and this crate deliberately does not link +//! the cosmic crates; mirroring the enum would mean re-copying it every time +//! upstream adds a variant, and the mapping table below only ever needs a few. + +use std::fmt::Write as _; + +use crate::parser::Span; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bind { + /// COSMIC modifier names, deduplicated and in COSMIC's own order. + pub mods: Vec<&'static str>, + /// xkb keysym name. `None` is a modifier-only binding, which COSMIC + /// supports and its defaults use for the launcher on bare Super. + pub key: Option, + /// Pre-rendered RON, e.g. `Spawn("rofi -show drun")` or `Focus(Left)`. + pub action: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindError { + pub message: String, + pub help: Option, + pub span: Span, +} + +fn err(span: Span, message: impl Into, help: Option<&str>) -> BindError { + BindError { + message: message.into(), + help: help.map(str::to_string), + span, + } +} + +/// Modifier spellings Hyprland accepts, longest first so that the greedy scan +/// below consumes `SUPERSHIFT` correctly rather than stopping at a prefix. +const MODIFIERS: &[(&str, &str)] = &[ + ("SUPERKEY", "Super"), + ("CONTROL", "Ctrl"), + ("SHIFT", "Shift"), + ("SUPER", "Super"), + ("LOGO", "Super"), + ("MOD4", "Super"), + ("MOD1", "Alt"), + ("CTRL", "Ctrl"), + ("META", "Super"), + ("ALT", "Alt"), + ("WIN", "Super"), +]; + +/// COSMIC writes modifiers in this order in its own defaults; matching it keeps +/// generated files diffable against hand-written ones. +const MODIFIER_ORDER: &[&str] = &["Super", "Ctrl", "Alt", "Shift"]; + +/// Hyprland allows `SUPER SHIFT`, `SUPER+SHIFT` and bare `SUPERSHIFT`, so +/// separators are stripped and the remainder is consumed greedily. +fn parse_modifiers(raw: &str, span: Span) -> Result, BindError> { + let mut rest: String = raw + .chars() + .filter(|c| !c.is_whitespace() && *c != '+' && *c != '_') + .collect::() + .to_ascii_uppercase(); + + let mut found: Vec<&'static str> = Vec::new(); + 'outer: while !rest.is_empty() { + for (spelling, cosmic) in MODIFIERS { + if let Some(tail) = rest.strip_prefix(spelling) { + if !found.contains(cosmic) { + found.push(cosmic); + } + rest = tail.to_string(); + continue 'outer; + } + } + return Err(err( + span, + format!("unknown modifier `{rest}`"), + Some("known modifiers: SUPER, CTRL, ALT, SHIFT"), + )); + } + + found.sort_by_key(|m| MODIFIER_ORDER.iter().position(|o| o == m).unwrap_or(usize::MAX)); + Ok(found) +} + +/// Named keys whose xkb spelling differs from what a Hyprland user types. +/// +/// Anything absent falls through unchanged, so exact keysyms such as +/// `XF86AudioRaiseVolume` keep working without needing an entry here. +const KEY_NAMES: &[(&str, &str)] = &[ + ("return", "Return"), + ("enter", "Return"), + ("escape", "Escape"), + ("esc", "Escape"), + ("tab", "Tab"), + ("backspace", "BackSpace"), + ("delete", "Delete"), + ("insert", "Insert"), + ("home", "Home"), + ("end", "End"), + ("pageup", "Prior"), + ("pagedown", "Next"), + ("left", "Left"), + ("right", "Right"), + ("up", "Up"), + ("down", "Down"), + ("print", "Print"), +]; + +/// COSMIC's defaults spell letters lowercase (`key: "q"`) and punctuation by +/// keysym name (`key: "slash"`), so normalise toward that. +fn normalize_key(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + + if let Some((_, name)) = KEY_NAMES.iter().find(|(k, _)| *k == lower) { + return Some((*name).to_string()); + } + // Function keys are uppercase-F in xkb. + if let Some(n) = lower.strip_prefix('f') { + if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) { + return Some(format!("F{n}")); + } + } + if trimmed.len() == 1 && trimmed.chars().all(|c| c.is_ascii_alphabetic()) { + return Some(lower); + } + Some(trimmed.to_string()) +} + +fn ron_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +fn direction(arg: &str, span: Span, dispatcher: &str) -> Result<&'static str, BindError> { + Ok(match arg.trim().to_ascii_lowercase().as_str() { + "l" | "left" => "Left", + "r" | "right" => "Right", + "u" | "up" => "Up", + "d" | "down" => "Down", + other => { + return Err(err( + span, + format!("`{dispatcher}` needs a direction, got `{other}`"), + Some("use l, r, u or d"), + )) + } + }) +} + +fn workspace_index(arg: &str, span: Span, dispatcher: &str) -> Result { + arg.trim().parse::().map_err(|_| { + err( + span, + format!("`{dispatcher}` needs a workspace number, got `{}`", arg.trim()), + Some("COSMIC addresses workspaces 1-255 by index"), + ) + }) +} + +/// Translate a Hyprland dispatcher and its argument into RON for COSMIC's +/// `Action`. +/// +/// Only dispatchers with a genuine COSMIC equivalent are mapped. A dispatcher +/// that merely looks similar is rejected instead of approximated, because a +/// keybinding that silently does the wrong thing is worse than one that fails +/// to compile. +fn action(dispatcher: &str, arg: &str, span: Span) -> Result { + let d = dispatcher.trim().to_ascii_lowercase(); + Ok(match d.as_str() { + "exec" => { + let cmd = arg.trim(); + if cmd.is_empty() { + return Err(err(span, "`exec` needs a command", None)); + } + // cosmic-comp runs this through `/bin/sh -c` + // (`src/input/actions.rs`: `spawn_command`), so a full command line + // with arguments and quoting behaves as written. + format!("Spawn({})", ron_string(cmd)) + } + "killactive" => "Close".into(), + "fullscreen" => "Fullscreen".into(), + "togglefloating" => "ToggleWindowFloating".into(), + "togglesplit" => "ToggleOrientation".into(), + "togglegroup" => "ToggleStacking".into(), + "pin" => "ToggleSticky".into(), + "exit" => "System(LogOut)".into(), + "movefocus" => format!("Focus({})", direction(arg, span, &d)?), + "movewindow" => format!("Move({})", direction(arg, span, &d)?), + "workspace" => format!("Workspace({})", workspace_index(arg, span, &d)?), + "movetoworkspace" => format!("MoveToWorkspace({})", workspace_index(arg, span, &d)?), + "movetoworkspacesilent" => format!("SendToWorkspace({})", workspace_index(arg, span, &d)?), + "focusmonitor" => format!("SwitchOutput({})", direction(arg, span, &d)?), + "movewindowtomonitor" => format!("MoveToOutput({})", direction(arg, span, &d)?), + + // Present in Hyprland, absent from COSMIC. Named explicitly so the + // error says why rather than "unknown". + "pseudo" | "forcerendererreload" | "submap" | "toggleopaque" | "centerwindow" + | "splitratio" | "cyclenext" | "swapnext" => { + return Err(err( + span, + format!("`{d}` has no COSMIC equivalent"), + Some("remove the bind, or use `exec` to run a program instead"), + )) + } + other => { + return Err(err( + span, + format!("unknown dispatcher `{other}`"), + Some("supported: exec, killactive, fullscreen, togglefloating, togglesplit, movefocus, movewindow, workspace, movetoworkspace, exit"), + )) + } + }) +} + +/// Parse the value of one `bind = ...` line. +/// +/// Shape is `MODS, KEY, dispatcher, args`, with args keeping any further +/// commas, since `exec` commands routinely contain them. +pub fn parse_bind(value: &str, span: Span) -> Result { + let parts: Vec<&str> = value.splitn(4, ',').collect(); + if parts.len() < 3 { + return Err(err( + span, + "a bind needs at least MODS, KEY and a dispatcher", + Some("for example: bind = SUPER, D, exec, rofi -show drun"), + )); + } + + let mods = parse_modifiers(parts[0], span)?; + let key = normalize_key(parts[1]); + if mods.is_empty() && key.is_none() { + return Err(err(span, "a bind needs a modifier or a key", None)); + } + + let arg = parts.get(3).copied().unwrap_or(""); + let action = action(parts[2], arg, span)?; + + Ok(Bind { mods, key, action }) +} + +/// Render the collected binds as the RON map COSMIC stores in `custom`. +pub fn render(binds: &[Bind]) -> String { + let mut out = String::from("{\n"); + for b in binds { + let mods = b + .mods + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", "); + match &b.key { + // `key` is `skip_serializing_if = "Option::is_none"` on COSMIC's + // `Binding`, and its own defaults omit it for `(modifiers: [Super])`. + Some(k) => { + let _ = writeln!( + out, + " (modifiers: [{mods}], key: {}): {},", + ron_string(k), + b.action + ); + } + None => { + let _ = writeln!(out, " (modifiers: [{mods}]): {},", b.action); + } + } + } + out.push_str("}\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const S: Span = Span { + line: 1, + col: 1, + len: 1, + }; + + fn bind(v: &str) -> Bind { + parse_bind(v, S).expect("should parse") + } + + #[test] + fn the_canonical_hyprland_launcher_bind() { + let b = bind("SUPER, D, exec, rofi -show drun"); + assert_eq!(b.mods, vec!["Super"]); + assert_eq!(b.key.as_deref(), Some("d")); + assert_eq!(b.action, r#"Spawn("rofi -show drun")"#); + } + + #[test] + fn modifiers_accept_every_separator_hyprland_does() { + for spelling in ["SUPER SHIFT", "SUPER+SHIFT", "SUPERSHIFT", "super shift"] { + assert_eq!( + bind(&format!("{spelling}, Q, killactive")).mods, + vec!["Super", "Shift"], + "failed for `{spelling}`" + ); + } + } + + #[test] + fn modifiers_are_ordered_like_cosmics_own_defaults() { + assert_eq!( + bind("SHIFT ALT CTRL SUPER, Q, killactive").mods, + vec!["Super", "Ctrl", "Alt", "Shift"] + ); + } + + #[test] + fn a_bind_with_no_key_is_modifier_only() { + // COSMIC's defaults bind bare Super to the launcher this way. + let b = bind("SUPER, , exec, rofi -show drun"); + assert_eq!(b.key, None); + assert_eq!(render(&[b]), "{\n (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n}\n"); + } + + #[test] + fn keys_normalise_to_xkb_spelling() { + assert_eq!(bind("SUPER, Q, killactive").key.as_deref(), Some("q")); + assert_eq!(bind("SUPER, Return, killactive").key.as_deref(), Some("Return")); + assert_eq!(bind("SUPER, enter, killactive").key.as_deref(), Some("Return")); + assert_eq!(bind("SUPER, f5, killactive").key.as_deref(), Some("F5")); + assert_eq!(bind("SUPER, slash, killactive").key.as_deref(), Some("slash")); + // Unknown names pass through so exact keysyms stay usable. + assert_eq!( + bind("SUPER, XF86AudioRaiseVolume, killactive").key.as_deref(), + Some("XF86AudioRaiseVolume") + ); + } + + #[test] + fn exec_keeps_commas_in_the_command() { + assert_eq!( + bind("SUPER, E, exec, sh -c 'echo a, b'").action, + r#"Spawn("sh -c 'echo a, b'")"# + ); + } + + #[test] + fn quotes_in_a_command_are_escaped_not_emitted_raw() { + // Otherwise the generated RON would not parse. + assert_eq!( + bind(r#"SUPER, E, exec, echo "hi""#).action, + r#"Spawn("echo \"hi\"")"# + ); + } + + #[test] + fn dispatchers_map_to_cosmic_actions() { + assert_eq!(bind("SUPER, Q, killactive").action, "Close"); + assert_eq!(bind("SUPER, F, fullscreen").action, "Fullscreen"); + assert_eq!(bind("SUPER, left, movefocus, l").action, "Focus(Left)"); + assert_eq!(bind("SUPER SHIFT, left, movewindow, l").action, "Move(Left)"); + assert_eq!(bind("SUPER, 1, workspace, 1").action, "Workspace(1)"); + assert_eq!( + bind("SUPER SHIFT, 1, movetoworkspace, 1").action, + "MoveToWorkspace(1)" + ); + } + + #[test] + fn a_dispatcher_without_an_equivalent_is_refused_not_approximated() { + let e = parse_bind("SUPER, P, pseudo", S).unwrap_err(); + assert!(e.message.contains("no COSMIC equivalent"), "{}", e.message); + assert!(e.help.is_some()); + } + + #[test] + fn unknown_dispatchers_and_modifiers_are_reported() { + assert!(parse_bind("SUPER, X, frobnicate", S) + .unwrap_err() + .message + .contains("unknown dispatcher")); + assert!(parse_bind("HYPER, X, killactive", S) + .unwrap_err() + .message + .contains("unknown modifier")); + } + + #[test] + fn a_truncated_bind_says_what_shape_is_expected() { + let e = parse_bind("SUPER, D", S).unwrap_err(); + assert!(e.help.unwrap().contains("rofi -show drun")); + } + + #[test] + fn rendering_matches_the_shape_cosmic_writes_in_its_defaults() { + let out = render(&[ + bind("SUPER, , exec, rofi -show drun"), + bind("SUPER, slash, exec, rofi -show drun"), + bind("SUPER SHIFT, Q, killactive"), + ]); + assert_eq!( + out, + concat!( + "{\n", + " (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n", + " (modifiers: [Super], key: \"slash\"): Spawn(\"rofi -show drun\"),\n", + " (modifiers: [Super, Shift], key: \"q\"): Close,\n", + "}\n" + ) + ); + } +} diff --git a/cosmic-conf/src/emit.rs b/cosmic-conf/src/emit.rs index 244ada2..c0a8a14 100644 --- a/cosmic-conf/src/emit.rs +++ b/cosmic-conf/src/emit.rs @@ -186,6 +186,10 @@ impl Emitter { WriteKind::Projected(fields) => { composite(&write.target, fields, previous.as_deref(), &path)? } + // No merge with `previous`: cosmic.conf owns this value outright, + // which is the whole point of the one-way model. Anything set in + // COSMIC's own settings UI is replaced, not accumulated. + WriteKind::Verbatim(s) => s.clone(), }; Ok(Planned { diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs index 7e6f570..d678edf 100644 --- a/cosmic-conf/src/lib.rs +++ b/cosmic-conf/src/lib.rs @@ -9,6 +9,7 @@ //! running. Only `emit` binds to cosmic-config, behind the `emit` feature. pub mod assets; +pub mod bind; pub mod emit; pub mod import; pub mod parser; @@ -16,6 +17,7 @@ pub mod resolve; pub mod schema; pub mod watch; +pub use bind::{parse_bind, Bind}; pub use emit::{EmitError, Emitter, Planned}; pub use import::{import_hypr_theme, render_report, Import}; pub use parser::{parse, Ast, ParseError, Span}; diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs index 6436d7e..3d500fa 100644 --- a/cosmic-conf/src/resolve.rs +++ b/cosmic-conf/src/resolve.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; +use crate::bind; use crate::parser::{Ast, Item, Span, Spanned}; use crate::schema::{self, Entry, Range, Target, Ty}; @@ -49,6 +50,13 @@ pub enum WriteKind { Whole(Value), /// Field path -> value, folded from every conf key touching this target. Projected(BTreeMap, Value>), + /// Pre-rendered RON owning the whole value. + /// + /// Used where the target's shape is a collection rather than a scalar, so + /// there is no `Value` to coerce into: keybindings fold many `bind` lines + /// into one map. Rendering happens at the point that understands the shape + /// (`bind::render`) instead of being reconstructed in `emit`. + Verbatim(String), } #[derive(Debug, Clone, PartialEq)] @@ -234,7 +242,41 @@ pub fn resolve(ast: &Ast) -> Result> { let mut projected: BTreeMap, Value>> = BTreeMap::new(); let mut whole: BTreeMap = BTreeMap::new(); + // `bind` is the one repeatable key in the language: many lines fold into a + // single map rather than the last one winning, so it cannot go through the + // schema, which is built around one conf key naming one value. + let mut binds: Vec<(bind::Bind, Span)> = Vec::new(); + for (conf, raw_value, key_span) in &flat { + if conf == "bind" { + let expanded = expand_vars(&raw_value.value, &vars); + match bind::parse_bind(&expanded, raw_value.span) { + Ok(b) => { + if let Some((prev, prev_span)) = binds + .iter() + .find(|(o, _)| o.mods == b.mods && o.key == b.key) + { + diags.push(Diagnostic { + message: format!( + "this key combination is already bound to `{}`", + prev.action + ), + span: raw_value.span, + help: Some(format!("the earlier bind is on line {}", prev_span.line)), + }); + continue; + } + binds.push((b, raw_value.span)); + } + Err(e) => diags.push(Diagnostic { + message: e.message, + span: e.span, + help: e.help, + }), + } + continue; + } + let Some(entry) = schema::lookup(conf) else { diags.push(Diagnostic { message: format!("unknown key `{conf}`"), @@ -277,6 +319,21 @@ pub fn resolve(ast: &Ast) -> Result> { kind: WriteKind::Projected(fields), })); + if !binds.is_empty() { + let rendered = bind::render(&binds.iter().map(|(b, _)| b.clone()).collect::>()); + writes.push(Write { + // cosmic-comp merges `custom` over `defaults` + // (cosmic-settings-daemon `config/src/shortcuts/mod.rs`), so writing + // here overrides a stock shortcut without touching the system file. + target: TargetKey { + component: "com.system76.CosmicSettings.Shortcuts".into(), + version: 1, + key: "custom".into(), + }, + kind: WriteKind::Verbatim(rendered), + }); + } + writes.sort_by(|a, b| a.target.cmp(&b.target)); Ok(Resolved { writes }) } @@ -346,6 +403,72 @@ mod tests { resolve(&ast).unwrap_err() } + #[test] + fn binds_fold_into_one_write_against_the_shortcuts_custom_key() { + let r = resolved( + "bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, Q, killactive\n", + ); + let w: Vec<_> = r + .writes + .iter() + .filter(|w| w.target.component == "com.system76.CosmicSettings.Shortcuts") + .collect(); + assert_eq!(w.len(), 1, "every bind belongs to one map"); + assert_eq!(w[0].target.key, "custom"); + assert_eq!(w[0].target.version, 1); + + let WriteKind::Verbatim(ron) = &w[0].kind else { + panic!("expected verbatim RON, got {:?}", w[0].kind); + }; + assert!(ron.contains(r#"(modifiers: [Super], key: "d"): Spawn("rofi -show drun")"#), "{ron}"); + assert!(ron.contains(r#"(modifiers: [Super], key: "q"): Close"#), "{ron}"); + } + + #[test] + fn a_bind_expands_variables_like_the_mainmod_idiom_everyone_uses() { + // Practically every hyprland.conf opens with `$mainMod = SUPER`. + let r = resolved("$mainMod = SUPER\nbind = $mainMod, D, exec, rofi -show drun\n"); + let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("modifiers: [Super]"), "{ron}"); + } + + #[test] + fn arithmetic_is_not_applied_to_a_command() { + // `eval_arith` would happily rewrite the `-` in a command line. + let r = resolved("bind = SUPER, V, exec, pactl set-sink-volume @DEFAULT_SINK@ -5%\n"); + let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("@DEFAULT_SINK@ -5%"), "{ron}"); + } + + #[test] + fn binding_the_same_combination_twice_is_an_error_not_a_silent_overwrite() { + let d = errors("bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, D, killactive\n"); + assert_eq!(d.len(), 1); + assert!(d[0].message.contains("already bound"), "{}", d[0].message); + assert!(d[0].help.as_ref().unwrap().contains("line 1"), "{:?}", d[0].help); + } + + #[test] + fn no_binds_means_the_shortcuts_file_is_left_alone() { + // Writing an empty map would wipe shortcuts set through COSMIC's UI for + // anyone whose cosmic.conf simply does not mention keybindings. + let r = resolved("general {\n gaps_in = 4\n}\n"); + assert!(r + .writes + .iter() + .all(|w| w.target.component != "com.system76.CosmicSettings.Shortcuts")); + } + + #[test] + fn a_bad_bind_is_reported_with_the_rest_of_the_file() { + let d = errors("bind = SUPER, X, frobnicate\ngeneral {\n gaps_inn = 8\n}\n"); + assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}"); + } + fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind { &r.writes .iter()