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}"); + } +}