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:
16 files changed
+401
-612
No files matched your search
@@ -0,0 +1,90 @@
|
||||
"""Parameterised evdev LED-lock service base.
|
||||
|
||||
NumLock and CapsLock services are structurally identical; only the LED
|
||||
glob pattern, LED index, and service name differ. This module provides a
|
||||
single base class that both services can inherit from.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import logger
|
||||
|
||||
from utils.gtk_utils import EvdevLEDMonitor
|
||||
|
||||
|
||||
class EvdevLockService(Service):
|
||||
"""Base service for keyboard lock-key monitoring via evdev LEDs.
|
||||
|
||||
Subclasses must set **class-level** attributes:
|
||||
|
||||
* ``_LED_GLOB`` – sysfs glob for the LED device
|
||||
(e.g. ``"input*::numlock"``).
|
||||
* ``_LED_INDEX`` – integer LED code passed to :class:`EvdevLEDMonitor`
|
||||
(0 = NUML, 1 = CAPSL).
|
||||
* ``_SERVICE_NAME`` – human-readable name for log messages.
|
||||
"""
|
||||
|
||||
_LED_GLOB: str = ""
|
||||
_LED_INDEX: int = 0
|
||||
_SERVICE_NAME: str = "EvdevLock"
|
||||
|
||||
_instance = None
|
||||
|
||||
@Signal
|
||||
def state_changed(self, is_on: bool) -> None:
|
||||
"""Emitted when the lock-key state changes."""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
leds = list(Path("/sys/class/leds").glob(self._LED_GLOB))
|
||||
self._led_path = leds[0] if leds else None
|
||||
self._last_state = None
|
||||
self._monitoring_started = False
|
||||
|
||||
if self._led_path is None:
|
||||
logger.warning(f"[{self._SERVICE_NAME}] Device not found, service disabled")
|
||||
return
|
||||
|
||||
self._monitor = EvdevLEDMonitor(
|
||||
self._led_path, self._LED_INDEX, 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._led_path:
|
||||
self._monitoring_started = True
|
||||
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._led_path:
|
||||
return False
|
||||
brightness_path = self._led_path / "brightness"
|
||||
if brightness_path.exists():
|
||||
try:
|
||||
return bool(int(brightness_path.read_text().strip()))
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"[{self._SERVICE_NAME}] Failed to read brightness: {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()
|
||||
@@ -418,6 +418,19 @@ def run_command(
|
||||
return CommandResult(1, "", str(e))
|
||||
|
||||
|
||||
def resolve_monitor(target: str) -> str | None:
|
||||
"""Resolve the ``"active"`` capture target to a concrete monitor name.
|
||||
|
||||
Returns ``None`` for non-active targets (the capture backend handles
|
||||
region/window/output directly).
|
||||
"""
|
||||
if target != "active":
|
||||
return None
|
||||
from services.modus import get_active_monitor_name
|
||||
|
||||
return get_active_monitor_name()
|
||||
|
||||
|
||||
def fuzzy_score(query: str, text: str) -> int:
|
||||
"""
|
||||
Fuzzy match scoring. Returns score > 0 if all query chars found in order.
|
||||
|
||||
+26
-1
@@ -3,7 +3,14 @@ import struct
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fabric.utils import Gdk, GLib, bulk_connect, exec_shell_command_async, logger
|
||||
from fabric.utils import (
|
||||
Gdk,
|
||||
GdkPixbuf,
|
||||
GLib,
|
||||
bulk_connect,
|
||||
exec_shell_command_async,
|
||||
logger,
|
||||
)
|
||||
from fabric.widgets.svg import Svg
|
||||
|
||||
|
||||
@@ -168,3 +175,21 @@ class EvdevLEDMonitor:
|
||||
except OSError as e:
|
||||
logger.warning(f"[utils] os.close(self._fd) failed: {e}")
|
||||
self._fd = None
|
||||
|
||||
|
||||
def load_pixbuf_thumbnail(
|
||||
path: str, width: int, height: int, *, fallback: str | None = None
|
||||
) -> GdkPixbuf.Pixbuf | None:
|
||||
"""Load a pixbuf scaled to fit within *width* x *height*.
|
||||
|
||||
Returns ``None`` on failure (file missing, corrupt, etc.). Pass a
|
||||
*fallback* path to try that on failure too.
|
||||
"""
|
||||
for p in (path, fallback):
|
||||
if p is None:
|
||||
continue
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(p, width, height, True)
|
||||
except Exception as e:
|
||||
logger.warning(f"[gtk_utils] Failed to load pixbuf from {p}: {e}")
|
||||
return None
|
||||
@@ -13,7 +13,6 @@ def __getattr__(name):
|
||||
"modus_service",
|
||||
"notification_service",
|
||||
"audio_service",
|
||||
"screen_capture_service",
|
||||
"screenshot_service",
|
||||
"screen_recorder_service",
|
||||
):
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Managed GLib timer helper.
|
||||
|
||||
Replaces the repeated schedule/cancel/cleanup pattern::
|
||||
|
||||
if self._timer_id:
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = GLib.timeout_add(ms, self._on_timeout)
|
||||
|
||||
# in destroy():
|
||||
if self._timer_id:
|
||||
GLib.source_remove(self._timer_id)
|
||||
|
||||
Usage::
|
||||
|
||||
self._hide_timer = ManagedTimer()
|
||||
# schedule
|
||||
self._hide_timer.schedule(DISPLAY_MS, self._do_hide)
|
||||
# cancel
|
||||
self._hide_timer.cancel()
|
||||
# cleanup in destroy()
|
||||
self._hide_timer.cancel()
|
||||
"""
|
||||
|
||||
from fabric.utils import GLib
|
||||
|
||||
|
||||
class ManagedTimer:
|
||||
"""A single-shot GLib timer that auto-cancels any pending source."""
|
||||
|
||||
__slots__ = ("_source_id",)
|
||||
|
||||
def __init__(self):
|
||||
self._source_id: int | None = None
|
||||
|
||||
def schedule(self, milliseconds: int, callback) -> None:
|
||||
"""Schedule *callback* after *milliseconds*.
|
||||
|
||||
If a timer is already pending it is cancelled first.
|
||||
"""
|
||||
self.cancel()
|
||||
self._source_id = GLib.timeout_add(milliseconds, self._on_fire, callback)
|
||||
|
||||
def _on_fire(self, callback):
|
||||
self._source_id = None
|
||||
callback()
|
||||
return False # one-shot
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the pending timer, if any."""
|
||||
if self._source_id is not None:
|
||||
GLib.source_remove(self._source_id)
|
||||
self._source_id = None
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self._source_id is not None
|
||||
Reference in new issue
Block a user