feat: use fenster ipc (sway/i3 compatible)

This commit is contained in:
2026-02-27 21:03:11 +01:00
parent 7962947f80
commit f1c45a7f8c
7 changed files with 344 additions and 48 deletions

View File

@@ -12,11 +12,9 @@ else:
logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}]) logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}])
from fabric import Application from fabric import Application
from fabric.i3 import I3, I3MessageType
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.river.widgets import (
get_river_connection,
)
from fabric.utils import ( from fabric.utils import (
get_relative_path, get_relative_path,
) )
@@ -24,10 +22,11 @@ from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder from .modules.window_fuzzy import FuzzyWindowFinder
from .modules.stylix import get_stylix_css_path from .modules.stylix import get_stylix_css_path
from .config import STYLIX from .config import STYLIX
from .services.fenster import get_i3_connection
tray = SystemTray(name="system-tray", spacing=4) tray = SystemTray(name="system-tray", spacing=4)
river = get_river_connection() i3 = get_i3_connection()
dummy = Window(visible=False) dummy = Window(visible=False)
finder = FuzzyWindowFinder() finder = FuzzyWindowFinder()
@@ -56,17 +55,22 @@ else:
def spawn_bars(): def spawn_bars():
global notmuch_widget global notmuch_widget
logger.info("[Bar] Spawning bars after river ready") logger.info("[Bar] Spawning bars")
outputs = river.outputs outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
if not outputs: if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
logger.warning("[Bar] No outputs found — skipping bar spawn") logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
return 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): if not outputs:
bar = StatusBar(display=output_id, tray=tray if i == 0 else None, monitor=i) 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) bar_windows.append(bar)
if i == 0 and bar.notmuch: if i == 0 and bar.notmuch:
notmuch_widget = bar.notmuch notmuch_widget = bar.notmuch
@@ -75,10 +79,10 @@ def spawn_bars():
def main(): def main():
if river.ready: if i3.ready:
spawn_bars() spawn_bars()
else: else:
river.connect("notify::ready", lambda sender, pspec: spawn_bars()) i3.connect("notify::ready", lambda *_: spawn_bars())
app.run() app.run()

View File

@@ -13,12 +13,8 @@ from bar.modules.calendar import CalendarService, CalendarPopup
from bar.modules.notmuch import NotmuchWidget from bar.modules.notmuch import NotmuchWidget
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from fabric.river.widgets import ( from bar.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
RiverWorkspaces, from bar.services.fenster import get_i3_connection
RiverWorkspaceButton,
RiverActiveWindow,
get_river_connection,
)
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from bar.services.system_stats import SystemStatsService 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): class StatusBar(Window):
def __init__( def __init__(
self, self,
display: int, display: str,
tray: SystemTray | None = None, tray: SystemTray | None = None,
monitor: int = 1, monitor: int = 1,
river_service=None,
): ):
super().__init__( super().__init__(
name="bar", name="bar",
@@ -43,17 +38,11 @@ class StatusBar(Window):
all_visible=False, all_visible=False,
monitor=monitor, monitor=monitor,
) )
if river_service:
self.river = river_service
else:
self.river = get_river_connection()
self.workspaces = RiverWorkspaces( self.workspaces = FensterWorkspaces(
display, output=display,
name="workspaces", name="workspaces",
spacing=4, spacing=4,
buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None),
river_service=self.river,
) )
# Create calendar components (refresh every 2 minutes) # Create calendar components (refresh every 2 minutes)
self.calendar_service = CalendarService(update_interval=120000) 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.calendar_service.connect("events-changed", self.update_calendar_display)
self.system_tray = tray self.system_tray = tray
self.active_window = RiverActiveWindow( self.active_window = FensterActiveWindow(
name="active-window", name="active-window",
max_length=50, max_length=50,
style="color: #ffffff; font-size: 14px; font-weight: bold;", style="color: #ffffff; font-size: 14px; font-weight: bold;",

View File

@@ -1,10 +1,10 @@
import operator from fabric.i3 import I3, I3MessageType
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.entry import Entry from fabric.widgets.entry import Entry
from fabric.utils import idle_add
from gi.repository import Gdk from gi.repository import Gdk
from bar.services.fenster import get_i3_connection
class FuzzyWindowFinder(Window): class FuzzyWindowFinder(Window):
@@ -21,7 +21,9 @@ class FuzzyWindowFinder(Window):
visible=False, 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") self.viewport = Box(name="viewport", spacing=4, orientation="v")
@@ -46,30 +48,81 @@ class FuzzyWindowFinder(Window):
self.add(self.picker_box) self.add(self.picker_box)
self.arrange_viewport("") 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, *_): def notify_text(self, entry, *_):
text = entry.get_text() text = entry.get_text()
self.arrange_viewport(text) # Update list on typing self.arrange_viewport(text)
print(text)
def on_search_entry_key_press(self, widget, event): 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]: if event.keyval in [Gdk.KEY_Escape, 103]:
self.hide() self.hide()
return True return True
return False return False
def on_search_entry_activate(self, text): 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 = ""): def arrange_viewport(self, query: str = ""):
self.viewport.children = [] # Clear previous entries 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: 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( self.viewport.add(
Box(name="slot-box", orientation="h", children=[Label(label=window)]) Box(name="slot-box", orientation="h", children=[Label(label=display_text)])
) )

29
bar/services/fenster.py Normal file
View File

@@ -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

221
bar/widgets/fenster.py Normal file
View File

@@ -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)

10
flake.lock generated
View File

@@ -6,15 +6,15 @@
"utils": "utils" "utils": "utils"
}, },
"locked": { "locked": {
"lastModified": 1747045720, "lastModified": 1770146720,
"narHash": "sha256-2Z0F4hnluJZunwRfx80EQXpjGLhunV2wrseT42nzh7M=", "narHash": "sha256-YVlwsUz4SLj8qYAb21ernT3lDB/piU1V6hTW/UjikWA=",
"owner": "Makesesama", "owner": "Fabric-Development",
"repo": "fabric", "repo": "fabric",
"rev": "dae50c763e8bf2b4e5807b49b9e62425e0725cfa", "rev": "fd2aabbd7e1859aa7c11c626a6c36a937aca736a",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "Makesesama", "owner": "Fabric-Development",
"repo": "fabric", "repo": "fabric",
"type": "github" "type": "github"
} }

View File

@@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs/24.11"; nixpkgs.url = "github:NixOS/nixpkgs/24.11";
unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
utils.url = "github:numtide/flake-utils"; 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.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs"; home-manager.inputs.nixpkgs.follows = "nixpkgs";
}; };