feat: add app launcher
This commit is contained in:
@@ -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:
|
||||
|
||||
16
flake.nix
16
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";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,12 @@ usage() {
|
||||
cat <<'EOF' >&2
|
||||
usage: sims-cli <command> [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
|
||||
|
||||
@@ -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)
|
||||
|
||||
57
sims/main.py
57
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
sims/modules/launcher/apps.py
Normal file
59
sims/modules/launcher/apps.py
Normal 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,
|
||||
)
|
||||
@@ -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 = []
|
||||
|
||||
56
sims/modules/launcher/screenrec.py
Normal file
56
sims/modules/launcher/screenrec.py
Normal 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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
58
sims/modules/screenrec.py
Normal file
58
sims/modules/screenrec.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
230
sims/services/screenrec.py
Normal file
230
sims/services/screenrec.py
Normal 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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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
55
sims/styles/launcher.css
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user