init: river widget
This commit is contained in:
parent
0fbf25d214
commit
72b635ff42
@ -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")
|
||||||
|
|
||||||
self._ready = True
|
# Create state object to hold our data
|
||||||
self.ready.emit()
|
state = {
|
||||||
logger.info("[RiverService] Ready. Monitoring tags.")
|
"display": display,
|
||||||
|
"registry": registry,
|
||||||
|
"outputs": {},
|
||||||
|
"river_status_mgr": None,
|
||||||
|
"seat": None,
|
||||||
|
"seat_status": None,
|
||||||
|
}
|
||||||
|
|
||||||
def on_tick(self):
|
# Set up registry handlers - using more direct approach like your example
|
||||||
# Periodic poll
|
def handle_global(registry, name, iface, version):
|
||||||
self.display.roundtrip()
|
logger.debug(
|
||||||
|
f"[RiverService] Global: {iface} (v{version}, name={name})"
|
||||||
def _on_global(self, registry, name, interface, version):
|
)
|
||||||
if interface == "wl_output":
|
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)
|
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":
|
def handle_global_remove(registry, name):
|
||||||
self.seat = registry.bind(name, WlSeat, version)
|
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":
|
# Set up the dispatchers
|
||||||
self.status_mgr = registry.bind(name, ZriverStatusManagerV1, version)
|
registry.dispatcher["global"] = handle_global
|
||||||
|
registry.dispatcher["global_remove"] = handle_global_remove
|
||||||
|
|
||||||
def _make_focused_handler(self, output_id):
|
# Discover globals
|
||||||
def handler(_, bitfield):
|
logger.debug("[RiverService] Performing initial roundtrip")
|
||||||
tags = self._decode_bitfield(bitfield)
|
display.roundtrip()
|
||||||
self.outputs[output_id].focused_tags = tags
|
|
||||||
logger.debug(f"[RiverService] Output {output_id} focused: {tags}")
|
# Check if we found the river status manager
|
||||||
self.emit(f"event::focused_tags::{output_id}", tags)
|
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
|
return handler
|
||||||
|
|
||||||
def _make_view_handler(self, output_id):
|
def make_focused_tags_handler(output_id):
|
||||||
def handler(_, array):
|
def handler(_, tags):
|
||||||
tags = self._decode_array_bitfields(array)
|
decoded = self._decode_bitfields(tags)
|
||||||
self.outputs[output_id].view_tags = tags
|
state["outputs"][output_id].tags_focused = decoded
|
||||||
logger.debug(f"[RiverService] Output {output_id} views: {tags}")
|
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)
|
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]:
|
@staticmethod
|
||||||
return [i for i in range(32) if bits & (1 << i)]
|
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)
|
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))
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user