main.py
Python script, ASCII text executable
1""" 2Izvor - modular, GTK+ desktop search and launcher 3Copyright (C) 2024 <root@roundabout-host.com> 4 5This program is free software: you can redistribute it and/or modify 6it under the terms of the GNU General Public License as published by 7the Free Software Foundation, either version 3 of the License, or 8(at your option) any later version. 9 10This program is distributed in the hope that it will be useful, 11but WITHOUT ANY WARRANTY; without even the implied warranty of 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13GNU General Public License for more details. 14 15You should have received a copy of the GNU General Public License 16along with this program. If not, see <https://www.gnu.org/licenses/>. 17""" 18 19import os 20import subprocess 21 22import gi 23import asyncio 24import importlib 25import gbulb 26from pathlib import Path 27from typing import AsyncIterable, AsyncIterator, Collection, TypeVar, Iterable, Callable, Any 28 29from xdg.Config import icon_size 30 31gi.require_version("Gtk", "3.0") 32from gi.repository import Gtk, Gdk, Pango, GLib 33import gettext 34import json 35import traceback 36import sys 37 38_ = gettext.gettext 39 40_T = TypeVar("_T") 41 42 43def get_parent_package(path): 44parent_path = os.path.split(path)[0] 45while parent_path != "/": 46if "__init__.py" in os.listdir(parent_path): 47return Path(parent_path) 48parent_path = os.path.split(parent_path)[0] 49return None 50 51 52async def merge_generators_with_params(generator_funcs: list[Callable[..., AsyncIterable]], 53*args, **kwargs): 54iterators = [gen_func(*args, **kwargs).__aiter__() for gen_func in generator_funcs] 55tasks = {asyncio.create_task(it.__anext__()): it for it in iterators} 56 57while tasks: 58done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) 59 60for task in done: 61iterator = tasks.pop(task) 62try: 63result = task.result() 64yield result 65tasks[asyncio.create_task(iterator.__anext__())] = iterator 66except StopAsyncIteration: 67pass 68 69 70class ResultInfoWidget(Gtk.ListBoxRow): 71def __init__(self, name: str, description: str, image: tuple[str, Any], execute: Callable[[], None], icon_size=32, show_description=True): 72super().__init__() 73self.name = name 74self.description = description 75self.image = image 76self.execute = execute 77 78self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) 79self.add(self.box) 80 81if icon_size: 82# If the icon size is 0, have no icons 83if image[0] == "logo": 84self.image = Gtk.Image.new_from_icon_name(self.image[1], Gtk.IconSize.LARGE_TOOLBAR) 85self.image.set_pixel_size(icon_size) 86self.box.pack_start(self.image, False, False, 0) 87 88self.label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 89self.box.pack_start(self.label_box, True, True, 0) 90 91self.name_label = Gtk.Label(label=self.name) 92self.name_label.set_line_wrap(True) 93self.name_label.set_justify(Gtk.Justification.LEFT) 94self.name_label.set_halign(Gtk.Align.START) 95attributes = Pango.AttrList() 96attributes.insert(Pango.AttrSize.new(16 * Pango.SCALE)) 97self.name_label.set_attributes(attributes) 98self.label_box.pack_start(self.name_label, True, True, 0) 99 100if show_description: 101self.description_label = Gtk.Label(label=self.description) 102self.description_label.set_line_wrap(True) 103self.description_label.set_justify(Gtk.Justification.LEFT) 104self.description_label.set_halign(Gtk.Align.START) 105self.label_box.pack_start(self.description_label, True, True, 0) 106 107 108class ProviderExceptionDialog(Gtk.Dialog): 109def __init__(self, provider: Path, exception: Exception, parent=None, tb=None): 110super().__init__(title=_("{provider} raised a {exception}".format(provider=get_parent_package(provider), exception=type(exception).__name__)), transient_for=parent) 111self.add_button(_("Open config"), 0) 112self.add_button(_("Reset config"), 1) 113self.add_button(_("Quit app"), Gtk.ResponseType.CLOSE) 114self.set_default_response(Gtk.ResponseType.CLOSE) 115self.set_default_size(360, 240) 116self.set_resizable(True) 117 118self.provider = provider 119self.exception = exception 120 121traceback_text = "\n".join(traceback.format_exception(type(exception), exception, tb)) 122 123self.label = Gtk.Label(label=traceback_text) 124 125monospaced_font = Pango.FontDescription() 126monospaced_font.set_family("monospace") 127self.label.override_font(monospaced_font) 128self.label.set_line_wrap(True) 129self.label.set_justify(Gtk.Justification.LEFT) 130self.label.set_halign(Gtk.Align.START) 131self.label.set_selectable(True) 132 133self.get_content_area().add(self.label) 134self.label.show() 135 136class Izvor(Gtk.Application): 137USER_CONFIGS = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "izvor" 138USER_DATA = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "izvor" 139USER_PROVIDERS = USER_DATA / "providers" 140USER_PROVIDER_CONFIGS = USER_CONFIGS / "providers" 141SYSTEM_CONFIGS = Path("/etc/izvor") 142SYSTEM_DATA = Path("/usr/share/izvor") 143SYSTEM_PROVIDERS = SYSTEM_DATA / "providers" 144SYSTEM_PROVIDER_CONFIGS = SYSTEM_CONFIGS / "providers" 145ENABLED_PROVIDERS_FILE = USER_CONFIGS / "enabled_providers" 146APPEARANCE_CONFIG_FILE = USER_CONFIGS / "appearance.json" 147 148def __init__(self): 149super().__init__(application_id="com.roundabout-host.roundabout.izvor") 150gbulb.install(gtk=True) 151self.loop = asyncio.get_event_loop() 152self.builder = Gtk.Builder() 153self.builder.add_from_file("izvor.ui") 154self.window = self.builder.get_object("root") 155self.window.connect("destroy", self.kill) 156self.window.connect("key-press-event", self.check_escape) 157self.window.set_title(_("Launcher")) 158 159if not os.getenv("XDG_DATA_HOME"): 160print("XDG_DATA_HOME is not set. Using default path.") 161if not os.getenv("XDG_CONFIG_HOME"): 162print("XDG_CONFIG_HOME is not set. Using default path.") 163if not self.USER_PROVIDER_CONFIGS.exists(): 164print("Creating user config directory.") 165self.USER_PROVIDER_CONFIGS.mkdir(parents=True) 166if not self.USER_PROVIDERS.exists(): 167print("Creating user data directory.") 168self.USER_PROVIDERS.mkdir(parents=True) 169 170self.available_providers = {} 171 172if self.SYSTEM_PROVIDERS.exists(): 173for provider in self.SYSTEM_PROVIDERS.iterdir(): 174if not (provider / "__init__.py").exists(): 175continue 176self.available_providers[provider.stem] = provider 177if self.USER_PROVIDERS.exists(): 178for provider in self.USER_PROVIDERS.iterdir(): 179if not (provider / "__init__.py").exists(): 180continue 181self.available_providers[provider.stem] = provider 182 183print(f"Available providers: {list(self.available_providers.keys())}") 184 185for provider, path in self.available_providers.items(): 186if not (self.USER_PROVIDER_CONFIGS / f"{provider}.json").exists(): 187with open(self.USER_PROVIDER_CONFIGS / f"{provider}.json", "w") as f: 188spec = importlib.util.spec_from_file_location(provider, path / "__init__.py") 189module = importlib.util.module_from_spec(spec) 190spec.loader.exec_module(module) 191if hasattr(module, "config_template"): 192json.dump(module.config_template, f) 193else: 194json.dump({}, f) 195 196if not self.ENABLED_PROVIDERS_FILE.exists(): 197self.ENABLED_PROVIDERS_FILE.touch() 198 199with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 200for provider in self.available_providers: 201f.write(provider + "\n") 202 203if not self.APPEARANCE_CONFIG_FILE.exists(): 204self.APPEARANCE_CONFIG_FILE.touch() 205with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 206json.dump({"icon_size": 32, "show_result_descriptions": True}, f) 207 208with open(self.APPEARANCE_CONFIG_FILE, "r") as f: 209appearance_config = json.load(f) 210self.icon_size = appearance_config.get("icon_size", 32) 211self.show_result_descriptions = appearance_config.get("show_result_descriptions", True) 212 213self.permanently_enabled_providers = {} 214 215with open(self.ENABLED_PROVIDERS_FILE, "r") as f: 216for line in f: 217provider = line.strip() 218if provider in self.available_providers: 219self.permanently_enabled_providers[provider] = self.available_providers[provider] 220 221self.enabled_providers = self.permanently_enabled_providers.copy() 222 223providers_menu = self.builder.get_object("providers-menu") 224 225self.providers = [] 226self.provider_checkboxes = {} 227 228for provider in self.permanently_enabled_providers.values(): 229spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 230module = importlib.util.module_from_spec(spec) 231spec.loader.exec_module(module) 232with open(self.USER_PROVIDER_CONFIGS / f"{provider.stem}.json", "r") as f: 233provider_config = json.load(f) 234loaded_provider = module.Provider(provider_config) 235self.providers.append(loaded_provider) 236self.provider_checkboxes[provider.stem] = Gtk.CheckMenuItem.new_with_label(loaded_provider.name) 237providers_menu.append(self.provider_checkboxes[provider.stem]) 238self.provider_checkboxes[provider.stem].set_active(True) 239self.provider_checkboxes[provider.stem].show() 240self.provider_checkboxes[provider.stem].connect("toggled", self.update_enabled_providers) 241 242self.update_task = None 243 244def call_update_results(widget): 245if self.update_task is not None: 246self.update_task.cancel() 247self.update_task = asyncio.create_task(self.update_results(widget)) 248 249def execute_result(widget, row): 250row.execute() 251self.kill() 252 253self.builder.connect_signals( 254{ 255"update-search": call_update_results, 256"result-activated": execute_result, 257"providers-menu-all-toggled": self.update_enabled_providers_all, 258"menu-about": self.about, 259"menu-providers": self.provider_menu, 260"menu-preferences": self.preferences, 261} 262) 263 264GLib.idle_add(self.update_enabled_providers, None) 265 266def update_enabled_providers(self, widget=None): 267self.providers = [] 268for provider, checkbox in self.provider_checkboxes.items(): 269if checkbox.get_active(): 270self.enabled_providers[provider] = self.permanently_enabled_providers[provider] 271spec = importlib.util.spec_from_file_location(provider, self.enabled_providers[provider] / "__init__.py") 272module = importlib.util.module_from_spec(spec) 273spec.loader.exec_module(module) 274with open(self.USER_PROVIDER_CONFIGS / f"{provider}.json", "r") as f: 275provider_config = json.load(f) 276loaded_provider = module.Provider(provider_config) 277self.providers.append(loaded_provider) 278else: 279self.enabled_providers.pop(provider, None) 280 281if self.update_task is not None: 282self.update_task.cancel() 283self.update_task = asyncio.create_task(self.update_results(widget)) 284 285def update_enabled_providers_all(self, widget): 286for checkbox in self.provider_checkboxes.values(): 287checkbox.set_active(widget.get_active()) 288self.update_enabled_providers() 289 290def kill(self, widget=None): 291self.loop.stop() 292self.quit() 293 294def check_escape(self, widget, event): 295results_list = self.builder.get_object("results-list") 296search_entry = self.builder.get_object("search-query") 297rows = results_list.get_children() 298current_row = results_list.get_selected_row() 299 300if event.keyval == Gdk.KEY_Escape: 301self.kill() 302return True 303 304if event.keyval == Gdk.KEY_Down: 305# Move to the next row 306if current_row: 307current_index = rows.index(current_row) 308next_index = (current_index + 1) % len(rows) # Wrap to the beginning if at the end 309else: 310next_index = 0 311 312results_list.select_row(rows[next_index]) 313search_entry.grab_focus() # Refocus the search entry 314self.scroll_to_row(rows[next_index], results_list.get_adjustment()) 315return True 316 317if event.keyval == Gdk.KEY_Up: 318# Move to the previous row 319if current_row: 320current_index = rows.index(current_row) 321prev_index = (current_index - 1) % len(rows) # Wrap to the end if at the beginning 322else: 323prev_index = len(rows) - 1 324 325results_list.select_row(rows[prev_index]) 326search_entry.grab_focus() # Refocus the search entry 327self.scroll_to_row(rows[prev_index], results_list.get_adjustment()) 328return True 329 330if event.keyval == Gdk.KEY_Return: 331if current_row: 332# Execute the selected row 333current_row.activate() 334return True 335elif len(rows) > 0: 336# Execute the first row 337rows[0].activate() 338return True 339 340return False 341 342def scroll_to_row(self, row, adjustment): 343row_geometry = row.get_allocation() 344row_y = row_geometry.y 345row_height = row_geometry.height 346adjustment_value = adjustment.get_value() 347adjustment_upper = adjustment.get_upper() - adjustment.get_page_size() 348 349if row_y + row_height > adjustment_value + adjustment.get_page_size(): 350adjustment.set_value(min(row_y + row_height - adjustment.get_page_size(), adjustment_upper)) 351elif row_y < adjustment_value: 352adjustment.set_value(max(row_y, 0)) 353 354def about(self, widget): 355about_builder = Gtk.Builder() 356about_builder.add_from_file("about.ui") 357about_window = about_builder.get_object("about-dialog") 358about_window.connect("destroy", lambda _: about_window.destroy()) 359about_window.connect("close", lambda _: about_window.destroy()) 360about_window.connect("response", lambda _, __: about_window.destroy()) 361about_window.show_all() 362 363def provider_menu(self, widget): 364providers_builder = Gtk.Builder() 365providers_builder.add_from_file("providers.ui") 366providers_window = providers_builder.get_object("providers-window") 367providers_window.connect("destroy", lambda _: providers_window.destroy()) 368 369providers_table = providers_builder.get_object("providers-table") # actually Gtk.Grid 370rows = 0 371for provider in self.available_providers.values(): 372spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 373module = importlib.util.module_from_spec(spec) 374spec.loader.exec_module(module) 375loaded_provider = module.Provider({}) 376 377provider_label = Gtk.Label(label=loaded_provider.name) 378provider_label.set_halign(Gtk.Align.START) 379provider_label.set_valign(Gtk.Align.CENTER) 380font_desc = Pango.FontDescription() 381font_desc.set_weight(Pango.Weight.BOLD) 382provider_label.override_font(font_desc) 383provider_label.set_line_wrap(True) 384provider_label.set_justify(Gtk.Justification.LEFT) 385 386provider_description = Gtk.Label(label=loaded_provider.description) 387provider_description.set_halign(Gtk.Align.START) 388provider_description.set_valign(Gtk.Align.CENTER) 389provider_description.set_justify(Gtk.Justification.LEFT) 390provider_description.set_line_wrap(True) 391 392icon = Gtk.Image.new_from_icon_name(loaded_provider.icon, Gtk.IconSize.LARGE_TOOLBAR) 393icon.set_pixel_size(self.icon_size) 394 395switch = Gtk.Switch() 396switch.set_valign(Gtk.Align.CENTER) 397switch.set_active(provider.stem in self.permanently_enabled_providers) 398switch.connect("notify::active", self.update_permanently_enabled_providers, provider.stem) 399 400config_button = Gtk.Button.new_with_label(_("Configure")) 401config_button.connect("clicked", lambda _, stem=provider.stem: subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{stem}.json")])) 402config_button.set_relief(Gtk.ReliefStyle.NONE) 403 404providers_table.attach(icon, 0, rows, 1, 1) 405providers_table.attach(provider_label, 1, rows, 1, 1) 406providers_table.attach(provider_description, 2, rows, 1, 1) 407providers_table.attach(switch, 3, rows, 1, 1) 408providers_table.attach(config_button, 4, rows, 1, 1) 409 410provider_label.show() 411provider_description.show() 412 413rows += 1 414 415providers_window.show_all() 416 417def preferences(self, widget): 418preferences_builder = Gtk.Builder() 419preferences_builder.add_from_file("preferences.ui") 420preferences_window = preferences_builder.get_object("preferences-window") 421preferences_window.connect("destroy", lambda _: preferences_window.destroy()) 422 423def update_appearance_preferences(widget, *args): 424self.icon_size = preferences_builder.get_object("icon-size").get_value() 425self.show_result_descriptions = preferences_builder.get_object("show-result-descriptions").get_active() 426 427with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 428json.dump({"icon_size": self.icon_size, "show_result_descriptions": self.show_result_descriptions}, f) 429 430# Regenerate the results to reflect the changes 431if self.update_task is not None: 432self.update_task.cancel() 433self.update_task = asyncio.create_task(self.update_results(widget)) 434 435preferences_builder.connect_signals( 436{ 437"update-appearance": update_appearance_preferences, 438} 439) 440 441preferences_window.show_all() 442 443def update_permanently_enabled_providers(self, widget, param, provider): 444if widget.get_active(): 445self.permanently_enabled_providers[provider] = self.available_providers[provider] 446else: 447self.permanently_enabled_providers.pop(provider, None) 448 449with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 450f.write("\n".join(self.permanently_enabled_providers.keys())) 451 452 453async def update_results(self, widget): 454spinner = self.builder.get_object("spinner") 455spinner.start() 456generators = [] 457query = self.builder.get_object("search-query-buffer").get_text() 458for provider in self.providers: 459generators.append(provider.search) 460 461# Clear the results list 462results_list = self.builder.get_object("results-list") 463for row in results_list.get_children(): 464results_list.remove(row) 465 466try: 467async for result in merge_generators_with_params(generators, query): 468result_box = ResultInfoWidget(result["name"], result["description"], result["image"], result["execute"], self.icon_size, self.show_result_descriptions) 469results_list.add(result_box) 470result_box.show_all() 471except Exception as e: 472exception_type, exception, tb = sys.exc_info() 473filename, line_number, function_name, line = traceback.extract_tb(tb)[-1] 474print(f"{filename} raised an exception!") 475print(f"{type(e).__name__}: {str(e)}") 476dialog = ProviderExceptionDialog(Path(filename), e, self.window, tb=tb) 477dialog.show_all() 478response = dialog.run() 479if response == 0: 480# Open config 481subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json")]) 482 483self.kill() 484elif response == 1: 485# Reset config 486spec = importlib.util.spec_from_file_location(get_parent_package(filename).stem, Path(filename)) 487module = importlib.util.module_from_spec(spec) 488spec.loader.exec_module(module) 489 490if hasattr(module, "config_template"): 491with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 492json.dump(module.config_template, f) 493else: 494with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 495json.dump({}, f) 496 497self.kill() 498elif response == Gtk.ResponseType.CLOSE: 499self.kill() 500 501results_list.show_all() 502spinner.stop() 503 504 505if __name__ == "__main__": 506izvor = Izvor() 507izvor.window.show_all() 508try: 509izvor.loop.run_forever() 510finally: 511izvor.loop.close() 512