refactor: extract reusable utils, remove screencapture facade

- Add ManagedTimer to replace repeated source_remove/timeout_add boilerplate
- Add EvdevLockService base class for numlock/capslock (cut 154→28 lines)
- Add BaseNotchIndicator for 5 near-identical indicator classes (cut 404→171 lines)
- Add load_pixbuf_thumbnail and resolve_monitor to utils
- Delete screencapture.py facade; callers use Screenshot/ScreenRecorder directly
This commit is contained in:
sankalp committed 2026-07-15 10:01:40 +05:45
1 parent ebe1238b3d
commit e63a2c04a5
16 files changed
+401 -612

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
from fabric.utils import logger
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from utils.gtk_utils import svg_file
from utils.timer import ManagedTimer
DISPLAY_MS = 2000 # how long each indicator stays visible (ms)
class BaseNotchIndicator(Box):
"""Shared timer + layout scaffolding for notch indicators.
Subclasses override :meth:`_on_activate` (called when the indicator
should become visible) and :meth:`_on_destroy` (optional extra cleanup).
"""
def __init__(
self,
name: str,
icon_name: str,
label_text: str,
show_cb,
hide_cb,
*,
icon_size: int = 16,
icon_first: bool = True,
**kwargs,
):
super().__init__(
name=name,
orientation="h",
h_expand=True,
v_align="center",
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer = ManagedTimer()
self._icon = svg_file(
icon_name,
size=icon_size,
name="notch-indicator-icon",
v_align="center",
)
self._label = Label(
name="notch-indicator-label",
label=label_text,
v_align="center",
)
if icon_first:
start, end = self._icon, self._label
else:
start, end = self._label, self._icon
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=start,
end_children=end,
)
)
def _schedule_hide(self):
self._hide_timer.schedule(DISPLAY_MS, self._hide_cb)
def destroy(self):
self._hide_timer.cancel()
try:
self._on_destroy()
except Exception as e:
logger.warning(f"[NotchIndicators] {self.get_name()} destroy: {e}")
super().destroy()
def _on_destroy(self):
"""Override for extra signal disconnection."""
+46 -266
View File
@@ -1,54 +1,25 @@
from fabric.audio import Audio
from fabric.utils import GLib, logger
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from services.battery import Battery
from services.capslock import CapsLock
from services.keyboard_layout import KeyboardLayout
from services.numlock import NumLock
from utils.gtk_utils import svg_file
DISPLAY_MS = 2000 # how long each indicator stays visible (ms)
from .indicator_base import BaseNotchIndicator
class KeyboardLayoutIndicator(Box):
class KeyboardLayoutIndicator(BaseNotchIndicator):
def __init__(self, show_cb, hide_cb, **kwargs):
self._kbd = KeyboardLayout.get_initial()
super().__init__(
name="notch-kbd-layout",
orientation="h",
h_expand=True,
v_align="center",
icon_name="notch/keyboard-layout.svg",
label_text=self._kbd.current_layout.upper(),
show_cb=show_cb,
hide_cb=hide_cb,
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer_id = 0
self._kbd = KeyboardLayout.get_initial()
self._icon = svg_file(
"notch/keyboard-layout.svg",
size=16,
name="notch-indicator-icon",
v_align="center",
)
self._label = Label(
name="notch-indicator-label",
label=self._kbd.current_layout.upper(),
v_align="center",
)
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=self._icon,
end_children=self._label,
)
)
self._kbd.connect("layout_changed", self._on_layout_changed)
def _on_layout_changed(self, _service, layout: str):
@@ -56,199 +27,68 @@ class KeyboardLayoutIndicator(Box):
self._show_cb(self)
self._schedule_hide()
def _schedule_hide(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide)
def _do_hide(self):
self._hide_timer_id = 0
self._hide_cb()
return False
def destroy(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = 0
try:
self._kbd.disconnect_by_func(self._on_layout_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] kbd disconnect: {e}")
super().destroy()
def _on_destroy(self):
self._kbd.disconnect_by_func(self._on_layout_changed)
# ── CapsLock ───────────────────────────────────────────────────────────────────
class CapsLockIndicator(Box):
class CapsLockIndicator(BaseNotchIndicator):
def __init__(self, show_cb, hide_cb, **kwargs):
self._caps = CapsLock.get_initial()
super().__init__(
name="notch-capslock",
orientation="h",
h_expand=True,
v_align="center",
icon_name="notch/caps-lock.svg",
label_text="On" if self._caps.is_on else "Off",
show_cb=show_cb,
hide_cb=hide_cb,
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer_id = 0
self._caps = CapsLock.get_initial()
self._icon = svg_file(
"notch/caps-lock.svg",
size=16,
name="notch-indicator-icon",
v_align="center",
)
self._label = Label(
name="notch-indicator-label",
label="On" if self._caps.is_on else "Off",
v_align="center",
)
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=self._icon,
end_children=self._label,
)
)
self._caps.connect("state_changed", self._on_state_changed)
def _on_state_changed(self, _service, is_on: bool):
# Show for BOTH On and Off
self._label.set_label("On" if is_on else "Off")
self._show_cb(self)
self._schedule_hide()
def _schedule_hide(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide)
def _do_hide(self):
self._hide_timer_id = 0
self._hide_cb()
return False
def destroy(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = 0
try:
self._caps.disconnect_by_func(self._on_state_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] caps disconnect: {e}")
super().destroy()
def _on_destroy(self):
self._caps.disconnect_by_func(self._on_state_changed)
class NumLockIndicator(Box):
class NumLockIndicator(BaseNotchIndicator):
def __init__(self, show_cb, hide_cb, **kwargs):
self._num = NumLock.get_initial()
super().__init__(
name="notch-numlock",
orientation="h",
h_expand=True,
v_align="center",
icon_name="notch/num-lock.svg",
label_text="On" if self._num.is_on else "Off",
show_cb=show_cb,
hide_cb=hide_cb,
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer_id = 0
self._num = NumLock.get_initial()
self._icon = svg_file(
"notch/num-lock.svg",
size=16,
name="notch-indicator-icon",
v_align="center",
)
self._label = Label(
name="notch-indicator-label",
label="On" if self._num.is_on else "Off",
v_align="center",
)
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=self._icon,
end_children=self._label,
)
)
# is_on access also starts the 500ms LED poll inside the service
self._num.connect("state_changed", self._on_state_changed)
def _on_state_changed(self, _service, is_on: bool):
# Show for BOTH On and Off
self._label.set_label("On" if is_on else "Off")
self._show_cb(self)
self._schedule_hide()
def _schedule_hide(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide)
def _do_hide(self):
self._hide_timer_id = 0
self._hide_cb()
return False
def destroy(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = 0
try:
self._num.disconnect_by_func(self._on_state_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] numlock disconnect: {e}")
super().destroy()
def _on_destroy(self):
self._num.disconnect_by_func(self._on_state_changed)
class ChargingIndicator(Box):
class ChargingIndicator(BaseNotchIndicator):
def __init__(self, show_cb, hide_cb, **kwargs):
self._battery = Battery.get_initial()
super().__init__(
name="notch-charging",
orientation="h",
h_expand=True,
v_align="center",
icon_name="notch/charging.svg",
label_text="Charging",
show_cb=show_cb,
hide_cb=hide_cb,
icon_size=24,
icon_first=False,
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer_id = 0
self._battery = Battery.get_initial()
self._label = Label(
name="notch-indicator-label",
label="Charging",
v_align="center",
)
self._icon = svg_file(
"notch/charging.svg",
size=24,
name="notch-indicator-icon",
v_align="center",
)
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=self._label,
end_children=self._icon,
)
)
self._battery.connect("changed", self._on_battery_changed)
GLib.timeout_add(500, self._initial_check)
def _is_charger_connected(self) -> bool:
@@ -268,67 +108,23 @@ class ChargingIndicator(Box):
self._schedule_hide()
self._last_charger_state = connected
def _schedule_hide(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide)
def _do_hide(self):
self._hide_timer_id = 0
self._hide_cb()
return False
def destroy(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = 0
try:
self._battery.disconnect_by_func(self._on_battery_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] charging disconnect: {e}")
super().destroy()
def _on_destroy(self):
self._battery.disconnect_by_func(self._on_battery_changed)
# ── Microphone ─────────────────────────────────────────────────────────────────
class MicrophoneIndicator(Box):
class MicrophoneIndicator(BaseNotchIndicator):
def __init__(self, show_cb, hide_cb, **kwargs):
super().__init__(
name="notch-mic",
orientation="h",
h_expand=True,
v_align="center",
**kwargs,
)
self._show_cb = show_cb
self._hide_cb = hide_cb
self._hide_timer_id = 0
self._audio = Audio()
self._mic_muted = True
self._label = Label(
name="notch-indicator-label",
label="Microphone",
v_align="center",
super().__init__(
name="notch-mic",
icon_name="notch/mic-off.svg",
label_text="Microphone",
show_cb=show_cb,
hide_cb=hide_cb,
icon_first=False,
**kwargs,
)
self._state_icon = svg_file(
"notch/mic-off.svg",
size=16,
name="notch-indicator-icon",
v_align="center",
)
self.add(
CenterBox(
orientation="h",
h_expand=True,
start_children=self._label,
end_children=self._state_icon,
)
)
self._audio.connect("notify::microphone", self._on_mic_device_changed)
if self._audio.microphone:
self._connect_mic_signals()
@@ -337,8 +133,6 @@ class MicrophoneIndicator(Box):
mic = self._audio.microphone
if mic is None:
return
# Disconnect from a previously-connected device if it changed, and
# avoid stacking duplicate handlers on the same device.
old = getattr(self, "_mic_device", None)
if old is not None and old is not mic:
try:
@@ -370,24 +164,11 @@ class MicrophoneIndicator(Box):
def _sync_state(self):
if self._audio.microphone:
self._mic_muted = self._audio.microphone.muted
self._state_icon.dynamic_file(
self._icon.dynamic_file(
"notch/mic-off.svg" if self._mic_muted else "notch/mic-on.svg"
)
def _schedule_hide(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide)
def _do_hide(self):
self._hide_timer_id = 0
self._hide_cb()
return False
def destroy(self):
if self._hide_timer_id:
GLib.source_remove(self._hide_timer_id)
self._hide_timer_id = 0
def _on_destroy(self):
try:
self._audio.disconnect_by_func(self._on_mic_device_changed)
except Exception as e:
@@ -401,4 +182,3 @@ class MicrophoneIndicator(Box):
target.disconnect_by_func(self._on_mic_stream_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] mic disconnect: {e}")
super().destroy()
+5 -5
View File
@@ -4,7 +4,7 @@ from fabric.widgets.centerbox import CenterBox
from fabric.widgets.shapes import Corner
from fabric.widgets.stack import Stack
from services.screencapture import screen_capture_service
from services.modus import screen_recorder_service
from window.controlcenter.player import get_shared_mpris_manager
from window.panel.notch.indicators import (
CapsLockIndicator,
@@ -77,8 +77,8 @@ class Notch(Box):
self._mpris.connect("new-player", self._on_new_player)
self._mpris.connect("player-vanish", self._on_player_vanish)
screen_capture_service.connect("recording-started", self._on_recording_started)
screen_capture_service.connect("recording-stopped", self._on_recording_stopped)
screen_recorder_service.connect("started", self._on_recording_started)
screen_recorder_service.connect("stopped", self._on_recording_stopped)
self.notch_stack = Stack(
name="panel-notch-stack",
@@ -156,7 +156,7 @@ class Notch(Box):
# ── Init / MPRIS / recording ──────────────────────────────────────────────
def _init_state(self):
self._is_active_recording = screen_capture_service.is_recording
self._is_active_recording = screen_recorder_service.recording
if self._is_active_recording:
self.recording_indicator.start_timer()
@@ -241,7 +241,7 @@ class Notch(Box):
now = GLib.get_monotonic_time()
if now - self._last_recording_check > 3_000_000:
self._last_recording_check = now
if self._is_active_recording and not screen_capture_service.is_recording:
if self._is_active_recording and not screen_recorder_service.recording:
self._is_active_recording = False
is_recording = False
self.recording_indicator.stop_timer()
+3 -5
View File
@@ -4,7 +4,7 @@ from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from services.screencapture import screen_capture_service
from services.modus import screen_recorder_service
from utils.gtk_utils import setup_cursor_hover, svg_file
@@ -71,12 +71,10 @@ class RecordingIndicator(Box):
def on_stop_recording(self, *args):
try:
screen_capture_service.stop_recording()
screen_recorder_service.stop()
self.stop_timer()
except Exception as e:
logger.warning(
f"[recording] screen_capture_service.stop_recording() failed: {e}"
)
logger.warning(f"[recording] screen_recorder_service.stop() failed: {e}")
def destroy(self):
self.stop_timer()
+54 -48
View File
@@ -4,7 +4,7 @@ from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.wayland import WaylandWindow as Window
from services.screencapture import screen_capture_service
from services.modus import screen_recorder_service, screenshot_service
from window.screencapture.options_menu import OptionsMenu
from window.screencapture.tool_button import ToolButton, ToolGroup
@@ -25,7 +25,7 @@ class ScreenCaptureWindow(Window):
title="modus-screencapture",
anchor="bottom",
layer="top",
keyboard_mode="on-demand",
keyboard_mode="exclusive",
margin="0px 0px 120px 0px",
visible=False,
**kwargs,
@@ -101,8 +101,8 @@ class ScreenCaptureWindow(Window):
self._tool_group.select_id("screenshot-screen")
self._tool_group.on_change(self._on_tool_changed)
screen_capture_service.connect("recording-started", self._on_recording_started)
screen_capture_service.connect("recording-stopped", self._on_recording_stopped)
screen_recorder_service.connect("started", self._on_recording_started)
screen_recorder_service.connect("stopped", self._on_recording_stopped)
self.connect("key-press-event", self._on_key_press)
@@ -167,7 +167,7 @@ class ScreenCaptureWindow(Window):
def _on_tool_changed(self, tool_id: str):
is_recording_tool = tool_id.startswith("record-")
is_active = screen_capture_service.is_recording
is_active = screen_recorder_service.recording
ctx = self._action_btn.get_style_context()
if is_active:
@@ -182,8 +182,8 @@ class ScreenCaptureWindow(Window):
def _on_action(self, *_):
# Stop an active recording
if screen_capture_service.is_recording:
screen_capture_service.stop_recording()
if screen_recorder_service.recording:
screen_recorder_service.stop()
return
delay = 0
@@ -216,15 +216,15 @@ class ScreenCaptureWindow(Window):
elif tool == "screenshot-region":
self._do_screenshot("region")
elif tool == "record-screen":
screen_capture_service.record(
screen_recorder_service.start(
"active", use_audio=use_audio, show_cursor=show_cursor
)
elif tool == "record-window":
screen_capture_service.record(
screen_recorder_service.start(
"window", use_audio=use_audio, show_cursor=show_cursor
)
elif tool == "record-region":
screen_capture_service.record(
screen_recorder_service.start(
"selection", use_audio=use_audio, show_cursor=show_cursor
)
@@ -233,10 +233,10 @@ class ScreenCaptureWindow(Window):
def _do_screenshot(self, target: str):
"""
Take a screenshot.
- On success: service emits screenshot_taken(path) → we stay hidden.
- On cancel: service emits screenshot_taken(None) → we re-show.
- If screenshot() itself fails immediately → we re-show.
The handler is always disconnected after one fire to avoid leaks.
- On success: service emits captured(path) → we stay hidden.
- On cancel: service emits failed(reason) → we re-show.
- If screenshot() itself fails immediately → we re-show.
The handlers are always disconnected after one fire to avoid leaks.
"""
# Gather options
output_dir = None
@@ -245,52 +245,58 @@ class ScreenCaptureWindow(Window):
output_dir = self._options_menu.get_save_dir()
show_cursor = self._options_menu.get_show_cursor()
_handler_id = [None]
_handler_ids = [None, None]
def _on_taken(svc, path, *_):
# Disconnect immediately — one-shot
if _handler_id[0] is not None:
try:
svc.disconnect(_handler_id[0])
except Exception as e:
logger.warning(f"[main] svc.disconnect(_handler_id[0]) failed: {e}")
_handler_id[0] = None
# Re-show only on cancel (path is None)
if not path:
GLib.timeout_add(120, self.show)
def _on_done(svc, *_args):
for hid in _handler_ids:
if hid is not None:
try:
svc.disconnect(hid)
except Exception as e:
logger.warning(f"[main] disconnect failed: {e}")
_handler_ids[0] = _handler_ids[1] = None
_handler_id[0] = screen_capture_service.connect("screenshot-taken", _on_taken)
def _on_captured(svc, path, *_):
_on_done(svc)
# success — stay hidden
def _on_failed(svc, _reason, *_):
_on_done(svc)
GLib.timeout_add(120, self.show)
_handler_ids[0] = screenshot_service.connect("captured", _on_captured)
_handler_ids[1] = screenshot_service.connect("failed", _on_failed)
def _safe_disconnect():
if _handler_id[0] is not None:
try:
screen_capture_service.disconnect(_handler_id[0])
except Exception as e:
logger.warning(
f"[main] screen_capture_service.disconnect(_handler_id[0]) failed: {e}"
)
_handler_id[0] = None
if any(hid is not None for hid in _handler_ids):
for hid in _handler_ids:
if hid is not None:
try:
screenshot_service.disconnect(hid)
except Exception as e:
logger.warning(f"[main] safe disconnect failed: {e}")
_handler_ids[0] = _handler_ids[1] = None
return False
# Safety net: disconnect if the service never emits (e.g. capture failed
# silently), otherwise the one-shot handler would leak on every shot.
GLib.timeout_add_seconds(10, _safe_disconnect)
ok = screen_capture_service.screenshot(
ok = screenshot_service.screenshot(
target,
output_dir=output_dir,
show_cursor=show_cursor,
)
if not ok:
# screenshot() returned False — clean up and re-show immediately
if _handler_id[0] is not None:
try:
screen_capture_service.disconnect(_handler_id[0])
except Exception as e:
logger.warning(
f"[main] screen_capture_service.disconnect(_handler_id[0]) failed: {e}"
)
_handler_id[0] = None
if any(hid is not None for hid in _handler_ids):
for hid in _handler_ids:
if hid is not None:
try:
screenshot_service.disconnect(hid)
except Exception as e:
logger.warning(f"[main] disconnect failed: {e}")
_handler_ids[0] = _handler_ids[1] = None
GLib.timeout_add(120, self.show)
def _on_recording_started(self, *_):
@@ -320,12 +326,12 @@ class ScreenCaptureWindow(Window):
}
self.hide()
target = target_map.get(ss, ss)
screen_capture_service.screenshot(target)
screenshot_service.screenshot(target)
return
if sr:
if screen_capture_service.is_recording:
screen_capture_service.stop_recording()
if screen_recorder_service.recording:
screen_recorder_service.stop()
return
target_map = {
@@ -339,7 +345,7 @@ class ScreenCaptureWindow(Window):
show_cursor = (
self._options_menu.get_show_cursor() if self._options_menu else False
)
screen_capture_service.record(
screen_recorder_service.start(
target_map.get(sr, sr),
use_audio=use_audio,
show_cursor=show_cursor,