Files
Modus/src/services/screenshot.py
T
sankalp f3f17bb5fc 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
2026-07-13 23:52:32 +05:45

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")