feat: sims launcher
This commit is contained in:
11
sims/main.py
11
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()
|
||||
|
||||
4
sims/modules/launcher/__init__.py
Normal file
4
sims/modules/launcher/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import FuzzyMenu, LauncherProvider
|
||||
from .windows import WindowProvider
|
||||
|
||||
__all__ = ["FuzzyMenu", "LauncherProvider", "WindowProvider"]
|
||||
104
sims/modules/launcher/base.py
Normal file
104
sims/modules/launcher/base.py
Normal 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()
|
||||
60
sims/modules/launcher/windows.py
Normal file
60
sims/modules/launcher/windows.py
Normal 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")
|
||||
@@ -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",
|
||||
def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=WindowProvider(),
|
||||
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)])
|
||||
)
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user