From 60757ee3368de6a167478b89a06d512826179038 Mon Sep 17 00:00:00 2001 From: Makesesama Date: Sun, 3 May 2026 20:40:21 +0200 Subject: [PATCH] feat: sims launcher --- sims/main.py | 11 +++ sims/modules/launcher/__init__.py | 4 + sims/modules/launcher/base.py | 104 +++++++++++++++++++++++ sims/modules/launcher/windows.py | 60 ++++++++++++++ sims/modules/window_fuzzy.py | 133 ++---------------------------- sims/styles/finder.css | 4 + 6 files changed, 190 insertions(+), 126 deletions(-) create mode 100644 sims/modules/launcher/__init__.py create mode 100644 sims/modules/launcher/base.py create mode 100644 sims/modules/launcher/windows.py diff --git a/sims/main.py b/sims/main.py index 6fb8222..8402db8 100644 --- a/sims/main.py +++ b/sims/main.py @@ -36,6 +36,17 @@ notmuch_widget = None app = Application("sims", dummy, finder) + +@Application.action() +def open_finder(): + finder.show() + + +@Application.action() +def refresh_notmuch(): + if notmuch_widget is not None: + notmuch_widget.service.update_unread_count() + # Load CSS - use Stylix if enabled, otherwise use default if STYLIX.get("enable", False): stylix_css_path = get_stylix_css_path() diff --git a/sims/modules/launcher/__init__.py b/sims/modules/launcher/__init__.py new file mode 100644 index 0000000..fe30706 --- /dev/null +++ b/sims/modules/launcher/__init__.py @@ -0,0 +1,4 @@ +from .base import FuzzyMenu, LauncherProvider +from .windows import WindowProvider + +__all__ = ["FuzzyMenu", "LauncherProvider", "WindowProvider"] diff --git a/sims/modules/launcher/base.py b/sims/modules/launcher/base.py new file mode 100644 index 0000000..23407bf --- /dev/null +++ b/sims/modules/launcher/base.py @@ -0,0 +1,104 @@ +from typing import Any, Protocol + +from fabric.widgets.box import Box +from fabric.widgets.entry import Entry +from fabric.widgets.wayland import WaylandWindow as Window +from gi.repository import Gdk, Gtk + + +class LauncherProvider(Protocol): + def items(self) -> list[Any]: ... + def filter(self, items: list[Any], query: str) -> list[Any]: ... + def render(self, item: Any) -> Gtk.Widget: ... + def activate(self, item: Any) -> None: ... + + +class FuzzyMenu(Window): + def __init__( + self, + provider: LauncherProvider, + monitor: int = 0, + placeholder: str = "Search...", + ): + super().__init__( + name="finder", + anchor="center", + monitor=monitor, + keyboard_mode="on-demand", + type="popup", + visible=False, + ) + self._provider = provider + self._items: list[Any] = [] + self._filtered: list[Any] = [] + self._selected_index: int = 0 + + self.viewport = Box(name="viewport", spacing=4, orientation="v") + + self.search_entry = Entry( + name="search-entry", + placeholder=placeholder, + 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", + spacing=4, + orientation="v", + children=[self.search_entry, self.viewport], + ) + + self.add(self.picker_box) + self._refresh_viewport("") + + def show(self): + self._items = self._provider.items() + self.search_entry.set_text("") + self._selected_index = 0 + self._refresh_viewport("") + super().show() + + 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: + 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() + 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() + return True + return False + + def _refresh_viewport(self, query: str): + self._filtered = self._provider.filter(self._items, query) + if self._selected_index >= len(self._filtered): + self._selected_index = 0 + self.viewport.children = [] + for item in self._filtered: + self.viewport.add(self._provider.render(item)) + self._update_selection_highlight() + + def _update_selection_highlight(self): + for i, child in enumerate(self.viewport.get_children()): + ctx = child.get_style_context() + if i == self._selected_index: + ctx.add_class("selected") + else: + ctx.remove_class("selected") + + def _activate_selected(self): + if self._filtered and 0 <= self._selected_index < len(self._filtered): + self._provider.activate(self._filtered[self._selected_index]) + self.hide() diff --git a/sims/modules/launcher/windows.py b/sims/modules/launcher/windows.py new file mode 100644 index 0000000..5037097 --- /dev/null +++ b/sims/modules/launcher/windows.py @@ -0,0 +1,60 @@ +from fabric.i3 import I3, I3MessageType +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from gi.repository import Gtk + + +class WindowProvider: + def items(self) -> list[dict]: + windows: list[dict] = [] + tree_reply = I3.send_command("", I3MessageType.GET_TREE) + if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)): + return windows + + tree = tree_reply.reply + for output_node in tree.get("nodes", []): + for ws_node in output_node.get("nodes", []): + ws_num = ws_node.get("num", 0) + for con in ws_node.get("nodes", []): + if con.get("type") == "con": + windows.append({ + "id": con.get("id"), + "app_id": con.get("app_id", ""), + "title": con.get("name", ""), + "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", ""), + "workspace": ws_num, + }) + return windows + + def filter(self, items: list[dict], query: str) -> list[dict]: + if not query: + return items + q = query.lower() + return [ + w for w in items + if q in w.get("title", "").lower() + or q in w.get("app_id", "").lower() + ] + + def render(self, item: dict) -> Gtk.Widget: + title = item.get("title", "") + app_id = item.get("app_id", "") + ws_num = item.get("workspace", 0) + text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}" + return Box( + name="slot-box", + orientation="h", + children=[Label(label=text)], + ) + + def activate(self, item: dict) -> None: + window_id = item.get("id") + if window_id is not None: + I3.send_command(f"[con_id={window_id}] focus") diff --git a/sims/modules/window_fuzzy.py b/sims/modules/window_fuzzy.py index 8aacc2d..7c02283 100644 --- a/sims/modules/window_fuzzy.py +++ b/sims/modules/window_fuzzy.py @@ -1,128 +1,9 @@ -from fabric.i3 import I3, I3MessageType -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.entry import Entry -from gi.repository import Gdk -from sims.services.fenster import get_i3_connection +from sims.modules.launcher import FuzzyMenu, WindowProvider -class FuzzyWindowFinder(Window): - def __init__( - self, - monitor: int = 0, - ): - super().__init__( - name="finder", - anchor="center", - monitor=monitor, - keyboard_mode="on-demand", - type="popup", - visible=False, - ) - - self._i3 = get_i3_connection() - self._all_windows = [] - self._refresh_windows() - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - - self.search_entry = Entry( - name="search-entry", - placeholder="Search Windows...", - h_expand=True, - editable=True, - notify_text=self.notify_text, - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, - ) - self.picker_box = Box( - name="picker-box", - spacing=4, - orientation="v", - children=[self.search_entry, self.viewport], - ) - - self.add(self.picker_box) - self.arrange_viewport("") - - def _refresh_windows(self): - """Refresh the window list via GET_TREE""" - self._all_windows = [] - tree_reply = I3.send_command("", I3MessageType.GET_TREE) - if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)): - return - - tree = tree_reply.reply - # Traverse: root → outputs → workspaces → containers - for output_node in tree.get("nodes", []): - for ws_node in output_node.get("nodes", []): - ws_num = ws_node.get("num", 0) - for con in ws_node.get("nodes", []): - if con.get("type") == "con": - self._all_windows.append({ - "id": con.get("id"), - "app_id": con.get("app_id", ""), - "title": con.get("name", ""), - "workspace": ws_num, - }) - for con in ws_node.get("floating_nodes", []): - if con.get("type") == "con": - self._all_windows.append({ - "id": con.get("id"), - "app_id": con.get("app_id", ""), - "title": con.get("name", ""), - "workspace": ws_num, - }) - - def show(self): - """Override show to refresh windows before displaying""" - self._refresh_windows() - self.arrange_viewport(self.search_entry.get_text()) - super().show() - - def notify_text(self, entry, *_): - text = entry.get_text() - self.arrange_viewport(text) - - def on_search_entry_key_press(self, widget, event): - if event.keyval in [Gdk.KEY_Escape, 103]: - self.hide() - return True - return False - - def on_search_entry_activate(self, text): - """Focus the first matching window""" - filtered = self._filter_windows(text) - if filtered: - window_id = filtered[0].get("id") - if window_id is not None: - I3.send_command(f"[con_id={window_id}] focus") - self.hide() - - def _filter_windows(self, query: str) -> list: - """Filter windows based on query matching title or app_id""" - if not query: - return self._all_windows - query_lower = query.lower() - return [ - w for w in self._all_windows - if query_lower in w.get("title", "").lower() - or query_lower in w.get("app_id", "").lower() - ] - - def arrange_viewport(self, query: str = ""): - self.viewport.children = [] # Clear previous entries - - filtered = self._filter_windows(query) - - for window in filtered: - title = window.get("title", "") - app_id = window.get("app_id", "") - ws_num = window.get("workspace", 0) - display_text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}" - self.viewport.add( - Box(name="slot-box", orientation="h", children=[Label(label=display_text)]) - ) +def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu: + return FuzzyMenu( + provider=WindowProvider(), + monitor=monitor, + placeholder="Search Windows...", + ) diff --git a/sims/styles/finder.css b/sims/styles/finder.css index 25414f0..3a4df18 100644 --- a/sims/styles/finder.css +++ b/sims/styles/finder.css @@ -24,6 +24,10 @@ 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 */ }