From 89fdebb85c37baf32c13237f0d2cf70408f2c3fd Mon Sep 17 00:00:00 2001 From: Akiva Bloch Date: Wed, 15 Apr 2026 01:16:58 +0100 Subject: [PATCH 1/3] Add niri compositor support Adds a WindowSystem implementation for the niri scrollable-tiling Wayland compositor. Uses niri's IPC (niri msg) to query focused window metadata and output geometry for hint overlay positioning. Co-Authored-By: Claude Opus 4.6 --- hints/hints.py | 4 +- hints/window_systems/niri.py | 95 ++++++++++++++++++++++ hints/window_systems/window_system_type.py | 2 +- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 hints/window_systems/niri.py diff --git a/hints/hints.py b/hints/hints.py index 4939b13..1c43256 100644 --- a/hints/hints.py +++ b/hints/hints.py @@ -280,6 +280,8 @@ def get_window_system_class( from hints.window_systems.plasmashell import Plasmashell as window_system case "gnome-shell": from hints.window_systems.gnome import Gnome as window_system + case "niri": + from hints.window_systems.niri import Niri as window_system return window_system @@ -301,7 +303,7 @@ def get_window_system(window_system_id: str = "") -> Type[WindowSystem]: if window_system_type == WindowSystemType.WAYLAND: # add new waland wms here, then add a match case below to import the class - supported_wayland_wms = {"sway", "Hyprland", "plasmashell", "gnome-shell"} + supported_wayland_wms = {"sway", "Hyprland", "plasmashell", "gnome-shell", "niri"} # Check if there is a process running that matches the supported_wayland_wms window_system_id = ( diff --git a/hints/window_systems/niri.py b/hints/window_systems/niri.py new file mode 100644 index 0000000..14ad89f --- /dev/null +++ b/hints/window_systems/niri.py @@ -0,0 +1,95 @@ +"""Niri window system.""" + +from json import loads +from subprocess import run + +from hints.window_systems.window_system import WindowSystem + + +class Niri(WindowSystem): + """Niri Window system class.""" + + def __init__(self): + super().__init__() + self._focused_window = self._get_focused_window() + self._focused_output = self._get_focused_output() + + def _get_focused_window(self): + result = run( + ["niri", "msg", "-j", "focused-window"], + capture_output=True, + check=True, + ) + return loads(result.stdout.decode("utf-8")) + + def _get_focused_output(self): + result = run( + ["niri", "msg", "-j", "focused-output"], + capture_output=True, + check=True, + ) + return loads(result.stdout.decode("utf-8")) + + @property + def window_system_name(self) -> str: + """Get the name of the window system. + + :return: The window system name + """ + return "niri" + + @property + def focused_window_extents(self) -> tuple[int, int, int, int]: + """Get active window extents. + + :return: Active window extents (x, y, width, height). + """ + output_logical = self._focused_output["logical"] + layout = self._focused_window["layout"] + + width = layout["window_size"][0] + height = layout["window_size"][1] + + tile_pos = layout.get("tile_pos_in_workspace_view") + if tile_pos is not None: + x = output_logical["x"] + int(tile_pos[0]) + y = output_logical["y"] + int(tile_pos[1]) + else: + # tile_pos_in_workspace_view can be null when the window is + # the only visible column. Derive position from the output + # geometry and tile/window offsets. + tile_w = layout["tile_size"][0] + offset_x = layout["window_offset_in_tile"][0] + offset_y = layout["window_offset_in_tile"][1] + + # Center the tile horizontally within the output (niri's default + # behavior for a single focused column). + x = output_logical["x"] + int( + (output_logical["width"] - tile_w) / 2 + offset_x + ) + + # Vertical: the bar (if any) takes the space that the tile + # doesn't occupy. Assume the strut is at the top. + bar_height = output_logical["height"] - int(layout["tile_size"][1]) + y = output_logical["y"] + bar_height + int(offset_y) + + return (x, y, width, height) + + @property + def focused_window_pid(self) -> int: + """Get Process ID corresponding to the focused window. + + :return: Process ID of focused window. + """ + return self._focused_window["pid"] + + @property + def focused_applicaiton_name(self) -> str: + """Get focused application name. + + This name is the name used to identify applications for per- + application rules. + + :return: Focused application name. + """ + return self._focused_window["app_id"] diff --git a/hints/window_systems/window_system_type.py b/hints/window_systems/window_system_type.py index 309ed18..ecdc20c 100644 --- a/hints/window_systems/window_system_type.py +++ b/hints/window_systems/window_system_type.py @@ -12,7 +12,7 @@ class WindowSystemType(Enum): WAYLAND = "wayland" -SupportedWindowSystems = Literal["x11", "sway", "hyprland"] +SupportedWindowSystems = Literal["x11", "sway", "hyprland", "niri"] def get_window_system_type() -> WindowSystemType: From 06ef82792d6d0c3a0c0c48622ee8d8edee86b803 Mon Sep 17 00:00:00 2001 From: Akiva Bloch Date: Mon, 20 Apr 2026 18:23:18 +0100 Subject: [PATCH 2/3] add mangowm to hints too --- hints/backends/atspi.py | 2 ++ hints/hints.py | 4 ++- hints/window_systems/mango.py | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 hints/window_systems/mango.py diff --git a/hints/backends/atspi.py b/hints/backends/atspi.py index 837e02d..f4c4fb3 100644 --- a/hints/backends/atspi.py +++ b/hints/backends/atspi.py @@ -296,6 +296,8 @@ def get_atspi_active_window(self) -> Atspi.Accessible | None: continue for window_index in range(window.get_child_count()): current_window = window.get_child_at_index(window_index) + if current_window is None: + continue # Some hidden windows that are minimized to status trays # (like discord) will still have the Atspi.StateType.Active # state, so the pid from the window manger allows us to filter diff --git a/hints/hints.py b/hints/hints.py index 1c43256..e84fdff 100644 --- a/hints/hints.py +++ b/hints/hints.py @@ -282,6 +282,8 @@ def get_window_system_class( from hints.window_systems.gnome import Gnome as window_system case "niri": from hints.window_systems.niri import Niri as window_system + case "mango": + from hints.window_systems.mango import Mango as window_system return window_system @@ -303,7 +305,7 @@ def get_window_system(window_system_id: str = "") -> Type[WindowSystem]: if window_system_type == WindowSystemType.WAYLAND: # add new waland wms here, then add a match case below to import the class - supported_wayland_wms = {"sway", "Hyprland", "plasmashell", "gnome-shell", "niri"} + supported_wayland_wms = {"sway", "Hyprland", "plasmashell", "gnome-shell", "niri", "mango"} # Check if there is a process running that matches the supported_wayland_wms window_system_id = ( diff --git a/hints/window_systems/mango.py b/hints/window_systems/mango.py new file mode 100644 index 0000000..be2adc4 --- /dev/null +++ b/hints/window_systems/mango.py @@ -0,0 +1,56 @@ +"""Mango window system.""" + +from subprocess import run + +from hints.window_systems.window_system import WindowSystem + + +def _parse_mmsg(output: str) -> dict[str, str]: + result = {} + for line in output.strip().splitlines(): + parts = line.split(None, 2) + if len(parts) == 3: + result[parts[1]] = parts[2] + return result + + +class Mango(WindowSystem): + """Mango (MangoWM) window system class.""" + + def __init__(self): + super().__init__() + self._state = self._query() + + def _query(self) -> dict[str, str]: + result = run( + ["mmsg", "-g", "-c", "-x"], + capture_output=True, + check=True, + ) + return _parse_mmsg(result.stdout.decode("utf-8")) + + @property + def window_system_name(self) -> str: + return "mango" + + @property + def focused_window_extents(self) -> tuple[int, int, int, int]: + return ( + int(self._state["x"]), + int(self._state["y"]), + int(self._state["width"]), + int(self._state["height"]), + ) + + @property + def focused_window_pid(self) -> int: + appid = self._state.get("appid", "") + result = run( + ["pgrep", "-n", appid], + capture_output=True, + ) + return int(result.stdout.strip()) if result.returncode == 0 else -1 + + @property + def focused_applicaiton_name(self) -> str: + return self._state.get("appid", "") From 395383798312b733d0c46d2729ff8ab93af5afa6 Mon Sep 17 00:00:00 2001 From: Akiva Bloch Date: Tue, 21 Apr 2026 10:30:44 +0100 Subject: [PATCH 3/3] niri: fall back to output extents for tiled windows tile_pos_in_workspace_view is only populated for floating windows; for tiled windows niri IPC does not expose the screen position (upstream issue niri-wm/niri#2381). The previous heuristic (centering a single column) was unreliable for multi-column layouts. Fall back to the full output geometry so hints still scans the screen correctly. Co-Authored-By: Claude Sonnet 4.6 --- hints/window_systems/niri.py | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/hints/window_systems/niri.py b/hints/window_systems/niri.py index 14ad89f..e682e30 100644 --- a/hints/window_systems/niri.py +++ b/hints/window_systems/niri.py @@ -47,31 +47,21 @@ def focused_window_extents(self) -> tuple[int, int, int, int]: output_logical = self._focused_output["logical"] layout = self._focused_window["layout"] - width = layout["window_size"][0] - height = layout["window_size"][1] - tile_pos = layout.get("tile_pos_in_workspace_view") if tile_pos is not None: + # Floating windows: niri populates tile_pos_in_workspace_view x = output_logical["x"] + int(tile_pos[0]) y = output_logical["y"] + int(tile_pos[1]) + width = layout["window_size"][0] + height = layout["window_size"][1] else: - # tile_pos_in_workspace_view can be null when the window is - # the only visible column. Derive position from the output - # geometry and tile/window offsets. - tile_w = layout["tile_size"][0] - offset_x = layout["window_offset_in_tile"][0] - offset_y = layout["window_offset_in_tile"][1] - - # Center the tile horizontally within the output (niri's default - # behavior for a single focused column). - x = output_logical["x"] + int( - (output_logical["width"] - tile_w) / 2 + offset_x - ) - - # Vertical: the bar (if any) takes the space that the tile - # doesn't occupy. Assume the strut is at the top. - bar_height = output_logical["height"] - int(layout["tile_size"][1]) - y = output_logical["y"] + bar_height + int(offset_y) + # Tiled windows: niri IPC does not expose the screen position + # (https://github.com/niri-wm/niri/issues/2381). Fall back to + # the full output geometry so hints still scans the screen. + x = output_logical["x"] + y = output_logical["y"] + width = output_logical["width"] + height = output_logical["height"] return (x, y, width, height)