feat: screen-share indicator

Replace the wl-screenrec bar widget with a generic capture indicator
driven by fenster's "capture" IPC event (riverfx capture_sessions). It
shows a red dot while any output/window is captured, reveals a summary
on hover with details in the tooltip, and stops a running sims
recording on click.
This commit is contained in:
2026-10-04 19:43:02 +02:00
parent 7cf69a8c75
commit 4721976c71
7 changed files with 174 additions and 71 deletions
+5 -1
View File
@@ -35,6 +35,7 @@ from .modules.vinyl import VinylButton
from .services.org import OrgService
from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL
from .services.fenster import get_i3_connection
from .services.capture import get_capture_service
from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService
@@ -43,6 +44,9 @@ from fabric.notifications import Notifications
tray = SystemTray(name="system-tray", spacing=4)
get_i3_connection()
# Subscribe before the main loop runs so fenster's replayed capture state
# (sent right after SUBSCRIBE) is not missed.
get_capture_service()
dummy = Window(visible=False)
finder = FuzzyWindowFinder()
@@ -239,7 +243,7 @@ def spawn_bars():
display=output_name,
tray=tray if i == 0 else None,
monitor=i,
screenrec_service=screenrec_service if i == 0 else None,
screenrec_service=screenrec_service,
control_center=control_center if i == 0 else None,
org_service=org_service,
org_dropdown=org_dropdown if i == 0 else None,
+3 -6
View File
@@ -10,7 +10,7 @@ from sims.modules.control_center import ControlCenter
from sims.modules.notmuch import NotmuchWidget
from sims.modules.buddy import Buddy
from sims.modules.org import OrgDropdown, OrgWidget
from sims.modules.screenrec import ScreenrecWidget
from sims.modules.capture import CaptureWidget
from sims.modules.microphone import MicrophoneWidget
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray
@@ -103,9 +103,7 @@ class StatusBar(Window):
notmuch_service = self.notmuch.service if self.notmuch is not None else None
self.buddy = Buddy(notmuch_service=notmuch_service)
self.screenrec = None
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
self.capture = CaptureWidget(screenrec_service=screenrec_service)
self.org = None
if org_service is not None:
@@ -135,8 +133,7 @@ class StatusBar(Window):
if self.notmuch:
end_container_children.append(self.notmuch)
if self.screenrec:
end_container_children.append(self.screenrec)
end_container_children.append(self.capture)
end_container_children.append(self.date_time)
+92
View File
@@ -0,0 +1,92 @@
"""Screen-share indicator.
Minimal by default: a single red dot, visible only while fenster reports an
active screen capture (portal screen shares, wl-screenrec, ...). Hovering
slides out a short summary of what is being captured; the tooltip lists every
captured output/window. Clicking stops a running sims screen recording.
"""
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from sims.services.capture import CaptureService, get_capture_service
from sims.services.screenrec import ScreenrecService
SUMMARY_MAX_CHARS = 40
def _window_label(window: dict) -> str:
return window.get("app_id") or window.get("title") or f"window {window.get('id')}"
def _sessions_suffix(entry: dict) -> str:
sessions = entry.get("sessions", 1)
return f" ({sessions}×)" if isinstance(sessions, int) and sessions > 1 else ""
class CaptureWidget(Button):
def __init__(
self,
service: CaptureService | None = None,
screenrec_service: ScreenrecService | None = None,
**kwargs,
):
self._service = service or get_capture_service()
self._screenrec = screenrec_service
self._dot = Label(name="capture-dot", label="●")
self._summary = Label(name="capture-summary", label="")
self._revealer = Revealer(
child=self._summary,
transition_type="slide-left",
transition_duration=200,
)
super().__init__(
name="capture-widget",
child=Box(orientation="h", children=[self._revealer, self._dot]),
on_clicked=self._on_clicked,
visible=False,
**kwargs,
)
self.set_no_show_all(True)
self.connect("enter-notify-event", lambda *_: self._revealer.reveal())
self.connect("leave-notify-event", lambda *_: self._revealer.unreveal())
self._service.connect("capture-changed", self._update)
if self._screenrec is not None:
self._screenrec.connect("recording-changed", self._update)
self._update()
def _on_clicked(self, *_):
if self._screenrec is not None and self._screenrec.recording:
self._screenrec.stop()
def _update(self, *_):
outputs = self._service.outputs
windows = self._service.windows
if not (outputs or windows):
self._revealer.unreveal()
self.set_visible(False)
return
names = [o.get("name", "?") for o in outputs] + [_window_label(w) for w in windows]
summary = "Sharing " + ", ".join(names)
if len(summary) > SUMMARY_MAX_CHARS:
summary = summary[: SUMMARY_MAX_CHARS - 1] + "…"
self._summary.set_label(summary)
lines = [f"Monitor {o.get('name', '?')}{_sessions_suffix(o)}" for o in outputs]
for w in windows:
title = w.get("title")
label = _window_label(w)
detail = f"{label} — {title}" if title and title != label else label
lines.append(f"Window {detail}{_sessions_suffix(w)}")
if self._screenrec is not None and self._screenrec.recording:
lines.append("Click to stop recording")
self.set_tooltip_text("\n".join(lines))
self.set_visible(True)
-59
View File
@@ -1,59 +0,0 @@
import time
from fabric.utils import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from gi.repository import GLib
from sims.services.screenrec import ScreenrecService
class ScreenrecWidget(Button):
def __init__(self, service: ScreenrecService, **kwargs):
self._service = service
self._timer_id: int | None = None
self._dot = Label(name="screenrec-dot", label="●")
self._elapsed = Label(name="screenrec-elapsed", label="00:00")
super().__init__(
name="screenrec-widget",
child=Box(
orientation="h",
spacing=6,
children=[self._dot, self._elapsed],
),
on_clicked=lambda *_: self._service.stop(),
visible=False,
**kwargs,
)
self.set_no_show_all(True)
self._service.connect("recording-changed", self._on_recording_changed)
if self._service.recording:
self._on_recording_changed(self._service, True)
def _on_recording_changed(self, _service, recording: bool):
if recording:
self._update_elapsed()
self.set_visible(True)
if self._timer_id is None:
self._timer_id = invoke_repeater(1000, self._update_elapsed)
else:
self.set_visible(False)
if self._timer_id is not None:
try:
GLib.source_remove(self._timer_id)
except Exception:
pass
self._timer_id = None
def _update_elapsed(self) -> bool:
started = self._service.started_at
if started is None:
self._elapsed.set_text("00:00")
return True
secs = int(time.monotonic() - started)
self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}")
return True
+68
View File
@@ -0,0 +1,68 @@
"""Screen-capture IPC subscriber.
Listens to fenster's `:capture` event, which is backed by riverfx's
`capture_sessions` events (river-window-management-v1 v5+). Fenster sends the
full aggregate on every change and replays it on subscribe:
{
"change": "active" | "inactive",
"outputs": [{"name": "DP-1", "sessions": 1}],
"windows": [{"id": 42, "app_id": "firefox", "title": "...", "sessions": 1}]
}
Create the singleton before the GLib main loop starts so the replayed initial
state is not missed.
"""
from fabric.core.service import Service, Signal
from loguru import logger
from sims.services.fenster import get_i3_connection
_service: "CaptureService | None" = None
class CaptureService(Service):
@Signal
def capture_changed(self) -> None:
"""Emitted when the set of captured outputs/windows changes."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._outputs: list[dict] = []
self._windows: list[dict] = []
i3 = get_i3_connection()
i3.connect("event::capture::active", self._on_event)
i3.connect("event::capture::inactive", self._on_event)
@property
def active(self) -> bool:
return bool(self._outputs or self._windows)
@property
def outputs(self) -> list[dict]:
"""Captured outputs: [{"name": str, "sessions": int}]."""
return list(self._outputs)
@property
def windows(self) -> list[dict]:
"""Captured windows: [{"id", "app_id", "title", "sessions"}]."""
return list(self._windows)
def _on_event(self, _i3, event):
outputs = event.data.get("outputs")
windows = event.data.get("windows")
if not isinstance(outputs, list) or not isinstance(windows, list):
logger.warning(f"[Capture] unexpected event payload: {event.data!r}")
return
if outputs == self._outputs and windows == self._windows:
return
self._outputs = outputs
self._windows = windows
self.capture_changed()
def get_capture_service() -> CaptureService:
global _service
if _service is None:
_service = CaptureService()
return _service
+1
View File
@@ -71,6 +71,7 @@ class I3MessageType(IntEnum):
INPUT_EVENT = 0x80000015
# fenster extensions (event id 100+)
SMART_CORNERS_EVENT = 0x80000064
CAPTURE_EVENT = 0x80000065
@dataclass(frozen=True)
+5 -5
View File
@@ -127,7 +127,7 @@
color: var(--blue);
}
#screenrec-widget {
#capture-widget {
background: transparent;
border: none;
padding: 0 6px;
@@ -135,14 +135,14 @@
box-shadow: none;
}
#screenrec-dot {
color: #ff4444;
#capture-dot {
color: var(--red);
font-size: 14px;
}
#screenrec-elapsed {
#capture-summary {
font-size: 12px;
font-family: monospace;
margin-right: 6px;
}
tooltip {