mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
cosmic-conf: a fixed set of workspaces, with no compositor change
COSMIC's workspaces are dynamic and there is no setting that turns that
off. ensure_last_empty 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.
The primitive that fixes it already exists. can_auto_remove is
is_empty() && !has_activation_token() && !pinned
and pinned_workspaces is an existing persisted CosmicCompConfig key that
Workspaces::add_output drains into the first output to appear. So this is
a projection and nothing else: no cosmic-comp patch, no new config key,
and the fast cosmic-conf CI job covers all of it rather than a 2.5 hour
package build per distro.
workspace = 1, name:term
workspace = 2, name:web
workspace = 3, name:code
Three things about the restore path shaped the module.
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 emits four, with 1..3 unnamed, or the browser
workspace comes back as workspace 1. That is why there is a cap: without
one, `workspace = 1000` is a typo that silently creates a thousand
workspaces rather than a diagnostic.
The dynamic workspace survives. Pinned workspaces are pushed into an
empty WorkspaceSet and ensure_last_empty then appends the usual empty
one, so declaring four gives 1-4 always present and a fifth appearing
when you use it. That is Hyprland's behaviour and not a compromise
either way.
It lands at the next login. Workspaces::new reads the key once at
compositor start and there is no reload path for it, while cosmic-conf
watch is started from the autostart file after COSMIC's own components.
Every other key in this file is live, so the one that is not is worth
saying out loud in cosmic.conf rather than leaving to be discovered.
Ids are generated rather than random
random_workspace_id is format!("{:x}", rand(0..2<<24)), at most seven hex
digits. Ours are FNV-1a of the index with the high nibble forced on, so
always eight -- a collision with a compositor-generated id is impossible
by construction rather than unlikely. FNV is written out rather than
reaching for DefaultHasher, which is explicitly not stable across Rust
releases: the id is what ties a window's saved workspace to the
workspace it reappears on, so it changing under a toolchain bump is a
bug that would be very hard to attribute.
monitor: is refused, not ignored
Hyprland's monitor: is the parameter a user reaches for first and the one
COSMIC cannot honour. output_matches compares the EDID before the name,
so an OutputMatch 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 an EDID -- it is
a manufacturer triple, product id, serial and manufacture date read off
the wire by the DRM backend.
So the parameter fails with that explanation, the same way follow_mouse =
2 does. Accepting it and quietly doing nothing was the third option and
the worst of the three. The emitted OutputMatch is empty, which is not a
placeholder: an unmatchable match is how a workspace says it has no
output preference, which is the only thing this file can truthfully say.
tiling: defaults to general:autotile
A PinnedWorkspace carries its own tiling_enabled. Defaulting it to false
would have meant that turning workspaces on quietly turned tiling off for
someone whose whole reason for editing the file was autotile = true, so
resolve reads the folded autotile write and passes it through.
CI
The assets job syntax-checked two of the four shell scripts;
hyprcosmic-fan and hyprcosmic-keybinds were added in fc8c2a7 and never
registered. Both are now checked and shellchecked, and each is checked
with the shell its shebang names -- hyprcosmic-fan is POSIX sh on
purpose, and `bash -n` would accept a bashism that fails where /bin/sh is
dash. All four are clean today, so the list stays a ratchet.
Verified
The RON shape was taken from the struct definitions rather than guessed:
PinnedWorkspace has no serde defaults, so all four fields are emitted,
and the (field: value) / None / Some(x) spelling matches what COSMIC
itself wrote to this machine's xkb_config. 19 unit tests in the new
module and 6 in resolve cover the gap-filling, the id range, the
inherited tiling default, the duplicate-index diagnostic, and that a
quote in a name cannot break out of the RON.
Not verified: nothing was compiled here. cargo test, clippy -D warnings
and the release build run in the cosmic-conf job.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+145
-3
@@ -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<Resolved, Vec<Diagnostic>> {
|
||||
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = 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.
|
||||
// `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<Resolved, Vec<Diagnostic>> {
|
||||
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<Write> = whole
|
||||
.into_iter()
|
||||
.map(|(target, (v, _))| Write {
|
||||
@@ -367,6 +411,25 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
||||
});
|
||||
}
|
||||
|
||||
if !workspaces.is_empty() {
|
||||
let rendered = workspace::render(
|
||||
&workspaces.iter().map(|(w, _)| w.clone()).collect::<Vec<_>>(),
|
||||
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()
|
||||
|
||||
@@ -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<String>,
|
||||
/// Per-workspace tiling. `None` inherits the session default.
|
||||
pub tiling: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceError {
|
||||
pub message: String,
|
||||
pub help: Option<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
fn err(span: Span, message: impl Into<String>, 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<WorkspaceDecl, WorkspaceError> {
|
||||
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::<u32>().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<PinnedWorkspace>` 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<String> = (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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user