From 72b635ff42e19716f9a52e45ce86f697c6993709 Mon Sep 17 00:00:00 2001 From: Makesesama Date: Thu, 1 May 2025 23:34:41 +0200 Subject: [PATCH] init: river widget --- bar/river/service.py | 289 ++++++++++++++++++++++++++++++++----------- bar/river/widgets.py | 122 +++++++++++++++--- 2 files changed, 317 insertions(+), 94 deletions(-) diff --git a/bar/river/service.py b/bar/river/service.py index 333a8ab..f995aa3 100644 --- a/bar/river/service.py +++ b/bar/river/service.py @@ -1,29 +1,42 @@ -import logging +import os +import threading +import time +from loguru import logger from dataclasses import dataclass, field -from typing import Dict, List +from typing import Dict, List, Optional, Any, Set from fabric.core.service import Service, Signal, Property -from pywayland.client import Display -from pywayland.protocol.wayland import WlOutput, WlSeat -from ..generated.river_status_unstable_v1 import ( - ZriverStatusManagerV1, - ZriverOutputStatusV1, - ZriverSeatStatusV1, -) +from fabric.utils.helpers import idle_add -logger = logging.getLogger(__name__) +# Import pywayland components - ensure these imports are correct +from pywayland.client import Display +from pywayland.protocol.wayland import WlOutput, WlRegistry, WlSeat +from ..generated.river_status_unstable_v1 import ZriverStatusManagerV1 @dataclass -class OutputState: - id: int +class OutputInfo: + """Information about a River output""" + + name: int output: WlOutput - status: ZriverOutputStatusV1 = None - focused_tags: List[int] = field(default_factory=list) - view_tags: List[int] = field(default_factory=list) + status: Any = None # ZriverOutputStatusV1 + tags_view: List[int] = field(default_factory=list) + tags_focused: List[int] = field(default_factory=list) + + +@dataclass(frozen=True) +class RiverEvent: + """Event data from River compositor""" + + name: str + data: List[Any] + output_id: Optional[int] = None class River(Service): + """Connection to River Wayland compositor via river-status protocol""" + @Property(bool, "readable", "is-ready", default_value=False) def ready(self) -> bool: return self._ready @@ -36,82 +49,210 @@ class River(Service): def event(self, event: object): ... def __init__(self, **kwargs): + """Initialize the River service""" super().__init__(**kwargs) self._ready = False - self.display = None - self.registry = None - self.outputs: Dict[int, OutputState] = {} - self.status_mgr = None + self.outputs: Dict[int, OutputInfo] = {} + self.river_status_mgr = None self.seat = None - self.seat_status: ZriverSeatStatusV1 = None - self.ready.emit() + self.seat_status = None - def on_start(self): - print("✅ River.on_start called") + # Start the connection in a separate thread + self.river_thread = threading.Thread( + target=self._river_connection_task, daemon=True, name="river-status-service" + ) + self.river_thread.start() - logger.info("[RiverService] Starting River service...") - self.display = Display() - self.display.connect() - self.registry = self.display.get_registry() + def _river_connection_task(self): + """Main thread that connects to River and listens for events""" + try: + # Create a new display connection - THIS IS WHERE THE ERROR OCCURS + logger.info("[RiverService] Starting connection to River") - self.registry.dispatcher["global"] = self._on_global - self.registry.dispatcher["global_remove"] = lambda *_: None + # Let's add some more diagnostic info to help troubleshoot + logger.debug( + f"[RiverService] XDG_RUNTIME_DIR={os.environ.get('XDG_RUNTIME_DIR', 'Not set')}" + ) + logger.debug( + f"[RiverService] WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY', 'Not set')}" + ) - self.display.roundtrip() + # Create the display connection + # with Display() as display: + # registry = display.get_registry() + # logger.debug("[RiverService] Registry obtained") - if self.seat and self.status_mgr: - self.seat_status = self.status_mgr.get_river_seat_status(self.seat) + # Discover globals - for id, output_state in self.outputs.items(): - status = self.status_mgr.get_river_output_status(output_state.output) - output_state.status = status - status.dispatcher["focused_tags"] = self._make_focused_handler(id) - status.dispatcher["view_tags"] = self._make_view_handler(id) + display = Display("wayland-1") + display.connect() + logger.debug("[RiverService] Display connection created") - self.display.roundtrip() + # Get the registry + registry = display.get_registry() + logger.debug("[RiverService] Registry obtained") + # Create state object to hold our data + state = { + "display": display, + "registry": registry, + "outputs": {}, + "river_status_mgr": None, + "seat": None, + "seat_status": None, + } + + # Set up registry handlers - using more direct approach like your example + def handle_global(registry, name, iface, version): + logger.debug( + f"[RiverService] Global: {iface} (v{version}, name={name})" + ) + if iface == "zriver_status_manager_v1": + state["river_status_mgr"] = registry.bind( + name, ZriverStatusManagerV1, version + ) + logger.info("[RiverService] Found river status manager") + elif iface == "wl_output": + output = registry.bind(name, WlOutput, version) + state["outputs"][name] = OutputInfo(name=name, output=output) + logger.info(f"[RiverService] Found output {name}") + elif iface == "wl_seat": + state["seat"] = registry.bind(name, WlSeat, version) + logger.info("[RiverService] Found seat") + + def handle_global_remove(registry, name): + if name in state["outputs"]: + logger.info(f"[RiverService] Output {name} removed") + del state["outputs"][name] + idle_add( + lambda: self.emit( + "event::output_removed", + RiverEvent("output_removed", [name]), + ) + ) + + # Set up the dispatchers + registry.dispatcher["global"] = handle_global + registry.dispatcher["global_remove"] = handle_global_remove + + # Discover globals + logger.debug("[RiverService] Performing initial roundtrip") + display.roundtrip() + + # Check if we found the river status manager + if not state["river_status_mgr"]: + logger.error("[RiverService] River status manager not found") + return + + # Create view tags and focused tags handlers + def make_view_tags_handler(output_id): + def handler(_, tags): + decoded = self._decode_bitfields(tags) + state["outputs"][output_id].tags_view = decoded + logger.debug( + f"[RiverService] Output {output_id} view tags: {decoded}" + ) + idle_add(lambda: self._emit_view_tags(output_id, decoded)) + + return handler + + def make_focused_tags_handler(output_id): + def handler(_, tags): + decoded = self._decode_bitfields(tags) + state["outputs"][output_id].tags_focused = decoded + logger.debug( + f"[RiverService] Output {output_id} focused tags: {decoded}" + ) + idle_add(lambda: self._emit_focused_tags(output_id, decoded)) + + return handler + + # Bind output status listeners + for name, info in list(state["outputs"].items()): + status = state["river_status_mgr"].get_river_output_status(info.output) + status.dispatcher["view_tags"] = make_view_tags_handler(name) + status.dispatcher["focused_tags"] = make_focused_tags_handler(name) + info.status = status + logger.info(f"[RiverService] Set up status for output {name}") + + # Initial data fetch + logger.debug("[RiverService] Performing second roundtrip") + display.roundtrip() + + # Update our outputs dictionary + self.outputs.update(state["outputs"]) + self.river_status_mgr = state["river_status_mgr"] + self.seat = state["seat"] + self.seat_status = state.get("seat_status") + + # Mark service as ready + idle_add(self._set_ready) + + # Main event loop + logger.info("[RiverService] Entering main event loop") + while True: + display.roundtrip() + time.sleep(0.01) # Small sleep to prevent CPU spinning + + except Exception as e: + logger.error(f"[RiverService] Error in River connection: {e}") + import traceback + + logger.error(traceback.format_exc()) + + def _set_ready(self): + """Set the service as ready (called on main thread via idle_add)""" self._ready = True + logger.info("[RiverService] Service ready") self.ready.emit() - logger.info("[RiverService] Ready. Monitoring tags.") + return False # Don't repeat - def on_tick(self): - # Periodic poll - self.display.roundtrip() + def _emit_view_tags(self, output_id, tags): + """Emit view_tags events (called on main thread)""" + event = RiverEvent("view_tags", tags, output_id) + self.emit("event::view_tags", event) + self.emit(f"event::view_tags::{output_id}", tags) + return False # Don't repeat - def _on_global(self, registry, name, interface, version): - if interface == "wl_output": - output = registry.bind(name, WlOutput, version) - self.outputs[name] = OutputState(id=name, output=output) + def _emit_focused_tags(self, output_id, tags): + """Emit focused_tags events (called on main thread)""" + event = RiverEvent("focused_tags", tags, output_id) + self.emit("event::focused_tags", event) + self.emit(f"event::focused_tags::{output_id}", tags) + return False # Don't repeat - elif interface == "wl_seat": - self.seat = registry.bind(name, WlSeat, version) + @staticmethod + def _decode_bitfields(bitfields) -> List[int]: + """Decode River's tag bitfields into a list of tag indices""" + tags: Set[int] = set() - elif interface == "zriver_status_manager_v1": - self.status_mgr = registry.bind(name, ZriverStatusManagerV1, version) + # Ensure we have an iterable + if not hasattr(bitfields, "__iter__"): + bitfields = [bitfields] - def _make_focused_handler(self, output_id): - def handler(_, bitfield): - tags = self._decode_bitfield(bitfield) - self.outputs[output_id].focused_tags = tags - logger.debug(f"[RiverService] Output {output_id} focused: {tags}") - self.emit(f"event::focused_tags::{output_id}", tags) + for bits in bitfields: + for i in range(32): + if bits & (1 << i): + tags.add(i) - return handler - - def _make_view_handler(self, output_id): - def handler(_, array): - tags = self._decode_array_bitfields(array) - self.outputs[output_id].view_tags = tags - logger.debug(f"[RiverService] Output {output_id} views: {tags}") - self.emit(f"event::view_tags::{output_id}", tags) - - return handler - - def _decode_bitfield(self, bits: int) -> List[int]: - return [i for i in range(32) if bits & (1 << i)] - - def _decode_array_bitfields(self, array) -> List[int]: - tags = set() - for bits in array: - tags.update(self._decode_bitfield(bits)) return sorted(tags) + + def run_command(self, command, *args): + """Run a riverctl command""" + import subprocess + + cmd = ["riverctl", command] + [str(arg) for arg in args] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + logger.info(f"[RiverService] Ran command: {' '.join(cmd)}") + return result.stdout.strip() + except subprocess.CalledProcessError as e: + logger.error( + f"[RiverService] Command failed: {' '.join(cmd)}, error: {e.stderr}" + ) + return None + + def toggle_focused_tag(self, tag): + """Toggle a tag in the focused tags""" + tag_mask = 1 << int(tag) + self.run_command("toggle-focused-tags", str(tag_mask)) diff --git a/bar/river/widgets.py b/bar/river/widgets.py index 0d01481..9b912fb 100644 --- a/bar/river/widgets.py +++ b/bar/river/widgets.py @@ -1,3 +1,4 @@ +from loguru import logger from fabric.core.service import Property from fabric.widgets.button import Button from fabric.widgets.box import Box @@ -49,22 +50,29 @@ class RiverWorkspaceButton(Button): class RiverWorkspaces(EventBox): - def __init__(self, output_id: int, max_tags: int = 9, **kwargs): + def __init__(self, output_id=None, max_tags=9, **kwargs): super().__init__(events="scroll") - self.output_id = output_id - self.max_tags = max_tags self.service = get_river_connection() self._box = Box(**kwargs) self.children = self._box + # Store output_id as received + self.output_id = output_id + + self.max_tags = max_tags + # Create buttons for tags 0 to max_tags-1 (to match River's 0-based tag indexing) self._buttons = {i: RiverWorkspaceButton(i) for i in range(max_tags)} + for btn in self._buttons.values(): btn.connect("clicked", self.on_workspace_click) self._box.add(btn) - # hook into River signals - self.service.connect(f"event::focused_tags::{output_id}", self.on_focus_change) - self.service.connect(f"event::view_tags::{output_id}", self.on_view_change) + # Connect to service events + self.service.connect("event::focused_tags", self.on_focus_change_general) + self.service.connect("event::view_tags", self.on_view_change_general) + self.service.connect("event::output_removed", self.on_output_removed) + + # Initial setup when service is ready if self.service.ready: self.on_ready(None) else: @@ -73,27 +81,101 @@ class RiverWorkspaces(EventBox): self.connect("scroll-event", self.on_scroll) def on_ready(self, _): - print(self.service.outputs) + """Initialize widget state when service is ready""" + logger.debug( + f"[RiverWorkspaces] Service ready, outputs: {list(self.service.outputs.keys())}" + ) - def on_focus_change(self, _, tags: list[int]): - print(tags) + # If no output_id was specified, take the first one + if self.output_id is None and self.service.outputs: + self.output_id = next(iter(self.service.outputs.keys())) + logger.info(f"[RiverWorkspaces] Selected output {self.output_id}") + + # Initialize state from selected output + if self.output_id is not None and self.output_id in self.service.outputs: + output_info = self.service.outputs[self.output_id] + + # Initialize buttons with current state + # Access fields directly on the OutputInfo dataclass + focused_tags = output_info.tags_focused + view_tags = output_info.tags_view + + logger.debug( + f"[RiverWorkspaces] Initial state - focused: {focused_tags}, view: {view_tags}" + ) + + for i, btn in self._buttons.items(): + btn.active = i in focused_tags + btn.empty = i not in view_tags + + def on_focus_change(self, _, tags): + """Handle focused tags change for our specific output""" + logger.debug( + f"[RiverWorkspaces] Focus change on output {self.output_id}: {tags}" + ) for i, btn in self._buttons.items(): btn.active = i in tags - def on_view_change(self, _, tags: list[int]): - print(tags) + def on_view_change(self, _, tags): + """Handle view tags change for our specific output""" + logger.debug( + f"[RiverWorkspaces] View change on output {self.output_id}: {tags}" + ) for i, btn in self._buttons.items(): btn.empty = i not in tags - def on_workspace_click(self, btn: RiverWorkspaceButton): - import subprocess + def on_focus_change_general(self, _, event): + """Handle general focused tags event""" + # Only handle event if it's for our output + if event.output_id == self.output_id: + logger.debug( + f"[RiverWorkspaces] General focus change for output {self.output_id}" + ) + self.on_focus_change(_, event.data) - subprocess.run(["riverctl", "tag", str(btn.id)]) - return + def on_view_change_general(self, _, event): + """Handle general view tags event""" + # Only handle event if it's for our output + if event.output_id == self.output_id: + logger.debug( + f"[RiverWorkspaces] General view change for output {self.output_id}" + ) + self.on_view_change(_, event.data) - def on_scroll(self, _, event: Gdk.EventScroll): - direction = event.direction # UP or DOWN - cmd = "tag +1" if direction == Gdk.ScrollDirection.DOWN else "tag -1" - import subprocess + def on_output_removed(self, _, event): + """Handle output removal""" + removed_id = event.data[0] - subprocess.run(["riverctl", *cmd.split()]) + if removed_id == self.output_id: + logger.info(f"[RiverWorkspaces] Our output {self.output_id} was removed") + + # Try to find another output + if self.service.outputs: + self.output_id = next(iter(self.service.outputs.keys())) + logger.info(f"[RiverWorkspaces] Switching to output {self.output_id}") + + # Update state for new output + if self.output_id in self.service.outputs: + output_info = self.service.outputs[self.output_id] + # Access fields directly on the OutputInfo dataclass + focused_tags = output_info.tags_focused + view_tags = output_info.tags_view + + for i, btn in self._buttons.items(): + btn.active = i in focused_tags + btn.empty = i not in view_tags + + def on_workspace_click(self, btn): + """Handle workspace button click""" + logger.info(f"[RiverWorkspaces] Clicked on workspace {btn.id}") + self.service.toggle_focused_tag(btn.id) + + def on_scroll(self, _, event): + """Handle scroll events""" + direction = event.direction + if direction == Gdk.ScrollDirection.DOWN: + logger.info("[RiverWorkspaces] Scroll down - focusing next view") + self.service.run_command("focus-view", "next") + elif direction == Gdk.ScrollDirection.UP: + logger.info("[RiverWorkspaces] Scroll up - focusing previous view") + self.service.run_command("focus-view", "previous")