Repository: qtile/qtile

Issue:
client_urgent_hint_changed hook not firing
### The issue:

I'm trying to use the client_urgent_hint_changed hook however it doesn't seem to be firing ever. The group box and task list widgets update and the next_urgent command works 
```
@hook.subscribe.client_urgent_hint_changed
def urgent_hint_changed(window):
    logger.warning(f"urgent hint changed for {window.name}")
```
I set up a key-bind to look at the window's attributes and saw that `window.urgent` and `window._demands_attention` were True but `window.hints["urgent"]` was False. Running `window.update_hints()` manually does cause the hook to fire

I found #1847 which might be related

### Required:

- [X] I have searched past issues to see if this bug has already been reported.


Discussion:
The groupbox and tasklist widgets use the same hook, so if they are updating then the hook must be getting fired. Are you certain your hook is being subscribed? E.g. if it's in a separate config file beside config.py, that file needs to be imported to get it to load.

> I set up a key-bind to look at the window's attributes and saw that window.urgent and window._demands_attention were True but window.hints["urgent"] was False. Running window.update_hints() manually does cause the hook to fire

The important one here is `window.urgent` so if it's `True` then the urgency is being correctly picked up.
Yeah, thats what I'm finding so weird. I'm certain it's subscribed because it does fire but only after I call update_hints manually.

code I'm using
```python
def debug_function(qtile):
    windows = list(qtile.windows_map.values())
    for w in windows:
        if "firefox" in w.window.get_wm_class():
            logger.warning("before")
            w.update_hints()
            logger.warning("after")

@hook.subscribe.client_urgent_hint_changed
def urgent_hint_changed(window):
    logger.warning(f"urgent hint changed for {window.name}")
```
I'll cause the window to become urgent, the groupbox will change color and nothing else will happen. Then I'll trigger `debug_function` and 
```
2022-12-18 00:05:29,720 WARNING libqtile config.py:debug_function():L84 before
2022-12-18 00:05:29,720 WARNING libqtile config.py:urgent_hint_changed():L518 urgent hint changed for Google — Mozilla Firefox
2022-12-18 00:05:29,720 WARNING libqtile config.py:urgent_hint_changed():L518 urgent hint changed for Google — Mozilla Firefox
2022-12-18 00:05:29,720 WARNING libqtile config.py:debug_function():L86 after
```
This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days.

Resolving patch:
diff --git a/libqtile/backend/x11/window.py b/libqtile/backend/x11/window.py
--- a/libqtile/backend/x11/window.py
+++ b/libqtile/backend/x11/window.py
@@ -2164,9 +2164,11 @@ def handle_ClientMessage(self, event):  # noqa: N802
                     else:  # self.group.screen != self.qtile.current_screen:
                         logger.debug("Setting urgent flag for window")
                         self.urgent = True
+                        hook.fire("client_urgent_hint_changed", self)
                 elif focus_behavior == "urgent":
                     logger.debug("Setting urgent flag for window")
                     self.urgent = True
+                    hook.fire("client_urgent_hint_changed", self)
                 elif focus_behavior == "never":
                     logger.debug("Ignoring focus request (focus_on_window_activation='never')")
                 else:


Test patch:
diff --git a/test/backend/x11/test_window.py b/test/backend/x11/test_window.py
--- a/test/backend/x11/test_window.py
+++ b/test/backend/x11/test_window.py
@@ -1,14 +1,16 @@
 import os
 import subprocess
 import tempfile
+from multiprocessing import Value
 
 import pytest
 import xcffib.xproto
 import xcffib.xtest
 
 import libqtile.config
-from libqtile import layout, utils
+from libqtile import hook, layout, utils
 from libqtile.backend.x11 import window, xcbq
+from libqtile.backend.x11.xcbq import Connection
 from test.conftest import dualmonitor
 from test.helpers import (
     HEIGHT,
@@ -60,6 +62,60 @@ def test_change_state_via_message(xmanager, conn):
     assert not xmanager.c.window.info()["minimized"]
 
 
+class UrgentConfig(BareConfig):
+    focus_on_window_activation = "urgent"
+
+
+class SmartConfig(BareConfig):
+    focus_on_window_activation = "smart"
+
+
+@dualmonitor
+def test_urgent_hook_fire(manager_nospawn):
+    manager_nospawn.display = manager_nospawn.backend.env["DISPLAY"]
+    conn = Connection(manager_nospawn.display)
+
+    manager_nospawn.hook_fired = Value("i", 0)
+
+    def _hook_test(val):
+        manager_nospawn.hook_fired.value += 1
+
+    hook.subscribe.client_urgent_hint_changed(_hook_test)
+
+    manager_nospawn.start(UrgentConfig)
+
+    manager_nospawn.test_window("one")
+    window_info = manager_nospawn.c.window.info()
+
+    # send activate window message
+    data = xcffib.xproto.ClientMessageData.synthetic([0, 0, 0, 0, 0], "IIIII")
+    ev = xcffib.xproto.ClientMessageEvent.synthetic(
+        32, window_info["id"], conn.atoms["_NET_ACTIVE_WINDOW"], data
+    )
+    conn.default_screen.root.send_event(ev, mask=xcffib.xproto.EventMask.SubstructureRedirect)
+    conn.xsync()
+
+    manager_nospawn.terminate()
+    assert manager_nospawn.hook_fired.value == 1
+
+    # test that focus_on_window_activation = "smart" also fires the hook
+    manager_nospawn.start(SmartConfig, no_spawn=True)
+
+    manager_nospawn.test_window("one")
+    window_info = manager_nospawn.c.window.info()
+    manager_nospawn.c.window.toscreen(1)
+
+    # send activate window message
+    ev = xcffib.xproto.ClientMessageEvent.synthetic(
+        32, window_info["id"], conn.atoms["_NET_ACTIVE_WINDOW"], data
+    )
+    conn.default_screen.root.send_event(ev, mask=xcffib.xproto.EventMask.SubstructureRedirect)
+    conn.xsync()
+    manager_nospawn.terminate()
+
+    assert manager_nospawn.hook_fired.value == 2
+
+
 @manager_config
 def test_default_float_hints(xmanager, conn):
     xmanager.c.next_layout()