Compare commits

..

3 Commits

Author SHA1 Message Date
9495dfba62 feat: river-control-unstable-v1 2025-05-06 13:57:30 +02:00
0cf1c5aeb7 isort 2025-05-06 13:15:44 +02:00
f8b352d624 multiple monitor support 2025-05-06 13:09:40 +02:00
10 changed files with 338 additions and 261 deletions

View File

@ -1,185 +1,56 @@
# fabric bar.py example
# https://github.com/Fabric-Development/fabric/blob/rewrite/examples/bar/bar.py
import psutil
from loguru import logger
from fabric import Application
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.eventbox import EventBox
from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox
from fabric.system_tray.widgets import SystemTray
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.wayland import WaylandWindow as Window
from .river.widgets import RiverWorkspaces, RiverWorkspaceButton, RiverActiveWindow
from .river.widgets import (
get_river_connection,
)
from fabric.utils import (
FormattedString,
bulk_replace,
invoke_repeater,
get_relative_path,
)
from bar.modules.player import Player
from bar.modules.vinyl import VinylButton
AUDIO_WIDGET = True
if AUDIO_WIDGET is True:
try:
from fabric.audio.service import Audio
except Exception as e:
print(e)
AUDIO_WIDGET = False
class VolumeWidget(Box):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.audio = Audio()
self.progress_bar = CircularProgressBar(
name="volume-progress-bar", pie=True, size=24
)
self.event_box = EventBox(
events="scroll",
child=Overlay(
child=self.progress_bar,
overlays=Label(
label="",
style="margin: 0px 6px 0px 0px; font-size: 12px", # to center the icon glyph
),
),
)
self.audio.connect("notify::speaker", self.on_speaker_changed)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
def on_scroll(self, _, event):
match event.direction:
case 0:
self.audio.speaker.volume += 8
case 1:
self.audio.speaker.volume -= 8
return
def on_speaker_changed(self, *_):
if not self.audio.speaker:
return
self.progress_bar.value = self.audio.speaker.volume / 100
self.audio.speaker.bind(
"volume", "value", self.progress_bar, lambda _, v: v / 100
)
return
class StatusBar(Window):
def __init__(self, display: int, monitor: int = 1, with_system_tray: bool = False):
super().__init__(
name="bar",
layer="top",
anchor="left top right",
margin="0px 0px -2px 0px",
exclusivity="auto",
visible=False,
all_visible=False,
monitor=monitor,
)
self.workspaces = RiverWorkspaces(
display,
name="workspaces",
spacing=4,
buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None),
)
self.date_time = DateTime(name="date-time", formatters="%d %b - %H:%M")
self.system_tray = None
if with_system_tray:
self.system_tray = SystemTray(name="system-tray", spacing=4)
self.active_window = RiverActiveWindow(
name="active-window",
max_length=50,
style="color: #ffffff; font-size: 14px; font-weight: bold;",
)
self.ram_progress_bar = CircularProgressBar(
name="ram-progress-bar", pie=True, size=24
)
self.cpu_progress_bar = CircularProgressBar(
name="cpu-progress-bar", pie=True, size=24
)
self.progress_bars_overlay = Overlay(
child=self.ram_progress_bar,
overlays=[
self.cpu_progress_bar,
Label("", style="margin: 0px 6px 0px 0px; font-size: 12px"),
],
)
self.player = Player()
self.vinyl = VinylButton()
self.status_container = Box(
name="widgets-container",
spacing=4,
orientation="h",
children=self.progress_bars_overlay,
)
self.status_container.add(VolumeWidget()) if AUDIO_WIDGET is True else None
end_container_children = [
self.vinyl,
self.status_container,
self.date_time,
]
if self.system_tray is not None:
end_container_children = [
self.vinyl,
self.status_container,
self.system_tray,
self.date_time,
]
self.children = CenterBox(
name="bar-inner",
start_children=Box(
name="start-container",
spacing=6,
orientation="h",
children=[
Label(name="nixos-label", markup=""),
self.workspaces,
],
),
center_children=Box(
name="center-container",
spacing=4,
orientation="h",
children=[self.active_window],
),
end_children=Box(
name="end-container",
spacing=4,
orientation="h",
children=end_container_children,
),
)
invoke_repeater(1000, self.update_progress_bars)
self.show_all()
def update_progress_bars(self):
self.ram_progress_bar.value = psutil.virtual_memory().percent / 100
self.cpu_progress_bar.value = psutil.cpu_percent() / 100
return True
from .modules.bar import StatusBar
def main():
bar = StatusBar(45)
bar_two = StatusBar(44, monitor=2, with_system_tray=True)
app = Application("bar", bar, bar_two)
app.set_stylesheet_from_file(get_relative_path("bar.css"))
tray = SystemTray(name="system-tray", spacing=4)
river = get_river_connection()
# Dummy window just to hold the event loop
dummy = Window(visible=False)
# Real bar windows will be added later
bar_windows = []
def spawn_bars():
logger.info("[Bar] Spawning bars after river ready")
outputs = river.outputs
if not outputs:
logger.warning("[Bar] No outputs found — skipping bar spawn")
return
output_ids = sorted(outputs.keys())
for i, output_id in enumerate(output_ids):
print("i", i)
print("output_id", output_id)
bar = StatusBar(display=output_id, tray=tray if i == 0 else None, monitor=i)
bar_windows.append(bar)
return False
if river.ready:
print("river ready", river._ready)
spawn_bars()
else:
river.connect("notify::ready", lambda sender, pspec: spawn_bars())
app = Application("bar", dummy)
app.set_stylesheet_from_file(get_relative_path("bar.css"))
app.run()

128
bar/modules/bar.py Normal file
View File

@ -0,0 +1,128 @@
import psutil
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox
from bar.modules.player import Player
from bar.modules.vinyl import VinylButton
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray
from ..river.widgets import (
RiverWorkspaces,
RiverWorkspaceButton,
RiverActiveWindow,
get_river_connection,
)
from fabric.utils import (
invoke_repeater,
)
from fabric.widgets.circularprogressbar import CircularProgressBar
class StatusBar(Window):
def __init__(
self,
display: int,
tray: SystemTray | None = None,
monitor: int = 1,
river_service=None,
):
super().__init__(
name="bar",
layer="top",
anchor="left top right",
margin="0px 0px -2px 0px",
exclusivity="auto",
visible=False,
all_visible=False,
monitor=monitor,
)
if river_service:
self.river = river_service
else:
self.river = get_river_connection()
self.workspaces = RiverWorkspaces(
display,
name="workspaces",
spacing=4,
buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None),
river_service=self.river,
)
self.date_time = DateTime(name="date-time", formatters="%d %b - %H:%M")
self.system_tray = tray
self.active_window = RiverActiveWindow(
name="active-window",
max_length=50,
style="color: #ffffff; font-size: 14px; font-weight: bold;",
)
self.ram_progress_bar = CircularProgressBar(
name="ram-progress-bar", pie=True, size=24
)
self.cpu_progress_bar = CircularProgressBar(
name="cpu-progress-bar", pie=True, size=24
)
self.progress_label = Label(
"", style="margin: 0px 6px 0px 0px; font-size: 12px"
)
self.progress_bars_overlay = Overlay(
child=self.ram_progress_bar,
overlays=[self.cpu_progress_bar, self.progress_label],
)
self.player = Player()
self.vinyl = VinylButton()
self.status_container = Box(
name="widgets-container",
spacing=4,
orientation="h",
children=self.progress_bars_overlay,
)
end_container_children = [
self.vinyl,
self.status_container,
]
if self.system_tray:
end_container_children.append(self.system_tray)
end_container_children.append(self.date_time)
self.children = CenterBox(
name="bar-inner",
start_children=Box(
name="start-container",
spacing=6,
orientation="h",
children=[
Label(name="nixos-label", markup=""),
self.workspaces,
],
),
center_children=Box(
name="center-container",
spacing=4,
orientation="h",
children=[self.active_window],
),
end_children=Box(
name="end-container",
spacing=4,
orientation="h",
children=end_container_children,
),
)
invoke_repeater(1000, self.update_progress_bars)
self.show_all()
def update_progress_bars(self):
self.ram_progress_bar.value = psutil.virtual_memory().percent / 100
self.cpu_progress_bar.value = psutil.cpu_percent() / 100
return True

48
bar/modules/volume.py Normal file
View File

@ -0,0 +1,48 @@
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.audio.service import Audio
from fabric.widgets.eventbox import EventBox
from fabric.widgets.box import Box
from fabric.widgets.overlay import Overlay
from fabric.widgets.label import Label
class VolumeWidget(Box):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.audio = Audio()
self.progress_bar = CircularProgressBar(
name="volume-progress-bar", pie=True, size=24
)
self.event_box = EventBox(
events="scroll",
child=Overlay(
child=self.progress_bar,
overlays=Label(
label="",
style="margin: 0px 6px 0px 0px; font-size: 12px", # to center the icon glyph
),
),
)
self.audio.connect("notify::speaker", self.on_speaker_changed)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
def on_scroll(self, _, event):
match event.direction:
case 0:
self.audio.speaker.volume += 8
case 1:
self.audio.speaker.volume -= 8
return
def on_speaker_changed(self, *_):
if not self.audio.speaker:
return
self.progress_bar.value = self.audio.speaker.volume / 100
self.audio.speaker.bind(
"volume", "value", self.progress_bar, lambda _, v: v / 100
)
return

View File

@ -16,14 +16,8 @@
from __future__ import annotations
from pywayland.protocol_core import (
Argument,
ArgumentType,
Global,
Interface,
Proxy,
Resource,
)
from pywayland.protocol_core import (Argument, ArgumentType, Global, Interface,
Proxy, Resource)
class ZriverCommandCallbackV1(Interface):

View File

@ -25,7 +25,7 @@ from pywayland.protocol_core import (
Resource,
)
from ..wayland import WlSeat
from pywayland.protocol.wayland import WlSeat
from .zriver_command_callback_v1 import ZriverCommandCallbackV1

View File

@ -16,14 +16,8 @@
from __future__ import annotations
from pywayland.protocol_core import (
Argument,
ArgumentType,
Global,
Interface,
Proxy,
Resource,
)
from pywayland.protocol_core import (Argument, ArgumentType, Global, Interface,
Proxy, Resource)
class ZriverOutputStatusV1(Interface):

View File

@ -16,16 +16,9 @@
from __future__ import annotations
from pywayland.protocol_core import (
Argument,
ArgumentType,
Global,
Interface,
Proxy,
Resource,
)
from pywayland.protocol.wayland import WlOutput
from pywayland.protocol_core import (Argument, ArgumentType, Global, Interface,
Proxy, Resource)
class ZriverSeatStatusV1(Interface):

View File

@ -16,17 +16,10 @@
from __future__ import annotations
from pywayland.protocol_core import (
Argument,
ArgumentType,
Global,
Interface,
Proxy,
Resource,
)
from pywayland.protocol.wayland import WlOutput, WlSeat
from pywayland.protocol_core import (Argument, ArgumentType, Global, Interface,
Proxy, Resource)
from pywayland.protocol.wayland import WlOutput
from pywayland.protocol.wayland import WlSeat
from .zriver_output_status_v1 import ZriverOutputStatusV1
from .zriver_seat_status_v1 import ZriverSeatStatusV1

View File

@ -1,16 +1,18 @@
import os
import threading
import time
from loguru import logger
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Set
from typing import Any, Dict, List, Optional, Set
from fabric.core.service import Service, Signal, Property
from fabric.core.service import Property, Service, Signal
from fabric.utils.helpers import idle_add
from gi.repository import GLib
from loguru import logger
# Import pywayland components - ensure these imports are correct
from pywayland.client import Display
from pywayland.protocol.wayland import WlOutput, WlSeat
from .generated.river_control_unstable_v1 import ZriverControlV1
from .generated.river_status_unstable_v1 import ZriverStatusManagerV1
@ -48,7 +50,7 @@ class River(Service):
return self._active_window_title
@Signal
def ready(self):
def ready_signal(self):
return self.notify("ready")
@Signal("event", flags="detailed")
@ -61,22 +63,20 @@ class River(Service):
self._active_window_title = ""
self.outputs: Dict[int, OutputInfo] = {}
self.river_status_mgr = None
self.river_control = None
self.seat = None
self.seat_status = None
# 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 = GLib.Thread.new(
"river-status-service", self._river_connection_task
)
self.river_thread.start()
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")
# 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')}"
)
@ -84,14 +84,7 @@ class River(Service):
f"[RiverService] WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY', 'Not set')}"
)
# Create the display connection
# with Display() as display:
# registry = display.get_registry()
# logger.debug("[RiverService] Registry obtained")
# Discover globals
display = Display("wayland-1")
display = Display()
display.connect()
logger.debug("[RiverService] Display connection created")
@ -105,11 +98,11 @@ class River(Service):
"registry": registry,
"outputs": {},
"river_status_mgr": None,
"river_control": 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})"
@ -119,6 +112,11 @@ class River(Service):
name, ZriverStatusManagerV1, version
)
logger.info("[RiverService] Found river status manager")
elif iface == "zriver_control_v1":
state["river_control"] = registry.bind(
name, ZriverControlV1, version
)
logger.info("[RiverService] Found river control interface")
elif iface == "wl_output":
output = registry.bind(name, WlOutput, version)
state["outputs"][name] = OutputInfo(name=name, output=output)
@ -153,6 +151,12 @@ class River(Service):
# Handle the window title updates through seat status
if not state["river_control"]:
logger.error(
"[RiverService] River control interface not found - falling back to riverctl"
)
# You could still fall back to the old riverctl method here if needed
def focused_view_handler(_, title):
logger.debug(f"[RiverService] Focused view title: {title}")
self._active_window_title = title
@ -218,8 +222,10 @@ class River(Service):
# Update our outputs dictionary
self.outputs.update(state["outputs"])
self.river_status_mgr = state["river_status_mgr"]
self.river_control = state["river_control"]
self.seat = state["seat"]
self.seat_status = state.get("seat_status")
self._display = display
# Mark service as ready
idle_add(self._set_ready)
@ -236,11 +242,13 @@ class River(Service):
logger.error(traceback.format_exc())
return True
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()
self.ready_signal.emit()
return False # Don't repeat
def _emit_view_tags(self, output_id, tags):
@ -287,8 +295,48 @@ class River(Service):
return sorted(tags)
def run_command(self, command, *args):
def run_command(self, command, *args, callback=None):
"""Run a riverctl command"""
if not self.river_control or not self.seat:
logger.warning(
"[RiverService] River control or seat not available, falling back to riverctl"
)
return self._run_command_fallback(command, *args)
self.river_control.add_argument(command)
for arg in args:
self.river_control.add_argument(str(arg))
# Execute the command
command_callback = self.river_control.run_command(self.seat)
# Set up callback handlers
result = {"stdout": None, "stderr": None, "success": None}
def handle_success(_, output):
logger.debug(f"[RiverService] Command success: {output}")
result["stdout"] = output
result["success"] = True
if callback:
idle_add(lambda: callback(True, output, None))
def handle_failure(_, failure_message):
logger.debug(f"[RiverService] Command failure: {failure_message}")
result["stderr"] = failure_message
result["success"] = False
if callback:
idle_add(lambda: callback(False, None, failure_message))
command_callback.dispatcher["success"] = handle_success
command_callback.dispatcher["failure"] = handle_failure
if hasattr(self, "_display"):
self._display.flush()
return True
def _run_command_fallback(self, command, *args):
"""Fallback to riverctl"""
import subprocess
cmd = ["riverctl", command] + [str(arg) for arg in args]
@ -302,7 +350,7 @@ class River(Service):
)
return None
def toggle_focused_tag(self, tag):
def toggle_focused_tag(self, tag, callback=None):
"""Toggle a tag in the focused tags"""
tag_mask = 1 << int(tag)
self.run_command("set-focused-tags", str(tag_mask))
self.run_command("set-focused-tags", str(tag_mask), callback=callback)

View File

@ -1,14 +1,13 @@
from loguru import logger
from fabric.core.service import Property
from fabric.widgets.button import Button
from fabric.utils.helpers import bulk_connect
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.utils.helpers import bulk_connect
from .service import River
from gi.repository import Gdk
from loguru import logger
from .service import River
_connection: River | None = None
@ -76,12 +75,16 @@ class RiverWorkspaceButton(Button):
class RiverWorkspaces(EventBox):
def __init__(self, output_id=None, max_tags=9, **kwargs):
def __init__(self, output_id, river_service=None, max_tags=9, **kwargs):
super().__init__(events="scroll")
self.service = get_river_connection()
self._box = Box(**kwargs)
self.children = self._box
if river_service:
self.river = river_service
else:
self.river = get_river_connection()
# Store output_id as received
self.output_id = output_id
@ -94,33 +97,33 @@ class RiverWorkspaces(EventBox):
self._box.add(btn)
# 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::urgent_tags", self.on_urgent_change_general)
self.service.connect("event::output_removed", self.on_output_removed)
self.river.connect("event::focused_tags", self.on_focus_change_general)
self.river.connect("event::view_tags", self.on_view_change_general)
self.river.connect("event::urgent_tags", self.on_urgent_change_general)
self.river.connect("event::output_removed", self.on_output_removed)
# Initial setup when service is ready
if self.service.ready:
if self.river.ready:
self.on_ready(None)
else:
self.service.connect("event::ready", self.on_ready)
self.river.connect("event::ready", self.on_ready)
self.connect("scroll-event", self.on_scroll)
def on_ready(self, _):
"""Initialize widget state when service is ready"""
logger.debug(
f"[RiverWorkspaces] Service ready, outputs: {list(self.service.outputs.keys())}"
f"[RiverWorkspaces] Service ready, outputs: {list(self.river.outputs.keys())}"
)
# 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()))
if self.output_id is None and self.river.outputs:
self.output_id = next(iter(self.river.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]
if self.output_id is not None and self.output_id in self.river.outputs:
output_info = self.river.outputs[self.output_id]
# Initialize buttons with current state
# Access fields directly on the OutputInfo dataclass
@ -196,13 +199,13 @@ class RiverWorkspaces(EventBox):
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()))
if self.river.outputs:
self.output_id = next(iter(self.river.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]
if self.output_id in self.river.outputs:
output_info = self.river.outputs[self.output_id]
# Access fields directly on the OutputInfo dataclass
focused_tags = output_info.tags_focused
view_tags = output_info.tags_view
@ -214,41 +217,46 @@ class RiverWorkspaces(EventBox):
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)
self.river.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")
self.river.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")
self.river.run_command("focus-view", "previous")
class RiverActiveWindow(Label):
"""Widget to display the currently active window's title"""
def __init__(self, max_length=None, ellipsize="end", **kwargs):
def __init__(self, max_length=None, ellipsize="end", river_service=None, **kwargs):
super().__init__(**kwargs)
self.service = get_river_connection()
if river_service:
self.river = river_service
else:
self.river = get_river_connection()
self.max_length = max_length
self.ellipsize = ellipsize
# Set initial state
if self.service.ready:
if self.river.ready:
self.on_ready(None)
else:
self.service.connect("event::ready", self.on_ready)
self.river.connect("event::ready", self.on_ready)
# Connect to active window changes
self.service.connect("event::active_window", self.on_active_window_changed)
self.river.connect("event::active_window", self.on_active_window_changed)
def on_ready(self, _):
"""Initialize widget when service is ready"""
logger.debug("[RiverActiveWindow] Service ready")
self.update_title(self.service.active_window)
self.update_title(self.river.active_window)
def on_active_window_changed(self, _, event):
"""Update widget when active window changes"""