feat: add app launcher

This commit is contained in:
2026-05-03 21:42:30 +02:00
parent 60757ee336
commit 7bdf23001f
18 changed files with 665 additions and 57 deletions

View File

@@ -13,6 +13,9 @@ notmuch:
enable: true enable: true
notmuch_path: "notmuch" # or full path like "/home/user/.nix-profile/bin/notmuch" 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" emacsclient_command: "emacsclient" # or full path like "/home/user/.nix-profile/bin/emacsclient"
screenrec:
enable: true
output_dir: "~/Videos/wl-screenrec"
stylix: stylix:
enable: true enable: true
colors: colors:

View File

@@ -136,6 +136,18 @@
description = "Path to the emacsclient binary"; 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 = { default = {
@@ -154,6 +166,10 @@
notmuch_path = "notmuch"; notmuch_path = "notmuch";
emacsclient_command = "emacsclient"; emacsclient_command = "emacsclient";
}; };
screenrec = {
enable = false;
output_dir = "~/Videos/wl-screenrec";
};
}; };
}; };
}; };

View File

@@ -21,7 +21,12 @@ usage() {
cat <<'EOF' >&2 cat <<'EOF' >&2
usage: sims-cli <command> [args] usage: sims-cli <command> [args]
finder open window finder finder open window finder
apps open application launcher
notmuch-refresh refresh unread mail count 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 list list registered actions
EOF EOF
exit 2 exit 2
@@ -29,7 +34,16 @@ EOF
case "${1:-}" in case "${1:-}" in
finder) invoke open-finder ;; finder) invoke open-finder ;;
apps) invoke open-app-launcher ;;
notmuch-refresh) invoke refresh-notmuch ;; 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 ;; list) list_actions ;;
*) usage ;; *) usage ;;
esac esac

View File

@@ -55,6 +55,10 @@ WINDOW_TITLE = app_config.get("window_title", {"enable": True})
STYLIX = app_config.get("stylix", {"enable": False}) STYLIX = app_config.get("stylix", {"enable": False})
CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"}) CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"})
NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient"}) 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) BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING") LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False) DEV = app_config.get("dev", False)

View File

@@ -20,9 +20,12 @@ from fabric.utils import (
) )
from .modules.bar import StatusBar from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder 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 .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.fenster import get_i3_connection
from .services.screenrec import ScreenrecService
tray = SystemTray(name="system-tray", spacing=4) tray = SystemTray(name="system-tray", spacing=4)
@@ -30,11 +33,23 @@ i3 = get_i3_connection()
dummy = Window(visible=False) dummy = Window(visible=False)
finder = FuzzyWindowFinder() 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 = [] bar_windows = []
notmuch_widget = None 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() @Application.action()
@@ -42,19 +57,46 @@ def open_finder():
finder.show() finder.show()
@Application.action()
def open_app_launcher():
app_launcher.show()
@Application.action() @Application.action()
def refresh_notmuch(): def refresh_notmuch():
if notmuch_widget is not None: if notmuch_widget is not None:
notmuch_widget.service.update_unread_count() 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 # Load CSS - use Stylix if enabled, otherwise use default
if STYLIX.get("enable", False): if STYLIX.get("enable", False):
stylix_css_path = get_stylix_css_path() stylix_css_path = get_stylix_css_path()
if stylix_css_path: if stylix_css_path:
logger.info("[Bar] Using Stylix CSS") logger.info("[Bar] Using Stylix CSS")
# Load base styles first for structure
app.set_stylesheet_from_file(get_relative_path("styles/main.css")) app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
# Then apply Stylix theme colors
app.set_stylesheet_from_file(stylix_css_path) app.set_stylesheet_from_file(stylix_css_path)
else: else:
logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default") 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): for i, output in enumerate(outputs):
output_name = output.get("name", f"Unknown-{i}") 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) bar_windows.append(bar)
if i == 0 and bar.notmuch: if i == 0 and bar.notmuch:
notmuch_widget = bar.notmuch notmuch_widget = bar.notmuch

View File

@@ -11,10 +11,12 @@ from sims.modules.quick_menu import QuickMenuOpener
from sims.modules.battery import Battery from sims.modules.battery import Battery
from sims.modules.calendar import CalendarService, CalendarPopup from sims.modules.calendar import CalendarService, CalendarPopup
from sims.modules.notmuch import NotmuchWidget from sims.modules.notmuch import NotmuchWidget
from sims.modules.screenrec import ScreenrecWidget
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
from sims.services.fenster import get_i3_connection from sims.services.fenster import get_i3_connection
from sims.services.screenrec import ScreenrecService
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from sims.services.system_stats import SystemStatsService from sims.services.system_stats import SystemStatsService
@@ -27,6 +29,7 @@ class StatusBar(Window):
display: str, display: str,
tray: SystemTray | None = None, tray: SystemTray | None = None,
monitor: int = 1, monitor: int = 1,
screenrec_service: ScreenrecService | None = None,
): ):
super().__init__( super().__init__(
name="sims", name="sims",
@@ -102,6 +105,10 @@ class StatusBar(Window):
if NOTMUCH["enable"]: if NOTMUCH["enable"]:
self.notmuch = NotmuchWidget() self.notmuch = NotmuchWidget()
self.screenrec = None
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
self.status_container = Box( self.status_container = Box(
name="widgets-container", name="widgets-container",
spacing=4, spacing=4,
@@ -121,6 +128,9 @@ class StatusBar(Window):
if self.notmuch: if self.notmuch:
end_container_children.append(self.notmuch) end_container_children.append(self.notmuch)
if self.screenrec:
end_container_children.append(self.screenrec)
# Add quick menu button next to time # Add quick menu button next to time
end_container_children.append(self.quick_menu) end_container_children.append(self.quick_menu)
end_container_children.append(self.date_time) end_container_children.append(self.date_time)

View File

@@ -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,
)

View File

@@ -19,9 +19,12 @@ class FuzzyMenu(Window):
provider: LauncherProvider, provider: LauncherProvider,
monitor: int = 0, monitor: int = 0,
placeholder: str = "Search...", placeholder: str = "Search...",
window_name: str = "finder",
max_results: int | None = None,
): ):
self._max_results = max_results
super().__init__( super().__init__(
name="finder", name=window_name,
anchor="center", anchor="center",
monitor=monitor, monitor=monitor,
keyboard_mode="on-demand", keyboard_mode="on-demand",
@@ -41,8 +44,6 @@ class FuzzyMenu(Window):
h_expand=True, h_expand=True,
editable=True, editable=True,
notify_text=self._on_text_changed, notify_text=self._on_text_changed,
on_activate=lambda *_: self._activate_selected(),
on_key_press_event=self._on_key_press,
) )
self.picker_box = Box( self.picker_box = Box(
name="picker-box", name="picker-box",
@@ -52,6 +53,7 @@ class FuzzyMenu(Window):
) )
self.add(self.picker_box) self.add(self.picker_box)
self.connect("key-press-event", self._on_key_press)
self._refresh_viewport("") self._refresh_viewport("")
def show(self): def show(self):
@@ -60,29 +62,40 @@ class FuzzyMenu(Window):
self._selected_index = 0 self._selected_index = 0
self._refresh_viewport("") self._refresh_viewport("")
super().show() super().show()
self.search_entry.grab_focus()
def _on_text_changed(self, entry, *_): def _on_text_changed(self, entry, *_):
self._selected_index = 0 self._selected_index = 0
self._refresh_viewport(entry.get_text()) self._refresh_viewport(entry.get_text())
def _on_key_press(self, _widget, event): 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() self.hide()
return True return True
if event.keyval == Gdk.KEY_Down: if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if self._filtered: self._activate_selected()
self._selected_index = (self._selected_index + 1) % len(self._filtered)
self._update_selection_highlight()
return True return True
if event.keyval == Gdk.KEY_Up: if keyval == Gdk.KEY_Down or (ctrl and keyval in (Gdk.KEY_n, Gdk.KEY_N)):
if self._filtered: self._move_selection(1)
self._selected_index = (self._selected_index - 1) % len(self._filtered) return True
self._update_selection_highlight() if keyval == Gdk.KEY_Up or (ctrl and keyval in (Gdk.KEY_p, Gdk.KEY_P)):
self._move_selection(-1)
return True return True
return False 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): def _refresh_viewport(self, query: str):
self._filtered = self._provider.filter(self._items, query) 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): if self._selected_index >= len(self._filtered):
self._selected_index = 0 self._selected_index = 0
self.viewport.children = [] self.viewport.children = []

View File

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

View File

@@ -19,16 +19,16 @@ class WindowProvider:
if con.get("type") == "con": if con.get("type") == "con":
windows.append({ windows.append({
"id": con.get("id"), "id": con.get("id"),
"app_id": con.get("app_id", ""), "app_id": con.get("app_id") or "",
"title": con.get("name", ""), "title": con.get("name") or "",
"workspace": ws_num, "workspace": ws_num,
}) })
for con in ws_node.get("floating_nodes", []): for con in ws_node.get("floating_nodes", []):
if con.get("type") == "con": if con.get("type") == "con":
windows.append({ windows.append({
"id": con.get("id"), "id": con.get("id"),
"app_id": con.get("app_id", ""), "app_id": con.get("app_id") or "",
"title": con.get("name", ""), "title": con.get("name") or "",
"workspace": ws_num, "workspace": ws_num,
}) })
return windows return windows

58
sims/modules/screenrec.py Normal file
View File

@@ -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

View File

@@ -361,6 +361,64 @@ tooltip>* {{
.toggle-inactive {{ .toggle-inactive {{
background-color: #{colors["base02"]}; 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 # Write to temporary file

230
sims/services/screenrec.py Normal file
View File

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

View File

@@ -60,6 +60,24 @@
color: var(--blue); 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 { tooltip {
border: solid 2px; border: solid 2px;
border-color: var(--border-color); border-color: var(--border-color);

View File

@@ -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 */
}

55
sims/styles/launcher.css Normal file
View File

@@ -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;
}

View File

@@ -3,7 +3,7 @@
@import url("./menu.css"); @import url("./menu.css");
@import url("./vinyl.css"); @import url("./vinyl.css");
@import url("./bar.css"); @import url("./bar.css");
@import url("./finder.css"); @import url("./launcher.css");
@import url("./calendar.css"); @import url("./calendar.css");
@import url("./notmuch.css"); @import url("./notmuch.css");

View File

@@ -213,7 +213,7 @@ class FensterActiveWindow(Label):
if tree_reply.is_ok and isinstance(tree_reply.reply, dict): if tree_reply.is_ok and isinstance(tree_reply.reply, dict):
focused = self._find_focused(tree_reply.reply) focused = self._find_focused(tree_reply.reply)
if focused: if focused:
self._set_title(focused.get("name", "")) self._set_title(focused.get("name") or "")
return return
self.set_label("") self.set_label("")
@@ -228,7 +228,7 @@ class FensterActiveWindow(Label):
def _on_window_event(self, _, event: I3Event): def _on_window_event(self, _, event: I3Event):
container = event.data.get("container", {}) 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): def _on_window_close(self, _, event: I3Event):
self._initialize() self._initialize()