refactor(services): split screen capture into screenshot + recorder services
Split the monolithic ScreenCapture service into focused Screenshot and ScreenRecorder services behind a pluggable capture backend. - Add shared/capture.py: CaptureBackend abstraction + HyprlandCaptureBackend (hyprshot/grim/slurp/wf-recorder) and reusable CaptureNotifier - Add services/screenshot.py and services/screenrecorder.py with Fabric properties/signals; recording state kept in memory (no /tmp files) - Keep services/screencapture.py as a backwards-compatible facade so existing widgets and signals keep working - Resolve monitors via Hyprland IPC helpers in services/modus.py instead of spawning `hyprctl monitors -j` per capture - Implement real window recording (slurp window boxes) and split the record-window / record-region UI mappings - Register screenshot_service / screen_recorder_service singletons - Remove dead src/utils/inhibit.py duplicate; minor modus.lua quoting fix
This commit is contained in:
9 files changed
+1063
-566
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""Screenshot service.
|
||||
|
||||
Owns screenshot capture only; recording lives in
|
||||
:mod:`services.screenrecorder`. The service resolves the desired target
|
||||
into a concrete request, hands it to a :class:`CaptureBackend`, then raises
|
||||
notifications and signals based on the result.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import exec_shell_command_async
|
||||
|
||||
from shared.capture import (
|
||||
CaptureBackend,
|
||||
CaptureNotifier,
|
||||
CaptureResult,
|
||||
HyprlandCaptureBackend,
|
||||
ScreenshotRequest,
|
||||
)
|
||||
from utils.functions import find_binary
|
||||
|
||||
|
||||
class Screenshot(Service):
|
||||
"""Capture screenshots through a pluggable :class:`CaptureBackend`."""
|
||||
|
||||
@Signal
|
||||
def captured(self, path: str) -> None:
|
||||
"""Emitted with the saved path when a screenshot succeeds."""
|
||||
|
||||
@Signal
|
||||
def failed(self, reason: str) -> None:
|
||||
"""Emitted when a screenshot is cancelled or fails."""
|
||||
|
||||
_instance: "Screenshot | None" = None
|
||||
|
||||
def __new__(cls) -> "Screenshot":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, backend: CaptureBackend | None = None, **kwargs) -> None:
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(**kwargs)
|
||||
self._initialized = True
|
||||
|
||||
self._backend = backend or HyprlandCaptureBackend()
|
||||
self._notifier = CaptureNotifier()
|
||||
self._busy = False
|
||||
self._last_file: str | None = None
|
||||
self._has_satty = find_binary("satty") is not None
|
||||
|
||||
self.screenshots_dir = Path.home() / "Pictures" / "Screenshots"
|
||||
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -- properties --------------------------------------------------------
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def busy(self) -> bool:
|
||||
"""Whether a capture is currently in progress."""
|
||||
return self._busy
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def last_file(self) -> str:
|
||||
"""Path of the most recently captured screenshot."""
|
||||
return self._last_file or ""
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def backend(self) -> str:
|
||||
"""Name of the active capture backend."""
|
||||
return self._backend.name
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def available(self) -> bool:
|
||||
"""Whether the backend can take screenshots on this system."""
|
||||
return self._backend.capabilities.can_screenshot
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_edit(self) -> bool:
|
||||
"""Whether an external editor (``satty``) is available."""
|
||||
return self._has_satty
|
||||
|
||||
# -- public API --------------------------------------------------------
|
||||
|
||||
def screenshot(
|
||||
self,
|
||||
target: str = "region",
|
||||
output_dir: str | Path | None = None,
|
||||
show_cursor: bool = False,
|
||||
) -> bool:
|
||||
"""Take a screenshot.
|
||||
|
||||
:param target: ``region``, ``window``, ``active``, ``output`` or a
|
||||
specific monitor name.
|
||||
:param output_dir: destination directory; defaults to the standard
|
||||
screenshots directory.
|
||||
:param show_cursor: include the mouse cursor in the capture.
|
||||
:returns: ``True`` if the capture attempt was launched.
|
||||
"""
|
||||
if not self._backend.capabilities.can_screenshot:
|
||||
self._fail("Screenshot backend is unavailable")
|
||||
return False
|
||||
|
||||
request = self._build_request(target, output_dir, show_cursor)
|
||||
self._set_busy(True)
|
||||
launched = self._backend.screenshot(request, self._on_result)
|
||||
if not launched:
|
||||
self._set_busy(False)
|
||||
return launched
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
def _build_request(
|
||||
self, target: str, output_dir: str | Path | None, show_cursor: bool
|
||||
) -> 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)
|
||||
return ScreenshotRequest(
|
||||
target=target,
|
||||
save_path=save_dir / f"{timestamp}.png",
|
||||
show_cursor=show_cursor,
|
||||
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:
|
||||
self._on_success(result.path)
|
||||
elif result.cancelled:
|
||||
self._notify_cancelled()
|
||||
self.failed("cancelled")
|
||||
else:
|
||||
self._fail(result.error or "Screenshot failed")
|
||||
|
||||
def _on_success(self, path: str) -> None:
|
||||
self._last_file = path
|
||||
self.notify("last-file")
|
||||
self._notify_saved(path)
|
||||
self.captured(path)
|
||||
|
||||
def _notify_saved(self, path: str) -> None:
|
||||
actions = [("files", "Show in Files"), ("view", "View")]
|
||||
if self._has_satty:
|
||||
actions.append(("edit", "Edit"))
|
||||
self._notifier.notify(
|
||||
"Screenshot Saved",
|
||||
f"Saved Screenshot at {path}",
|
||||
icon="camera-photo-symbolic",
|
||||
actions=actions,
|
||||
hints=[f"STRING:image-path:{path}"],
|
||||
on_action=lambda action: self._handle_action(action, path),
|
||||
)
|
||||
|
||||
def _handle_action(self, action: str, path: str) -> None:
|
||||
match action:
|
||||
case "files":
|
||||
exec_shell_command_async(f"xdg-open {self.screenshots_dir}")
|
||||
case "view":
|
||||
exec_shell_command_async(f"xdg-open {path}")
|
||||
case "edit":
|
||||
exec_shell_command_async(f"satty -f {path}")
|
||||
|
||||
def _notify_cancelled(self) -> None:
|
||||
self._notifier.notify(
|
||||
"Screenshot cancelled",
|
||||
"Selection was cancelled",
|
||||
icon="camera-photo-symbolic",
|
||||
)
|
||||
|
||||
def _fail(self, reason: str) -> None:
|
||||
self.failed(reason)
|
||||
|
||||
def _set_busy(self, value: bool) -> None:
|
||||
if value != self._busy:
|
||||
self._busy = value
|
||||
self.notify("busy")
|
||||
Reference in new issue
Block a user