feat: sims launcher

This commit is contained in:
2026-05-03 20:40:21 +02:00
parent c10709df26
commit 60757ee336
6 changed files with 190 additions and 126 deletions

View File

@@ -36,6 +36,17 @@ notmuch_widget = None
app = Application("sims", dummy, finder) 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 # 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()

View File

@@ -0,0 +1,4 @@
from .base import FuzzyMenu, LauncherProvider
from .windows import WindowProvider
__all__ = ["FuzzyMenu", "LauncherProvider", "WindowProvider"]

View File

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

View File

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

View File

@@ -1,128 +1,9 @@
from fabric.i3 import I3, I3MessageType from sims.modules.launcher import FuzzyMenu, WindowProvider
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
class FuzzyWindowFinder(Window): def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu:
def __init__( return FuzzyMenu(
self, provider=WindowProvider(),
monitor: int = 0,
):
super().__init__(
name="finder",
anchor="center",
monitor=monitor, 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...", 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)])
) )

View File

@@ -24,6 +24,10 @@
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
} }
#viewport > *.selected {
background-color: rgba(255, 255, 255, 0.18);
}
#viewport:hover { #viewport:hover {
background-color: rgba(255, 255, 255, 0.15); /* hover feedback */ background-color: rgba(255, 255, 255, 0.15); /* hover feedback */
} }