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 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")
self._ready = True
self.ready.emit()
logger.info("[RiverService] Ready. Monitoring tags.")
# Create state object to hold our data
state = {
"display": display,
"registry": registry,
"outputs": {},
"river_status_mgr": None,
"seat": None,
"seat_status": None,
}
def on_tick(self):
# Periodic poll
self.display.roundtrip()
def _on_global(self, registry, name, interface, version):
if interface == "wl_output":
# 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)
self.outputs[name] = OutputState(id=name, output=output)
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")
elif interface == "wl_seat":
self.seat = registry.bind(name, WlSeat, version)
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]),
)
)
elif interface == "zriver_status_manager_v1":
self.status_mgr = registry.bind(name, ZriverStatusManagerV1, version)
# Set up the dispatchers
registry.dispatcher["global"] = handle_global
registry.dispatcher["global_remove"] = handle_global_remove
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)
# 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_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}")
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()
return False # Don't repeat
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
return handler
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
def _decode_bitfield(self, bits: int) -> List[int]:
return [i for i in range(32) if bits & (1 << i)]
@staticmethod
def _decode_bitfields(bitfields) -> List[int]:
"""Decode River's tag bitfields into a list of tag indices"""
tags: Set[int] = set()
# Ensure we have an iterable
if not hasattr(bitfields, "__iter__"):
bitfields = [bitfields]
for bits in bitfields:
for i in range(32):
if bits & (1 << i):
tags.add(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))

View File

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