From 43507504f4142bb2a942bc8d3c4b69df83d8dd1b Mon Sep 17 00:00:00 2001 From: dingo Date: Tue, 11 Aug 2026 08:22:03 +0700 Subject: [PATCH] cosmic-conf: windowrule, and the rules it refuses windowrule = workspace name:web, class:^(vivaldi|firefox)$ windowrule = workspace 1, class:^(kitty)$, title:^(dev)$ Pairs with the workspace lines from the previous commit: those make a named workspace exist, this sends an application to it. `windowrulev2` is accepted as the same key, since that is the spelling configs in the wild are written with. Hyprland's windowrule has around forty actions. One of them is implemented, because one of them has a COSMIC counterpart, and the other thirty-nine fail with an explanation rather than parsing into nothing. A rule that looks right and silently does not fire is the worst outcome available -- the window opens in the wrong place and there is nothing to read that says why. So `float` points at the tiling exceptions in cosmic-settings, which is where floating actually lives; the rest say that only `workspace` exists here. Matchers that ask about window state -- floating:, fullscreen:, onworkspace: -- say that matching happens once, as the window opens, so only what the window arrives with can be tested. Three things the parser has to get right `+1` is not workspace 1. u32::from_str accepts a leading sign, so Hyprland's relative target would have parsed as an absolute one and sent the window somewhere the rule never asked for. The digits are checked before parsing rather than the result checked after. `silent` is stripped from the end, not parsed as one word among several, because a workspace name may contain spaces. `workspace = 2, name:web and mail` is a legal declaration, so `workspace name:web and mail` has to be a legal rule. A rule with neither class nor title is refused. Both are regular expressions and an empty one matches everything, so the rule that looks like it does nothing would in fact send the entire session to one workspace. Order is the semantics Rules are emitted in the order they were written and not deduplicated: two rules can differ only in their title and both be wanted, and the compositor takes the first that matches, so a file that reads top to bottom has to be written top to bottom. The regex dependency Expressions are compiled here so a broken one is a diagnostic against the line that wrote it, with the crate's own message pointing at the offending character, rather than a warning in the compositor log nobody reads. Pinned to the same major cosmic-comp matches with, so what compiles here compiles there. Cargo.lock carries the five new entries at the versions cosmic-comp already resolved. Verified 24 unit tests in the new module and 6 in resolve, covering the sign trap, the name with spaces, both spellings of the key, the order of the emitted list, variable expansion, and that each refused action and matcher carries its explanation. Not verified here: nothing is compiled on this machine. --- config/cosmic.conf | 34 +++ cosmic-comp | 2 +- cosmic-conf/Cargo.lock | 45 +++ cosmic-conf/Cargo.toml | 4 + cosmic-conf/src/lib.rs | 2 + cosmic-conf/src/resolve.rs | 121 +++++++- cosmic-conf/src/windowrule.rs | 532 ++++++++++++++++++++++++++++++++++ 7 files changed, 735 insertions(+), 5 deletions(-) create mode 100644 cosmic-conf/src/windowrule.rs diff --git a/config/cosmic.conf b/config/cosmic.conf index 7f1ff80..737a286 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -90,6 +90,40 @@ general { # workspace = 3, name:code # workspace = 4, name:chat +# --- Window rules -------------------------------------------------------- +# +# Where an application opens, decided from what it is rather than from where +# you happened to be standing. `windowrule = workspace 4, class:^(vivaldi)$` +# means the browser lands on workspace 4 no matter which one is in front of you. +# +# Rules are matched once, as the window opens, and the first one that matches +# wins -- so put the specific ones above the general ones. `class:` and `title:` +# are regular expressions; give both and both have to match. A rule with neither +# is refused, since it would match every window in the session. +# +# These pair with the workspace lines above. `workspace name:web` is worth +# preferring over `workspace 2`: a name follows the workspace if you renumber +# it, and only exists because you declared it, so a typo fails loudly rather +# than sending the window to whatever happens to be second. +# +# A rule never switches you to the workspace it used -- Hyprland's `silent`, +# always on. The word is accepted so pasted-in configs keep working. +# +# Only `workspace` is supported. Hyprland's float, size, move, opacity and the +# rest have nothing in COSMIC to project onto, and matchers that ask about +# window state -- floating:, fullscreen:, onworkspace: -- cannot be answered at +# the moment a window opens. Both fail with an explanation rather than parsing +# and doing nothing. Floating for a particular application is a tiling +# exception, which lives in COSMIC's own settings rather than in this file. +# +# Unlike the workspace lines, these are live: save the file and the next window +# to open obeys them. +# +# windowrule = workspace name:web, class:^(vivaldi|firefox)$ +# windowrule = workspace name:code, class:^(codium|code)$ +# windowrule = workspace name:chat, class:^(discord|Element)$ +# windowrule = workspace 1, class:^(kitty)$, title:^(dev)$ + # --- Input --------------------------------------------------------------- # # Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland diff --git a/cosmic-comp b/cosmic-comp index 5f17699..a73734e 160000 --- a/cosmic-comp +++ b/cosmic-comp @@ -1 +1 @@ -Subproject commit 5f176995dc901e7f9a0f351e3091589bdff29173 +Subproject commit a73734edb02f1946d8e8243b242d2b4a3a5fccd9 diff --git a/cosmic-conf/Cargo.lock b/cosmic-conf/Cargo.lock index bdd7e05..df17992 100644 --- a/cosmic-conf/Cargo.lock +++ b/cosmic-conf/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -29,6 +38,7 @@ version = "0.1.0" dependencies = [ "flate2", "notify", + "regex", "ron", "serde", "tar", @@ -158,6 +168,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -237,6 +253,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ron" version = "0.12.2" diff --git a/cosmic-conf/Cargo.toml b/cosmic-conf/Cargo.toml index bf9b1a9..988d12c 100644 --- a/cosmic-conf/Cargo.toml +++ b/cosmic-conf/Cargo.toml @@ -8,6 +8,10 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf [dependencies] flate2 = "1.1.9" notify = "8.2.0" +# Only to reject a bad `windowrule` expression at apply time rather than +# letting it fail silently in the compositor. Pinned to the same major the +# compositor matches with, so what compiles here compiles there. +regex = "1" ron = "0.12" serde = { version = "1.0.229", features = ["derive"] } tar = "0.4.46" diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs index 20b8ad1..04d62b9 100644 --- a/cosmic-conf/src/lib.rs +++ b/cosmic-conf/src/lib.rs @@ -16,6 +16,7 @@ pub mod parser; pub mod resolve; pub mod schema; pub mod watch; +pub mod windowrule; pub mod workspace; pub use bind::{parse_bind, Bind}; @@ -23,6 +24,7 @@ pub use emit::{EmitError, Emitter, Planned}; pub use import::{import_hypr_theme, render_report, Import}; pub use parser::{parse, Ast, ParseError, Span}; pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind}; +pub use windowrule::{parse_window_rule, WindowRuleDecl}; pub use workspace::{parse_workspace, WorkspaceDecl}; /// Render a diagnostic against source text, cargo-style. diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs index 6108697..9976f68 100644 --- a/cosmic-conf/src/resolve.rs +++ b/cosmic-conf/src/resolve.rs @@ -15,6 +15,7 @@ use std::collections::BTreeMap; use crate::bind; use crate::parser::{Ast, Item, Span, Spanned}; use crate::schema::{self, Entry, Range, Target, Ty}; +use crate::windowrule; use crate::workspace; #[derive(Debug, Clone, PartialEq)] @@ -269,14 +270,33 @@ pub fn resolve(ast: &Ast) -> Result> { let mut projected: BTreeMap, Value>> = BTreeMap::new(); let mut whole: BTreeMap = BTreeMap::new(); - // `bind` and `workspace` are the repeatable keys in the language: many - // lines fold into a single value rather than the last one winning, so - // neither can go through the schema, which is built around one conf key - // naming one value. + // `bind`, `workspace` and `windowrule` are the repeatable keys in the + // language: many lines fold into a single value rather than the last one + // winning, so none of them can go through the schema, which is built around + // one conf key naming one value. let mut binds: Vec<(bind::Bind, Span)> = Vec::new(); let mut workspaces: Vec<(workspace::WorkspaceDecl, Span)> = Vec::new(); + let mut window_rules: Vec = Vec::new(); for (conf, raw_value, key_span) in &flat { + // `windowrulev2` was Hyprland's name for this syntax before it became + // the only one; configs in the wild are still full of it. + if conf == "windowrule" || conf == "windowrulev2" { + let expanded = expand_vars(&raw_value.value, &vars); + // Not deduplicated: two rules can differ only in their title and + // both be wanted, and the compositor takes the first that matches, + // so the order they were written in is the whole semantics. + match windowrule::parse_window_rule(&expanded, raw_value.span) { + Ok(r) => window_rules.push(r), + Err(e) => diags.push(Diagnostic { + message: e.message, + span: e.span, + help: e.help, + }), + } + continue; + } + if conf == "workspace" { let expanded = expand_vars(&raw_value.value, &vars); match workspace::parse_workspace(&expanded, raw_value.span) { @@ -430,6 +450,21 @@ pub fn resolve(ast: &Ast) -> Result> { }); } + if !window_rules.is_empty() { + let rendered = windowrule::render(&window_rules); + writes.push(Write { + // Read live: cosmic-comp's config watcher has a `window_rules` arm, + // so an edit applies to the next window that opens. Unlike + // `pinned_workspaces`, which waits for the next login. + target: TargetKey { + component: "com.system76.CosmicComp".into(), + version: 1, + key: "window_rules".into(), + }, + kind: WriteKind::Verbatim(rendered), + }); + } + writes.sort_by(|a, b| a.target.cmp(&b.target)); Ok(Resolved { writes }) } @@ -652,6 +687,84 @@ mod tests { assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}"); } + #[test] + fn window_rules_fold_into_one_write_against_the_comp_window_rules_key() { + let r = resolved( + "windowrule = workspace name:web, class:^(vivaldi)$\n\ + windowrule = workspace 1, class:^(kitty)$\n", + ); + let w: Vec<_> = r + .writes + .iter() + .filter(|w| w.target.key == "window_rules") + .collect(); + assert_eq!(w.len(), 1, "every rule belongs to one list"); + assert_eq!(w[0].target.component, "com.system76.CosmicComp"); + 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#"workspace: Name("web")"#), "{ron}"); + assert!(ron.contains("workspace: Index(1)"), "{ron}"); + } + + /// The compositor takes the first rule that matches, so a file that reads + /// top to bottom has to be emitted top to bottom. + #[test] + fn window_rules_keep_the_order_they_were_written_in() { + let r = resolved( + "windowrule = workspace 1, class:^(a)$\n\ + windowrule = workspace 2, class:^(b)$\n\ + windowrule = workspace 3, class:^(c)$\n", + ); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else { + panic!("expected verbatim RON"); + }; + let seen: Vec<&str> = ron + .lines() + .filter(|l| l.contains("app_id:")) + .map(|l| l.trim()) + .collect(); + assert_eq!(seen.len(), 3); + assert!(seen[0].contains("^(a)$"), "{ron}"); + assert!(seen[1].contains("^(b)$"), "{ron}"); + assert!(seen[2].contains("^(c)$"), "{ron}"); + } + + /// The v2 spelling is what configs in the wild are written with. + #[test] + fn windowrulev2_is_the_same_key() { + let r = resolved("windowrulev2 = workspace 2, class:^(firefox)$\n"); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("workspace: Index(2)"), "{ron}"); + } + + /// Symmetric with the bind and workspace cases: writing an empty list would + /// be a change for someone whose cosmic.conf never mentions window rules. + #[test] + fn no_window_rules_means_the_list_is_left_alone() { + let r = resolved("general {\n gaps_in = 4\n}\n"); + assert!(r.writes.iter().all(|w| w.target.key != "window_rules")); + } + + #[test] + fn a_window_rule_expands_variables() { + let r = resolved("$browser = vivaldi\nwindowrule = workspace 2, class:^($browser)$\n"); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("^(vivaldi)$"), "{ron}"); + } + + #[test] + fn a_bad_window_rule_is_reported_with_the_rest_of_the_file() { + let d = errors("windowrule = float, class:foo\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() diff --git a/cosmic-conf/src/windowrule.rs b/cosmic-conf/src/windowrule.rs new file mode 100644 index 0000000..8e382f8 --- /dev/null +++ b/cosmic-conf/src/windowrule.rs @@ -0,0 +1,532 @@ +//! Hyprland `windowrule` lines -> COSMIC window rules. +//! +//! Hyprland's `windowrule` is a large surface: an action, a match, and around +//! forty possible actions ranging from `float` to `bordercolor`. Exactly one of +//! them is implemented here, `workspace`, because it is the one with a real +//! COSMIC counterpart -- a window can be mapped onto a workspace other than the +//! active one, which is what `windowrule = workspace 4, class:...` means. +//! +//! Everything else is refused with an explanation rather than accepted and +//! dropped. A rule that parses and then does nothing is the worst outcome +//! available: the config looks right, the window opens in the wrong place, and +//! there is nothing to read that says why. +//! +//! The match half is `class:` and `title:`, both regular expressions, both +//! compiled here so a broken one is a diagnostic against the line that wrote it +//! rather than a warning in the compositor log nobody reads. + +use std::fmt::Write as _; + +use crate::parser::Span; + +/// Where a matching window opens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceTarget { + /// 1-based, as the user counts them. + Index(u32), + /// Matched against the workspace name, which is what a `workspace` line + /// sets. + Name(String), +} + +/// One `windowrule = ...` line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowRuleDecl { + /// Regular expression for the window's app id. Empty matches anything. + pub class: String, + /// Regular expression for the window's title. Empty matches anything. + pub title: String, + pub workspace: WorkspaceTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowRuleError { + pub message: String, + pub help: Option, + pub span: Span, +} + +fn err(span: Span, message: impl Into, help: Option<&str>) -> WindowRuleError { + WindowRuleError { + message: message.into(), + help: help.map(str::to_string), + span, + } +} + +/// The actions Hyprland has that this cannot do, and why saying so beats +/// guessing. `float` and `tile` are called out separately because they are the +/// next most likely thing to be reached for and COSMIC does have a mechanism -- +/// just not one cosmic.conf owns. +const FLOAT_HELP: &str = "cosmic-comp decides floating from its tiling exceptions, which belong to \ + cosmic-settings (com.system76.CosmicSettings.WindowRules) rather than to \ + cosmic.conf. Add the application there and it will float on every \ + workspace."; + +const ACTION_HELP: &str = "only `workspace` is supported. Hyprland's other rules -- float, size, \ + move, opacity, bordercolor and the rest -- have no COSMIC equivalent to \ + project onto."; + +/// Matchers Hyprland has that depend on window state at match time. Ours runs +/// once, when the window is mapped, so none of these can be answered. +const MATCHER_HELP: &str = "rules are matched once, as the window opens, so only what the window \ + arrives with can be tested: class and title."; + +/// Parse the body of a `windowrule` line. +/// +/// `windowrule = workspace 4, class:^(vivaldi.*)$` +/// +/// The first field is the action, the rest are matchers. `windowrulev2` is the +/// same thing under an older name -- Hyprland merged v2's syntax into +/// `windowrule` and kept the alias, and HyDE-era configs are full of it. +pub fn parse_window_rule(value: &str, span: Span) -> Result { + let mut parts = value.split(','); + + let action = parts + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + err( + span, + "a window rule needs an action", + Some("for example: windowrule = workspace 4, class:^(vivaldi)$"), + ) + })?; + + let workspace = parse_action(action, span)?; + + let mut class = String::new(); + let mut title = String::new(); + let mut matched_on = false; + + for raw in parts { + let param = raw.trim(); + if param.is_empty() { + continue; + } + let Some((key, arg)) = param.split_once(':') else { + return Err(err( + span, + format!("expected `key:value`, found `{param}`"), + Some("known matchers: class, title"), + )); + }; + let arg = arg.trim(); + // `initialClass`/`initialTitle` are accepted as spellings of the same + // thing rather than as approximations of it: the match happens as the + // window is mapped, so the title being tested *is* the initial one. + match key.trim().to_ascii_lowercase().as_str() { + "class" | "initialclass" => { + check_regex(arg, "class", span)?; + class = arg.to_string(); + matched_on = true; + } + "title" | "initialtitle" => { + check_regex(arg, "title", span)?; + title = arg.to_string(); + matched_on = true; + } + // Named rather than swept into the catch-all so the message can say + // why a matcher Hyprland does have is not accepted here. + "floating" | "fullscreen" | "pinned" | "focus" | "workspace" | "onworkspace" + | "xwayland" | "tag" | "fullscreenstate" => { + return Err(err( + span, + format!("`{}:` cannot be matched on", key.trim()), + Some(MATCHER_HELP), + )); + } + other => { + return Err(err( + span, + format!("unknown matcher `{other}`"), + Some("known matchers: class, title"), + )); + } + } + } + + if !matched_on { + return Err(err( + span, + "a window rule needs something to match on", + Some( + "without a class or a title the rule matches every window, which \ + would send the whole session to one workspace.", + ), + )); + } + + Ok(WindowRuleDecl { + class, + title, + workspace, + }) +} + +/// `workspace 4`, `workspace name:web`, either with a trailing `silent`. +fn parse_action(action: &str, span: Span) -> Result { + let (verb, rest) = match action.split_once(char::is_whitespace) { + Some((verb, rest)) => (verb, rest.trim()), + None => (action, ""), + }; + if !verb.eq_ignore_ascii_case("workspace") { + return Err(err( + span, + format!("unsupported window rule `{verb}`"), + Some( + if verb.eq_ignore_ascii_case("float") || verb.eq_ignore_ascii_case("tile") { + FLOAT_HELP + } else { + ACTION_HELP + }, + ), + )); + } + + // Hyprland's `silent` means "put it there without switching to it". That is + // unconditionally what happens here -- a rule places its window and leaves + // the focus alone -- so the word is accepted as a description of the + // behaviour rather than ignored as a request that went unheard. + // + // Stripped from the end rather than parsed as one word among several, + // because a workspace name may contain spaces: `workspace = 2, name:web and + // mail` is a legal declaration, so `workspace name:web and mail` has to be a + // legal rule. + let target = rest + .rsplit_once(char::is_whitespace) + .filter(|(_, last)| last.eq_ignore_ascii_case("silent")) + .map_or(rest, |(head, _)| head.trim_end()); + + if target.is_empty() { + return Err(err( + span, + "`workspace` needs a workspace to send the window to", + Some("for example: workspace 4, or workspace name:web"), + )); + } + + if let Some(name) = target.strip_prefix("name:") { + if name.is_empty() { + return Err(err(span, "`name:` needs a workspace name", None)); + } + return Ok(WorkspaceTarget::Name(name.to_string())); + } + + // Digits checked before parsing, not after: `u32::from_str` accepts a + // leading `+`, so `workspace +1` -- Hyprland's "one to the right" -- would + // otherwise parse as the absolute workspace 1 and send the window somewhere + // the rule never asked for. + if target.bytes().all(|b| b.is_ascii_digit()) { + match target.parse::() { + Ok(0) => return Err(err(span, "workspaces are numbered from 1", None)), + Ok(index) => return Ok(WorkspaceTarget::Index(index)), + // Only reachable by overflow, which the message below covers. + Err(_) => {} + } + } + + // Everything else Hyprland accepts here is relative to where you are -- + // `+1`, `previous`, `empty`, `special` -- and a rule fires when a window + // opens, so "the next workspace" would mean a different one every time. + Err(err( + span, + format!("cannot send a window to `{target}`"), + Some( + "a rule names one fixed workspace: a number, or `name:` and the name \ + from a `workspace` line, optionally followed by `silent`. Relative \ + and special workspaces have no COSMIC equivalent.", + ), + )) +} + +fn check_regex(pattern: &str, field: &str, span: Span) -> Result<(), WindowRuleError> { + if pattern.is_empty() { + return Err(err( + span, + format!("`{field}:` needs a value"), + Some("an empty expression matches every window; leave the matcher out instead."), + )); + } + regex::Regex::new(pattern).map_err(|e| { + // The crate's own message is multi-line and already points at the + // offending character, which is more useful than anything paraphrased. + err( + span, + format!("`{field}:` is not a valid expression"), + Some(&e.to_string().replace('\n', " ")), + ) + })?; + Ok(()) +} + +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 +} + +/// Render the declarations as the RON `Vec` cosmic-comp stores in +/// `window_rules`. +/// +/// Order is preserved because it is meaningful: the compositor takes the first +/// rule that matches, and a file reads top to bottom, so the earlier line is +/// the one a person expects to win. +pub fn render(decls: &[WindowRuleDecl]) -> String { + let mut out = String::from("[\n"); + for decl in decls { + let workspace = match &decl.workspace { + WorkspaceTarget::Index(n) => format!("Index({n})"), + WorkspaceTarget::Name(name) => format!("Name({})", ron_string(name)), + }; + let _ = writeln!( + out, + " (app_id: {}, title: {}, workspace: {workspace}),", + ron_string(&decl.class), + ron_string(&decl.title), + ); + } + out.push_str("]\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span() -> Span { + Span { + line: 1, + col: 1, + len: 1, + } + } + + fn ok(s: &str) -> WindowRuleDecl { + parse_window_rule(s, span()).expect(s) + } + + fn fail(s: &str) -> WindowRuleError { + parse_window_rule(s, span()).expect_err(s) + } + + #[test] + fn the_hyprland_form_parses() { + let r = ok("workspace 4, class:^(vivaldi.*)$"); + assert_eq!(r.workspace, WorkspaceTarget::Index(4)); + assert_eq!(r.class, "^(vivaldi.*)$"); + assert_eq!(r.title, ""); + } + + #[test] + fn a_named_workspace_is_carried_through() { + assert_eq!( + ok("workspace name:web, class:vivaldi").workspace, + WorkspaceTarget::Name("web".into()) + ); + } + + #[test] + fn class_and_title_can_both_be_given() { + let r = ok("workspace 2, class:^(firefox)$, title:.*Mail.*"); + assert_eq!(r.class, "^(firefox)$"); + assert_eq!(r.title, ".*Mail.*"); + } + + #[test] + fn a_title_alone_is_enough_to_match_on() { + let r = ok("workspace 2, title:.*Mail.*"); + assert_eq!(r.class, "", "an empty class matches every app id"); + assert_eq!(r.title, ".*Mail.*"); + } + + #[test] + fn initial_spellings_are_the_same_matchers() { + let r = ok("workspace 1, initialClass:foo, initialTitle:bar"); + assert_eq!(r.class, "foo"); + assert_eq!(r.title, "bar"); + } + + #[test] + fn silent_is_accepted_because_it_describes_what_happens() { + assert_eq!( + ok("workspace 3 silent, class:foo").workspace, + WorkspaceTarget::Index(3) + ); + } + + #[test] + fn case_does_not_matter_for_keywords() { + assert_eq!( + ok("Workspace 3 SILENT, CLASS:foo").workspace, + WorkspaceTarget::Index(3) + ); + } + + #[test] + fn whitespace_around_everything_is_tolerated() { + let r = ok(" workspace 4 , class : ^foo$ "); + assert_eq!(r.workspace, WorkspaceTarget::Index(4)); + assert_eq!(r.class, "^foo$"); + } + + #[test] + fn a_rule_with_nothing_to_match_on_is_refused() { + let e = fail("workspace 4"); + assert!(e.message.contains("something to match on"), "{e:?}"); + } + + #[test] + fn an_unsupported_action_says_which_one_is_supported() { + let e = fail("size 100 100, class:foo"); + assert!(e.message.contains("unsupported window rule"), "{e:?}"); + assert!(e.help.unwrap().contains("only `workspace`")); + } + + #[test] + fn float_points_at_the_tiling_exceptions_instead() { + let e = fail("float, class:foo"); + assert!( + e.help.as_deref().unwrap_or_default().contains("cosmic-settings"), + "{e:?}" + ); + } + + #[test] + fn a_state_matcher_explains_that_matching_happens_once() { + let e = fail("workspace 4, class:foo, floating:1"); + assert!(e.message.contains("cannot be matched on"), "{e:?}"); + assert!(e.help.unwrap().contains("as the window opens")); + } + + #[test] + fn an_unknown_matcher_lists_the_known_ones() { + let e = fail("workspace 4, klass:foo"); + assert!(e.message.contains("unknown matcher"), "{e:?}"); + } + + /// `u32::from_str` accepts a leading sign, so `+1` would silently become + /// the absolute workspace 1 if the digits were not checked first. + #[test] + fn a_relative_workspace_is_refused_with_a_reason() { + for target in ["+1", "-1", "previous", "empty", "e+1"] { + let e = fail(&format!("workspace {target}, class:foo")); + assert!( + e.message.contains("cannot send a window to"), + "{target}: {}", + e.message + ); + assert!( + e.help.as_deref().unwrap_or_default().contains("one fixed workspace"), + "{target}: {:?}", + e.help + ); + } + } + + #[test] + fn a_special_workspace_is_refused() { + assert!(fail("workspace special:magic, class:foo") + .message + .contains("cannot send a window to")); + } + + #[test] + fn workspace_zero_is_refused() { + assert!(fail("workspace 0, class:foo") + .message + .contains("numbered from 1")); + } + + #[test] + fn a_broken_expression_is_caught_here_not_in_the_compositor() { + let e = fail("workspace 4, class:^(unclosed"); + assert!(e.message.contains("not a valid expression"), "{e:?}"); + assert!(e.help.is_some(), "the regex crate's own message is passed on"); + } + + #[test] + fn an_empty_matcher_is_refused_rather_than_matching_everything() { + let e = fail("workspace 4, class:"); + assert!(e.message.contains("needs a value"), "{e:?}"); + } + + /// `silent` is stripped off the end, so anything else trailing an index is + /// part of the target and fails as one rather than being quietly dropped. + #[test] + fn a_modifier_that_is_not_silent_is_refused() { + let e = fail("workspace 4 loud, class:foo"); + assert!(e.message.contains("cannot send a window to `4 loud`"), "{e:?}"); + } + + /// A `workspace` line accepts a name with spaces in it, so a rule aiming at + /// that workspace has to as well. + #[test] + fn a_workspace_name_may_contain_spaces() { + assert_eq!( + ok("workspace name:web and mail, class:foo").workspace, + WorkspaceTarget::Name("web and mail".into()) + ); + assert_eq!( + ok("workspace name:web and mail silent, class:foo").workspace, + WorkspaceTarget::Name("web and mail".into()) + ); + } + + #[test] + fn workspace_with_nothing_after_it_says_so() { + let e = fail("workspace, class:foo"); + assert!(e.message.contains("needs a workspace"), "{e:?}"); + } + + #[test] + fn rendering_matches_the_ron_shape_cosmic_comp_reads() { + let out = render(&[ + WindowRuleDecl { + class: "^(vivaldi)$".into(), + title: String::new(), + workspace: WorkspaceTarget::Name("web".into()), + }, + WindowRuleDecl { + class: "^(kitty)$".into(), + title: String::new(), + workspace: WorkspaceTarget::Index(1), + }, + ]); + + assert_eq!( + out, + concat!( + "[\n", + " (app_id: \"^(vivaldi)$\", title: \"\", workspace: Name(\"web\")),\n", + " (app_id: \"^(kitty)$\", title: \"\", workspace: Index(1)),\n", + "]\n", + ) + ); + } + + #[test] + fn nothing_renders_as_an_empty_list() { + assert_eq!(render(&[]), "[\n]\n"); + } + + #[test] + fn a_quote_in_an_expression_cannot_break_out_of_the_ron() { + let out = render(&[WindowRuleDecl { + class: r#"^(say "hi")$"#.into(), + title: String::new(), + workspace: WorkspaceTarget::Index(1), + }]); + assert!(out.contains(r#"\"hi\""#), "{out}"); + } +}