From 7bdf23001f9e645674ed525586d1b70493a83c3d Mon Sep 17 00:00:00 2001 From: Makesesama Date: Sun, 3 May 2026 21:42:30 +0200 Subject: [PATCH] feat: add app launcher --- example-stylix-dev.yaml | 3 + flake.nix | 16 ++ scripts/sims-cli.sh | 14 ++ sims/config.py | 4 + sims/main.py | 57 ++++++- sims/modules/bar.py | 10 ++ sims/modules/launcher/apps.py | 59 ++++++++ sims/modules/launcher/base.py | 37 +++-- sims/modules/launcher/screenrec.py | 56 +++++++ sims/modules/launcher/windows.py | 8 +- sims/modules/screenrec.py | 58 ++++++++ sims/modules/stylix.py | 58 ++++++++ sims/services/screenrec.py | 230 +++++++++++++++++++++++++++++ sims/styles/bar.css | 18 +++ sims/styles/finder.css | 33 ----- sims/styles/launcher.css | 55 +++++++ sims/styles/main.css | 2 +- sims/widgets/fenster.py | 4 +- 18 files changed, 665 insertions(+), 57 deletions(-) create mode 100644 sims/modules/launcher/apps.py create mode 100644 sims/modules/launcher/screenrec.py create mode 100644 sims/modules/screenrec.py create mode 100644 sims/services/screenrec.py delete mode 100644 sims/styles/finder.css create mode 100644 sims/styles/launcher.css diff --git a/example-stylix-dev.yaml b/example-stylix-dev.yaml index 01462a8..46d44e6 100644 --- a/example-stylix-dev.yaml +++ b/example-stylix-dev.yaml @@ -13,6 +13,9 @@ notmuch: enable: true notmuch_path: "notmuch" # or full path like "/home/user/.nix-profile/bin/notmuch" emacsclient_command: "emacsclient" # or full path like "/home/user/.nix-profile/bin/emacsclient" +screenrec: + enable: true + output_dir: "~/Videos/wl-screenrec" stylix: enable: true colors: diff --git a/flake.nix b/flake.nix index 2fefdf6..7c7f648 100644 --- a/flake.nix +++ b/flake.nix @@ -136,6 +136,18 @@ description = "Path to the emacsclient binary"; }; }; + screenrec = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable the screenrec widget and menu"; + }; + output_dir = lib.mkOption { + type = lib.types.str; + default = "~/Videos/wl-screenrec"; + description = "Directory to save recordings into"; + }; + }; }; }; default = { @@ -154,6 +166,10 @@ notmuch_path = "notmuch"; emacsclient_command = "emacsclient"; }; + screenrec = { + enable = false; + output_dir = "~/Videos/wl-screenrec"; + }; }; }; }; diff --git a/scripts/sims-cli.sh b/scripts/sims-cli.sh index 14f476b..19e29ea 100755 --- a/scripts/sims-cli.sh +++ b/scripts/sims-cli.sh @@ -21,7 +21,12 @@ usage() { cat <<'EOF' >&2 usage: sims-cli [args] finder open window finder + apps open application launcher notmuch-refresh refresh unread mail count + screenrec menu open screenrec menu (auto-detects state) + screenrec start-monitor start recording the focused monitor + screenrec start-region start recording a slurp-selected region + screenrec stop stop active recording list list registered actions EOF exit 2 @@ -29,7 +34,16 @@ EOF case "${1:-}" in finder) invoke open-finder ;; + apps) invoke open-app-launcher ;; notmuch-refresh) invoke refresh-notmuch ;; + screenrec) + case "${2:-}" in + menu) invoke open-screenrec-menu ;; + start-monitor) invoke screenrec-start-monitor ;; + start-region) invoke screenrec-start-region ;; + stop) invoke screenrec-stop ;; + *) usage ;; + esac ;; list) list_actions ;; *) usage ;; esac diff --git a/sims/config.py b/sims/config.py index 58ca209..81e306d 100644 --- a/sims/config.py +++ b/sims/config.py @@ -55,6 +55,10 @@ WINDOW_TITLE = app_config.get("window_title", {"enable": True}) STYLIX = app_config.get("stylix", {"enable": False}) CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"}) NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient"}) +SCREENREC = app_config.get("screenrec", { + "enable": False, + "output_dir": "~/Videos/wl-screenrec", +}) 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 8402db8..d9f91a7 100644 --- a/sims/main.py +++ b/sims/main.py @@ -20,9 +20,12 @@ from fabric.utils import ( ) from .modules.bar import StatusBar from .modules.window_fuzzy import FuzzyWindowFinder +from .modules.launcher.apps import AppLauncher +from .modules.launcher.screenrec import ScreenrecMenu from .modules.stylix import get_stylix_css_path -from .config import STYLIX +from .config import SCREENREC, STYLIX from .services.fenster import get_i3_connection +from .services.screenrec import ScreenrecService tray = SystemTray(name="system-tray", spacing=4) @@ -30,11 +33,23 @@ i3 = get_i3_connection() dummy = Window(visible=False) finder = FuzzyWindowFinder() +app_launcher = AppLauncher() + +screenrec_service: ScreenrecService | None = None +screenrec_menu = None +if SCREENREC.get("enable", False): + screenrec_service = ScreenrecService( + output_dir=SCREENREC.get("output_dir", "~/Videos/wl-screenrec") + ) + screenrec_menu = ScreenrecMenu(screenrec_service) bar_windows = [] notmuch_widget = None -app = Application("sims", dummy, finder) +_app_windows = [dummy, finder, app_launcher] +if screenrec_menu is not None: + _app_windows.append(screenrec_menu) +app = Application("sims", *_app_windows) @Application.action() @@ -42,19 +57,46 @@ def open_finder(): finder.show() +@Application.action() +def open_app_launcher(): + app_launcher.show() + + @Application.action() def refresh_notmuch(): if notmuch_widget is not None: notmuch_widget.service.update_unread_count() + +@Application.action() +def open_screenrec_menu(): + if screenrec_menu is not None: + screenrec_menu.show() + + +@Application.action() +def screenrec_start_monitor(): + if screenrec_service is not None: + screenrec_service.start_monitor("videos") + + +@Application.action() +def screenrec_start_region(): + if screenrec_service is not None: + screenrec_service.start_region("videos") + + +@Application.action() +def screenrec_stop(): + if screenrec_service is not None: + screenrec_service.stop() + # Load CSS - use Stylix if enabled, otherwise use default if STYLIX.get("enable", False): stylix_css_path = get_stylix_css_path() if stylix_css_path: logger.info("[Bar] Using Stylix CSS") - # Load base styles first for structure app.set_stylesheet_from_file(get_relative_path("styles/main.css")) - # Then apply Stylix theme colors app.set_stylesheet_from_file(stylix_css_path) else: logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default") @@ -81,7 +123,12 @@ def spawn_bars(): for i, output in enumerate(outputs): output_name = output.get("name", f"Unknown-{i}") - bar = StatusBar(display=output_name, tray=tray if i == 0 else None, monitor=i) + bar = StatusBar( + display=output_name, + tray=tray if i == 0 else None, + monitor=i, + screenrec_service=screenrec_service if i == 0 else None, + ) bar_windows.append(bar) if i == 0 and bar.notmuch: notmuch_widget = bar.notmuch diff --git a/sims/modules/bar.py b/sims/modules/bar.py index 9759309..8018232 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -11,10 +11,12 @@ from sims.modules.quick_menu import QuickMenuOpener from sims.modules.battery import Battery from sims.modules.calendar import CalendarService, CalendarPopup from sims.modules.notmuch import NotmuchWidget +from sims.modules.screenrec import ScreenrecWidget from fabric.widgets.wayland import WaylandWindow as Window from fabric.system_tray.widgets import SystemTray from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow from sims.services.fenster import get_i3_connection +from sims.services.screenrec import ScreenrecService from fabric.widgets.circularprogressbar import CircularProgressBar from sims.services.system_stats import SystemStatsService @@ -27,6 +29,7 @@ class StatusBar(Window): display: str, tray: SystemTray | None = None, monitor: int = 1, + screenrec_service: ScreenrecService | None = None, ): super().__init__( name="sims", @@ -102,6 +105,10 @@ class StatusBar(Window): if NOTMUCH["enable"]: self.notmuch = NotmuchWidget() + self.screenrec = None + if screenrec_service is not None: + self.screenrec = ScreenrecWidget(screenrec_service) + self.status_container = Box( name="widgets-container", spacing=4, @@ -121,6 +128,9 @@ class StatusBar(Window): if self.notmuch: end_container_children.append(self.notmuch) + if self.screenrec: + end_container_children.append(self.screenrec) + # Add quick menu button next to time end_container_children.append(self.quick_menu) end_container_children.append(self.date_time) diff --git a/sims/modules/launcher/apps.py b/sims/modules/launcher/apps.py new file mode 100644 index 0000000..4c61d3f --- /dev/null +++ b/sims/modules/launcher/apps.py @@ -0,0 +1,59 @@ +from fabric.utils.helpers import DesktopApp, get_desktop_applications +from fabric.widgets.box import Box +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from gi.repository import Gtk + +from .base import FuzzyMenu + + +ICON_SIZE = 32 + + +class AppProvider: + def items(self) -> list[DesktopApp]: + return get_desktop_applications() + + def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]: + if not query: + return items + q = query.lower() + return [a for a in items if _matches(a, q)] + + def render(self, item: DesktopApp) -> Gtk.Widget: + children: list[Gtk.Widget] = [] + + pixbuf = item.get_icon_pixbuf(size=ICON_SIZE) + if pixbuf is not None: + children.append(Image(pixbuf=pixbuf, name="app-icon")) + + primary = item.display_name or item.name or "" + text_box = Box(name="app-text", orientation="v", spacing=0) + text_box.add(Label(label=primary, name="app-name", h_align="start")) + if item.generic_name and item.generic_name != primary: + text_box.add( + Label(label=item.generic_name, name="app-generic", h_align="start") + ) + children.append(text_box) + + return Box(name="slot-box", orientation="h", spacing=10, children=children) + + def activate(self, item: DesktopApp) -> None: + item.launch() + + +def _matches(app: DesktopApp, q: str) -> bool: + for field in (app.name, app.display_name, app.generic_name, app.executable): + if field and q in field.lower(): + return True + return False + + +def AppLauncher(monitor: int = 0) -> FuzzyMenu: + return FuzzyMenu( + provider=AppProvider(), + monitor=monitor, + placeholder="Search Apps...", + window_name="app-launcher", + max_results=8, + ) diff --git a/sims/modules/launcher/base.py b/sims/modules/launcher/base.py index 23407bf..d89b119 100644 --- a/sims/modules/launcher/base.py +++ b/sims/modules/launcher/base.py @@ -19,9 +19,12 @@ class FuzzyMenu(Window): provider: LauncherProvider, monitor: int = 0, placeholder: str = "Search...", + window_name: str = "finder", + max_results: int | None = None, ): + self._max_results = max_results super().__init__( - name="finder", + name=window_name, anchor="center", monitor=monitor, keyboard_mode="on-demand", @@ -41,8 +44,6 @@ class FuzzyMenu(Window): h_expand=True, editable=True, notify_text=self._on_text_changed, - on_activate=lambda *_: self._activate_selected(), - on_key_press_event=self._on_key_press, ) self.picker_box = Box( name="picker-box", @@ -52,6 +53,7 @@ class FuzzyMenu(Window): ) self.add(self.picker_box) + self.connect("key-press-event", self._on_key_press) self._refresh_viewport("") def show(self): @@ -60,29 +62,40 @@ class FuzzyMenu(Window): self._selected_index = 0 self._refresh_viewport("") super().show() + self.search_entry.grab_focus() def _on_text_changed(self, entry, *_): self._selected_index = 0 self._refresh_viewport(entry.get_text()) def _on_key_press(self, _widget, event): - if event.keyval == Gdk.KEY_Escape: + ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK) + keyval = event.keyval + + if keyval == Gdk.KEY_Escape: self.hide() return True - if event.keyval == Gdk.KEY_Down: - if self._filtered: - self._selected_index = (self._selected_index + 1) % len(self._filtered) - self._update_selection_highlight() + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + self._activate_selected() return True - if event.keyval == Gdk.KEY_Up: - if self._filtered: - self._selected_index = (self._selected_index - 1) % len(self._filtered) - self._update_selection_highlight() + if keyval == Gdk.KEY_Down or (ctrl and keyval in (Gdk.KEY_n, Gdk.KEY_N)): + self._move_selection(1) + return True + if keyval == Gdk.KEY_Up or (ctrl and keyval in (Gdk.KEY_p, Gdk.KEY_P)): + self._move_selection(-1) return True return False + def _move_selection(self, delta: int): + if not self._filtered: + return + self._selected_index = (self._selected_index + delta) % len(self._filtered) + self._update_selection_highlight() + def _refresh_viewport(self, query: str): self._filtered = self._provider.filter(self._items, query) + if self._max_results is not None: + self._filtered = self._filtered[: self._max_results] if self._selected_index >= len(self._filtered): self._selected_index = 0 self.viewport.children = [] diff --git a/sims/modules/launcher/screenrec.py b/sims/modules/launcher/screenrec.py new file mode 100644 index 0000000..a8fbb62 --- /dev/null +++ b/sims/modules/launcher/screenrec.py @@ -0,0 +1,56 @@ +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 + + +@dataclass +class _Item: + label: str + handler: Callable[[], None] + + +class ScreenrecProvider: + def __init__(self, service: ScreenrecService): + self._service = 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() + + +def ScreenrecMenu(service: ScreenrecService, monitor: int = 0) -> FuzzyMenu: + return FuzzyMenu( + provider=ScreenrecProvider(service), + monitor=monitor, + placeholder="Screen Recording...", + window_name="screenrec-menu", + ) diff --git a/sims/modules/launcher/windows.py b/sims/modules/launcher/windows.py index 5037097..9c2798b 100644 --- a/sims/modules/launcher/windows.py +++ b/sims/modules/launcher/windows.py @@ -19,16 +19,16 @@ class WindowProvider: if con.get("type") == "con": windows.append({ "id": con.get("id"), - "app_id": con.get("app_id", ""), - "title": con.get("name", ""), + "app_id": con.get("app_id") or "", + "title": con.get("name") or "", "workspace": ws_num, }) for con in ws_node.get("floating_nodes", []): if con.get("type") == "con": windows.append({ "id": con.get("id"), - "app_id": con.get("app_id", ""), - "title": con.get("name", ""), + "app_id": con.get("app_id") or "", + "title": con.get("name") or "", "workspace": ws_num, }) return windows diff --git a/sims/modules/screenrec.py b/sims/modules/screenrec.py new file mode 100644 index 0000000..ecbd48c --- /dev/null +++ b/sims/modules/screenrec.py @@ -0,0 +1,58 @@ +import time + +from fabric.utils import invoke_repeater +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from gi.repository import GLib + +from sims.services.screenrec import ScreenrecService + + +class ScreenrecWidget(Button): + def __init__(self, service: ScreenrecService, **kwargs): + self._service = service + self._timer_id: int | None = None + + self._dot = Label(name="screenrec-dot", label="●") + self._elapsed = Label(name="screenrec-elapsed", label="00:00") + + super().__init__( + name="screenrec-widget", + child=Box( + orientation="h", + spacing=6, + children=[self._dot, self._elapsed], + ), + on_clicked=lambda *_: self._service.stop(), + visible=False, + **kwargs, + ) + + self._service.connect("recording-changed", self._on_recording_changed) + if self._service.recording: + self._on_recording_changed(self._service, True) + + def _on_recording_changed(self, _service, recording: bool): + if recording: + self._update_elapsed() + self.set_visible(True) + if self._timer_id is None: + self._timer_id = invoke_repeater(1000, self._update_elapsed) + else: + self.set_visible(False) + if self._timer_id is not None: + try: + GLib.source_remove(self._timer_id) + except Exception: + pass + self._timer_id = None + + def _update_elapsed(self) -> bool: + started = self._service.started_at + if started is None: + self._elapsed.set_text("00:00") + return True + secs = int(time.monotonic() - started) + self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}") + return True diff --git a/sims/modules/stylix.py b/sims/modules/stylix.py index b2ceee7..aab5bc3 100644 --- a/sims/modules/stylix.py +++ b/sims/modules/stylix.py @@ -361,6 +361,64 @@ tooltip>* {{ .toggle-inactive {{ background-color: #{colors["base02"]}; }} + +/* Launcher (FuzzyMenu — finder, app launcher, screenrec menu) */ +#picker-box {{ + background-color: #{colors["base00"]}; + border: solid 1px #{colors["base02"]}; + border-radius: 8px; + padding: 12px; + color: #{colors["base05"]}; +}} + +#search-entry {{ + background-color: #{colors["base01"]}; + color: #{colors["base05"]}; + border: solid 1px #{colors["base02"]}; + border-radius: 6px; + padding: 6px 10px; +}} + +#viewport {{ + background-color: #{colors["base00"]}; + border-radius: 6px; + padding: 4px; + color: #{colors["base05"]}; +}} + +#viewport > * {{ + background-color: #{colors["base01"]}; + border-left: 3px solid transparent; + border-radius: 4px; + padding: 8px 12px; + margin-bottom: 4px; + transition: background-color 80ms ease, border-color 80ms ease; +}} + +#viewport > *.selected {{ + background-color: alpha(#{colors["base0D"]}, 0.28); + border-left-color: #{colors["base0D"]}; + color: #{colors["base05"]}; + font-weight: 600; +}} + +#viewport > *.selected label {{ + color: #{colors["base05"]}; +}} + +#app-icon {{ + margin-right: 4px; +}} + +#app-name {{ + color: #{colors["base05"]}; + font-weight: 500; +}} + +#app-generic {{ + color: #{colors["base04"]}; + font-size: {small_font}px; +}} """ # Write to temporary file diff --git a/sims/services/screenrec.py b/sims/services/screenrec.py new file mode 100644 index 0000000..7510407 --- /dev/null +++ b/sims/services/screenrec.py @@ -0,0 +1,230 @@ +"""wl-screenrec process manager. + +Owns the wl-screenrec subprocess so the bar reflects real recording state +without polling. Survives bar restarts via orphan adoption. +""" + +import os +import signal +import subprocess +import time +from datetime import datetime +from typing import Literal + +from fabric.core.service import Service, Signal +from fabric.i3 import I3, I3MessageType +from gi.repository import GLib +from loguru import logger + +Destination = Literal["videos", "clipboard"] + + +class ScreenrecService(Service): + @Signal + def recording_changed(self, recording: bool) -> None: ... + + def __init__(self, output_dir: str = "~/Videos/wl-screenrec"): + super().__init__() + self._output_dir = os.path.expanduser(output_dir) + self._proc: subprocess.Popen | None = None + self._adopted_pid: int | None = None + self._started_at: float | None = None + self._output_path: str | None = None + self._destination: Destination | None = None + self._watch_id: int | None = None + + self._adopt_orphan_if_running() + + @property + def recording(self) -> bool: + return self._proc is not None or self._adopted_pid is not None + + @property + def started_at(self) -> float | None: + return self._started_at + + def start_monitor(self, dest: Destination = "videos", output: str | None = None) -> None: + if self.recording: + logger.warning("[Screenrec] start_monitor: already recording") + return + path = self._make_output_path(dest) + + # Prefer an explicit output name when the caller passes one, but fenster's + # IPC reports synthetic names ("Unknown-XXXX") that wl-screenrec can't + # resolve. Fall back to the focused output's geometry, which works + # across i3/sway/fenster. + if output and not output.startswith("Unknown-"): + self._spawn(["wl-screenrec", "-o", output, "-f", path], path, dest) + return + + geom = self._focused_output_geometry() + if not geom: + logger.error("[Screenrec] no focused output found") + return + self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest) + + def start_region(self, dest: Destination = "videos") -> None: + if self.recording: + logger.warning("[Screenrec] start_region: already recording") + return + geom = self._slurp_region() + if not geom: + logger.info("[Screenrec] region selection cancelled") + return + path = self._make_output_path(dest) + self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest) + + def stop(self) -> None: + if self._proc is not None: + logger.info("[Screenrec] sending SIGINT to wl-screenrec") + try: + self._proc.send_signal(signal.SIGINT) + except ProcessLookupError: + self._on_exit(self._proc.pid, 0) + return + if self._adopted_pid is not None: + logger.info(f"[Screenrec] sending SIGINT to adopted pid {self._adopted_pid}") + try: + os.kill(self._adopted_pid, signal.SIGINT) + except ProcessLookupError: + pass + self._poll_adopted_until_gone() + + def _spawn(self, argv: list[str], path: str, dest: Destination) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + logger.info(f"[Screenrec] spawning: {' '.join(argv)}") + try: + self._proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except FileNotFoundError: + logger.error("[Screenrec] wl-screenrec not found on PATH") + return + self._output_path = path + self._destination = dest + self._started_at = time.monotonic() + self._watch_id = GLib.child_watch_add( + GLib.PRIORITY_DEFAULT, self._proc.pid, self._on_exit + ) + self.recording_changed(True) + + def _on_exit(self, pid: int, status: int) -> None: + logger.info(f"[Screenrec] wl-screenrec exited (pid={pid} status={status})") + path = self._output_path + dest = self._destination + self._reset_state() + if dest == "clipboard" and path and os.path.exists(path): + self._copy_to_clipboard(path) + self.recording_changed(False) + + def _reset_state(self) -> None: + self._proc = None + self._adopted_pid = None + self._started_at = None + self._output_path = None + self._destination = None + if self._watch_id is not None: + try: + GLib.source_remove(self._watch_id) + except Exception: + pass + self._watch_id = None + + def _copy_to_clipboard(self, path: str) -> None: + try: + with open(path, "rb") as f: + subprocess.Popen( + ["wl-copy", "-t", "video/mp4"], + stdin=f, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info(f"[Screenrec] copied {path} to clipboard as video/mp4") + except FileNotFoundError: + logger.error("[Screenrec] wl-copy not found on PATH") + + def _slurp_region(self) -> str | None: + try: + result = subprocess.run( + ["slurp"], capture_output=True, text=True, check=False + ) + except FileNotFoundError: + logger.error("[Screenrec] slurp not found on PATH") + return None + geom = result.stdout.strip() + return geom or None + + def _focused_output(self) -> str | None: + reply = I3.send_command("", I3MessageType.GET_WORKSPACES) + if not (reply.is_ok and isinstance(reply.reply, list)): + return None + for ws in reply.reply: + if ws.get("focused"): + return ws.get("output") + return None + + def _focused_output_geometry(self) -> str | None: + """Return the focused output's geometry as 'X,Y WxH' (slurp format). + + Used as a portable fallback when the IPC's output names aren't real + wl_output names (e.g. fenster reports 'Unknown-XXXX'). + """ + reply = I3.send_command("", I3MessageType.GET_WORKSPACES) + if not (reply.is_ok and isinstance(reply.reply, list)): + return None + for ws in reply.reply: + if ws.get("focused"): + rect = ws.get("rect") or {} + x = rect.get("x") + y = rect.get("y") + w = rect.get("width") + h = rect.get("height") + if None in (x, y, w, h) or w <= 0 or h <= 0: + return None + return f"{x},{y} {w}x{h}" + return None + + def _make_output_path(self, dest: Destination) -> str: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + if dest == "clipboard": + tmp = os.path.join(GLib.get_tmp_dir(), f"sims-screenrec-{stamp}.mp4") + return tmp + return os.path.join(self._output_dir, f"{stamp}.mp4") + + def _adopt_orphan_if_running(self) -> None: + try: + result = subprocess.run( + ["pgrep", "-x", "wl-screenrec"], capture_output=True, text=True + ) + except FileNotFoundError: + return + pid_str = result.stdout.strip().split("\n")[0] if result.stdout.strip() else "" + if not pid_str: + return + try: + self._adopted_pid = int(pid_str) + except ValueError: + return + logger.info(f"[Screenrec] adopted orphan wl-screenrec pid={self._adopted_pid}") + self._started_at = time.monotonic() + self.recording_changed(True) + + def _poll_adopted_until_gone(self) -> None: + def check() -> bool: + if self._adopted_pid is None: + return False + try: + os.kill(self._adopted_pid, 0) + except ProcessLookupError: + logger.info("[Screenrec] adopted wl-screenrec finished") + self._reset_state() + self.recording_changed(False) + return False + return True + + GLib.timeout_add(250, check) diff --git a/sims/styles/bar.css b/sims/styles/bar.css index 91cf7b1..17b4f2e 100644 --- a/sims/styles/bar.css +++ b/sims/styles/bar.css @@ -60,6 +60,24 @@ color: var(--blue); } +#screenrec-widget { + background: transparent; + border: none; + padding: 0 6px; + margin: 0; + box-shadow: none; +} + +#screenrec-dot { + color: #ff4444; + font-size: 14px; +} + +#screenrec-elapsed { + font-size: 12px; + font-family: monospace; +} + tooltip { border: solid 2px; border-color: var(--border-color); diff --git a/sims/styles/finder.css b/sims/styles/finder.css deleted file mode 100644 index 3a4df18..0000000 --- a/sims/styles/finder.css +++ /dev/null @@ -1,33 +0,0 @@ -#picker-box { - padding: 12px; - background-color: rgba(40, 40, 40, 0.95); /* darker for contrast */ - border-radius: 8px; - font-family: sans-serif; - font-size: 14px; - color: white; -} - - -#viewport { - padding: 8px; - background-color: rgba(30, 30, 30, 0.9); /* dark background for contrast */ - border-radius: 6px; - font-family: sans-serif; - font-size: 14px; - color: white; /* ensure contrast */ -} - -#viewport > * { - padding: 6px 10px; - margin-bottom: 4px; - border-radius: 4px; - background-color: rgba(255, 255, 255, 0.05); -} - -#viewport > *.selected { - background-color: rgba(255, 255, 255, 0.18); -} - -#viewport:hover { - background-color: rgba(255, 255, 255, 0.15); /* hover feedback */ -} diff --git a/sims/styles/launcher.css b/sims/styles/launcher.css new file mode 100644 index 0000000..4e28575 --- /dev/null +++ b/sims/styles/launcher.css @@ -0,0 +1,55 @@ +/* Shared styles for the fuzzy-menu launcher (window finder, app launcher, + screenrec menu, and any future provider built on FuzzyMenu). + Element IDs come from sims/modules/launcher/base.py. */ + +#picker-box { + padding: 12px; + background-color: rgba(40, 40, 40, 0.95); + border-radius: 8px; + font-family: sans-serif; + font-size: 14px; + color: white; +} + +#viewport { + padding: 8px; + background-color: rgba(30, 30, 30, 0.9); + border-radius: 6px; + font-family: sans-serif; + font-size: 14px; + color: white; +} + +#viewport > * { + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.04); + border-left: 3px solid transparent; + transition: background-color 80ms ease, border-color 80ms ease; +} + +#viewport > *.selected { + background-color: rgba(137, 180, 250, 0.28); + border-left-color: #89b4fa; + color: #ffffff; + font-weight: 600; +} + +#viewport > *.selected label { + color: #ffffff; +} + +/* Provider-specific tweaks (apps launcher) */ +#app-icon { + margin-right: 4px; +} + +#app-name { + font-weight: 500; +} + +#app-generic { + font-size: 11px; + opacity: 0.6; +} diff --git a/sims/styles/main.css b/sims/styles/main.css index 43f884e..83ae04d 100644 --- a/sims/styles/main.css +++ b/sims/styles/main.css @@ -3,7 +3,7 @@ @import url("./menu.css"); @import url("./vinyl.css"); @import url("./bar.css"); -@import url("./finder.css"); +@import url("./launcher.css"); @import url("./calendar.css"); @import url("./notmuch.css"); diff --git a/sims/widgets/fenster.py b/sims/widgets/fenster.py index 12d874d..4e1c1ed 100644 --- a/sims/widgets/fenster.py +++ b/sims/widgets/fenster.py @@ -213,7 +213,7 @@ class FensterActiveWindow(Label): if tree_reply.is_ok and isinstance(tree_reply.reply, dict): focused = self._find_focused(tree_reply.reply) if focused: - self._set_title(focused.get("name", "")) + self._set_title(focused.get("name") or "") return self.set_label("") @@ -228,7 +228,7 @@ class FensterActiveWindow(Label): def _on_window_event(self, _, event: I3Event): container = event.data.get("container", {}) - self._set_title(container.get("name", "")) + self._set_title(container.get("name") or "") def _on_window_close(self, _, event: I3Event): self._initialize()