From f1c45a7f8c7087a4dbe77ac3c0b3e357a5efa82b Mon Sep 17 00:00:00 2001 From: Makesesama Date: Fri, 27 Feb 2026 21:03:11 +0100 Subject: [PATCH] feat: use fenster ipc (sway/i3 compatible) --- bar/main.py | 30 ++--- bar/modules/bar.py | 23 +--- bar/modules/window_fuzzy.py | 77 +++++++++++-- bar/services/fenster.py | 29 +++++ bar/widgets/fenster.py | 221 ++++++++++++++++++++++++++++++++++++ flake.lock | 10 +- flake.nix | 2 +- 7 files changed, 344 insertions(+), 48 deletions(-) create mode 100644 bar/services/fenster.py create mode 100644 bar/widgets/fenster.py diff --git a/bar/main.py b/bar/main.py index 0bacb20..bb2bb56 100644 --- a/bar/main.py +++ b/bar/main.py @@ -12,11 +12,9 @@ else: logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}]) from fabric import Application +from fabric.i3 import I3, I3MessageType from fabric.system_tray.widgets import SystemTray from fabric.widgets.wayland import WaylandWindow as Window -from fabric.river.widgets import ( - get_river_connection, -) from fabric.utils import ( get_relative_path, ) @@ -24,10 +22,11 @@ from .modules.bar import StatusBar from .modules.window_fuzzy import FuzzyWindowFinder from .modules.stylix import get_stylix_css_path from .config import STYLIX +from .services.fenster import get_i3_connection tray = SystemTray(name="system-tray", spacing=4) -river = get_river_connection() +i3 = get_i3_connection() dummy = Window(visible=False) finder = FuzzyWindowFinder() @@ -56,17 +55,22 @@ else: def spawn_bars(): global notmuch_widget - logger.info("[Bar] Spawning bars after river ready") - outputs = river.outputs + logger.info("[Bar] Spawning bars") + outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS) - if not outputs: - logger.warning("[Bar] No outputs found — skipping bar spawn") + if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)): + logger.warning("[Bar] Failed to get outputs — skipping bar spawn") return - output_ids = sorted(outputs.keys()) + outputs = [o for o in outputs_reply.reply if o.get("active")] - for i, output_id in enumerate(output_ids): - bar = StatusBar(display=output_id, tray=tray if i == 0 else None, monitor=i) + if not outputs: + logger.warning("[Bar] No active outputs found — skipping bar spawn") + return + + for i, output in enumerate(outputs): + output_name = output.get("name", f"Unknown-{i}") + bar = StatusBar(display=output_name, tray=tray if i == 0 else None, monitor=i) bar_windows.append(bar) if i == 0 and bar.notmuch: notmuch_widget = bar.notmuch @@ -75,10 +79,10 @@ def spawn_bars(): def main(): - if river.ready: + if i3.ready: spawn_bars() else: - river.connect("notify::ready", lambda sender, pspec: spawn_bars()) + i3.connect("notify::ready", lambda *_: spawn_bars()) app.run() diff --git a/bar/modules/bar.py b/bar/modules/bar.py index 0280794..0069439 100644 --- a/bar/modules/bar.py +++ b/bar/modules/bar.py @@ -13,12 +13,8 @@ from bar.modules.calendar import CalendarService, CalendarPopup from bar.modules.notmuch import NotmuchWidget from fabric.widgets.wayland import WaylandWindow as Window from fabric.system_tray.widgets import SystemTray -from fabric.river.widgets import ( - RiverWorkspaces, - RiverWorkspaceButton, - RiverActiveWindow, - get_river_connection, -) +from bar.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow +from bar.services.fenster import get_i3_connection from fabric.widgets.circularprogressbar import CircularProgressBar from bar.services.system_stats import SystemStatsService @@ -28,10 +24,9 @@ from bar.config import VINYL, BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH class StatusBar(Window): def __init__( self, - display: int, + display: str, tray: SystemTray | None = None, monitor: int = 1, - river_service=None, ): super().__init__( name="bar", @@ -43,17 +38,11 @@ class StatusBar(Window): all_visible=False, monitor=monitor, ) - if river_service: - self.river = river_service - else: - self.river = get_river_connection() - self.workspaces = RiverWorkspaces( - display, + self.workspaces = FensterWorkspaces( + output=display, name="workspaces", spacing=4, - buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None), - river_service=self.river, ) # Create calendar components (refresh every 2 minutes) self.calendar_service = CalendarService(update_interval=120000) @@ -74,7 +63,7 @@ class StatusBar(Window): self.calendar_service.connect("events-changed", self.update_calendar_display) self.system_tray = tray - self.active_window = RiverActiveWindow( + self.active_window = FensterActiveWindow( name="active-window", max_length=50, style="color: #ffffff; font-size: 14px; font-weight: bold;", diff --git a/bar/modules/window_fuzzy.py b/bar/modules/window_fuzzy.py index 20dfe12..cba2dc6 100644 --- a/bar/modules/window_fuzzy.py +++ b/bar/modules/window_fuzzy.py @@ -1,10 +1,10 @@ -import operator +from fabric.i3 import I3, I3MessageType from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.entry import Entry -from fabric.utils import idle_add from gi.repository import Gdk +from bar.services.fenster import get_i3_connection class FuzzyWindowFinder(Window): @@ -21,7 +21,9 @@ class FuzzyWindowFinder(Window): visible=False, ) - self._all_windows = ["Test", "Uwu", "Tidal"] + self._i3 = get_i3_connection() + self._all_windows = [] + self._refresh_windows() self.viewport = Box(name="viewport", spacing=4, orientation="v") @@ -46,30 +48,81 @@ class FuzzyWindowFinder(Window): self.add(self.picker_box) self.arrange_viewport("") + def _refresh_windows(self): + """Refresh the window list via GET_TREE""" + self._all_windows = [] + tree_reply = I3.send_command("", I3MessageType.GET_TREE) + if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)): + return + + tree = tree_reply.reply + # Traverse: root → outputs → workspaces → containers + for output_node in tree.get("nodes", []): + for ws_node in output_node.get("nodes", []): + ws_num = ws_node.get("num", 0) + for con in ws_node.get("nodes", []): + if con.get("type") == "con": + self._all_windows.append({ + "id": con.get("id"), + "app_id": con.get("app_id", ""), + "title": con.get("name", ""), + "workspace": ws_num, + }) + for con in ws_node.get("floating_nodes", []): + if con.get("type") == "con": + self._all_windows.append({ + "id": con.get("id"), + "app_id": con.get("app_id", ""), + "title": con.get("name", ""), + "workspace": ws_num, + }) + + def show(self): + """Override show to refresh windows before displaying""" + self._refresh_windows() + self.arrange_viewport(self.search_entry.get_text()) + super().show() + def notify_text(self, entry, *_): text = entry.get_text() - self.arrange_viewport(text) # Update list on typing - print(text) + self.arrange_viewport(text) def on_search_entry_key_press(self, widget, event): - # if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): - # self.move_selection_2d(event.keyval) - # return True - print(event.keyval) if event.keyval in [Gdk.KEY_Escape, 103]: self.hide() return True return False def on_search_entry_activate(self, text): - print(f"activate {text}") + """Focus the first matching window""" + filtered = self._filter_windows(text) + if filtered: + window_id = filtered[0].get("id") + if window_id is not None: + I3.send_command(f"[con_id={window_id}] focus") + self.hide() + + def _filter_windows(self, query: str) -> list: + """Filter windows based on query matching title or app_id""" + if not query: + return self._all_windows + query_lower = query.lower() + return [ + w for w in self._all_windows + if query_lower in w.get("title", "").lower() + or query_lower in w.get("app_id", "").lower() + ] def arrange_viewport(self, query: str = ""): self.viewport.children = [] # Clear previous entries - filtered = [w for w in self._all_windows if query.lower() in w.lower()] + filtered = self._filter_windows(query) for window in filtered: + title = window.get("title", "") + app_id = window.get("app_id", "") + ws_num = window.get("workspace", 0) + display_text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}" self.viewport.add( - Box(name="slot-box", orientation="h", children=[Label(label=window)]) + Box(name="slot-box", orientation="h", children=[Label(label=display_text)]) ) diff --git a/bar/services/fenster.py b/bar/services/fenster.py new file mode 100644 index 0000000..fe86ee2 --- /dev/null +++ b/bar/services/fenster.py @@ -0,0 +1,29 @@ +""" +Fenster/Sway IPC connection helper. + +Provides a singleton I3 connection configured for Fenster's SWAYSOCK. +""" + +import os +from fabric.i3 import I3 + + +_connection: I3 | None = None + + +def get_i3_connection() -> I3: + """Get the singleton I3 connection, configured for Fenster.""" + global _connection + if _connection is None: + swaysock = os.environ.get("SWAYSOCK") + if swaysock: + I3.SOCKET_PATH = swaysock + elif not I3.SOCKET_PATH: + runtime_dir = os.environ.get( + "XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}" + ) + fallback = os.path.join(runtime_dir, "fenster.sock") + if os.path.exists(fallback): + I3.SOCKET_PATH = fallback + _connection = I3() + return _connection diff --git a/bar/widgets/fenster.py b/bar/widgets/fenster.py new file mode 100644 index 0000000..6a01d08 --- /dev/null +++ b/bar/widgets/fenster.py @@ -0,0 +1,221 @@ +""" +Fenster widgets for workspace and window management via sway IPC. +""" + +from fabric.i3 import I3, I3Event, I3MessageType +from fabric.utils.helpers import bulk_connect +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from bar.services.fenster import get_i3_connection + + +class FensterWorkspaceButton(Button): + """Button representing a single workspace""" + + def __init__( + self, + workspace_num: int, + i3: I3 | None = None, + label: str | None = None, + **kwargs, + ): + self._workspace_num = workspace_num + self._i3 = i3 or get_i3_connection() + + display_label = label if label is not None else str(workspace_num) + + super().__init__( + name=f"workspace-button-{workspace_num}", + child=Label(label=display_label), + on_clicked=self._on_clicked, + **kwargs, + ) + + self.add_style_class("workspace-button") + + @property + def workspace_num(self) -> int: + return self._workspace_num + + def _on_clicked(self, *args): + self._i3.send_command(f"workspace {self._workspace_num}") + + def set_focused(self, focused: bool): + if focused: + self.add_style_class("focused") + else: + self.remove_style_class("focused") + + def set_visible_on_output(self, visible: bool): + if visible: + self.add_style_class("visible") + else: + self.remove_style_class("visible") + + def set_has_windows(self, has_windows: bool): + if has_windows: + self.add_style_class("has-windows") + else: + self.remove_style_class("has-windows") + + +class FensterWorkspaces(Box): + """Container widget showing all workspaces""" + + def __init__( + self, + output: str | None = None, + i3: I3 | None = None, + buttons_factory=None, + **kwargs, + ): + super().__init__( + name=kwargs.pop("name", "workspaces"), + spacing=kwargs.pop("spacing", 4), + orientation="h", + **kwargs, + ) + + self._output = output + self._i3 = i3 or get_i3_connection() + self._buttons_factory = buttons_factory or self._default_button_factory + self._buttons = {} + + bulk_connect( + self._i3, + { + "event::workspace::focus": self._on_workspace_event, + "event::workspace::init": self._on_workspace_event, + "event::workspace::empty": self._on_workspace_event, + "event::workspace::urgent": self._on_workspace_event, + "event::window::new": self._on_window_event, + "event::window::close": self._on_window_event, + }, + ) + + if self._i3.ready: + self._refresh_workspaces() + else: + self._i3.connect("notify::ready", lambda *_: self._refresh_workspaces()) + + def _default_button_factory(self, workspace_num: int) -> FensterWorkspaceButton: + return FensterWorkspaceButton(workspace_num=workspace_num, i3=self._i3) + + def _on_workspace_event(self, _, event: I3Event): + self._refresh_workspaces() + + def _on_window_event(self, _, event: I3Event): + self._refresh_workspaces() + + def _refresh_workspaces(self): + reply = I3.send_command("", I3MessageType.GET_WORKSPACES) + if reply.is_ok and isinstance(reply.reply, list): + self._update_workspaces(reply.reply) + + def _update_workspaces(self, workspaces: list): + focused_ws = None + workspace_nums = set() + for ws in workspaces: + ws_num = ws.get("num") + if ws_num is not None: + workspace_nums.add(ws_num) + if ws.get("focused"): + focused_ws = ws_num + + # Remove buttons for workspaces that no longer exist + for ws_num in list(self._buttons.keys()): + if ws_num not in workspace_nums: + button = self._buttons.pop(ws_num) + self.remove(button) + + # Add/update buttons for current workspaces + for ws in sorted(workspaces, key=lambda w: w.get("num", 0)): + ws_num = ws.get("num") + if ws_num is None: + continue + + if ws_num not in self._buttons: + button = self._buttons_factory(ws_num) + self._buttons[ws_num] = button + self.add(button) + + button = self._buttons[ws_num] + button.set_focused(ws_num == focused_ws) + + ws_output = ws.get("output") + is_visible = ws_output == self._output if self._output is not None else False + button.set_visible_on_output(is_visible) + + window_count = ws.get("window_count", 0) + button.set_has_windows(window_count > 0) + + # Sort buttons by workspace number + sorted_buttons = sorted(self._buttons.values(), key=lambda b: b.workspace_num) + for i, button in enumerate(sorted_buttons): + self.reorder_child(button, i) + + self.show_all() + + +class FensterActiveWindow(Label): + """Label showing the title of the focused window""" + + def __init__( + self, + i3: I3 | None = None, + max_length: int = 50, + **kwargs, + ): + super().__init__( + name=kwargs.pop("name", "active-window"), + label="", + **kwargs, + ) + + self._i3 = i3 or get_i3_connection() + self._max_length = max_length + + bulk_connect( + self._i3, + { + "event::window::focus": self._on_window_event, + "event::window::title": self._on_window_event, + "event::window::close": self._on_window_close, + }, + ) + + if self._i3.ready: + self._initialize() + else: + self._i3.connect("notify::ready", lambda *_: self._initialize()) + + def _initialize(self): + tree_reply = I3.send_command("", I3MessageType.GET_TREE) + if tree_reply.is_ok and isinstance(tree_reply.reply, dict): + focused = self._find_focused(tree_reply.reply) + if focused: + self._set_title(focused.get("name", "")) + return + self.set_label("") + + def _find_focused(self, node: dict) -> dict | None: + if node.get("focused") and node.get("type") == "con": + return node + for child in node.get("nodes", []) + node.get("floating_nodes", []): + result = self._find_focused(child) + if result: + return result + return None + + def _on_window_event(self, _, event: I3Event): + container = event.data.get("container", {}) + self._set_title(container.get("name", "")) + + def _on_window_close(self, _, event: I3Event): + self._initialize() + + def _set_title(self, title: str): + if len(title) > self._max_length: + title = title[: self._max_length - 3] + "..." + self.set_label(title) diff --git a/flake.lock b/flake.lock index 73e5784..8bacb4f 100644 --- a/flake.lock +++ b/flake.lock @@ -6,15 +6,15 @@ "utils": "utils" }, "locked": { - "lastModified": 1747045720, - "narHash": "sha256-2Z0F4hnluJZunwRfx80EQXpjGLhunV2wrseT42nzh7M=", - "owner": "Makesesama", + "lastModified": 1770146720, + "narHash": "sha256-YVlwsUz4SLj8qYAb21ernT3lDB/piU1V6hTW/UjikWA=", + "owner": "Fabric-Development", "repo": "fabric", - "rev": "dae50c763e8bf2b4e5807b49b9e62425e0725cfa", + "rev": "fd2aabbd7e1859aa7c11c626a6c36a937aca736a", "type": "github" }, "original": { - "owner": "Makesesama", + "owner": "Fabric-Development", "repo": "fabric", "type": "github" } diff --git a/flake.nix b/flake.nix index 3b8aa80..2b10ad9 100644 --- a/flake.nix +++ b/flake.nix @@ -5,7 +5,7 @@ nixpkgs.url = "github:NixOS/nixpkgs/24.11"; unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; utils.url = "github:numtide/flake-utils"; - fabric.url = "github:Makesesama/fabric"; + fabric.url = "github:Fabric-Development/fabric"; home-manager.url = "github:nix-community/home-manager"; home-manager.inputs.nixpkgs.follows = "nixpkgs"; };