mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
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.
This commit is contained in:
+2
-2
@@ -13,8 +13,8 @@
|
|||||||
waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s /usr/share/hyprcosmic/waybar/style.css
|
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
|
# 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
|
# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those
|
||||||
# binding is set, run `rofi -show drun` from a terminal.
|
# 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
|
# HyDE drives wallpaper through swww, which is not packaged for Fedora. Once
|
||||||
# it is available, uncomment:
|
# it is available, uncomment:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, 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<Vec<&'static str>, BindError> {
|
||||||
|
let mut rest: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != '+' && *c != '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.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<String> {
|
||||||
|
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<u8, BindError> {
|
||||||
|
arg.trim().parse::<u8>().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<String, BindError> {
|
||||||
|
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<Bind, BindError> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,10 @@ impl Emitter {
|
|||||||
WriteKind::Projected(fields) => {
|
WriteKind::Projected(fields) => {
|
||||||
composite(&write.target, fields, previous.as_deref(), &path)?
|
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 {
|
Ok(Planned {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod bind;
|
||||||
pub mod emit;
|
pub mod emit;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
@@ -16,6 +17,7 @@ pub mod resolve;
|
|||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
|
|
||||||
|
pub use bind::{parse_bind, Bind};
|
||||||
pub use emit::{EmitError, Emitter, Planned};
|
pub use emit::{EmitError, Emitter, Planned};
|
||||||
pub use import::{import_hypr_theme, render_report, Import};
|
pub use import::{import_hypr_theme, render_report, Import};
|
||||||
pub use parser::{parse, Ast, ParseError, Span};
|
pub use parser::{parse, Ast, ParseError, Span};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::bind;
|
||||||
use crate::parser::{Ast, Item, Span, Spanned};
|
use crate::parser::{Ast, Item, Span, Spanned};
|
||||||
use crate::schema::{self, Entry, Range, Target, Ty};
|
use crate::schema::{self, Entry, Range, Target, Ty};
|
||||||
|
|
||||||
@@ -49,6 +50,13 @@ pub enum WriteKind {
|
|||||||
Whole(Value),
|
Whole(Value),
|
||||||
/// Field path -> value, folded from every conf key touching this target.
|
/// Field path -> value, folded from every conf key touching this target.
|
||||||
Projected(BTreeMap<Vec<String>, Value>),
|
Projected(BTreeMap<Vec<String>, 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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -234,7 +242,41 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
|||||||
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
||||||
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
|
let mut whole: BTreeMap<TargetKey, (Value, Span)> = 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 {
|
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 {
|
let Some(entry) = schema::lookup(conf) else {
|
||||||
diags.push(Diagnostic {
|
diags.push(Diagnostic {
|
||||||
message: format!("unknown key `{conf}`"),
|
message: format!("unknown key `{conf}`"),
|
||||||
@@ -277,6 +319,21 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
|||||||
kind: WriteKind::Projected(fields),
|
kind: WriteKind::Projected(fields),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if !binds.is_empty() {
|
||||||
|
let rendered = bind::render(&binds.iter().map(|(b, _)| b.clone()).collect::<Vec<_>>());
|
||||||
|
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));
|
writes.sort_by(|a, b| a.target.cmp(&b.target));
|
||||||
Ok(Resolved { writes })
|
Ok(Resolved { writes })
|
||||||
}
|
}
|
||||||
@@ -346,6 +403,72 @@ mod tests {
|
|||||||
resolve(&ast).unwrap_err()
|
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 {
|
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
|
||||||
&r.writes
|
&r.writes
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user