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

+12 -72
View File
@@ -1,77 +1,17 @@
from pathlib import Path
from fabric.core.service import Property, Service, Signal
from fabric.utils import logger
from utils.gtk_utils import EvdevLEDMonitor
# Discover CapsLock LED device
capslock_leds = list(Path("/sys/class/leds").glob("input*::capslock"))
capslock_device = capslock_leds[0] if capslock_leds else None
from utils.evdev_lock import EvdevLockService
class CapsLock(Service):
instance = None
class CapsLock(EvdevLockService):
"""Service for monitoring CapsLock LED state via evdev."""
_LED_GLOB = "input*::capslock"
_LED_INDEX = 1 # LED_CAPSL
_SERVICE_NAME = "CapsLock"
_instance = None
@staticmethod
def get_initial():
if CapsLock.instance is None:
CapsLock.instance = CapsLock()
return CapsLock.instance
@Signal
def state_changed(self, is_on: bool) -> None:
"""Signal emitted when CapsLock state changes."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.capslock_led_path = capslock_device
self._last_state = None
self._monitoring_started = False
if capslock_device is None:
logger.warning("CapsLock device not found, CapsLock service disabled")
return
# LED_CAPSL = 1
self._monitor = EvdevLEDMonitor(
self.capslock_led_path, 1, self._on_state_changed
)
def _on_state_changed(self, is_on: bool):
if is_on != self._last_state:
self._last_state = is_on
self.emit("state_changed", is_on)
def stop(self):
if hasattr(self, "_monitor"):
self._monitor.stop()
def _ensure_monitoring_started(self):
if not self._monitoring_started and self.capslock_led_path:
self._monitoring_started = True
# Set initial state before starting monitoring
self._last_state = self._get_current_state()
self._monitor._last_state = self._last_state
self._monitor.start()
def _get_current_state(self) -> bool:
if not self.capslock_led_path:
return False
brightness_path = self.capslock_led_path / "brightness"
if brightness_path.exists():
try:
return bool(int(brightness_path.read_text().strip()))
except (ValueError, OSError) as e:
logger.warning(
f"[capslock] return bool(int(brightness_path.read_text().strip())) failed: {e}"
)
return False
return False
@Property(bool, "read-write", default_value=False)
def is_on(self) -> bool:
self._ensure_monitoring_started()
return self._get_current_state()
if CapsLock._instance is None:
CapsLock._instance = CapsLock()
return CapsLock._instance
-6
View File
@@ -37,12 +37,6 @@ def __getattr__(name):
_inst = None
setattr(mod, name, _inst)
return _inst
if name == "screen_capture_service":
from services.screencapture import ScreenCapture
_inst = ScreenCapture()
setattr(mod, name, _inst)
return _inst
if name == "screenshot_service":
from services.screenshot import Screenshot
+12 -72
View File
@@ -1,77 +1,17 @@
from pathlib import Path
from fabric.core.service import Property, Service, Signal
from fabric.utils import logger
from utils.gtk_utils import EvdevLEDMonitor
# Discover NumLock LED device
numlock_leds = list(Path("/sys/class/leds").glob("input*::numlock"))
numlock_device = numlock_leds[0] if numlock_leds else None
from utils.evdev_lock import EvdevLockService
class NumLock(Service):
instance = None
class NumLock(EvdevLockService):
"""Service for monitoring NumLock LED state via evdev."""
_LED_GLOB = "input*::numlock"
_LED_INDEX = 0 # LED_NUML
_SERVICE_NAME = "NumLock"
_instance = None
@staticmethod
def get_initial():
if NumLock.instance is None:
NumLock.instance = NumLock()
return NumLock.instance
@Signal
def state_changed(self, is_on: bool) -> None:
"""Signal emitted when NumLock state changes."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.numlock_led_path = numlock_device
self._last_state = None
self._monitoring_started = False
if numlock_device is None:
logger.warning("NumLock device not found, NumLock service disabled")
return
# LED_NUML = 0
self._monitor = EvdevLEDMonitor(
self.numlock_led_path, 0, self._on_state_changed
)
def _on_state_changed(self, is_on: bool):
if is_on != self._last_state:
self._last_state = is_on
self.emit("state_changed", is_on)
def stop(self):
if hasattr(self, "_monitor"):
self._monitor.stop()
def _ensure_monitoring_started(self):
if not self._monitoring_started and self.numlock_led_path:
self._monitoring_started = True
# Set initial state before starting monitoring
self._last_state = self._get_current_state()
self._monitor._last_state = self._last_state
self._monitor.start()
def _get_current_state(self) -> bool:
if not self.numlock_led_path:
return False
brightness_path = self.numlock_led_path / "brightness"
if brightness_path.exists():
try:
return bool(int(brightness_path.read_text().strip()))
except (ValueError, OSError) as e:
logger.warning(
f"[numlock] return bool(int(brightness_path.read_text().strip())) failed: {e}"
)
return False
return False
@Property(bool, "read-write", default_value=False)
def is_on(self) -> bool:
self._ensure_monitoring_started()
return self._get_current_state()
if NumLock._instance is None:
NumLock._instance = NumLock()
return NumLock._instance
-116
View File
@@ -1,116 +0,0 @@
"""Backwards-compatible facade over the split capture services.
The screenshot and recording logic now live in :mod:`services.screenshot`
and :mod:`services.screenrecorder`. This module keeps the historical
``ScreenCapture`` API and its ``screenshot-taken`` / ``recording-started``
/ ``recording-stopped`` signals so existing widgets keep working while new
code can depend on the focused services directly.
"""
from pathlib import Path
from fabric.core.service import Property, Service, Signal
from services.screenrecorder import ScreenRecorder
from services.screenshot import Screenshot
class ScreenCapture(Service):
"""Facade delegating to :class:`Screenshot` and :class:`ScreenRecorder`."""
@Signal
def screenshot_taken(self, path: str) -> None:
"""Emitted after a screenshot attempt (``None`` path on cancel/fail)."""
@Signal
def recording_started(self, path: str) -> None:
"""Emitted when a recording starts."""
@Signal
def recording_stopped(self, path: str) -> None:
"""Emitted when a recording stops."""
_instance: "ScreenCapture | None" = None
def __new__(cls) -> "ScreenCapture":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, **kwargs) -> None:
if getattr(self, "_initialized", False):
return
super().__init__(**kwargs)
self._initialized = True
self._screenshot = Screenshot()
self._recorder = ScreenRecorder()
self.screenshots_dir = self._screenshot.screenshots_dir
self.recordings_dir = self._recorder.recordings_dir
self._screenshot.connect("captured", self._on_captured)
self._screenshot.connect("failed", self._on_screenshot_failed)
self._recorder.connect("started", self._on_recording_started)
self._recorder.connect("stopped", self._on_recording_stopped)
@property
def screenshot_service(self) -> Screenshot:
"""The underlying screenshot service."""
return self._screenshot
@property
def recorder_service(self) -> ScreenRecorder:
"""The underlying recorder service."""
return self._recorder
def screenshot(
self,
target: str = "region",
output_dir: str | Path | None = None,
show_cursor: bool = False,
) -> bool:
"""Take a screenshot. See :meth:`Screenshot.screenshot`."""
return self._screenshot.screenshot(
target, output_dir=output_dir, show_cursor=show_cursor
)
def record(
self,
target: str = "selection",
use_audio: bool = False,
show_cursor: bool = False,
) -> bool:
"""Start a recording. See :meth:`ScreenRecorder.start`."""
return self._recorder.start(
target, use_audio=use_audio, show_cursor=show_cursor
)
def stop_recording(self) -> bool:
"""Stop the active recording."""
return self._recorder.stop()
@Property(bool, "readable", default_value=False)
def is_recording(self) -> bool:
"""Whether a recorder process is currently running."""
return self._recorder.active
def _on_captured(self, _service: Screenshot, path: str) -> None:
self.screenshot_taken(path)
def _on_screenshot_failed(self, _service: Screenshot, _reason: str) -> None:
self.screenshot_taken(None)
def _on_recording_started(self, _service: ScreenRecorder, path: str) -> None:
self.recording_started(path)
def _on_recording_stopped(self, _service: ScreenRecorder, path: str) -> None:
self.recording_stopped(path)
def __getattr__(name: str):
if name == "screen_capture_service":
import sys
return sys.modules["services.modus"].screen_capture_service
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+2 -10
View File
@@ -19,7 +19,7 @@ from shared.capture import (
HyprlandCaptureBackend,
RecordingRequest,
)
from utils.functions import is_app_running
from utils.functions import is_app_running, resolve_monitor
_RECORDER_PROCESSES = ("wf-recorder", "gpu-screen-recorder")
@@ -201,17 +201,9 @@ class ScreenRecorder(Service):
output_file=self.recordings_dir / f"{timestamp}.mkv",
use_audio=use_audio,
show_cursor=show_cursor,
monitor=self._resolve_monitor(target),
monitor=resolve_monitor(target),
)
@staticmethod
def _resolve_monitor(target: str) -> str | None:
if target != "active":
return None
from services.modus import get_active_monitor_name
return get_active_monitor_name()
def _on_start_result(self, result: CaptureResult) -> None:
if result.success and result.path:
self._setup_state(result.path)
+2 -10
View File
@@ -19,7 +19,7 @@ from shared.capture import (
HyprlandCaptureBackend,
ScreenshotRequest,
)
from utils.functions import find_binary
from utils.functions import find_binary, resolve_monitor
class Screenshot(Service):
@@ -117,7 +117,7 @@ class Screenshot(Service):
) -> ScreenshotRequest:
save_dir = Path(output_dir).expanduser() if output_dir else self.screenshots_dir
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
monitor = self._resolve_monitor(target)
monitor = resolve_monitor(target)
return ScreenshotRequest(
target=target,
save_path=save_dir / f"{timestamp}.png",
@@ -125,14 +125,6 @@ class Screenshot(Service):
monitor=monitor,
)
@staticmethod
def _resolve_monitor(target: str) -> str | None:
if target != "active":
return None
from services.modus import get_active_monitor_name
return get_active_monitor_name()
def _on_result(self, result: CaptureResult) -> None:
self._set_busy(False)
if result.success and result.path: