__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")) 402config_button.connect("clicked", lambda _, stem=provider.stem: subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{stem}.json")])) 403config_button.set_relief(Gtk.ReliefStyle.NONE) 404 405providers_table.attach(icon, 0, rows, 1, 1) 406providers_table.attach(provider_label, 1, rows, 1, 1) 407providers_table.attach(provider_description, 2, rows, 1, 1) 408providers_table.attach(switch, 3, rows, 1, 1) 409providers_table.attach(config_button, 4, rows, 1, 1) 410 411provider_label.show() 412provider_description.show() 413 414rows += 1 415 416providers_window.show_all() 417 418def preferences(self, widget): 419preferences_builder = Gtk.Builder() 420preferences_builder.add_from_file(str(PACKAGE_DIRECTORY / "preferences.ui")) 421preferences_window = preferences_builder.get_object("preferences-window") 422preferences_window.connect("destroy", lambda _: preferences_window.destroy()) 423 424def update_appearance_preferences(widget, *args): 425self.icon_size = preferences_builder.get_object("icon-size").get_value() 426self.show_result_descriptions = preferences_builder.get_object("show-result-descriptions").get_active() 427 428with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 429json.dump({"icon_size": self.icon_size, "show_result_descriptions": self.show_result_descriptions}, f) 430 431# Regenerate the results to reflect the changes 432if self.update_task is not None: 433self.update_task.cancel() 434self.update_task = asyncio.create_task(self.update_results(widget)) 435 436preferences_builder.connect_signals( 437{ 438"update-appearance": update_appearance_preferences, 439} 440) 441 442preferences_window.show_all() 443 444def update_permanently_enabled_providers(self, widget, param, provider): 445if widget.get_active(): 446self.permanently_enabled_providers[provider] = self.available_providers[provider] 447else: 448self.permanently_enabled_providers.pop(provider, None) 449 450with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 451f.write("\n".join(self.permanently_enabled_providers.keys())) 452 453 454async def update_results(self, widget): 455spinner = self.builder.get_object("spinner") 456spinner.start() 457generators = [] 458query = self.builder.get_object("search-query-buffer").get_text() 459for provider in self.providers: 460generators.append(provider.search) 461 462# Clear the results list 463results_list = self.builder.get_object("results-list") 464for row in results_list.get_children(): 465results_list.remove(row) 466 467try: 468async for result in merge_generators_with_params(generators, query): 469result_box = ResultInfoWidget(result["name"], result["description"], result["image"], result["execute"], self.icon_size, self.show_result_descriptions) 470results_list.add(result_box) 471result_box.show_all() 472except Exception as e: 473exception_type, exception, tb = sys.exc_info() 474filename, line_number, function_name, line = traceback.extract_tb(tb)[-1] 475print(f"{filename} raised an exception!") 476print(f"{type(e).__name__}: {str(e)}") 477dialog = ProviderExceptionDialog(Path(filename), e, self.window, tb=tb) 478dialog.show_all() 479response = dialog.run() 480if response == 0: 481# Open config 482subprocess.Popen(["xdg-open", str(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json")]) 483 484self.kill() 485elif response == 1: 486# Reset config 487spec = importlib.util.spec_from_file_location(get_parent_package(filename).stem, Path(filename)) 488module = importlib.util.module_from_spec(spec) 489spec.loader.exec_module(module) 490 491if hasattr(module, "config_template"): 492with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 493json.dump(module.config_template, f) 494else: 495with open(self.USER_PROVIDER_CONFIGS / f"{get_parent_package(filename).stem}.json", "w") as f: 496json.dump({}, f) 497 498self.kill() 499elif response == Gtk.ResponseType.CLOSE: 500self.kill() 501 502results_list.show_all() 503spinner.stop() 504 505 506if __name__ == "__main__": 507izvor = Izvor() 508izvor.window.show_all() 509try: 510izvor.loop.run_forever() 511finally: 512izvor.loop.close() 513