From a73734edb02f1946d8e8243b242d2b4a3a5fccd9 Mon Sep 17 00:00:00 2001 From: dingo Date: Tue, 11 Aug 2026 08:21:36 +0700 Subject: [PATCH] shell: place a window from its app id, not from where you were standing Hyprland's `windowrule = workspace 4, class:^(vivaldi)$` has no COSMIC counterpart, and the missing piece is small: map_window already accepts a workspace other than the active one, because activation tokens use that path. This adds a second source for the same decision. `window_rules` is a new CosmicCompConfig key rather than an addition to com.system76.CosmicSettings.WindowRules. That component belongs to cosmic-settings-daemon and holds one kind of rule -- tiling exceptions, `{ appid, title }` with no action, because the action is implied. There is nowhere in it to say *which workspace*, and putting one there would mean forking a second upstream crate to add a field cosmic-settings' own UI could not show. `preserve_split` set the precedent for keys the fork adds. The matching half is deliberately the same shape as the exceptions: two RegexSets, app ids and titles, a rule matching when the same index matches in both. A regex that works in a tiling exception works here. The third vector is what an exception list does not need -- exceptions answer yes or no, a rule has to say where -- and it is index-aligned with the two sets, which is why an invalid expression is skipped from all three together. Precedence An activation token wins. It is an application saying where it wants this window right now -- a file manager reopening a document where you launched it from -- and that outranks a rule written months ago. So the rules are consulted only when nothing else claimed a workspace. `was_activated` stays keyed to the token rather than to the resulting handle. It raises the urgency hint on the target workspace, and a window that went exactly where the config said is not worth flagging; one that turned up somewhere unexpected is. Without splitting the two apart, every rule-placed window would have made its workspace blink. That also means a rule never switches you to the workspace it used, which is Hyprland's `silent` and is now unconditional. Missing targets miss Workspaces are resolved per output, because a WorkspaceSet is, so "workspace 4" is the fourth workspace of the screen the window would have opened on. A rule naming a workspace that does not exist returns None and the window opens where it would have anyway: `workspace 4` with three workspaces must not conjure a fourth, and a name only exists because a `workspace` line created it. Live The key has an arm in the config watcher, so an edit applies to the next window that opens. Nothing moves -- windows already open stay put, since the rules are only read at map time. That is the opposite of pinned_workspaces, which is read once at startup and lands at next login. Not verified: nothing was compiled here. This half is built by packages.yml; the cosmic-conf job covers the parser that writes the key. --- cosmic-comp-config/src/lib.rs | 7 +++ cosmic-comp-config/src/window_rule.rs | 46 ++++++++++++++ src/config/mod.rs | 15 +++++ src/shell/mod.rs | 65 +++++++++++++++++++- src/shell/window_rule.rs | 88 +++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 cosmic-comp-config/src/window_rule.rs create mode 100644 src/shell/window_rule.rs diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index 59ba0eb..79047e9 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -9,6 +9,7 @@ use crate::input::TouchpadOverride; pub mod input; #[cfg(feature = "output")] pub mod output; +pub mod window_rule; pub mod workspace; #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -71,6 +72,11 @@ impl Default for AppearanceConfig { pub struct CosmicCompConfig { pub workspaces: workspace::WorkspaceConfig, pub pinned_workspaces: Vec, + /// Where a window opens, decided from its app id and title. + /// + /// Not upstream: this is a field the fork adds, for Hyprland's + /// `windowrule = workspace 4, class:...`. See `window_rule`. + pub window_rules: Vec, pub input_default: input::InputConfig, pub input_touchpad: input::InputConfig, pub input_touchpad_override: TouchpadOverride, @@ -122,6 +128,7 @@ impl Default for CosmicCompConfig { Self { workspaces: Default::default(), pinned_workspaces: Vec::new(), + window_rules: Vec::new(), input_default: Default::default(), // By default, enable tap-to-click and disable-while-typing. input_touchpad: input::InputConfig { diff --git a/cosmic-comp-config/src/window_rule.rs b/cosmic-comp-config/src/window_rule.rs new file mode 100644 index 0000000..98ef08b --- /dev/null +++ b/cosmic-comp-config/src/window_rule.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Rules that decide where a window opens, from its app id and title. +//! +//! COSMIC already has a `com.system76.CosmicSettings.WindowRules` component, +//! but it belongs to cosmic-settings-daemon and holds exactly one kind of rule: +//! `tiling_exception_defaults` / `tiling_exception_custom`, both lists of +//! `{ appid, title }` with no action attached, because the action is implied. +//! There is nowhere in it to say *which workspace*, and extending it would mean +//! forking a second upstream crate to add a field cosmic-settings' own UI would +//! not know how to show. +//! +//! So this lives on `CosmicCompConfig` instead, next to the other keys the fork +//! adds. The matching half is deliberately identical to the exceptions: `appid` +//! and `title` are regular expressions, matched with the same `RegexSet` the +//! tiling exceptions use, so one mental model covers both. + +use serde::{Deserialize, Serialize}; + +/// Which workspace a rule sends its window to. +/// +/// A name is not a nicety here. Workspace numbers shift the moment an empty +/// workspace is collected, so a rule written against a number is a rule that +/// can quietly start pointing somewhere else; a name pins it to the workspace +/// the user actually meant. Both are accepted because Hyprland's own +/// `windowrule = workspace 4` is written with a number. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WorkspaceTarget { + /// 1-based, as the user counts them. + Index(u32), + /// Matched against `Workspace::name`, which is what `pinned_workspaces` + /// sets and what waybar shows. + Name(String), +} + +/// One rule: match a window, then place it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowRule { + /// Regular expression matched against the window's app id. + pub app_id: String, + /// Regular expression matched against the window's title. An empty + /// expression matches everything, which is what "no title condition" means. + pub title: String, + /// Where a matching window opens. + pub workspace: WorkspaceTarget, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 86344b1..848c4c2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -51,6 +51,7 @@ use cosmic_comp_config::{ output::comp::{ OutputConfig, OutputInfo, OutputState, OutputsConfig, TransformDef, load_outputs, }, + window_rule::WindowRule, workspace::WorkspaceConfig, }; pub use key_bindings::{Action, PrivateAction}; @@ -883,6 +884,20 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut ); } } + "window_rules" => { + let new = get_config::>(&config, "window_rules"); + if new != state.common.config.cosmic_conf.window_rules { + state.common.config.cosmic_conf.window_rules = new; + // Only consulted when a window is mapped, so there is + // nothing to move: windows already open stay where they + // are, and the next one to open obeys the new rules. + state + .common + .shell + .write() + .update_window_rules(state.common.config.cosmic_conf.window_rules.iter()); + } + } "preserve_split" => { let new = get_config::(&config, "preserve_split"); if new != state.common.config.cosmic_conf.preserve_split { diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 64c7681..e63621e 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -24,6 +24,7 @@ use crate::{ }; use cosmic_comp_config::{ AppearanceConfig, TileBehavior, ZoomConfig, ZoomMovement, + window_rule::WorkspaceTarget, workspace::{PinnedWorkspace, WorkspaceLayout, WorkspaceMode}, }; use cosmic_config::ConfigSet; @@ -94,6 +95,7 @@ pub mod focus; pub mod grabs; pub mod layout; mod seats; +pub mod window_rule; mod workspace; pub mod zoom; pub use self::element::{CosmicMapped, CosmicMappedRenderElement, CosmicSurface}; @@ -296,6 +298,7 @@ pub struct Shell { zoom_state: Option, appearance_conf: AppearanceConfig, tiling_exceptions: TilingExceptions, + window_rules: window_rule::WindowRules, #[cfg(feature = "debug")] pub debug_active: bool, @@ -1706,6 +1709,7 @@ impl Shell { let theme = cosmic::theme::system_preference(); let tiling_exceptions = layout::TilingExceptions::new(config.tiling_exceptions.iter()); + let window_rules = window_rule::WindowRules::new(config.cosmic_conf.window_rules.iter()); Shell { workspaces: Workspaces::new(config, theme.clone()), @@ -1729,6 +1733,7 @@ impl Shell { appearance_conf: config.cosmic_conf.appearance_settings, zoom_state: None, tiling_exceptions, + window_rules, #[cfg(feature = "debug")] debug_active: false, @@ -2861,7 +2866,7 @@ impl Shell { }; let pending_activation = self.pending_activations.remove(&(&window).into()); - let workspace_handle = match pending_activation { + let mut workspace_handle = match pending_activation { Some(ActivationContext::Workspace(handle)) => Some(handle), _ => None, }; @@ -2869,6 +2874,20 @@ impl Shell { let should_be_fullscreen = output.is_some(); let mut output = output.unwrap_or_else(|| seat.active_output()); + // Only when nothing else already claimed a workspace. An activation + // token is an application saying where it wants this window -- a file + // manager opening a document back where you launched it from -- and + // that is a live request, so it outranks a rule written months ago. + // + // `was_activated` below stays keyed to the token rather than to + // `workspace_handle`, so a rule places its window without raising the + // urgency hint. A window that appeared somewhere unexpected is worth + // flagging; one that went exactly where the config said is not. + let was_requested = workspace_handle.is_some(); + if workspace_handle.is_none() { + workspace_handle = self.workspace_for_window_rules(&window, &output); + } + // this is beyond stupid, just to make the borrow checker happy let workspace = if let Some(handle) = workspace_handle.filter(|handle| { self.workspaces @@ -2907,7 +2926,7 @@ impl Shell { let mut workspace_state = workspace_state.update(); let workspace_output = workspace.output.clone(); - let was_activated = workspace_handle.is_some() + let was_activated = was_requested && (workspace_output != seat.active_output() || active_handle != workspace.handle); let workspace_handle = workspace.handle; let is_dialog = layout::is_dialog(&window); @@ -5025,6 +5044,48 @@ impl Shell { &self.theme } + /// Where the window rules say this window belongs, if they say anything. + /// + /// Resolved against `output` rather than globally because a `WorkspaceSet` + /// is per output, so "workspace 4" means the fourth workspace of the screen + /// the window would otherwise have opened on. + /// + /// A rule naming a workspace that does not exist resolves to `None` and the + /// window opens where it would have anyway. That is the useful failure: + /// `workspace 4` with three workspaces should not conjure a fourth, and a + /// name only exists if `pinned_workspaces` created it. + fn workspace_for_window_rules( + &self, + window: &CosmicSurface, + output: &Output, + ) -> Option { + if self.window_rules.is_empty() { + return None; + } + + let target = self.window_rules.workspace_for(window)?; + let workspaces = &self.workspaces.sets.get(output)?.workspaces; + + let workspace = match target { + // 1-based for the user, 0-based in the Vec. `checked_sub` rather + // than `- 1` because a rule saying `workspace 0` must miss rather + // than underflow into the last workspace. + WorkspaceTarget::Index(n) => workspaces.get((*n as usize).checked_sub(1)?)?, + WorkspaceTarget::Name(name) => workspaces + .iter() + .find(|w| w.name.as_deref() == Some(name.as_str()))?, + }; + + Some(workspace.handle) + } + + pub fn update_window_rules<'a, I>(&mut self, rules: I) + where + I: Iterator, + { + self.window_rules = window_rule::WindowRules::new(rules); + } + pub fn update_tiling_exceptions<'a, I>(&mut self, exceptions: I) where I: Iterator, diff --git a/src/shell/window_rule.rs b/src/shell/window_rule.rs new file mode 100644 index 0000000..7e074e0 --- /dev/null +++ b/src/shell/window_rule.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Compiled form of `CosmicCompConfig::window_rules`. +//! +//! Deliberately built the same way `layout::TilingExceptions` is: two +//! `RegexSet`s, one for app ids and one for titles, with a rule matching only +//! when the same index matches in both. Sharing the shape means a regex that +//! works in a tiling exception works here, and the compiler is invoked once at +//! config load rather than once per window. +//! +//! The third vector is what makes this more than an exception list: exceptions +//! only answer yes or no, so they need no payload, while a rule has to say +//! *where*. `targets` is index-aligned with the two sets, which is why an +//! invalid expression has to be skipped from all three together rather than +//! from one. + +use cosmic_comp_config::window_rule::{WindowRule, WorkspaceTarget}; +use regex::{Regex, RegexSet}; +use tracing::warn; + +use super::CosmicSurface; + +#[derive(Debug, Clone, Default)] +pub struct WindowRules { + app_ids: RegexSet, + titles: RegexSet, + targets: Vec, +} + +impl WindowRules { + pub fn new<'a, I>(rules: I) -> Self + where + I: Iterator, + { + let mut app_ids = Vec::new(); + let mut titles = Vec::new(); + let mut targets = Vec::new(); + + for rule in rules { + // A bad expression drops its own rule and nothing else. Refusing + // the whole list would let one typo silently unplace every window, + // which is far harder to notice than one rule that stopped working. + if let Err(e) = Regex::new(&rule.app_id) { + warn!("Invalid regex for window rule app_id: {}, {}", rule.app_id, e); + continue; + } + if let Err(e) = Regex::new(&rule.title) { + warn!("Invalid regex for window rule title: {}, {}", rule.title, e); + continue; + } + + app_ids.push(rule.app_id.clone()); + titles.push(rule.title.clone()); + targets.push(rule.workspace.clone()); + } + + Self { + // Both were checked one at a time above, so the set cannot fail to + // compile -- same reasoning as TilingExceptions::new. + app_ids: RegexSet::new(app_ids).unwrap(), + titles: RegexSet::new(titles).unwrap(), + targets, + } + } + + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } + + /// The first rule that matches, or `None`. + /// + /// First rather than last: rules are in the order they were written, and a + /// config file reads top to bottom, so the earlier line is the one a + /// person expects to win when two of them overlap. + pub fn workspace_for(&self, window: &CosmicSurface) -> Option<&WorkspaceTarget> { + if self.targets.is_empty() { + return None; + } + + let app_id_matches = self.app_ids.matches(&window.app_id()); + let title_matches = self.titles.matches(&window.title()); + + app_id_matches + .into_iter() + .find(|idx| title_matches.matched(*idx)) + .and_then(|idx| self.targets.get(idx)) + } +}