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