diff --git a/sims/main.py b/sims/main.py index 2e6f43a..4268daa 100644 --- a/sims/main.py +++ b/sims/main.py @@ -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, diff --git a/sims/modules/bar.py b/sims/modules/bar.py index c2362da..f666a29 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -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) diff --git a/sims/modules/capture.py b/sims/modules/capture.py new file mode 100644 index 0000000..8e8d100 --- /dev/null +++ b/sims/modules/capture.py @@ -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) diff --git a/sims/modules/screenrec.py b/sims/modules/screenrec.py deleted file mode 100644 index 24eedea..0000000 --- a/sims/modules/screenrec.py +++ /dev/null @@ -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 diff --git a/sims/services/capture.py b/sims/services/capture.py new file mode 100644 index 0000000..d2eca93 --- /dev/null +++ b/sims/services/capture.py @@ -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 diff --git a/sims/services/i3.py b/sims/services/i3.py index 69e4c66..fa31dbc 100644 --- a/sims/services/i3.py +++ b/sims/services/i3.py @@ -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) diff --git a/sims/styles/bar.css b/sims/styles/bar.css index d7601b4..352edfd 100644 --- a/sims/styles/bar.css +++ b/sims/styles/bar.css @@ -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 {