init: river widget

This commit is contained in:
Makesesama 2025-05-01 23:34:41 +02:00
parent 0fbf25d214
commit 72b635ff42
2 changed files with 317 additions and 94 deletions

View File

@ -1,29 +1,42 @@
import logging import os
import threading
import time
from loguru import logger
from dataclasses import dataclass, field 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 fabric.core.service import Service, Signal, Property
from pywayland.client import Display from fabric.utils.helpers import idle_add
from pywayland.protocol.wayland import WlOutput, WlSeat
from ..generated.river_status_unstable_v1 import (
ZriverStatusManagerV1,
ZriverOutputStatusV1,
ZriverSeatStatusV1,
)
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 @dataclass
class OutputState: class OutputInfo:
id: int """Information about a River output"""
name: int
output: WlOutput output: WlOutput
status: ZriverOutputStatusV1 = None status: Any = None # ZriverOutputStatusV1
focused_tags: List[int] = field(default_factory=list) tags_view: List[int] = field(default_factory=list)
view_tags: 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): class River(Service):
"""Connection to River Wayland compositor via river-status protocol"""
@Property(bool, "readable", "is-ready", default_value=False) @Property(bool, "readable", "is-ready", default_value=False)
def ready(self) -> bool: def ready(self) -> bool:
return self._ready return self._ready
@ -36,82 +49,210 @@ class River(Service):
def event(self, event: object): ... def event(self, event: object): ...
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Initialize the River service"""
super().__init__(**kwargs) super().__init__(**kwargs)
self._ready = False self._ready = False
self.display = None self.outputs: Dict[int, OutputInfo] = {}
self.registry = None self.river_status_mgr = None
self.outputs: Dict[int, OutputState] = {}
self.status_mgr = None
self.seat = None self.seat = None
self.seat_status: ZriverSeatStatusV1 = None self.seat_status = None
self.ready.emit()
def on_start(self): # Start the connection in a separate thread
print("✅ River.on_start called") 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...") def _river_connection_task(self):
self.display = Display() """Main thread that connects to River and listens for events"""
self.display.connect() try:
self.registry = self.display.get_registry() # 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 # Let's add some more diagnostic info to help troubleshoot
self.registry.dispatcher["global_remove"] = lambda *_: None 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: # Discover globals
self.seat_status = self.status_mgr.get_river_seat_status(self.seat)
for id, output_state in self.outputs.items(): display = Display("wayland-1")
status = self.status_mgr.get_river_output_status(output_state.output) display.connect()
output_state.status = status logger.debug("[RiverService] Display connection created")
status.dispatcher["focused_tags"] = self._make_focused_handler(id)
status.dispatcher["view_tags"] = self._make_view_handler(id)
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 self._ready = True
logger.info("[RiverService] Service ready")
self.ready.emit() self.ready.emit()
logger.info("[RiverService] Ready. Monitoring tags.") return False # Don't repeat
def on_tick(self): def _emit_view_tags(self, output_id, tags):
# Periodic poll """Emit view_tags events (called on main thread)"""
self.display.roundtrip() 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): def _emit_focused_tags(self, output_id, tags):
if interface == "wl_output": """Emit focused_tags events (called on main thread)"""
output = registry.bind(name, WlOutput, version) event = RiverEvent("focused_tags", tags, output_id)
self.outputs[name] = OutputState(id=name, output=output) self.emit("event::focused_tags", event)
self.emit(f"event::focused_tags::{output_id}", tags)
return False # Don't repeat
elif interface == "wl_seat": @staticmethod
self.seat = registry.bind(name, WlSeat, version) 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": # Ensure we have an iterable
self.status_mgr = registry.bind(name, ZriverStatusManagerV1, version) if not hasattr(bitfields, "__iter__"):
bitfields = [bitfields]
def _make_focused_handler(self, output_id): for bits in bitfields:
def handler(_, bitfield): for i in range(32):
tags = self._decode_bitfield(bitfield) if bits & (1 << i):
self.outputs[output_id].focused_tags = tags tags.add(i)
logger.debug(f"[RiverService] Output {output_id} focused: {tags}")
self.emit(f"event::focused_tags::{output_id}", tags)
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) 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))

View File

@ -1,3 +1,4 @@
from loguru import logger
from fabric.core.service import Property from fabric.core.service import Property
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.box import Box from fabric.widgets.box import Box
@ -49,22 +50,29 @@ class RiverWorkspaceButton(Button):
class RiverWorkspaces(EventBox): 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") super().__init__(events="scroll")
self.output_id = output_id
self.max_tags = max_tags
self.service = get_river_connection() self.service = get_river_connection()
self._box = Box(**kwargs) self._box = Box(**kwargs)
self.children = self._box 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)} self._buttons = {i: RiverWorkspaceButton(i) for i in range(max_tags)}
for btn in self._buttons.values(): for btn in self._buttons.values():
btn.connect("clicked", self.on_workspace_click) btn.connect("clicked", self.on_workspace_click)
self._box.add(btn) self._box.add(btn)
# hook into River signals # Connect to service events
self.service.connect(f"event::focused_tags::{output_id}", self.on_focus_change) self.service.connect("event::focused_tags", self.on_focus_change_general)
self.service.connect(f"event::view_tags::{output_id}", self.on_view_change) 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: if self.service.ready:
self.on_ready(None) self.on_ready(None)
else: else:
@ -73,27 +81,101 @@ class RiverWorkspaces(EventBox):
self.connect("scroll-event", self.on_scroll) self.connect("scroll-event", self.on_scroll)
def on_ready(self, _): 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]): # If no output_id was specified, take the first one
print(tags) 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(): for i, btn in self._buttons.items():
btn.active = i in tags btn.active = i in tags
def on_view_change(self, _, tags: list[int]): def on_view_change(self, _, tags):
print(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(): for i, btn in self._buttons.items():
btn.empty = i not in tags btn.empty = i not in tags
def on_workspace_click(self, btn: RiverWorkspaceButton): def on_focus_change_general(self, _, event):
import subprocess """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)]) def on_view_change_general(self, _, event):
return """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): def on_output_removed(self, _, event):
direction = event.direction # UP or DOWN """Handle output removal"""
cmd = "tag +1" if direction == Gdk.ScrollDirection.DOWN else "tag -1" removed_id = event.data[0]
import subprocess
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")