diff --git a/flake.nix b/flake.nix index 7c7f648..770788b 100644 --- a/flake.nix +++ b/flake.nix @@ -148,6 +148,13 @@ description = "Directory to save recordings into"; }; }; + power = { + lock_command = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "waylock" ]; + description = "argv for the Lock action in the power menu"; + }; + }; }; }; default = { @@ -170,6 +177,9 @@ enable = false; output_dir = "~/Videos/wl-screenrec"; }; + power = { + lock_command = [ "waylock" ]; + }; }; }; }; diff --git a/scripts/sims-cli.sh b/scripts/sims-cli.sh index 19e29ea..9b44654 100755 --- a/scripts/sims-cli.sh +++ b/scripts/sims-cli.sh @@ -22,6 +22,8 @@ usage() { usage: sims-cli [args] finder open window finder apps open application launcher + power open power menu + screenshot open screenshot menu notmuch-refresh refresh unread mail count screenrec menu open screenrec menu (auto-detects state) screenrec start-monitor start recording the focused monitor @@ -35,6 +37,8 @@ EOF case "${1:-}" in finder) invoke open-finder ;; apps) invoke open-app-launcher ;; + power) invoke open-power-menu ;; + screenshot) invoke open-screenshot-menu ;; notmuch-refresh) invoke refresh-notmuch ;; screenrec) case "${2:-}" in diff --git a/sims/config.py b/sims/config.py index 81e306d..dd70487 100644 --- a/sims/config.py +++ b/sims/config.py @@ -59,6 +59,9 @@ SCREENREC = app_config.get("screenrec", { "enable": False, "output_dir": "~/Videos/wl-screenrec", }) +POWER = app_config.get("power", { + "lock_command": ["waylock"], +}) BAR_HEIGHT = app_config.get("height", 40) LOG_LEVEL = app_config.get("logLevel", "WARNING") DEV = app_config.get("dev", False) diff --git a/sims/main.py b/sims/main.py index d9f91a7..349c9cc 100644 --- a/sims/main.py +++ b/sims/main.py @@ -21,9 +21,11 @@ from fabric.utils import ( from .modules.bar import StatusBar from .modules.window_fuzzy import FuzzyWindowFinder from .modules.launcher.apps import AppLauncher +from .modules.launcher.power import PowerMenu from .modules.launcher.screenrec import ScreenrecMenu +from .modules.launcher.screenshot import ScreenshotMenu from .modules.stylix import get_stylix_css_path -from .config import SCREENREC, STYLIX +from .config import POWER, SCREENREC, STYLIX from .services.fenster import get_i3_connection from .services.screenrec import ScreenrecService @@ -34,6 +36,8 @@ i3 = get_i3_connection() dummy = Window(visible=False) finder = FuzzyWindowFinder() app_launcher = AppLauncher() +power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"])) +screenshot_menu = ScreenshotMenu() screenrec_service: ScreenrecService | None = None screenrec_menu = None @@ -46,7 +50,7 @@ if SCREENREC.get("enable", False): bar_windows = [] notmuch_widget = None -_app_windows = [dummy, finder, app_launcher] +_app_windows = [dummy, finder, app_launcher, power_menu, screenshot_menu] if screenrec_menu is not None: _app_windows.append(screenrec_menu) app = Application("sims", *_app_windows) @@ -62,6 +66,16 @@ def open_app_launcher(): app_launcher.show() +@Application.action() +def open_power_menu(): + power_menu.show() + + +@Application.action() +def open_screenshot_menu(): + screenshot_menu.show() + + @Application.action() def refresh_notmuch(): if notmuch_widget is not None: diff --git a/sims/modules/launcher/__init__.py b/sims/modules/launcher/__init__.py index fe30706..a5b9659 100644 --- a/sims/modules/launcher/__init__.py +++ b/sims/modules/launcher/__init__.py @@ -1,4 +1,10 @@ -from .base import FuzzyMenu, LauncherProvider +from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider from .windows import WindowProvider -__all__ = ["FuzzyMenu", "LauncherProvider", "WindowProvider"] +__all__ = [ + "FuzzyMenu", + "LauncherProvider", + "StaticAction", + "StaticActionProvider", + "WindowProvider", +] diff --git a/sims/modules/launcher/base.py b/sims/modules/launcher/base.py index d89b119..89800a5 100644 --- a/sims/modules/launcher/base.py +++ b/sims/modules/launcher/base.py @@ -1,7 +1,9 @@ -from typing import Any, Protocol +from dataclasses import dataclass +from typing import Any, Callable, Protocol from fabric.widgets.box import Box from fabric.widgets.entry import Entry +from fabric.widgets.label import Label from fabric.widgets.wayland import WaylandWindow as Window from gi.repository import Gdk, Gtk @@ -13,6 +15,59 @@ class LauncherProvider(Protocol): def activate(self, item: Any) -> None: ... +@dataclass +class StaticAction: + label: str + handler: Callable[[], None] + + +class StaticActionProvider: + """Provider for menus whose items are a fixed list of (label, handler) pairs. + + Pass either StaticAction instances or (label, handler) tuples; tuples are + coerced. items_factory lets the list re-evaluate on each open (e.g. for + state-dependent menus) — otherwise the list is captured at construction. + """ + + def __init__( + self, + actions: list[StaticAction | tuple[str, Callable[[], None]]] | None = None, + items_factory: Callable[[], list[StaticAction | tuple[str, Callable[[], None]]]] | None = None, + ): + if (actions is None) == (items_factory is None): + raise ValueError("pass exactly one of actions or items_factory") + self._static = [_coerce(a) for a in actions] if actions is not None else None + self._factory = items_factory + + def items(self) -> list[StaticAction]: + if self._factory is not None: + return [_coerce(a) for a in self._factory()] + return list(self._static or []) + + def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]: + if not query: + return items + q = query.lower() + return [i for i in items if q in i.label.lower()] + + def render(self, item: StaticAction) -> Gtk.Widget: + return Box( + name="slot-box", + orientation="h", + children=[Label(label=item.label, h_align="start")], + ) + + def activate(self, item: StaticAction) -> None: + item.handler() + + +def _coerce(a: StaticAction | tuple[str, Callable[[], None]]) -> StaticAction: + if isinstance(a, StaticAction): + return a + label, handler = a + return StaticAction(label=label, handler=handler) + + class FuzzyMenu(Window): def __init__( self, diff --git a/sims/modules/launcher/power.py b/sims/modules/launcher/power.py new file mode 100644 index 0000000..dfe8708 --- /dev/null +++ b/sims/modules/launcher/power.py @@ -0,0 +1,25 @@ +import subprocess + +from .base import FuzzyMenu, StaticActionProvider + + +def _spawn(argv: list[str]) -> None: + subprocess.Popen(argv, start_new_session=True) + + +def PowerMenu(monitor: int = 0, lock_command: list[str] | None = None) -> FuzzyMenu: + lock = lock_command or ["waylock"] + provider = StaticActionProvider( + actions=[ + ("⏻ Poweroff", lambda: _spawn(["systemctl", "poweroff"])), + ("🔁 Reboot", lambda: _spawn(["systemctl", "reboot"])), + ("⏾ Suspend", lambda: _spawn(["systemctl", "suspend"])), + ("Lock", lambda: _spawn(lock)), + ] + ) + return FuzzyMenu( + provider=provider, + monitor=monitor, + placeholder="Power Menu...", + window_name="power-menu", + ) diff --git a/sims/modules/launcher/screenrec.py b/sims/modules/launcher/screenrec.py index a8fbb62..71809ab 100644 --- a/sims/modules/launcher/screenrec.py +++ b/sims/modules/launcher/screenrec.py @@ -1,50 +1,24 @@ -from dataclasses import dataclass -from typing import Callable - -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from gi.repository import Gtk - from sims.services.screenrec import ScreenrecService -from .base import FuzzyMenu +from .base import FuzzyMenu, StaticActionProvider -@dataclass -class _Item: - label: str - handler: Callable[[], None] +def _idle_actions(service: ScreenrecService): + return [ + ("Monitor → Videos", lambda: service.start_monitor("videos")), + ("Region → Videos", lambda: service.start_region("videos")), + ("Monitor → Clipboard", lambda: service.start_monitor("clipboard")), + ("Region → Clipboard", lambda: service.start_region("clipboard")), + ] -class ScreenrecProvider: - def __init__(self, service: ScreenrecService): - self._service = service +def ScreenrecProvider(service: ScreenrecService) -> StaticActionProvider: + def items(): + if service.recording: + return [("Stop Recording", service.stop)] + return _idle_actions(service) - def items(self) -> list[_Item]: - if self._service.recording: - return [_Item("Stop Recording", self._service.stop)] - return [ - _Item("Monitor → Videos", lambda: self._service.start_monitor("videos")), - _Item("Region → Videos", lambda: self._service.start_region("videos")), - _Item("Monitor → Clipboard", lambda: self._service.start_monitor("clipboard")), - _Item("Region → Clipboard", lambda: self._service.start_region("clipboard")), - ] - - def filter(self, items: list[_Item], query: str) -> list[_Item]: - if not query: - return items - q = query.lower() - return [i for i in items if q in i.label.lower()] - - def render(self, item: _Item) -> Gtk.Widget: - return Box( - name="slot-box", - orientation="h", - children=[Label(label=item.label, h_align="start")], - ) - - def activate(self, item: _Item) -> None: - item.handler() + return StaticActionProvider(items_factory=items) def ScreenrecMenu(service: ScreenrecService, monitor: int = 0) -> FuzzyMenu: diff --git a/sims/modules/launcher/screenshot.py b/sims/modules/launcher/screenshot.py new file mode 100644 index 0000000..e4c4eff --- /dev/null +++ b/sims/modules/launcher/screenshot.py @@ -0,0 +1,23 @@ +import subprocess + +from .base import FuzzyMenu, StaticActionProvider + + +def _spawn(argv: list[str]) -> None: + subprocess.Popen(argv, start_new_session=True) + + +def ScreenshotMenu(monitor: int = 0) -> FuzzyMenu: + provider = StaticActionProvider( + actions=[ + ("Normal", lambda: _spawn(["grimnorm"])), + ("To Clipboard", lambda: _spawn(["grim2clip"])), + ("To Imv", lambda: _spawn(["grim2imv"])), + ] + ) + return FuzzyMenu( + provider=provider, + monitor=monitor, + placeholder="Screenshot...", + window_name="screenshot-menu", + )