mirror of
https://github.com/outbackdingo/hyprcosmic-comp.git
synced 2026-08-25 07:10:25 +00:00
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.
This commit is contained in:
@@ -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<workspace::PinnedWorkspace>,
|
||||
/// 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<window_rule::WindowRule>,
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<String>, state: &mut
|
||||
);
|
||||
}
|
||||
}
|
||||
"window_rules" => {
|
||||
let new = get_config::<Vec<WindowRule>>(&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::<bool>(&config, "preserve_split");
|
||||
if new != state.common.config.cosmic_conf.preserve_split {
|
||||
|
||||
+63
-2
@@ -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<ZoomState>,
|
||||
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<WorkspaceHandle> {
|
||||
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<Item = &'a cosmic_comp_config::window_rule::WindowRule>,
|
||||
{
|
||||
self.window_rules = window_rule::WindowRules::new(rules);
|
||||
}
|
||||
|
||||
pub fn update_tiling_exceptions<'a, I>(&mut self, exceptions: I)
|
||||
where
|
||||
I: Iterator<Item = &'a ApplicationException>,
|
||||
|
||||
@@ -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<WorkspaceTarget>,
|
||||
}
|
||||
|
||||
impl WindowRules {
|
||||
pub fn new<'a, I>(rules: I) -> Self
|
||||
where
|
||||
I: Iterator<Item = &'a WindowRule>,
|
||||
{
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user