Files
Modus/modules/launcher/components/applauncher.py
T
2025-02-25 05:21:53 +05:45

353 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Iterator
import operator
from fabric.utils import DesktopApp, get_desktop_applications, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import GLib, Gdk
from snippets import read_config
from fabric.widgets.image import Image
import json
import os
import re
import math
import subprocess
import webbrowser
class AppLauncher(Box):
def __init__(self, **kwargs):
super().__init__(
name="app-launcher",
visible=False,
all_visible=False,
**kwargs,
)
self.launcher = kwargs["launcher"]
self.selected_index = -1
self._arranger_handler: int = 0
self._all_apps = get_desktop_applications()
self.config = read_config()
# Calculator history initialization
self.calc_history_path = os.path.expanduser("~/.cache/modus/calc.json")
if os.path.exists(self.calc_history_path):
with open(self.calc_history_path, "r") as f:
self.calc_history = json.load(f)
else:
self.calc_history = []
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
h_expand=True,
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
on_activate=lambda entry, *_: self.on_search_entry_activate(
entry.get_text()
),
on_key_press_event=self.on_search_entry_key_press,
)
self.scrolled_window = None
self.header_box = Box(
name="header-box",
orientation="h",
children=[self.search_entry],
)
self.launcher_box = Box(
name="launcher-box",
h_expand=True,
orientation="v",
children=[self.header_box],
)
self.add(self.launcher_box)
self.show_all()
def close_launcher(self, *_):
self.viewport.children = []
self.selected_index = -1
self.launcher.close()
def open_launcher(self):
self._all_apps = get_desktop_applications()
self.arrange_viewport("")
def arrange_viewport(self, query: str = ""):
if query.startswith(":"): # Don't show ScrolledWindow for web search
self.destroy_scrolled_window()
self.launcher.dashboard.hide()
return
if query.startswith("="):
self.show_scrolled_window()
# In calculator mode, update history view once (not per keystroke)
self.update_calculator_viewport()
self.launcher.dashboard.hide()
return
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.viewport.children = []
self.selected_index = -1
if query.strip():
self.launcher.dashboard.hide()
self.show_scrolled_window()
else:
self.launcher.dashboard.show()
self.destroy_scrolled_window()
return False
filtered_apps_iter = iter(
sorted(
[
app
for app in self._all_apps
if query.casefold()
in (
(app.display_name or "")
+ (" " + app.name + " ")
+ (app.generic_name or "")
).casefold()
],
key=lambda app: (app.display_name or "").casefold(),
)
)
should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
self._arranger_handler = idle_add(
lambda apps_iter: self.add_next_application(apps_iter)
or self.handle_arrange_complete(should_resize, query),
filtered_apps_iter,
pin=True,
)
def show_scrolled_window(self):
if self.scrolled_window is None:
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
min_content_size=(-1, -1),
h_scrollbar_policy="never",
child=self.viewport,
v_expand=True,
)
self.launcher_box.add(self.scrolled_window)
self.scrolled_window.show_all()
self.get_style_context().add_class("applauncher")
def destroy_scrolled_window(self):
if hasattr(self, "scrolled_window") and self.scrolled_window:
# self.scrolled_window.destroy()
self.launcher_box.remove(self.scrolled_window)
self.scrolled_window = None
self.get_style_context().remove_class("applauncher")
def handle_arrange_complete(self, should_resize, query):
if should_resize:
self.resize_viewport()
# Only auto-select first item if query exists
if query.strip() != "" and self.viewport.get_children():
self.update_selection(0)
return False
def add_next_application(self, apps_iter: Iterator[DesktopApp]):
if not (app := next(apps_iter, None)):
return False
self.viewport.add(self.bake_application_slot(app))
return True
def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
button = Button(
name="launcher-app",
child=Box(
orientation="h",
children=[
Image(
pixbuf=app.get_icon_pixbuf(size=32),
h_align="start",
name="launcher-app-icon",
),
Label(
label=app.display_name or "Unknown",
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=app.description,
on_clicked=lambda *_: (app.launch(), self.close_launcher()),
**kwargs,
)
return button
def update_selection(self, new_index: int):
if self.selected_index != -1 and self.selected_index < len(
self.viewport.get_children()
):
current_button = self.viewport.get_children()[self.selected_index]
current_button.get_style_context().remove_class("selected")
if new_index != -1 and new_index < len(self.viewport.get_children()):
new_button = self.viewport.get_children()[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
self.scroll_to_selected(new_button)
else:
self.selected_index = -1
def scroll_to_selected(self, button):
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
adj.set_value(y)
elif y + height > visible_bottom:
new_value = y + height - page_size
adj.set_value(new_value)
return False
GLib.idle_add(scroll)
def on_search_entry_activate(self, text):
commands = self.config.get("commands", {})
if text.startswith(":i "): # Detecting ':i' search
search_query = text[3:].strip() # Extract query after ':i '
if search_query:
url = f"https://www.google.com/search?q={search_query}"
webbrowser.open(url)
self.close_launcher()
return
if text.startswith("="):
# If in calculator mode and no history item is selected, evaluate new expression.
if self.selected_index == -1:
self.evaluate_calculator_expression(text)
return
if text in commands:
command = commands[text]
exec(command, {"launcher": self.launcher})
else:
children = self.viewport.get_children()
if children:
if text.strip() == "" and self.selected_index == -1:
return
selected_index = self.selected_index if self.selected_index != -1 else 0
if 0 <= selected_index < len(children):
children[selected_index].clicked()
def on_search_entry_key_press(self, widget, event):
keyval = event.keyval
if keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif keyval == Gdk.KEY_Escape:
self.close_launcher()
return True
return False
def move_selection(self, delta: int):
children = self.viewport.get_children()
if not children:
return
if self.selected_index == -1 and delta == 1:
new_index = 0
else:
new_index = self.selected_index + delta
new_index = max(0, min(new_index, len(children) - 1))
self.update_selection(new_index)
def save_calc_history(self):
with open(self.calc_history_path, "w") as f:
json.dump(self.calc_history, f)
def evaluate_calculator_expression(self, text: str):
# Remove the '=' prefix and extra spaces
expr = text.lstrip("=").strip()
if not expr:
return
# Replace operators: '^' -> '**', and '×' -> '*'
expr = expr.replace("^", "**").replace("×", "*")
# Replace factorial: e.g. 5! -> math.factorial(5)
expr = re.sub(r"(\d+)!", r"math.factorial(\1)", expr)
# Replace brackets: allow [] and {} as ()
for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]:
expr = expr.replace(old, new)
try:
result = eval(expr, {"__builtins__": None, "math": math})
except Exception as e:
result = f"Error: {e}"
# Prepend to history (newest first)
self.calc_history.insert(0, f"{text} => {result}")
self.save_calc_history()
self.update_calculator_viewport()
def update_calculator_viewport(self):
self.viewport.children = []
for item in self.calc_history:
btn = self.create_calc_history_button(item)
self.viewport.add(btn)
# Remove resetting selected_index unconditionally so that a highlighted result isn't lost.
# Optionally, only reset if the input is not more than "=".
# if self.search_entry.get_text().strip() != "=":
# self.selected_index = -1
def create_calc_history_button(self, text: str) -> Button:
btn = Button(
name="app-slot-button", # reuse existing CSS styling
child=Box(
name="calc-slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="calc-label",
label=text,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=text,
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
)
return btn
def copy_text_to_clipboard(self, text: str):
# Split the text on "=>" and copy only the result part if available
parts = text.split("=>", 1)
copy_text = parts[1].strip() if len(parts) > 1 else text
try:
subprocess.run(["wl-copy"], input=copy_text.encode(), check=True)
except subprocess.CalledProcessError as e:
print(f"Clipboard copy failed: {e}")
def delete_selected_calc_history(self):
if self.selected_index != -1 and self.selected_index < len(self.calc_history):
del self.calc_history[self.selected_index]
self.save_calc_history()
self.update_calculator_viewport()