Repository: YaLTeR/niri
Pull request #1319: window urgency
Author: login: Duncaen
Created: 2025-03-21T17:19:54Z | Merged: 2025-05-10T19:14:41Z

quick draft of urgency support through serial-less xdg-activation. This PR adds an urgency boolean to windows and adds methods to set and get the urgency through the `LayoutElement` trait.
The next commit adds IPC events that indicate workspace urgency based on whether a window on the workspace has urgency, not sure if the IPC should also emit window events for that.

yambar patch:
```patch
From 90871daaac49fd87c29445b072ab7fc85d924eec Mon Sep 17 00:00:00 2001
From: Duncan Overbruck <mail@duncano.de>
Date: Fri, 21 Mar 2025 18:12:18 +0100
Subject: [PATCH] add urgency tag to niri-workspaces

---
 modules/niri-common.c     | 23 +++++++++++++++++++++++
 modules/niri-common.h     |  2 ++
 modules/niri-workspaces.c |  5 +++--
 3 files changed, 28 insertions(+), 2 deletions(-)

diff --git a/modules/niri-common.c b/modules/niri-common.c
index ac53921..f380d85 100644
--- a/modules/niri-common.c
+++ b/modules/niri-common.c
@@ -12,6 +12,7 @@
 #include <unistd.h>
 
 #include "../log.h"
+#include "json_object.h"
 #include "niri-common.h"
 
 #define LOG_MODULE "niri:common"
@@ -82,6 +83,7 @@ parser(char *response)
                 ws->active = json_object_get_boolean(json_object_object_get(ws_obj, "is_active"));
                 ws->focused = json_object_get_boolean(json_object_object_get(ws_obj, "is_focused"));
                 ws->empty = json_object_get_int(json_object_object_get(ws_obj, "active_window_id")) == 0;
+                ws->urgent = json_object_get_boolean(json_object_object_get(ws_obj, "is_urgent"));
 
                 char const *name = json_object_get_string(json_object_object_get(ws_obj, "name"));
                 if (name)
@@ -185,6 +187,27 @@ parser(char *response)
             events |= keyboard_layouts_switched;
         }
 
+        // "WorkspaceUrgencyChanged": {
+        //   "id": 1,
+        //   "urgent": true
+        // }
+        else if (strcmp(key, "WorkspaceUrgencyChanged") == 0) {
+            json_object *obj = json_object_iter_peek_value(&it);
+            int id = json_object_get_int(json_object_object_get(obj, "id"));
+            bool urgent = json_object_get_boolean(json_object_object_get(obj, "urgent"));
+
+            tll_foreach(instance.workspaces, it)
+            {
+                if (it->item->id == id) {
+                    it->item->urgent = urgent;
+                    break;
+                }
+            }
+            mtx_unlock(&instance.mtx);
+
+            events |= workspace_urgency_changed;
+        }
+
         json_object_iter_next(&it);
     }
 
diff --git a/modules/niri-common.h b/modules/niri-common.h
index 18afe38..8aa2f39 100644
--- a/modules/niri-common.h
+++ b/modules/niri-common.h
@@ -10,6 +10,7 @@ enum niri_event {
     workspace_active_window_changed = (1 << 2),
     keyboard_layouts_changed = (1 << 3),
     keyboard_layouts_switched = (1 << 4),
+    workspace_urgency_changed = (1 << 5),
 };
 
 struct niri_subscriber {
@@ -24,6 +25,7 @@ struct niri_workspace {
     bool active;
     bool focused;
     bool empty;
+    bool urgent;
 };
 
 struct niri_socket {
diff --git a/modules/niri-workspaces.c b/modules/niri-workspaces.c
index bca0150..99a8c83 100644
--- a/modules/niri-workspaces.c
+++ b/modules/niri-workspaces.c
@@ -56,8 +56,9 @@ content(struct module *module)
                 tag_new_bool(module, "active", it->item->active),
                 tag_new_bool(module, "focused", it->item->focused),
                 tag_new_bool(module, "empty", it->item->empty),
+                tag_new_bool(module, "urgent", it->item->urgent),
             },
-            .count = 5,
+            .count = 6,
         };
 
         exposable[i++] = private->label->instantiate(private->label, &tags);
@@ -86,7 +87,7 @@ run(struct module *module)
     if (private->niri == NULL)
         return 1;
 
-    int fd = niri_socket_subscribe(workspaces_changed | workspace_activated | workspace_active_window_changed);
+    int fd = niri_socket_subscribe(workspaces_changed | workspace_activated | workspace_active_window_changed | workspace_urgency_changed);
     if (fd == -1) {
         niri_socket_close();
         return 1;
-- 
2.49.0
```

And a yambar configuration example:
```yaml
  left:
    - niri-workspaces:
        content:
            map:
              default: {string: {text: "{id}", margin: 5}}
              conditions:
                urgent: {string: {text: "{id}", deco: {background: {color: af3029ff } }, margin: 5}}
                active: {string: {text: "{id}", deco: {background: {color: 383838ff } }, margin: 5}}
```


Diff:
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index 257c78edc9..b63cdd03a9 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -583,10 +583,14 @@ pub struct FocusRing {
     pub active_color: Color,
     #[knuffel(child, default = Self::default().inactive_color)]
     pub inactive_color: Color,
+    #[knuffel(child, default = Self::default().urgent_color)]
+    pub urgent_color: Color,
     #[knuffel(child)]
     pub active_gradient: Option<Gradient>,
     #[knuffel(child)]
     pub inactive_gradient: Option<Gradient>,
+    #[knuffel(child)]
+    pub urgent_gradient: Option<Gradient>,
 }
 
 impl Default for FocusRing {
@@ -596,8 +600,10 @@ impl Default for FocusRing {
             width: FloatOrInt(4.),
             active_color: Color::from_rgba8_unpremul(127, 200, 255, 255),
             inactive_color: Color::from_rgba8_unpremul(80, 80, 80, 255),
+            urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
             active_gradient: None,
             inactive_gradient: None,
+            urgent_gradient: None,
         }
     }
 }
@@ -669,10 +675,14 @@ pub struct Border {
     pub active_color: Color,
     #[knuffel(child, default = Self::default().inactive_color)]
     pub inactive_color: Color,
+    #[knuffel(child, default = Self::default().urgent_color)]
+    pub urgent_color: Color,
     #[knuffel(child)]
     pub active_gradient: Option<Gradient>,
     #[knuffel(child)]
     pub inactive_gradient: Option<Gradient>,
+    #[knuffel(child)]
+    pub urgent_gradient: Option<Gradient>,
 }
 
 impl Default for Border {
@@ -682,8 +692,10 @@ impl Default for Border {
             width: FloatOrInt(4.),
             active_color: Color::from_rgba8_unpremul(255, 200, 127, 255),
             inactive_color: Color::from_rgba8_unpremul(80, 80, 80, 255),
+            urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
             active_gradient: None,
             inactive_gradient: None,
+            urgent_gradient: None,
         }
     }
 }
@@ -695,8 +707,10 @@ impl From<Border> for FocusRing {
             width: value.width,
             active_color: value.active_color,
             inactive_color: value.inactive_color,
+            urgent_color: value.urgent_color,
             active_gradient: value.active_gradient,
             inactive_gradient: value.inactive_gradient,
+            urgent_gradient: value.urgent_gradient,
         }
     }
 }
@@ -708,8 +722,10 @@ impl From<FocusRing> for Border {
             width: value.width,
             active_color: value.active_color,
             inactive_color: value.inactive_color,
+            urgent_color: value.urgent_color,
             active_gradient: value.active_gradient,
             inactive_gradient: value.inactive_gradient,
+            urgent_gradient: value.urgent_gradient,
         }
     }
 }
@@ -1487,9 +1503,13 @@ pub struct BorderRule {
     #[knuffel(child)]
     pub inactive_color: Option<Color>,
     #[knuffel(child)]
+    pub urgent_color: Option<Color>,
+    #[knuffel(child)]
     pub active_gradient: Option<Gradient>,
     #[knuffel(child)]
     pub inactive_gradient: Option<Gradient>,
+    #[knuffel(child)]
+    pub urgent_gradient: Option<Gradient>,
 }
 
 #[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)]
@@ -1854,6 +1874,12 @@ pub enum Action {
     ToggleOverview,
     OpenOverview,
     CloseOverview,
+    #[knuffel(skip)]
+    ToggleUrgent(u64),
+    #[knuffel(skip)]
+    SetUrgent(u64),
+    #[knuffel(skip)]
+    UnsetUrgent(u64),
 }
 
 impl From<niri_ipc::Action> for Action {
@@ -2125,6 +2151,9 @@ impl From<niri_ipc::Action> for Action {
             niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview,
             niri_ipc::Action::OpenOverview {} => Self::OpenOverview,
             niri_ipc::Action::CloseOverview {} => Self::CloseOverview,
+            niri_ipc::Action::ToggleUrgent { id } => Self::ToggleUrgent(id),
+            niri_ipc::Action::SetUrgent { id } => Self::SetUrgent(id),
+            niri_ipc::Action::UnsetUrgent { id } => Self::UnsetUrgent(id),
         }
     }
 }
@@ -2353,12 +2382,18 @@ impl BorderRule {
         if let Some(x) = other.inactive_color {
             self.inactive_color = Some(x);
         }
+        if let Some(x) = other.urgent_color {
+            self.urgent_color = Some(x);
+        }
         if let Some(x) = other.active_gradient {
             self.active_gradient = Some(x);
         }
         if let Some(x) = other.inactive_gradient {
             self.inactive_gradient = Some(x);
         }
+        if let Some(x) = other.urgent_gradient {
+            self.urgent_gradient = Some(x);
+        }
     }
 
     pub fn resolve_against(&self, mut config: Border) -> Border {
@@ -2378,12 +2413,19 @@ impl BorderRule {
             config.inactive_color = x;
             config.inactive_gradient = None;
         }
+        if let Some(x) = self.urgent_color {
+            config.urgent_color = x;
+            config.urgent_gradient = None;
+        }
         if let Some(x) = self.active_gradient {
             config.active_gradient = Some(x);
         }
         if let Some(x) = self.inactive_gradient {
             config.inactive_gradient = Some(x);
         }
+        if let Some(x) = self.urgent_gradient {
+            config.urgent_gradient = Some(x);
+        }
 
         config
     }
@@ -4321,6 +4363,12 @@ mod tests {
                         b: 0.39215687,
                         a: 0.0,
                     },
+                    urgent_color: Color {
+                        r: 0.60784316,
+                        g: 0.0,
+                        b: 0.0,
+                        a: 1.0,
+                    },
                     active_gradient: Some(
                         Gradient {
                             from: Color {
@@ -4344,6 +4392,7 @@ mod tests {
                         },
                     ),
                     inactive_gradient: None,
+                    urgent_gradient: None,
                 },
                 border: Border {
                     off: false,
@@ -4362,8 +4411,15 @@ mod tests {
                         b: 0.39215687,
                         a: 0.0,
                     },
+                    urgent_color: Color {
+                        r: 0.60784316,
+                        g: 0.0,
+                        b: 0.0,
+                        a: 1.0,
+                    },
                     active_gradient: None,
                     inactive_gradient: None,
+                    urgent_gradient: None,
                 },
                 shadow: Shadow {
                     on: false,
@@ -4805,8 +4861,10 @@ mod tests {
                         ),
                         active_color: None,
                         inactive_color: None,
+                        urgent_color: None,
                         active_gradient: None,
                         inactive_gradient: None,
+                        urgent_gradient: None,
                     },
                     border: BorderRule {
                         off: false,
@@ -4818,8 +4876,10 @@ mod tests {
                         ),
                         active_color: None,
                         inactive_color: None,
+                        urgent_color: None,
                         active_gradient: None,
                         inactive_gradient: None,
+                        urgent_gradient: None,
                     },
                     shadow: ShadowRule {
                         off: false,
@@ -5552,8 +5612,10 @@ mod tests {
                 width: None,
                 active_color: None,
                 inactive_color: None,
+                urgent_color: None,
                 active_gradient: None,
                 inactive_gradient: None,
+                urgent_gradient: None,
             };
 
             for rule in rules.iter().copied() {
diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs
index 75d9a5435f..6d7ea92d39 100644
--- a/niri-ipc/src/lib.rs
+++ b/niri-ipc/src/lib.rs
@@ -803,6 +803,24 @@ pub enum Action {
     OpenOverview {},
     /// Close the Overview.
     CloseOverview {},
+    /// Toggle urgent status of a window.
+    ToggleUrgent {
+        /// Id of the window to toggle urgent.
+        #[cfg_attr(feature = "clap", arg(long))]
+        id: u64,
+    },
+    /// Set urgent status of a window.
+    SetUrgent {
+        /// Id of the window to set urgent.
+        #[cfg_attr(feature = "clap", arg(long))]
+        id: u64,
+    },
+    /// Unset urgent status of a window.
+    UnsetUrgent {
+        /// Id of the window to unset urgent.
+        #[cfg_attr(feature = "clap", arg(long))]
+        id: u64,
+    },
 }
 
 /// Change in window or column size.
@@ -1111,6 +1129,8 @@ pub struct Window {
     ///
     /// If the window isn't floating then it is in the tiling layout.
     pub is_floating: bool,
+    /// Whether this window requests your attention.
+    pub is_urgent: bool,
 }
 
 /// Output configuration change result.
@@ -1150,6 +1170,8 @@ pub struct Workspace {
     ///
     /// Can be `None` if no outputs are currently connected.
     pub output: Option<String>,
+    /// Whether the workspace currently has an urgent window in its output.
+    pub is_urgent: bool,
     /// Whether the workspace is currently active on its output.
     ///
     /// Every output has one active workspace, the one that is currently visible on that output.
@@ -1224,6 +1246,13 @@ pub enum Event {
         /// workspaces are missing from here, then they were deleted.
         workspaces: Vec<Workspace>,
     },
+    /// The workspace urgency changed.
+    WorkspaceUrgencyChanged {
+        /// Id of the workspace.
+        id: u64,
+        /// Whether this workspace has an urgent window.
+        urgent: bool,
+    },
     /// A workspace was activated on an output.
     ///
     /// This doesn't always mean the workspace became focused, just that it's now the active
@@ -1271,6 +1300,13 @@ pub enum Event {
         /// Id of the newly focused window, or `None` if no window is now focused.
         id: Option<u64>,
     },
+    /// Window urgency changed.
+    WindowUrgencyChanged {
+        /// Id of the window.
+        id: u64,
+        /// The new urgency state of the window.
+        urgent: bool,
+    },
     /// The configured keyboard layouts have changed.
     KeyboardLayoutsChanged {
         /// The new keyboard layout configuration.
diff --git a/niri-ipc/src/state.rs b/niri-ipc/src/state.rs
index 0689104df4..ef03321f2f 100644
--- a/niri-ipc/src/state.rs
+++ b/niri-ipc/src/state.rs
@@ -103,6 +103,13 @@ impl EventStreamStatePart for WorkspacesState {
             Event::WorkspacesChanged { workspaces } => {
                 self.workspaces = workspaces.into_iter().map(|ws| (ws.id, ws)).collect();
             }
+            Event::WorkspaceUrgencyChanged { id, urgent } => {
+                for ws in self.workspaces.values_mut() {
+                    if ws.id == id {
+                        ws.is_urgent = urgent;
+                    }
+                }
+            }
             Event::WorkspaceActivated { id, focused } => {
                 let ws = self.workspaces.get(&id);
                 let ws = ws.expect("activated workspace was missing from the map");
@@ -174,6 +181,14 @@ impl EventStreamStatePart for WindowsState {
                     win.is_focused = Some(win.id) == id;
                 }
             }
+            Event::WindowUrgencyChanged { id, urgent } => {
+                for win in self.windows.values_mut() {
+                    if win.id == id {
+                        win.is_urgent = urgent;
+                        break;
+                    }
+                }
+            }
             event => return Some(event),
         }
         None
diff --git a/niri-visual-tests/src/cases/gradient_area.rs b/niri-visual-tests/src/cases/gradient_area.rs
index 290e0a828f..03f206c35a 100644
--- a/niri-visual-tests/src/cases/gradient_area.rs
+++ b/niri-visual-tests/src/cases/gradient_area.rs
@@ -23,8 +23,10 @@ impl GradientArea {
             width: FloatOrInt(1.),
             active_color: Color::from_rgba8_unpremul(255, 255, 255, 128),
             inactive_color: Color::default(),
+            urgent_color: Color::default(),
             active_gradient: None,
             inactive_gradient: None,
+            urgent_gradient: None,
         });
 
         Self {
@@ -81,6 +83,7 @@ impl TestCase for GradientArea {
             g_size,
             true,
             true,
+            false,
             Rectangle::default(),
             CornerRadius::default(),
             1.,
diff --git a/niri-visual-tests/src/cases/layout.rs b/niri-visual-tests/src/cases/layout.rs
index 81748e8c75..879af48b51 100644
--- a/niri-visual-tests/src/cases/layout.rs
+++ b/niri-visual-tests/src/cases/layout.rs
@@ -60,8 +60,10 @@ impl Layout {
                 width: FloatOrInt(4.),
                 active_color: Color::from_rgba8_unpremul(255, 163, 72, 255),
                 inactive_color: Color::from_rgba8_unpremul(50, 50, 50, 255),
+                urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
                 active_gradient: None,
                 inactive_gradient: None,
+                urgent_gradient: None,
             },
             ..Default::default()
         };
diff --git a/niri-visual-tests/src/test_window.rs b/niri-visual-tests/src/test_window.rs
index f6da07c22f..7ac9c4635a 100644
--- a/niri-visual-tests/src/test_window.rs
+++ b/niri-visual-tests/src/test_window.rs
@@ -272,4 +272,8 @@ impl LayoutElement for TestWindow {
     fn interactive_resize_data(&self) -> Option<InteractiveResizeData> {
         None
     }
+
+    fn is_urgent(&self) -> bool {
+        false
+    }
 }
diff --git a/resources/default-config.kdl b/resources/default-config.kdl
index 213a8ba176..d1cc8b45c9 100644
--- a/resources/default-config.kdl
+++ b/resources/default-config.kdl
@@ -192,6 +192,9 @@ layout {
         active-color "#ffc87f"
         inactive-color "#505050"
 
+        // Color of the border around windows that request your attention.
+        urgent-color "#9b0000"
+
         // active-gradient from="#ffbb66" to="#ffc880" angle=45 relative-to="workspace-view"
         // inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
     }
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index f2613d19d4..ffbf243938 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -707,6 +707,8 @@ impl GammaControlHandler for State {
 }
 delegate_gamma_control!(State);
 
+struct UrgentOnlyMarker;
+
 impl XdgActivationHandler for State {
     fn activation_state(&mut self) -> &mut XdgActivationState {
         &mut self.niri.activation_state
@@ -716,11 +718,10 @@ impl XdgActivationHandler for State {
         // Tokens without a serial are urgency-only. This is not specified, but it seems to be the
         // common client behavior.
         //
-        // We don't have urgency yet, so just ignore such tokens.
-        //
         // See also: https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/150
         let Some((serial, seat)) = data.serial else {
-            return false;
+            data.user_data.insert_if_missing(|| UrgentOnlyMarker);
+            return true;
         };
         let Some(seat) = Seat::<State>::from_resource(&seat) else {
             return false;
@@ -760,11 +761,16 @@ impl XdgActivationHandler for State {
         surface: WlSurface,
     ) {
         if token_data.timestamp.elapsed() < XDG_ACTIVATION_TOKEN_TIMEOUT {
-            if let Some((mapped, _)) = self.niri.layout.find_window_and_output(&surface) {
+            if let Some((mapped, _)) = self.niri.layout.find_window_and_output_mut(&surface) {
                 let window = mapped.window.clone();
-                self.niri.layout.activate_window(&window);
-                self.niri.layer_shell_on_demand_focus = None;
-                self.niri.queue_redraw_all();
+                if token_data.user_data.get::<UrgentOnlyMarker>().is_some() {
+                    mapped.set_urgent(true);
+                    self.niri.queue_redraw_all();
+                } else {
+                    self.niri.layout.activate_window(&window);
+                    self.niri.layer_shell_on_demand_focus = None;
+                    self.niri.queue_redraw_all();
+                }
             } else if let Some(unmapped) = self.niri.unmapped_windows.get_mut(&surface) {
                 unmapped.activation_token_data = Some(token_data);
             }
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 1f790f166f..1204ff5bdb 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -1971,6 +1971,37 @@ impl State {
                     self.niri.queue_redraw_all();
                 }
             }
+            Action::ToggleUrgent(id) => {
+                let window = self
+                    .niri
+                    .layout
+                    .workspaces_mut()
+                    .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id));
+                if let Some(window) = window {
+                    let urgent = window.is_urgent();
+                    window.set_urgent(!urgent);
+                }
+            }
+            Action::SetUrgent(id) => {
+                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_urgent(true);
+                }
+            }
+            Action::UnsetUrgent(id) => {
+                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_urgent(false);
+                }
+            }
         }
     }
 
diff --git a/src/ipc/client.rs b/src/ipc/client.rs
index 6f96970306..55034ce0cc 100644
--- a/src/ipc/client.rs
+++ b/src/ipc/client.rs
@@ -405,6 +405,9 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
                     Event::WorkspacesChanged { workspaces } => {
                         println!("Workspaces changed: {workspaces:?}");
                     }
+                    Event::WorkspaceUrgencyChanged { id, urgent } => {
+                        println!("Workspace {id}: urgency changed to {urgent}");
+                    }
                     Event::WorkspaceActivated { id, focused } => {
                         let word = if focused { "focused" } else { "activated" };
                         println!("Workspace {word}: {id}");
@@ -430,6 +433,9 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
                     Event::WindowFocusChanged { id } => {
                         println!("Window focus changed: {id:?}");
                     }
+                    Event::WindowUrgencyChanged { id, urgent } => {
+                        println!("Window {id}: urgency changed to {urgent}");
+                    }
                     Event::KeyboardLayoutsChanged { keyboard_layouts } => {
                         println!("Keyboard layouts changed: {keyboard_layouts:?}");
                     }
diff --git a/src/ipc/server.rs b/src/ipc/server.rs
index 06ff0fd1d7..ef9d9c87ba 100644
--- a/src/ipc/server.rs
+++ b/src/ipc/server.rs
@@ -476,6 +476,7 @@ fn make_ipc_window(mapped: &Mapped, workspace_id: Option<WorkspaceId>) -> niri_i
         workspace_id: workspace_id.map(|id| id.get()),
         is_focused: mapped.is_focused(),
         is_floating: mapped.is_floating(),
+        is_urgent: mapped.is_urgent(),
     })
 }
 
@@ -579,6 +580,12 @@ impl State {
                 });
             }
 
+            // Check if this workspace urgent state changed.
+            let urgent = ws.is_urgent();
+            if urgent != ipc_ws.is_urgent {
+                events.push(Event::WorkspaceUrgencyChanged { id, urgent });
+            }
+
             // Check if this workspace became focused.
             let is_focused = Some(id) == focused_ws_id;
             if is_focused && !ipc_ws.is_focused {
@@ -610,6 +617,7 @@ impl State {
                         idx: u8::try_from(ws_idx + 1).unwrap_or(u8::MAX),
                         name: ws.name().cloned(),
                         output: mon.map(|mon| mon.output_name().clone()),
+                        is_urgent: ws.is_urgent(),
                         is_active: mon.is_some_and(|mon| mon.active_workspace_idx() == ws_idx),
                         is_focused: Some(id) == focused_ws_id,
                         active_window_id: ws.active_window().map(|win| win.id().get()),
@@ -673,6 +681,11 @@ impl State {
             if mapped.is_focused() && !ipc_win.is_focused {
                 events.push(Event::WindowFocusChanged { id: Some(id) });
             }
+
+            let urgent = mapped.is_urgent();
+            if urgent != ipc_win.is_urgent {
+                events.push(Event::WindowUrgencyChanged { id, urgent })
+            }
         });
 
         // Check for closed windows.
diff --git a/src/layout/focus_ring.rs b/src/layout/focus_ring.rs
index 047fc3b7fe..5cb797e049 100644
--- a/src/layout/focus_ring.rs
+++ b/src/layout/focus_ring.rs
@@ -59,6 +59,7 @@ impl FocusRing {
         win_size: Size<f64, Logical>,
         is_active: bool,
         is_border: bool,
+        is_urgent: bool,
         view_rect: Rectangle<f64, Logical>,
         radius: CornerRadius,
         scale: f64,
@@ -67,7 +68,9 @@ impl FocusRing {
         let width = self.config.width.0;
         self.full_size = win_size + Size::from((width, width)).upscale(2.);
 
-        let color = if is_active {
+        let color = if is_urgent {
+            self.config.urgent_color
+        } else if is_active {
             self.config.active_color
         } else {
             self.config.inactive_color
@@ -79,7 +82,9 @@ impl FocusRing {
 
         let radius = radius.fit_to(self.full_size.w as f32, self.full_size.h as f32);
 
-        let gradient = if is_active {
+        let gradient = if is_urgent {
+            self.config.urgent_gradient
+        } else if is_active {
             self.config.active_gradient
         } else {
             self.config.inactive_gradient
diff --git a/src/layout/insert_hint_element.rs b/src/layout/insert_hint_element.rs
index 89dfcd14c1..92f9b480c9 100644
--- a/src/layout/insert_hint_element.rs
+++ b/src/layout/insert_hint_element.rs
@@ -19,8 +19,10 @@ impl InsertHintElement {
                 width: FloatOrInt(0.),
                 active_color: config.color,
                 inactive_color: config.color,
+                urgent_color: config.color,
                 active_gradient: config.gradient,
                 inactive_gradient: config.gradient,
+                urgent_gradient: config.gradient,
             }),
         }
     }
@@ -31,8 +33,10 @@ impl InsertHintElement {
             width: FloatOrInt(0.),
             active_color: config.color,
             inactive_color: config.color,
+            urgent_color: config.color,
             active_gradient: config.gradient,
             inactive_gradient: config.gradient,
+            urgent_gradient: config.gradient,
         });
     }
 
@@ -48,7 +52,7 @@ impl InsertHintElement {
         scale: f64,
     ) {
         self.inner
-            .update_render_elements(size, true, false, view_rect, radius, scale, 1.);
+            .update_render_elements(size, true, false, false, view_rect, radius, scale, 1.);
     }
 
     pub fn render(
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index d42f05a529..b2726bef9e 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -210,6 +210,8 @@ pub trait LayoutElement {
     fn set_bounds(&self, bounds: Size<i32, Logical>);
     fn is_ignoring_opacity_window_rule(&self) -> bool;
 
+    fn is_urgent(&self) -> bool;
+
     fn configure_intent(&self) -> ConfigureIntent;
     fn send_pending_configure(&mut self);
 
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
index 4ac5aaf7e7..a02d11207b 100644
--- a/src/layout/tests.rs
+++ b/src/layout/tests.rs
@@ -261,6 +261,10 @@ impl LayoutElement for TestWindow {
     fn interactive_resize_data(&self) -> Option<InteractiveResizeData> {
         None
     }
+
+    fn is_urgent(&self) -> bool {
+        false
+    }
 }
 
 fn arbitrary_bbox() -> impl Strategy<Value = Rectangle<i32, Logical>> {
diff --git a/src/layout/tile.rs b/src/layout/tile.rs
index 1b423c0782..454b109c36 100644
--- a/src/layout/tile.rs
+++ b/src/layout/tile.rs
@@ -366,6 +366,7 @@ impl<W: LayoutElement> Tile<W> {
             self.animated_window_size(),
             is_active,
             !draw_border_with_background,
+            self.window.is_urgent(),
             Rectangle::new(
                 view_rect.loc - Point::from((border_width, border_width)),
                 view_rect.size,
@@ -400,6 +401,7 @@ impl<W: LayoutElement> Tile<W> {
             self.animated_tile_size(),
             is_active,
             !draw_focus_ring_with_background,
+            self.window.is_urgent(),
             view_rect,
             radius,
             self.scale,
diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs
index 6ae045f7fe..c66622147c 100644
--- a/src/layout/workspace.rs
+++ b/src/layout/workspace.rs
@@ -1608,6 +1608,10 @@ impl<W: LayoutElement> Workspace<W> {
         self.scrolling.scroll_amount_to_activate(window)
     }
 
+    pub fn is_urgent(&self) -> bool {
+        self.windows().any(|win| win.is_urgent())
+    }
+
     pub fn activate_window(&mut self, window: &W::Id) -> bool {
         if self.floating.activate_window(window) {
             self.floating_is_active = FloatingActive::Yes;
diff --git a/src/window/mapped.rs b/src/window/mapped.rs
index 8273422076..da6caf5483 100644
--- a/src/window/mapped.rs
+++ b/src/window/mapped.rs
@@ -77,6 +77,9 @@ pub struct Mapped {
     /// If `None`, then the window is not offscreened.
     offscreen_data: RefCell<Option<OffscreenData>>,
 
+    /// Whether this has an urgent indicator.
+    is_urgent: bool,
+
     /// Whether this window has the keyboard focus.
     is_focused: bool,
 
@@ -231,6 +234,7 @@ impl Mapped {
             needs_configure: false,
             needs_frame_callback: false,
             offscreen_data: RefCell::new(None),
+            is_urgent: false,
             is_focused: false,
             is_active_in_column: true,
             is_floating: false,
@@ -328,6 +332,7 @@ impl Mapped {
         }
 
         self.is_focused = is_focused;
+        self.is_urgent = false;
         self.need_to_recompute_rules = true;
     }
 
@@ -510,6 +515,19 @@ impl Mapped {
     pub fn is_windowed_fullscreen(&self) -> bool {
         self.is_windowed_fullscreen
     }
+
+    pub fn set_urgent(&mut self, urgent: bool) {
+        if self.is_focused && urgent {
+            return;
+        }
+        let changed = self.is_urgent != urgent;
+        self.is_urgent = urgent;
+        self.need_to_recompute_rules |= changed;
+    }
+
+    pub fn is_urgent(&self) -> bool {
+        self.is_urgent
+    }
 }
 
 impl Drop for Mapped {
@@ -830,6 +848,10 @@ impl LayoutElement for Mapped {
         }
     }
 
+    fn is_urgent(&self) -> bool {
+        self.is_urgent
+    }
+
     fn set_activated(&mut self, active: bool) {
         let changed = self.toplevel().with_pending_state(|state| {
             if active {
diff --git a/src/window/mod.rs b/src/window/mod.rs
index 7c0da71b6a..4410febcb8 100644
--- a/src/window/mod.rs
+++ b/src/window/mod.rs
@@ -131,6 +131,13 @@ impl<'a> WindowRef<'a> {
         }
     }
 
+    pub fn is_urgent(self) -> bool {
+        match self {
+            WindowRef::Unmapped(_) => false,
+            WindowRef::Mapped(mapped) => mapped.is_urgent(),
+        }
+    }
+
     pub fn is_active_in_column(self) -> bool {
         match self {
             WindowRef::Unmapped(_) => true,
@@ -184,8 +191,10 @@ impl ResolvedWindowRules {
                 width: None,
                 active_color: None,
                 inactive_color: None,
+                urgent_color: None,
                 active_gradient: None,
                 inactive_gradient: None,
+                urgent_gradient: None,
             },
             border: BorderRule {
                 off: false,
@@ -193,8 +202,10 @@ impl ResolvedWindowRules {
                 width: None,
                 active_color: None,
                 inactive_color: None,
+                urgent_color: None,
                 active_gradient: None,
                 inactive_gradient: None,
+                urgent_gradient: None,
             },
             shadow: ShadowRule {
                 off: false,


Reviews:
user: login: YaLTeR
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-10T07:39:53Z
user: login: Duncaen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T16:13:58Z
user: login: YaLTeR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T16:30:56Z

Review comments:
diff_hunk: @@ -830,6 +839,16 @@ impl LayoutElement for Mapped {
         }
     }
 
+    fn set_urgent(&mut self, urgent: bool) {
+        let changed = self.is_urgent != urgent;
path: src/window/mapped.rs
user: login: YaLTeR
body: If the window is focused, probably don't want to set urgent?
created_at: 2025-05-10T07:12:34Z
author_association: MEMBER
diff_hunk: @@ -1583,6 +1586,14 @@ impl<W: LayoutElement> Layout<W> {
         ws_idx == mon.active_workspace_idx
     }
 
+    pub fn urgent_window(&mut self, id: &W::Id) {
path: src/layout/mod.rs
user: login: YaLTeR
body: I don't think this is needed here, along with `LayoutElement::set_urgent()`. Since the layout code doesn't need to set urgency, it's always done externally where it can just use a `Mapped` method.
created_at: 2025-05-10T07:18:40Z
author_association: MEMBER
diff_hunk: @@ -31,6 +31,7 @@ struct TestWindowInner {
     is_fullscreen: Cell<bool>,
     is_windowed_fullscreen: Cell<bool>,
     is_pending_windowed_fullscreen: Cell<bool>,
+    is_urgent: Cell<bool>,
path: src/layout/tests.rs
user: login: YaLTeR
body: If removing `set_urgent()`, then this is not necessary, can just return constant false from `is_urgent()`.
created_at: 2025-05-10T07:20:58Z
author_association: MEMBER
diff_hunk: @@ -707,6 +707,11 @@ impl GammaControlHandler for State {
 }
 delegate_gamma_control!(State);
 
+pub enum ActivationContext {
+    UrgentOnly,
+    FocusIfPossible,
+}
path: src/handlers/mod.rs
user: login: YaLTeR
body: I'm thinking, maybe leave just a marker `struct UrgentOnlyMarker;`? Since we already filter tokens as valid/invalid by returning true/false from `token_created()`. Then only insert `UrgentOnlyMarker` in the urgent-only branch, and check if it's present in `request_activation()`.
created_at: 2025-05-10T07:23:47Z
author_association: MEMBER
diff_hunk: @@ -761,10 +772,21 @@ impl XdgActivationHandler for State {
     ) {
         if token_data.timestamp.elapsed() < XDG_ACTIVATION_TOKEN_TIMEOUT {
             if let Some((mapped, _)) = self.niri.layout.find_window_and_output(&surface) {
+                let Some(context) = token_data.user_data.get::<ActivationContext>() else {
+                    warn!("missing ActivationContext in token user-data");
+                    return;
+                };
                 let window = mapped.window.clone();
-                self.niri.layout.activate_window(&window);
-                self.niri.layer_shell_on_demand_focus = None;
-                self.niri.queue_redraw_all();
+                match context {
+                    ActivationContext::FocusIfPossible => {
+                        self.niri.layout.activate_window(&window);
+                        self.niri.layer_shell_on_demand_focus = None;
+                        self.niri.queue_redraw_all();
+                    }
+                    ActivationContext::UrgentOnly => {
+                        self.niri.layout.urgent_window(&window);
path: src/handlers/mod.rs
user: login: YaLTeR
body: Needs `queue_redraw_all()` if it changes the focus ring/border color
created_at: 2025-05-10T07:24:15Z
author_association: MEMBER
diff_hunk: @@ -1608,6 +1608,10 @@ impl<W: LayoutElement> Workspace<W> {
         self.scrolling.scroll_amount_to_activate(window)
     }
 
+    pub fn is_urgent(&self) -> bool {
path: src/layout/workspace.rs
user: login: YaLTeR
body: I think this belongs in the next commit
created_at: 2025-05-10T07:25:08Z
author_association: MEMBER
path: niri-ipc/src/lib.rs
user: login: YaLTeR
body: What about Window is_urgent and WindowUrgencyChanged?
created_at: 2025-05-10T07:28:54Z
author_association: MEMBER
diff_hunk: @@ -1224,6 +1226,13 @@ pub enum Event {
         /// workspaces are missing from here, then they were deleted.
         workspaces: Vec<Workspace>,
     },
+    /// A window on a workspace became urgent.
path: niri-ipc/src/lib.rs
user: login: YaLTeR
body: I think this is better as just "The workspace urgency changed." because it could change due to window closing or moving away, etc.
created_at: 2025-05-10T07:30:50Z
author_association: MEMBER
diff_hunk: @@ -591,6 +591,12 @@ impl State {
             if is_active && !ipc_ws.is_active {
                 events.push(Event::WorkspaceActivated { id, focused: false });
             }
+
+            // Check if this workspace urgent state changed.
path: src/ipc/server.rs
user: login: YaLTeR
body: Shouldn't this go higher, above the `continue;` in the focused branch?
created_at: 2025-05-10T07:32:33Z
author_association: MEMBER
diff_hunk: @@ -69,6 +70,8 @@ impl FocusRing {
 
         let color = if is_active {
             self.config.active_color
+        } else if is_urgent {
path: src/layout/focus_ring.rs
user: login: YaLTeR
body: Should this maybe take precedence over is_active? So that in the overview you can see urgent active windows on other workspaces with urgent color.
created_at: 2025-05-10T07:34:48Z
author_association: MEMBER
diff_hunk: @@ -191,6 +191,7 @@ layout {
         width 4
         active-color "#ffc87f"
         inactive-color "#505050"
+        urgent-color "#9b0000"
path: resources/default-config.kdl
user: login: YaLTeR
body: // Color of the border around windows that request your attention.
created_at: 2025-05-10T07:36:37Z
author_association: MEMBER
diff_hunk: @@ -1971,6 +1971,37 @@ impl State {
                     self.niri.queue_redraw_all();
                 }
             }
+            Action::ToggleUrgent(id) => {
+                let window = self
+                    .niri
+                    .layout
+                    .workspaces_mut()
+                    .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id));
+                if let Some(window) = window {
path: src/input/mod.rs
user: login: YaLTeR
body: ```
                let window = self.niri.layout.windows().find(|(_, m)| m.id().get() == id);
                if let Some((_, mapped)) = window {
```
created_at: 2025-05-10T07:39:46Z
author_association: MEMBER
diff_hunk: @@ -1971,6 +1971,37 @@ impl State {
                     self.niri.queue_redraw_all();
                 }
             }
+            Action::ToggleUrgent(id) => {
+                let window = self
+                    .niri
+                    .layout
+                    .workspaces_mut()
+                    .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id));
+                if let Some(window) = window {
path: src/input/mod.rs
user: login: Duncaen
body: This does not work:
```
error[E0596]: cannot borrow `*mapped` as mutable, as it is behind a `&` reference                                                             
    --> src/input/mod.rs:1978:21                                                                                                              
     |                                                                                                                                        
1976 |                 if let Some((_, mapped)) = window {                                                                                    
     |                                 ------ consider changing this binding's type to be: `&mut Mapped`                                      
1977 |                     let urgent = mapped.is_urgent();                                                                                   
1978 |                     mapped.set_urgent(!urgent);                                                                                        
     |                     ^^^^^^ `mapped` is a `&` reference, so the data it refers to cannot be borrowed as mutable      
```
created_at: 2025-05-10T16:13:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1971,6 +1971,37 @@ impl State {
                     self.niri.queue_redraw_all();
                 }
             }
+            Action::ToggleUrgent(id) => {
+                let window = self
+                    .niri
+                    .layout
+                    .workspaces_mut()
+                    .find_map(|ws| ws.windows_mut().find(|w| w.id().get() == id));
+                if let Some(window) = window {
path: src/input/mod.rs
user: login: YaLTeR
body: Ah right. We have with_windows_mut() but not windows_mut()
created_at: 2025-05-10T16:30:56Z
author_association: MEMBER

PR comments:
user: login: FreeFull
created_at: 2025-03-26T13:33:01Z
body: I think it would also be nice to have a niri action to set a specific window as urgent, similar to `niri msg action focus-window --id <ID>`
author_association: CONTRIBUTOR
user: login: YaLTeR
created_at: 2025-05-10T17:24:58Z
body: All looks good but I think it would be good to include Window-level is_urgent into the IPC and event stream, rather than just Workspace-level?
author_association: MEMBER
user: login: YaLTeR
created_at: 2025-05-10T19:07:13Z
body: Renamed is_urgent to urgent in the WindowUrgencyChanged event for consistency. Gave it a test, seems to work well.
author_association: MEMBER
user: login: YaLTeR
created_at: 2025-05-10T19:15:55Z
body: Thanks! And thanks for separating the commits out cleanly.
author_association: MEMBER
user: login: Duncaen
created_at: 2025-05-10T20:04:12Z
body: Awesome thank you. I'll look into up-streaming support for this into yambar and maybe some other status bars if its not too complicated soon.
author_association: CONTRIBUTOR
user: login: YaLTeR
created_at: 2025-05-10T20:12:22Z
body: Cool! I just pushed some follow-ups for the missing settings.

I guess waybar would be nice and shouldn't be too hard if i recall correctly from when doing the initial impl
author_association: MEMBER
user: login: YaLTeR
created_at: 2025-05-22T06:10:30Z
body: So, apparently something I've missed is that the actions should ideally be named un/set/toggle-*window*-urgent rather than just un/set/toggle-urgent. I'm thinking how bad of an idea it would be to correct this in a hotfix release.

Do you think that's a good idea? They are not bindable in the config so it's not a config-breaking change, and I don't think any bars use those actions yet?
author_association: MEMBER
user: login: Duncaen
created_at: 2025-05-22T11:18:01Z
body: Seems reasonable and early enough that nothing really depends on it.
author_association: CONTRIBUTOR