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