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) 236print(f"Loaded provider: {loaded_provider.name}") 237print(loaded_provider.description) 238self.provider_checkboxes[provider.stem] = Gtk.CheckMenuItem.new_with_label(loaded_provider.name) 239providers_menu.append(self.provider_checkboxes[provider.stem]) 240self.provider_checkboxes[provider.stem].set_active(True) 241self.provider_checkboxes[provider.stem].show() 242self.provider_checkboxes[provider.stem].connect("toggled", self.update_enabled_providers) 243 244self.update_task = None 245 246def call_update_results(widget): 247if self.update_task is not None: 248self.update_task.cancel() 249self.update_task = asyncio.create_task(self.update_results(widget)) 250 251def execute_result(widget, row): 252row.execute() 253self.kill() 254 255self.builder.connect_signals( 256{ 257"update-search": call_update_results, 258"result-activated": execute_result, 259"providers-menu-all-toggled": self.update_enabled_providers_all, 260"menu-about": self.about, 261"menu-providers": self.provider_menu, 262"menu-preferences": self.preferences, 263} 264) 265 266GLib.idle_add(self.update_enabled_providers, None) 267 268def update_enabled_providers(self, widget=None): 269self.providers = [] 270for provider, checkbox in self.provider_checkboxes.items(): 271if checkbox.get_active(): 272self.enabled_providers[provider] = self.permanently_enabled_providers[provider] 273spec = importlib.util.spec_from_file_location(provider, self.enabled_providers[provider] / "__init__.py") 274module = importlib.util.module_from_spec(spec) 275spec.loader.exec_module(module) 276with open(self.USER_PROVIDER_CONFIGS / f"{provider}.json", "r") as f: 277provider_config = json.load(f) 278loaded_provider = module.Provider(provider_config) 279self.providers.append(loaded_provider) 280else: 281self.enabled_providers.pop(provider, None) 282 283if self.update_task is not None: 284self.update_task.cancel() 285self.update_task = asyncio.create_task(self.update_results(widget)) 286 287def update_enabled_providers_all(self, widget): 288for checkbox in self.provider_checkboxes.values(): 289checkbox.set_active(widget.get_active()) 290self.update_enabled_providers() 291 292def kill(self, widget=None): 293self.loop.stop() 294self.quit() 295 296def check_escape(self, widget, event): 297results_list = self.builder.get_object("results-list") 298search_entry = self.builder.get_object("search-query") 299rows = results_list.get_children() 300current_row = results_list.get_selected_row() 301 302if event.keyval == Gdk.KEY_Escape: 303self.kill() 304return True 305 306if event.keyval == Gdk.KEY_Down: 307# Move to the next row 308if current_row: 309current_index = rows.index(current_row) 310next_index = (current_index + 1) % len(rows) # Wrap to the beginning if at the end 311else: 312next_index = 0 313 314results_list.select_row(rows[next_index]) 315search_entry.grab_focus() # Refocus the search entry 316self.scroll_to_row(rows[next_index], results_list.get_adjustment()) 317return True 318 319if event.keyval == Gdk.KEY_Up: 320# Move to the previous row 321if current_row: 322current_index = rows.index(current_row) 323prev_index = (current_index - 1) % len(rows) # Wrap to the end if at the beginning 324else: 325prev_index = len(rows) - 1 326 327results_list.select_row(rows[prev_index]) 328search_entry.grab_focus() # Refocus the search entry 329self.scroll_to_row(rows[prev_index], results_list.get_adjustment()) 330return True 331 332if event.keyval == Gdk.KEY_Return: 333if current_row: 334# Execute the selected row 335current_row.activate() 336return True 337elif len(rows) > 0: 338# Execute the first row 339rows[0].activate() 340return True 341 342return False 343 344def scroll_to_row(self, row, adjustment): 345row_geometry = row.get_allocation() 346row_y = row_geometry.y 347row_height = row_geometry.height 348adjustment_value = adjustment.get_value() 349adjustment_upper = adjustment.get_upper() - adjustment.get_page_size() 350 351if row_y + row_height > adjustment_value + adjustment.get_page_size(): 352adjustment.set_value(min(row_y + row_height - adjustment.get_page_size(), adjustment_upper)) 353elif row_y < adjustment_value: 354adjustment.set_value(max(row_y, 0)) 355 356def about(self, widget): 357about_builder = Gtk.Builder() 358about_builder.add_from_file("about.ui") 359about_window = about_builder.get_object("about-dialog") 360about_window.connect("destroy", lambda _: about_window.destroy()) 361about_window.connect("close", lambda _: about_window.destroy()) 362about_window.connect("response", lambda _, __: about_window.destroy()) 363about_window.show_all() 364 365def provider_menu(self, widget): 366providers_builder = Gtk.Builder() 367providers_builder.add_from_file("providers.ui") 368providers_window = providers_builder.get_object("providers-window") 369providers_window.connect("destroy", lambda _: providers_window.destroy()) 370 371providers_table = providers_builder.get_object("providers-table") # actually Gtk.Grid 372rows = 0 373for provider in self.available_providers.values(): 374spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 375module = importlib.util.module_from_spec(spec) 376spec.loader.exec_module(module) 377loaded_provider = module.Provider({}) 378 379provider_label = Gtk.Label(label=loaded_provider.name) 380provider_label.set_halign(Gtk.Align.START) 381provider_label.set_valign(Gtk.Align.CENTER) 382font_desc = Pango.FontDescription() 383font_desc.set_weight(Pango.Weight.BOLD) 384provider_label.override_font(font_desc) 385provider_label.set_line_wrap(True) 386provider_label.set_justify(Gtk.Justification.LEFT) 387 388provider_description = Gtk.Label(label=loaded_provider.description) 389provider_description.set_halign(Gtk.Align.START) 390provider_description.set_valign(Gtk.Align.CENTER) 391provider_description.set_justify(Gtk.Justification.LEFT) 392provider_description.set_line_wrap(True) 393 394icon = Gtk.Image.new_from_icon_name(loaded_provider.icon, Gtk.IconSize.LARGE_TOOLBAR) 395icon.set_pixel_size(self.icon_size) 396 397switch = Gtk.Switch() 398switch.set_valign(Gtk.Align.CENTER) 399switch.set_active(provider.stem in self.permanently_enabled_providers) 400switch.connect("notify::active", self.update_permanently_enabled_providers, provider.stem) 401 402config_button = Gtk.Button.new_with_label(_("Configure")) 403config_button.connect("clicked", lambda _, stem=provider.stem: subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{stem}.json")])) 404config_button.set_relief(Gtk.ReliefStyle.NONE) 405 406providers_table.attach(icon, 0, rows, 1, 1) 407providers_table.attach(provider_label, 1, rows, 1, 1) 408providers_table.attach(provider_description, 2, rows, 1, 1) 409providers_table.attach(switch, 3, rows, 1, 1) 410providers_table.attach(config_button, 4, rows, 1, 1) 411 412provider_label.show() 413provider_description.show() 414 415rows += 1 416 417providers_window.show_all() 418 419def preferences(self, widget): 420preferences_builder = Gtk.Builder() 421preferences_builder.add_from_file("preferences.ui") 422preferences_window = preferences_builder.get_object("preferences-window") 423preferences_window.connect("destroy", lambda _: preferences_window.destroy()) 424 425def update_appearance_preferences(widget, *args): 426self.icon_size = preferences_builder.get_object("icon-size").get_value() 427self.show_result_descriptions = preferences_builder.get_object("show-result-descriptions").get_active() 428 429with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 430json.dump({"icon_size": self.icon_size, "show_result_descriptions": self.show_result_descriptions}, f) 431 432# Regenerate the results to reflect the changes 433if self.update_task is not None: 434self.update_task.cancel() 435self.update_task = asyncio.create_task(self.update_results(widget)) 436 437preferences_builder.connect_signals( 438{ 439"update-appearance": update_appearance_preferences, 440} 441) 442 443preferences_window.show_all() 444 445def update_permanently_enabled_providers(self, widget, param, provider): 446if widget.get_active(): 447self.permanently_enabled_providers[provider] = self.available_providers[provider] 448else: 449self.permanently_enabled_providers.pop(provider, None) 450 451with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 452f.write("\n".join(self.permanently_enabled_providers.keys())) 453 454 455async def update_results(self, widget): 456print("Updating results...") 457spinner = self.builder.get_object("spinner") 458spinner.start() 459generators = [] 460query = self.builder.get_object("search-query-buffer").get_text() 461# print(f"Query: {query}") 462for provider in self.providers: 463generators.append(provider.search) 464 465# Clear the results list 466results_list = self.builder.get_object("results-list") 467for row in results_list.get_children(): 468results_list.remove(row) 469 470try: 471async for result in merge_generators_with_params(generators, query): 472# print(result) 473result_box = ResultInfoWidget(result["name"], result["description"], result["image"], result["execute"], self.icon_size, self.show_result_descriptions) 474results_list.add(result_box) 475result_box.show_all() 476except Exception as e: 477exception_type, exception, tb = sys.exc_info() 478filename, line_number, function_name, line = traceback.extract_tb(tb)[-1] 479print(f"{filename} raised an exception!") 480print(f"{type(e).__name__}: {str(e)}") 481dialog = ProviderExceptionDialog(Path(filename), e, self.window, tb=tb) 482dialog.show_all() 483response = dialog.run() 484if response == 0: 485# Open config 486subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json")]) 487 488self.kill() 489elif response == 1: 490# Reset config 491spec = importlib.util.spec_from_file_location(get_parent_package(filename).stem, Path(filename)) 492module = importlib.util.module_from_spec(spec) 493spec.loader.exec_module(module) 494 495if hasattr(module, "config_template"): 496with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 497json.dump(module.config_template, f) 498else: 499with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 500json.dump({}, f) 501 502self.kill() 503elif response == Gtk.ResponseType.CLOSE: 504self.kill() 505 506results_list.show_all() 507spinner.stop() 508 509 510if __name__ == "__main__": 511izvor = Izvor() 512izvor.window.show_all() 513try: 514izvor.loop.run_forever() 515finally: 516izvor.loop.close() 517