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
188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
"""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")
|