diff --git a/.github/workflows/hyprcosmic.yml b/.github/workflows/hyprcosmic.yml index 14d9f37..991459b 100644 --- a/.github/workflows/hyprcosmic.yml +++ b/.github/workflows/hyprcosmic.yml @@ -171,13 +171,19 @@ jobs: # A login-time script with a syntax error is a black screen with nowhere to # print the reason. + # Each script is checked with the shell its shebang names rather than with + # bash across the board: hyprcosmic-fan is POSIX sh on purpose, and `bash + # -n` would happily accept a bashism that fails on a system where /bin/sh + # is dash. - name: Syntax-check the shell scripts run: | set -eux + sh -n config/bin/hyprcosmic-fan + bash -n config/bin/hyprcosmic-keybinds bash -n config/bin/hyprcosmic-powermenu bash -n tools/install-assets.sh - # Both scripts are shellcheck-clean today, so this starts as a ratchet + # Every script is shellcheck-clean today, so this starts as a ratchet # rather than a backlog. The runner image ships shellcheck; the install is # there so that stopping to be true is a slow step and not a broken job. - name: Shellcheck @@ -187,7 +193,10 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends shellcheck } - shellcheck config/bin/hyprcosmic-powermenu tools/install-assets.sh + shellcheck config/bin/hyprcosmic-fan \ + config/bin/hyprcosmic-keybinds \ + config/bin/hyprcosmic-powermenu \ + tools/install-assets.sh # A round trip. Installing into a staging root and then asking --check to # confirm it exercises both halves of the script against each other, and diff --git a/config/cosmic.conf b/config/cosmic.conf index 0da53fd..7f1ff80 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -50,6 +50,46 @@ general { gaps_out = $gap * 2 } +# --- Workspaces ---------------------------------------------------------- +# +# COSMIC's workspaces are dynamic and there is no setting that turns that off: +# it keeps exactly one trailing empty workspace and collects every other empty +# one the moment its last window closes. So "workspace 4 is the browser" is only +# true while the browser is open, and the number moves under you as workspaces +# come and go. +# +# These lines declare a fixed set instead, which is what a Hyprland user expects. +# A declared workspace is pinned, and pinning is precisely what exempts it from +# that collection, so it stays at its number whether or not anything is on it. +# +# The dynamic workspace is not lost. Declaring four leaves you with 1-4 always +# present and a fifth appearing the moment you use it, then a sixth after that, +# exactly as Hyprland does. +# +# Two things worth knowing before you edit: +# +# * The index is required and workspaces below it are created too, because +# COSMIC restores them by position rather than by number. `workspace = 4` +# on its own gives you four workspaces, three of them unnamed. +# * This is the one key in this file that is not live. The compositor reads +# it once at startup, so an edit applies at your next login. Everything +# else here takes effect as soon as you save. +# +# `name:` is what waybar and anything else reading ext-workspace will show. +# `tiling:` overrides `general:autotile` for that workspace alone; leave it out +# and the workspace follows the setting above. Hyprland's `monitor:` is not +# accepted -- COSMIC matches a workspace to a monitor by EDID rather than by +# name, and there is no way to write an EDID down here. Saying so is better +# than accepting the parameter and quietly ignoring it. +# +# Commented out because a fixed set is a preference, not an improvement, and +# leaving these off gives you COSMIC's stock behaviour. Uncomment to opt in. +# +# workspace = 1, name:term +# workspace = 2, name:web +# workspace = 3, name:code +# workspace = 4, name:chat + # --- Input --------------------------------------------------------------- # # Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs index 3120296..20b8ad1 100644 --- a/cosmic-conf/src/lib.rs +++ b/cosmic-conf/src/lib.rs @@ -16,12 +16,14 @@ pub mod parser; pub mod resolve; pub mod schema; pub mod watch; +pub mod workspace; 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}; pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind}; +pub use workspace::{parse_workspace, WorkspaceDecl}; /// Render a diagnostic against source text, cargo-style. pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String { diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs index 5010d41..6108697 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::workspace; #[derive(Debug, Clone, PartialEq)] pub enum Value { @@ -268,12 +269,42 @@ 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. + // `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. let mut binds: Vec<(bind::Bind, Span)> = Vec::new(); + let mut workspaces: Vec<(workspace::WorkspaceDecl, Span)> = Vec::new(); for (conf, raw_value, key_span) in &flat { + if conf == "workspace" { + let expanded = expand_vars(&raw_value.value, &vars); + match workspace::parse_workspace(&expanded, raw_value.span) { + Ok(w) => { + if let Some((_, prev_span)) = + workspaces.iter().find(|(o, _)| o.index == w.index) + { + diags.push(Diagnostic { + message: format!("workspace {} is already declared", w.index), + span: raw_value.span, + help: Some(format!( + "the earlier declaration is on line {}", + prev_span.line + )), + }); + continue; + } + workspaces.push((w, raw_value.span)); + } + Err(e) => diags.push(Diagnostic { + message: e.message, + span: e.span, + help: e.help, + }), + } + continue; + } + if conf == "bind" { let expanded = expand_vars(&raw_value.value, &vars); match bind::parse_bind(&expanded, raw_value.span) { @@ -339,6 +370,19 @@ pub fn resolve(ast: &Ast) -> Result> { return Err(diags); } + // A pinned workspace carries its own `tiling_enabled`, so one that did not + // say has to inherit the session default rather than default to off -- + // otherwise declaring workspaces would quietly undo `general:autotile`. + // Read before `whole` is consumed below. + let default_tiling = whole + .get(&TargetKey { + component: "com.system76.CosmicComp".into(), + version: 1, + key: "autotile".into(), + }) + .map(|(v, _)| *v == Value::Bool(true)) + .unwrap_or(false); + let mut writes: Vec = whole .into_iter() .map(|(target, (v, _))| Write { @@ -367,6 +411,25 @@ pub fn resolve(ast: &Ast) -> Result> { }); } + if !workspaces.is_empty() { + let rendered = workspace::render( + &workspaces.iter().map(|(w, _)| w.clone()).collect::>(), + default_tiling, + ); + writes.push(Write { + // `Workspaces::add_output` drains this into the first output that + // appears (cosmic-comp `shell/mod.rs`), and `from_pinned` sets + // `pinned: true`, which is what stops `can_auto_remove` collecting + // the workspace the moment its last window closes. + target: TargetKey { + component: "com.system76.CosmicComp".into(), + version: 1, + key: "pinned_workspaces".into(), + }, + kind: WriteKind::Verbatim(rendered), + }); + } + writes.sort_by(|a, b| a.target.cmp(&b.target)); Ok(Resolved { writes }) } @@ -510,6 +573,85 @@ mod tests { assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}"); } + #[test] + fn workspaces_fold_into_one_write_against_the_comp_pinned_workspaces_key() { + let r = resolved("workspace = 1, name:term\nworkspace = 2, name:web\n"); + let w: Vec<_> = r + .writes + .iter() + .filter(|w| w.target.key == "pinned_workspaces") + .collect(); + assert_eq!(w.len(), 1, "every workspace 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#"name: Some("term")"#), "{ron}"); + assert!(ron.contains(r#"name: Some("web")"#), "{ron}"); + } + + /// Without this the pinned workspaces would come back floating for a user + /// whose whole reason for editing the file was `autotile = true`. + #[test] + fn a_workspace_that_did_not_say_inherits_general_autotile() { + let r = resolved("general {\n autotile = true\n}\nworkspace = 1\n"); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces") + else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("tiling_enabled: true"), "{ron}"); + + let r = resolved("general {\n autotile = false\n}\nworkspace = 1\n"); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces") + else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains("tiling_enabled: false"), "{ron}"); + } + + #[test] + fn declaring_the_same_workspace_twice_is_an_error_not_a_silent_overwrite() { + let d = errors("workspace = 2, name:web\nworkspace = 2, name:mail\n"); + assert_eq!(d.len(), 1); + assert!( + d[0].message.contains("already declared"), + "{}", + d[0].message + ); + assert!( + d[0].help.as_ref().unwrap().contains("line 1"), + "{:?}", + d[0].help + ); + } + + /// Symmetric with `no_binds_means_the_shortcuts_file_is_left_alone`: writing + /// an empty list would unpin the workspaces of anyone whose cosmic.conf + /// simply does not mention them. + #[test] + fn no_workspaces_means_the_pinned_list_is_left_alone() { + let r = resolved("general {\n gaps_in = 4\n}\n"); + assert!(r.writes.iter().all(|w| w.target.key != "pinned_workspaces")); + } + + #[test] + fn a_workspace_expands_variables() { + let r = resolved("$browser = web\nworkspace = 1, name:$browser\n"); + let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces") + else { + panic!("expected verbatim RON"); + }; + assert!(ron.contains(r#"name: Some("web")"#), "{ron}"); + } + + #[test] + fn a_bad_workspace_is_reported_with_the_rest_of_the_file() { + let d = errors("workspace = 0\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/workspace.rs b/cosmic-conf/src/workspace.rs new file mode 100644 index 0000000..99cf0bd --- /dev/null +++ b/cosmic-conf/src/workspace.rs @@ -0,0 +1,482 @@ +//! Hyprland `workspace` lines -> COSMIC pinned workspaces. +//! +//! COSMIC's workspaces are dynamic and unconditionally so: `ensure_last_empty` +//! keeps exactly one trailing empty workspace and garbage-collects every other +//! empty one, and there is no setting anywhere that turns that off. A Hyprland +//! user expects the opposite -- a fixed set of workspaces that exist whether or +//! not anything is on them, so that "workspace 4 is the browser" stays true +//! across a reboot. +//! +//! The primitive that bridges the two already exists in the compositor. +//! `Workspace::can_auto_remove` is `is_empty() && !has_activation_token() && +//! !pinned`, so a pinned workspace survives being emptied, and +//! `CosmicCompConfig::pinned_workspaces` is a persisted key that +//! `Workspaces::add_output` drains into the first output that appears. Nothing +//! in cosmic-comp needs to change: declaring workspaces here is enough. +//! +//! Three consequences of that restore path shape this module: +//! +//! 1. **Restore is positional.** `PinnedWorkspace` has no index field -- the +//! order of the Vec becomes the order of the workspaces. So `workspace = 4` +//! cannot emit one entry; it has to emit four, with 1..3 unnamed, or the +//! declared workspace would land at index 1. +//! 2. **The trailing dynamic workspace is kept.** Pinned workspaces are pushed +//! into an empty `WorkspaceSet`, and `ensure_last_empty` then appends the +//! usual empty one. Declaring four leaves you on 1-4 with a fifth appearing +//! when you use it, which is Hyprland's behaviour rather than a compromise. +//! 3. **It lands at the next login, not on apply.** `Workspaces::new` reads the +//! key once when the compositor starts and there is no reload path for it, +//! while `cosmic-conf watch` is started from the autostart file *after* +//! COSMIC's own components. So an edit is written immediately and takes +//! effect the next time the session starts. Every other key in cosmic.conf +//! is live, so this one is worth saying out loud. + +use std::fmt::Write as _; + +use crate::parser::Span; + +/// One `workspace = ...` line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceDecl { + /// 1-based, as written. Workspaces below this one are materialised too. + pub index: u32, + /// Shown by anything reading ext-workspace, waybar included. `None` leaves + /// COSMIC to label it by number. + pub name: Option, + /// Per-workspace tiling. `None` inherits the session default. + pub tiling: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceError { + pub message: String, + pub help: Option, + pub span: Span, +} + +fn err(span: Span, message: impl Into, help: Option<&str>) -> WorkspaceError { + WorkspaceError { + message: message.into(), + help: help.map(str::to_string), + span, + } +} + +/// Declaring index N materialises N workspaces, so a typo like `workspace = 100` +/// would silently produce a hundred of them. Nobody drives a hundred +/// workspaces; the cap turns a fat-finger into a diagnostic. +const MAX_INDEX: u32 = 32; + +/// Why `monitor:` is refused rather than accepted and ignored. +/// +/// A `PinnedWorkspace` names its output through `OutputMatch { name, edid }`, +/// and cosmic-comp's `output_matches` compares the EDID *first*: a match with +/// `edid: None` is rejected outright against any output that reports one, and +/// only falls through to the name when neither side has an EDID. Every real +/// panel reports one, so a name-only match would work on a VM and nowhere else. +/// +/// cosmic.conf cannot supply the EDID -- it is a manufacturer triple, product +/// id, serial and manufacture date read off the wire by the DRM backend, not +/// something a user can write down. Accepting `monitor:` would give a parameter +/// that silently does nothing on the hardware people actually have. +const MONITOR_HELP: &str = "COSMIC matches a workspace to a monitor by EDID rather than by name, \ + and cosmic.conf has no way to spell an EDID. Pinned workspaces are created \ + on the first output the session sees; move them with the workspace \ + shortcuts once they exist."; + +/// Parse the body of a `workspace` line. +/// +/// `workspace = 4, name:web, tiling:true` +/// +/// The index comes first and is required. Hyprland also allows a leading +/// `name:` with no index, for its special workspaces; COSMIC has no equivalent +/// and the restore is positional besides, so that form is rejected with an +/// explanation rather than guessed at. +pub fn parse_workspace(value: &str, span: Span) -> Result { + let mut parts = value.split(','); + + let head = parts + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + err( + span, + "a workspace needs an index", + Some("for example: workspace = 4, name:web"), + ) + })?; + + let index = head.parse::().map_err(|_| { + if head.contains(':') { + err( + span, + format!("a workspace starts with its index, found `{head}`"), + Some( + "COSMIC restores pinned workspaces by position, so every one \ + needs a number. Write `workspace = 4, name:web` rather than \ + `workspace = name:web`.", + ), + ) + } else { + err( + span, + format!("expected a workspace index, found `{head}`"), + Some("for example: workspace = 4, name:web"), + ) + } + })?; + + if index == 0 || index > MAX_INDEX { + return Err(err( + span, + format!("workspace index {index} is outside 1..={MAX_INDEX}"), + Some( + "workspaces are numbered from 1, and declaring one materialises \ + every workspace below it, so the highest index is capped.", + ), + )); + } + + let mut decl = WorkspaceDecl { + index, + name: None, + tiling: None, + }; + + 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 parameters: name, tiling"), + )); + }; + let arg = arg.trim(); + match key.trim().to_ascii_lowercase().as_str() { + "name" => { + if arg.is_empty() { + return Err(err(span, "`name:` needs a value", None)); + } + decl.name = Some(arg.to_string()); + } + // Named rather than swept into the catch-all so the message can say + // why a parameter Hyprland does have is not accepted here. + "monitor" | "output" => { + return Err(err( + span, + format!("`{}:` has no COSMIC equivalent", key.trim()), + Some(MONITOR_HELP), + )); + } + "tiling" => { + decl.tiling = Some(match arg.to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => true, + "false" | "no" | "off" | "0" => false, + other => { + return Err(err( + span, + format!("`tiling:` expects a boolean, found `{other}`"), + None, + )) + } + }); + } + other => { + return Err(err( + span, + format!("unknown workspace parameter `{other}`"), + Some("known parameters: name, tiling"), + )); + } + } + } + + Ok(decl) +} + +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 +} + +/// FNV-1a, written out rather than reached for. +/// +/// `DefaultHasher` is explicitly documented as not stable across Rust releases, +/// which would make workspace ids change under the user when the toolchain +/// moves -- and the id is what ties a window's saved workspace to the workspace +/// it reappears on. Twelve lines of FNV is cheaper than that class of bug. +fn fnv1a(s: &str) -> u32 { + let mut h: u32 = 0x811c9dc5; + for b in s.as_bytes() { + h ^= *b as u32; + h = h.wrapping_mul(0x01000193); + } + h +} + +/// A stable id for the workspace at `index`. +/// +/// cosmic-comp's own `random_workspace_id` is `format!("{:x}", rand(0..2<<24))`, +/// which is at most seven hex digits. Forcing the high nibble on here makes ours +/// exactly eight, so a generated id and a compositor-generated one cannot +/// collide by construction rather than by being unlikely to. +fn workspace_id(index: u32) -> String { + let h = fnv1a(&format!("hyprcosmic:workspace:{index}")); + format!("{:08x}", 0x1000_0000 | (h & 0x0fff_ffff)) +} + +/// Render the declarations as the RON `Vec` cosmic-comp stores +/// in `pinned_workspaces`. +/// +/// `default_tiling` is the session-wide `general:autotile`, used for any +/// workspace that did not say. Without it, declaring workspaces would silently +/// turn tiling off on all of them for a user who had asked for it globally. +pub fn render(decls: &[WorkspaceDecl], default_tiling: bool) -> String { + let highest = decls.iter().map(|d| d.index).max().unwrap_or(0); + + let mut out = String::from("[\n"); + for index in 1..=highest { + // Gaps are filled rather than skipped: the restore is positional, so an + // absent workspace 3 would put the declared workspace 4 at position 3. + let decl = decls.iter().find(|d| d.index == index); + + let tiling = decl.and_then(|d| d.tiling).unwrap_or(default_tiling); + let name = match decl.and_then(|d| d.name.as_deref()) { + Some(n) => format!("Some({})", ron_string(n)), + None => "None".to_string(), + }; + + // An empty `OutputMatch` is "no preference", not a guess. It is the + // front of the workspace's `output_stack`, which `prefers_output` + // consults when an output appears; a name nothing can match leaves the + // workspace where `add_output` created it. See MONITOR_HELP. + let _ = writeln!( + out, + r#" (output: (name: "", edid: None), tiling_enabled: {tiling}, id: Some({}), name: {name}),"#, + ron_string(&workspace_id(index)), + ); + } + out.push_str("]\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const S: Span = Span { + line: 1, + col: 1, + len: 1, + }; + + fn ws(v: &str) -> WorkspaceDecl { + parse_workspace(v, S).expect("should parse") + } + + fn bad(v: &str) -> WorkspaceError { + parse_workspace(v, S).expect_err("should not parse") + } + + #[test] + fn the_shortest_useful_declaration_is_a_bare_index() { + assert_eq!( + ws("4"), + WorkspaceDecl { + index: 4, + name: None, + tiling: None, + } + ); + } + + #[test] + fn every_parameter_together() { + assert_eq!( + ws("4, name:web, tiling:true"), + WorkspaceDecl { + index: 4, + name: Some("web".into()), + tiling: Some(true), + } + ); + } + + #[test] + fn parameters_are_order_independent_and_tolerate_spacing() { + assert_eq!(ws("2,tiling:off,name:mail"), ws("2, name:mail, tiling:off")); + } + + /// Hyprland's `monitor:` is the parameter a user is most likely to reach + /// for, and the one COSMIC cannot honour. Accepting and ignoring it would + /// be the worst of the three options, so it must fail and say why. + #[test] + fn monitor_is_refused_with_the_reason_rather_than_silently_ignored() { + for spelling in ["monitor", "output"] { + let e = bad(&format!("1, {spelling}:eDP-1")); + assert!( + e.message.contains("no COSMIC equivalent"), + "{spelling}: {}", + e.message + ); + assert!( + e.help.as_deref().unwrap_or_default().contains("EDID"), + "{spelling}: {:?}", + e.help + ); + } + } + + #[test] + fn a_name_may_contain_spaces() { + assert_eq!(ws("1, name:web and mail").name.as_deref(), Some("web and mail")); + } + + /// Hyprland's named-workspace form has no COSMIC equivalent, and the failure + /// if it were guessed at would be workspaces in the wrong order. + #[test] + fn the_hyprland_name_first_form_is_refused_with_the_reason() { + let e = bad("name:web, tiling:true"); + assert!(e.message.contains("starts with its index"), "{}", e.message); + assert!( + e.help.as_deref().unwrap_or_default().contains("by position"), + "{:?}", + e.help + ); + } + + #[test] + fn workspace_zero_does_not_exist() { + let e = bad("0"); + assert!(e.message.contains("outside 1..="), "{}", e.message); + } + + /// The cap is the whole reason this check exists: without it the typo below + /// creates a thousand workspaces rather than reporting anything. + #[test] + fn an_absurd_index_is_a_diagnostic_not_a_thousand_workspaces() { + let e = bad("1000"); + assert!(e.message.contains("outside 1..=32"), "{}", e.message); + } + + #[test] + fn an_unknown_parameter_lists_the_known_ones() { + let e = bad("1, gapsin:0"); + assert!(e.message.contains("gapsin"), "{}", e.message); + assert!( + e.help.as_deref().unwrap_or_default().contains("name, tiling"), + "{:?}", + e.help + ); + } + + #[test] + fn a_parameter_without_a_colon_is_rejected() { + let e = bad("1, web"); + assert!(e.message.contains("key:value"), "{}", e.message); + } + + #[test] + fn tiling_takes_the_same_boolean_spellings_as_the_rest_of_the_file() { + for v in ["true", "yes", "on", "1"] { + assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(true), "{v}"); + } + for v in ["false", "no", "off", "0"] { + assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(false), "{v}"); + } + assert!(bad("1, tiling:sometimes").message.contains("boolean")); + } + + /// The property the positional restore turns on: declaring only 4 must still + /// emit 1, 2 and 3, or the browser workspace comes back as workspace 1. + #[test] + fn declaring_one_high_index_materialises_every_workspace_below_it() { + let ron = render(&[ws("4, name:web")], false); + assert_eq!(ron.lines().filter(|l| l.contains("output:")).count(), 4, "{ron}"); + + let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect(); + assert!(lines[0].contains("name: None"), "{}", lines[0]); + assert!(lines[1].contains("name: None"), "{}", lines[1]); + assert!(lines[2].contains("name: None"), "{}", lines[2]); + assert!(lines[3].contains(r#"name: Some("web")"#), "{}", lines[3]); + } + + #[test] + fn gaps_between_declarations_are_filled_in_order() { + let ron = render(&[ws("3, name:code"), ws("1, name:term")], false); + let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect(); + assert_eq!(lines.len(), 3); + assert!(lines[0].contains(r#"name: Some("term")"#), "{}", lines[0]); + assert!(lines[1].contains("name: None"), "{}", lines[1]); + assert!(lines[2].contains(r#"name: Some("code")"#), "{}", lines[2]); + } + + #[test] + fn no_declarations_render_as_an_empty_list() { + assert_eq!(render(&[], false), "[\n]\n"); + } + + /// A workspace that did not mention tiling must not quietly contradict + /// `general:autotile`. + #[test] + fn unset_tiling_follows_the_session_default_both_ways() { + assert!(render(&[ws("1")], true).contains("tiling_enabled: true")); + assert!(render(&[ws("1")], false).contains("tiling_enabled: false")); + // ...and an explicit value still wins over it. + assert!(render(&[ws("1, tiling:false")], true).contains("tiling_enabled: false")); + } + + /// Not a placeholder: an unmatchable `OutputMatch` is how a workspace says + /// it has no output preference, which is the only thing cosmic.conf can + /// truthfully express. See MONITOR_HELP. + #[test] + fn the_output_match_is_always_empty() { + assert!(render(&[ws("1")], false).contains(r#"output: (name: "", edid: None)"#)); + } + + /// Ids have to survive a re-apply, or every `cosmic-conf apply` would hand + /// the same workspaces new identities. + #[test] + fn ids_are_stable_across_runs_and_unique_per_index() { + assert_eq!(workspace_id(4), workspace_id(4)); + let ids: BTreeSet = (1..=MAX_INDEX).map(workspace_id).collect(); + assert_eq!(ids.len(), MAX_INDEX as usize, "an index collided"); + } + + /// cosmic-comp's `random_workspace_id` is `{:x}` of a number below 2<<24, + /// so it is never more than seven hex digits. Ours are always eight, which + /// is what makes a collision impossible rather than unlikely. + #[test] + fn ids_cannot_collide_with_a_compositor_generated_one() { + for i in 1..=MAX_INDEX { + let id = workspace_id(i); + assert_eq!(id.len(), 8, "{id}"); + assert!( + u32::from_str_radix(&id, 16).unwrap() >= 0x1000_0000, + "{id} is inside the compositor's own range" + ); + } + } + + #[test] + fn a_quote_in_a_name_cannot_break_out_of_the_ron() { + let ron = render(&[ws(r#"1, name:say "hi""#)], false); + assert!(ron.contains(r#"name: Some("say \"hi\"")"#), "{ron}"); + } +}