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
}