diff --git a/docs/wiki/Configuration:-Window-Rules.md b/docs/wiki/Configuration:-Window-Rules.md index 5c7dd28e65..d15c1c88af 100644 --- a/docs/wiki/Configuration:-Window-Rules.md +++ b/docs/wiki/Configuration:-Window-Rules.md @@ -37,6 +37,7 @@ window-rule { match is-window-cast-target=true match is-urgent=true match at-startup=true + match window-label="private" with-value="yes" with-no-value=true // Properties that apply once upon window opening. default-column-width { proportion 0.75; } @@ -177,7 +178,7 @@ You can find the title and the app ID of a window by running `niri msg pick-wind > [!TIP] > Another way to find the window title and app ID is to configure the `wlr/taskbar` module in [Waybar](https://github.com/Alexays/Waybar) to include them in the tooltip: -> +> > ```json > "wlr/taskbar": { > "tooltip-format": "{title} | {app_id}", @@ -318,6 +319,47 @@ window-rule { } ``` +#### `window-label` + +Since: 26.x + +Matches the windows by their assigned label by a regex on the label name. Each window can have multiple labels. Label has a name and optionally also a value. +You can read about the supported regular expression syntax [here](https://docs.rs/regex/latest/regex/#syntax). + +The following properties can further limit the match by examining the value of the label that matches the `window-label`: + +* `with-value` - only matches if the label also has a matching value. This is also a regex. +* `with-no-value` - only matches the label if it has no value. + +```kdl +// Change the border for windows that are labeled "important" +window-rule { + match window-label="important" + + border { + active-color "#ee0000" + inactive-color "#ee5555" + } +} + +binds { + Mod+F5 { toggle-label-on-focused-window "important"; } +} +``` + +```kdl +// Hide the windows labeled nsfw from screen cast +window-rule { + match window-label="role" with-value="nsfw" + + block-out-from "screencast" +} + +binds { + Mod+F12 { set-label-on-focused-window "role" value="nsfw"; } +} +``` + ### Window Opening Properties These properties apply once, when a window first opens. @@ -627,9 +669,10 @@ Can be `normal` or `tabbed`. This is used any time a window goes into its own column. For example: -- Opening a new window. -- Expelling a window into its own column. -- Moving a window from the floating layout to the tiling layout. + +* Opening a new window. +* Expelling a window into its own column. +* Moving a window from the floating layout to the tiling layout. ```kdl // Make Evince windows open as tabbed columns. @@ -905,7 +948,7 @@ window-rule { diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 5b0efb97d2..2a835007a2 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -389,6 +389,18 @@ pub enum Action { MruSetScope(MruScope), #[knuffel(skip)] MruCycleScope, + #[knuffel(skip)] + SetWindowLabel(u64, String, Option), + #[knuffel(skip)] + UnsetWindowLabel(u64, String), + SetLabelOnFocusedWindow( + #[knuffel(argument)] String, + #[knuffel(property(name = "value"))] Option, + ), + UnsetLabelOnFocusedWindow(#[knuffel(argument)] String), + #[knuffel(skip)] + ToggleWindowLabel(u64, String), + ToggleLabelOnFocusedWindow(#[knuffel(argument)] String), } impl From for Action { @@ -700,6 +712,20 @@ impl From for Action { niri_ipc::Action::SetWindowUrgent { id } => Self::SetWindowUrgent(id), niri_ipc::Action::UnsetWindowUrgent { id } => Self::UnsetWindowUrgent(id), niri_ipc::Action::LoadConfigFile { path } => Self::LoadConfigFile(path), + niri_ipc::Action::SetWindowLabel { id, name, value } => { + Self::SetWindowLabel(id, name, value) + } + niri_ipc::Action::UnsetWindowLabel { id, name } => Self::UnsetWindowLabel(id, name), + niri_ipc::Action::SetLabelOnFocusedWindow { name, value } => { + Self::SetLabelOnFocusedWindow(name, value) + } + niri_ipc::Action::UnsetLabelOnFocusedWindow { name } => { + Self::UnsetLabelOnFocusedWindow(name) + } + niri_ipc::Action::ToggleWindowLabel { id, name } => Self::ToggleWindowLabel(id, name), + niri_ipc::Action::ToggleLabelOnFocusedWindow { name } => { + Self::ToggleLabelOnFocusedWindow(name) + } } } } diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index b61fe1c1a0..bb930dccf0 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -1700,6 +1700,9 @@ mod tests { is_window_cast_target: None, is_urgent: None, at_startup: None, + window_label: None, + with_value: None, + with_no_value: None, }, ], excludes: [ @@ -1719,6 +1722,9 @@ mod tests { is_window_cast_target: None, is_urgent: None, at_startup: None, + window_label: None, + with_value: None, + with_no_value: None, }, Match { app_id: None, @@ -1734,6 +1740,9 @@ mod tests { is_window_cast_target: None, is_urgent: None, at_startup: None, + window_label: None, + with_value: None, + with_no_value: None, }, ], default_column_width: None, diff --git a/niri-config/src/window_rule.rs b/niri-config/src/window_rule.rs index 0465d28fad..5910e15f16 100644 --- a/niri-config/src/window_rule.rs +++ b/niri-config/src/window_rule.rs @@ -94,6 +94,12 @@ pub struct Match { pub is_urgent: Option, #[knuffel(property)] pub at_startup: Option, + #[knuffel(property, str)] + pub window_label: Option, + #[knuffel(property, str)] + pub with_value: Option, + #[knuffel(property)] + pub with_no_value: Option, } #[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 508456333d..60bb425edf 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -119,6 +119,11 @@ pub enum Request { OverviewState, /// Request information about screencasts. Casts, + /// Request window labels + WindowLabels { + /// Window id + id: u64, + }, } /// Reply from niri to client. @@ -165,6 +170,8 @@ pub enum Response { OverviewState(Overview), /// Information about screencasts. Casts(Vec), + /// Information about the window labels + WindowLabels(Option>>), } /// Overview information. @@ -943,6 +950,62 @@ pub enum Action { #[cfg_attr(feature = "clap", arg(long))] path: Option, }, + /// Sets a label on a window + SetWindowLabel { + /// Id of the window. + #[cfg_attr(feature = "clap", arg(long))] + id: u64, + + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + + /// the value of the label + #[cfg_attr(feature = "clap", arg(long))] + value: Option, + }, + /// Unsets a label on a window + UnsetWindowLabel { + /// Id of the window. + #[cfg_attr(feature = "clap", arg(long))] + id: u64, + + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + }, + /// Set a label on the currently focused window + SetLabelOnFocusedWindow { + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + + /// the value of the label + #[cfg_attr(feature = "clap", arg(long))] + value: Option, + }, + /// Unsets a label on the currenlty focused window + UnsetLabelOnFocusedWindow { + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + }, + /// Toggles an unvalued label on a window + ToggleWindowLabel { + /// Id of the window. + #[cfg_attr(feature = "clap", arg(long))] + id: u64, + + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + }, + /// Toggles an unvalued label on the focused window + ToggleLabelOnFocusedWindow { + /// the name of the label + #[cfg_attr(feature = "clap", arg(long))] + name: String, + }, } /// Change in window or column size. @@ -1334,6 +1397,9 @@ pub struct Window { /// /// The timestamp comes from the monotonic clock. pub focus_timestamp: Option, + + ///Window labels + pub labels: Option>>, } /// A moment in time. diff --git a/src/cli.rs b/src/cli.rs index 308b3127b7..4dd25ce88a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -109,6 +109,12 @@ pub enum Msg { OverviewState, /// List screencasts. Casts, + /// List window labels + WindowLabels { + /// Window id + #[arg()] + id: u64, + }, } #[derive(Clone, Debug, clap::ValueEnum)] diff --git a/src/input/mod.rs b/src/input/mod.rs index ab31df9403..6ebbd2f523 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -2407,6 +2407,72 @@ impl State { self.niri.queue_redraw_mru_output(); } } + Action::SetWindowLabel(id, name, value) => { + let window = self + .niri + .layout + .workspaces_mut() + .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id)); + if let Some(window) = window { + window.set_label(name, value); + self.niri.queue_redraw_all(); + } + } + Action::UnsetWindowLabel(id, name) => { + let window = self + .niri + .layout + .workspaces_mut() + .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id)); + if let Some(window) = window { + window.unset_label(name); + self.niri.queue_redraw_all(); + } + } + Action::SetLabelOnFocusedWindow(name, value) => { + let window = self + .niri + .layout + .active_workspace_mut() + .and_then(|ws| ws.active_window_mut()); + if let Some(window) = window { + window.set_label(name, value); + self.niri.queue_redraw_all(); + } + } + Action::UnsetLabelOnFocusedWindow(name) => { + let window = self + .niri + .layout + .active_workspace_mut() + .and_then(|ws| ws.active_window_mut()); + if let Some(window) = window { + window.unset_label(name); + self.niri.queue_redraw_all(); + } + } + Action::ToggleWindowLabel(id, name) => { + let window = self + .niri + .layout + .workspaces_mut() + .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id)); + if let Some(window) = window { + window.toggle_label(name); + self.niri.queue_redraw_all(); + } + } + Action::ToggleLabelOnFocusedWindow(name) => { + let window = self + .niri + .layout + .active_workspace_mut() + .and_then(|ws| ws.active_window_mut()); + if let Some(window) = window { + window.toggle_label(name); + self.niri.queue_redraw_all(); + } + } } } diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 3c826bfff1..f32847f130 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -49,6 +49,7 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> { Msg::RequestError => Request::ReturnError, Msg::OverviewState => Request::OverviewState, Msg::Casts => Request::Casts, + Msg::WindowLabels { id } => Request::WindowLabels { id: *id }, }; let mut socket = Socket::connect().context("error connecting to the niri socket")?; @@ -550,6 +551,32 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> { println!(); } } + Msg::WindowLabels { .. } => { + let Response::WindowLabels(labels) = response else { + bail!("unexpected response: expected WindowLabels, got {response:?}"); + }; + + if json { + let labels = serde_json::to_string(&labels).context("error formatting response")?; + println!("{labels}"); + return Ok(()); + } + + if labels.is_none() { + println!("no labels present"); + return Ok(()); + } + + let mut labels: Vec<(String, Option)> = + labels.map(|mut ls| ls.drain().collect()).unwrap_or(vec![]); + labels.sort_by(|a, b| a.0.cmp(&b.0)); + + let empty = "".to_string(); + for (k, v) in labels { + println!("{}: {}", k, v.as_ref().unwrap_or(&empty)); + println!(); + } + } } Ok(()) @@ -736,6 +763,16 @@ fn print_window(window: &Window) { fmt_rounded(window_offset_in_tile.0), fmt_rounded(window_offset_in_tile.1) ); + + if let Some(labels) = &window.labels { + if !labels.is_empty() { + let empty = "".to_string(); + println!(" Labels:"); + for (n, v) in labels { + println!(" {}: {}", n, v.as_ref().unwrap_or(&empty)); + } + } + } } fn print_cast(cast: &Cast) { diff --git a/src/ipc/server.rs b/src/ipc/server.rs index db71da6dbd..d557cfcae8 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -455,6 +455,16 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { let casts = state.casts.casts.values().cloned().collect(); Response::Casts(casts) } + Request::WindowLabels { id } => { + let state = ctx.event_stream_state.borrow(); + Response::WindowLabels( + state + .windows + .windows + .get(&id) + .and_then(|w| w.labels.clone()), + ) + } }; Ok(response) @@ -528,6 +538,7 @@ fn make_ipc_window( is_urgent: mapped.is_urgent(), layout, focus_timestamp: mapped.get_focus_timestamp().map(Timestamp::from), + labels: mapped.labels().cloned(), }) } diff --git a/src/window/mapped.rs b/src/window/mapped.rs index e951e3324d..b74dc6758f 100644 --- a/src/window/mapped.rs +++ b/src/window/mapped.rs @@ -1,4 +1,5 @@ use std::cell::{Cell, Ref, RefCell}; +use std::collections::HashMap; use std::time::Duration; use niri_config::{Color, CornerRadius, GradientInterpolation, WindowRule}; @@ -188,6 +189,9 @@ pub struct Mapped { /// Most recent monotonic time when the window had the focus. focus_timestamp: Option, + + /// The labels assigned to the window + labels: Option>>, } niri_render_elements! { @@ -285,6 +289,7 @@ impl Mapped { is_pending_maximized: false, uncommitted_maximized: Vec::new(), focus_timestamp: None, + labels: None, }; rv.is_maximized = rv.sizing_mode().is_maximized(); @@ -583,6 +588,36 @@ impl Mapped { pub fn is_urgent(&self) -> bool { self.is_urgent } + + pub fn set_label(&mut self, name: String, value: Option) { + let mut labels = self.labels.take().unwrap_or_else(HashMap::new); + let old = labels.insert(name, value.clone()); + + let changed = old.map(|v| v != value).unwrap_or(true); + + self.labels = Some(labels); + self.need_to_recompute_rules |= changed; + } + + pub fn unset_label(&mut self, name: String) { + let old = self.labels.as_mut().and_then(|ls| ls.remove(&name)); + self.need_to_recompute_rules |= old.is_some(); + } + + pub fn toggle_label(&mut self, name: String) { + let mut labels = self.labels.take().unwrap_or_else(HashMap::new); + + if labels.remove(&name).is_none() { + labels.insert(name, None); + } + + self.labels = Some(labels); + self.need_to_recompute_rules = true; + } + + pub fn labels(&self) -> Option<&HashMap>> { + self.labels.as_ref() + } } impl Drop for Mapped { diff --git a/src/window/mod.rs b/src/window/mod.rs index c8c358f156..7ead990d61 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -1,4 +1,5 @@ use std::cmp::{max, min}; +use std::collections::HashMap; use niri_config::utils::MergeWith as _; use niri_config::window_rule::{Match, WindowRule}; @@ -171,6 +172,13 @@ impl<'a> WindowRef<'a> { WindowRef::Mapped(mapped) => mapped.is_window_cast_target(), } } + + pub fn labels(self) -> Option<&'a HashMap>> { + match self { + WindowRef::Unmapped(_) => None, + WindowRef::Mapped(mapped) => mapped.labels(), + } + } } impl ResolvedWindowRules { @@ -433,5 +441,33 @@ fn window_matches(window: WindowRef, role: &XdgToplevelSurfaceRoleAttributes, m: } } + if let Some(window_label_re) = &m.window_label { + let matching_entry_value = window + .labels() + .and_then(|ls| ls.iter().find(|(k, _)| window_label_re.0.is_match(k))) + .map(|(_, v)| v.as_ref()); + + if let Some(value) = matching_entry_value { + // there is a label with the matching key, let's look at the value, if needed + if let Some(value_re) = &m.with_value { + match value { + Some(val) => { + if !value_re.0.is_match(val) { + return false; + } + } + None => return false, + } + } else if let Some(true) = &m.with_no_value { + if value.is_some() { + return false; + } + } + } else { + // there was no label with a matching key + return false; + } + } + true }