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 gi 21import asyncio 22import importlib 23import gbulb 24from pathlib import Path 25from typing import AsyncIterable, AsyncIterator, Collection, TypeVar, Iterable, Callable, Any 26 27from xdg.Config import icon_size 28 29gi.require_version("Gtk", "3.0") 30from gi.repository import Gtk, Gdk, Pango, GLib 31import gettext 32import json 33 34_ = gettext.gettext 35 36_T = TypeVar("_T") 37 38 39async def merge_generators_with_params(generator_funcs: list[Callable[..., AsyncIterable]], 40*args, **kwargs): 41iterators = [gen_func(*args, **kwargs).__aiter__() for gen_func in generator_funcs] 42tasks = {asyncio.create_task(it.__anext__()): it for it in iterators} 43 44while tasks: 45done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) 46 47for task in done: 48iterator = tasks.pop(task) 49try: 50result = task.result() 51yield result 52tasks[asyncio.create_task(iterator.__anext__())] = iterator 53except StopAsyncIteration: 54pass 55 56 57class ResultInfoWidget(Gtk.ListBoxRow): 58def __init__(self, name: str, description: str, image: tuple[str, Any], execute: Callable[[], None], icon_size=32, show_description=True): 59super().__init__() 60self.name = name 61self.description = description 62self.image = image 63self.execute = execute 64 65self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) 66self.add(self.box) 67 68if icon_size: 69# If the icon size is 0, have no icons 70if image[0] == "logo": 71self.image = Gtk.Image.new_from_icon_name(self.image[1], Gtk.IconSize.LARGE_TOOLBAR) 72self.image.set_pixel_size(icon_size) 73self.box.pack_start(self.image, False, False, 0) 74 75self.label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 76self.box.pack_start(self.label_box, True, True, 0) 77 78self.name_label = Gtk.Label(label=self.name) 79self.name_label.set_line_wrap(True) 80self.name_label.set_justify(Gtk.Justification.LEFT) 81self.name_label.set_halign(Gtk.Align.START) 82attributes = Pango.AttrList() 83attributes.insert(Pango.AttrSize.new(16 * Pango.SCALE)) 84self.name_label.set_attributes(attributes) 85self.label_box.pack_start(self.name_label, True, True, 0) 86 87if show_description: 88self.description_label = Gtk.Label(label=self.description) 89self.description_label.set_line_wrap(True) 90self.description_label.set_justify(Gtk.Justification.LEFT) 91self.description_label.set_halign(Gtk.Align.START) 92self.label_box.pack_start(self.description_label, True, True, 0) 93 94class Izvor(Gtk.Application): 95USER_CONFIGS = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "izvor" 96USER_DATA = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "izvor" 97USER_PROVIDERS = USER_DATA / "providers" 98USER_PROVIDER_CONFIGS = USER_CONFIGS / "providers" 99SYSTEM_CONFIGS = Path("/etc/izvor") 100SYSTEM_DATA = Path("/usr/share/izvor") 101SYSTEM_PROVIDERS = SYSTEM_DATA / "providers" 102SYSTEM_PROVIDER_CONFIGS = SYSTEM_CONFIGS / "providers" 103ENABLED_PROVIDERS_FILE = USER_CONFIGS / "enabled_providers" 104APPEARANCE_CONFIG_FILE = USER_CONFIGS / "appearance.json" 105 106def __init__(self): 107super().__init__(application_id="com.roundabout-host.roundabout.izvor") 108gbulb.install(gtk=True) 109self.loop = asyncio.get_event_loop() 110self.builder = Gtk.Builder() 111self.builder.add_from_file("izvor.ui") 112self.window = self.builder.get_object("root") 113self.window.connect("destroy", self.kill) 114self.window.connect("key-press-event", self.check_escape) 115self.window.set_title(_("Launcher")) 116 117if not os.getenv("XDG_DATA_HOME"): 118print("XDG_DATA_HOME is not set. Using default path.") 119if not os.getenv("XDG_CONFIG_HOME"): 120print("XDG_CONFIG_HOME is not set. Using default path.") 121if not self.USER_PROVIDER_CONFIGS.exists(): 122print("Creating user config directory.") 123self.USER_PROVIDER_CONFIGS.mkdir(parents=True) 124if not self.USER_PROVIDERS.exists(): 125print("Creating user data directory.") 126self.USER_PROVIDERS.mkdir(parents=True) 127 128self.available_providers = {} 129 130if self.SYSTEM_PROVIDERS.exists(): 131for provider in self.SYSTEM_PROVIDERS.iterdir(): 132if not (provider / "__init__.py").exists(): 133continue 134self.available_providers[provider.stem] = provider 135if self.USER_PROVIDERS.exists(): 136for provider in self.USER_PROVIDERS.iterdir(): 137if not (provider / "__init__.py").exists(): 138continue 139self.available_providers[provider.stem] = provider 140 141print(f"Available providers: {list(self.available_providers.keys())}") 142 143if not self.ENABLED_PROVIDERS_FILE.exists(): 144self.ENABLED_PROVIDERS_FILE.touch() 145 146with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 147for provider in self.available_providers: 148f.write(provider + "\n") 149 150if not self.APPEARANCE_CONFIG_FILE.exists(): 151self.APPEARANCE_CONFIG_FILE.touch() 152with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 153json.dump({"icon_size": 32, "show_result_descriptions": True}, f) 154 155with open(self.APPEARANCE_CONFIG_FILE, "r") as f: 156appearance_config = json.load(f) 157self.icon_size = appearance_config.get("icon_size", 32) 158self.show_result_descriptions = appearance_config.get("show_result_descriptions", True) 159 160self.permanently_enabled_providers = {} 161 162with open(self.ENABLED_PROVIDERS_FILE, "r") as f: 163for line in f: 164provider = line.strip() 165if provider in self.available_providers: 166self.permanently_enabled_providers[provider] = self.available_providers[provider] 167 168self.enabled_providers = self.permanently_enabled_providers.copy() 169 170providers_menu = self.builder.get_object("providers-menu") 171 172self.providers = [] 173self.provider_checkboxes = {} 174 175for provider in self.permanently_enabled_providers.values(): 176spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 177module = importlib.util.module_from_spec(spec) 178spec.loader.exec_module(module) 179loaded_provider = module.Provider({}) 180self.providers.append(loaded_provider) 181print(f"Loaded provider: {loaded_provider.name}") 182print(loaded_provider.description) 183self.provider_checkboxes[provider.stem] = Gtk.CheckMenuItem.new_with_label(loaded_provider.name) 184providers_menu.append(self.provider_checkboxes[provider.stem]) 185self.provider_checkboxes[provider.stem].set_active(True) 186self.provider_checkboxes[provider.stem].show() 187self.provider_checkboxes[provider.stem].connect("toggled", self.update_enabled_providers) 188 189self.update_task = None 190 191def call_update_results(widget): 192if self.update_task is not None: 193self.update_task.cancel() 194self.update_task = asyncio.create_task(self.update_results(widget)) 195 196def execute_result(widget, row): 197row.execute() 198self.kill() 199 200self.builder.connect_signals( 201{ 202"update-search": call_update_results, 203"result-activated": execute_result, 204"providers-menu-all-toggled": self.update_enabled_providers_all, 205"menu-about": self.about, 206"menu-providers": self.provider_menu, 207"menu-preferences": self.preferences, 208} 209) 210 211GLib.idle_add(self.update_enabled_providers, None) 212 213def update_enabled_providers(self, widget=None): 214for provider, checkbox in self.provider_checkboxes.items(): 215if checkbox.get_active(): 216self.enabled_providers[provider] = self.permanently_enabled_providers[provider] 217else: 218self.enabled_providers.pop(provider, None) 219 220if self.update_task is not None: 221self.update_task.cancel() 222self.update_task = asyncio.create_task(self.update_results(widget)) 223 224def update_enabled_providers_all(self, widget): 225for checkbox in self.provider_checkboxes.values(): 226checkbox.set_active(widget.get_active()) 227self.update_enabled_providers() 228 229def kill(self, widget=None): 230self.loop.stop() 231self.quit() 232 233def check_escape(self, widget, event): 234results_list = self.builder.get_object("results-list") 235search_entry = self.builder.get_object("search-query") 236rows = results_list.get_children() 237current_row = results_list.get_selected_row() 238 239if event.keyval == Gdk.KEY_Escape: 240self.kill() 241return True 242 243if event.keyval == Gdk.KEY_Down: 244# Move to the next row 245if current_row: 246current_index = rows.index(current_row) 247next_index = (current_index + 1) % len(rows) # Wrap to the beginning if at the end 248else: 249next_index = 0 250 251results_list.select_row(rows[next_index]) 252search_entry.grab_focus() # Refocus the search entry 253self.scroll_to_row(rows[next_index], results_list.get_adjustment()) 254return True 255 256if event.keyval == Gdk.KEY_Up: 257# Move to the previous row 258if current_row: 259current_index = rows.index(current_row) 260prev_index = (current_index - 1) % len(rows) # Wrap to the end if at the beginning 261else: 262prev_index = len(rows) - 1 263 264results_list.select_row(rows[prev_index]) 265search_entry.grab_focus() # Refocus the search entry 266self.scroll_to_row(rows[prev_index], results_list.get_adjustment()) 267return True 268 269if event.keyval == Gdk.KEY_Return: 270if current_row: 271# Execute the selected row 272current_row.activate() 273return True 274elif len(rows) > 0: 275# Execute the first row 276rows[0].activate() 277return True 278 279return False 280 281def scroll_to_row(self, row, adjustment): 282row_geometry = row.get_allocation() 283row_y = row_geometry.y 284row_height = row_geometry.height 285adjustment_value = adjustment.get_value() 286adjustment_upper = adjustment.get_upper() - adjustment.get_page_size() 287 288if row_y + row_height > adjustment_value + adjustment.get_page_size(): 289adjustment.set_value(min(row_y + row_height - adjustment.get_page_size(), adjustment_upper)) 290elif row_y < adjustment_value: 291adjustment.set_value(max(row_y, 0)) 292 293def about(self, widget): 294about_builder = Gtk.Builder() 295about_builder.add_from_file("about.ui") 296about_window = about_builder.get_object("about-dialog") 297about_window.connect("destroy", lambda _: about_window.destroy()) 298about_window.connect("close", lambda _: about_window.destroy()) 299about_window.connect("response", lambda _, __: about_window.destroy()) 300about_window.show_all() 301 302def provider_menu(self, widget): 303providers_builder = Gtk.Builder() 304providers_builder.add_from_file("providers.ui") 305providers_window = providers_builder.get_object("providers-window") 306providers_window.connect("destroy", lambda _: providers_window.destroy()) 307 308providers_table = providers_builder.get_object("providers-table") # actually Gtk.Grid 309rows = 0 310for provider in self.available_providers.values(): 311spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 312module = importlib.util.module_from_spec(spec) 313spec.loader.exec_module(module) 314loaded_provider = module.Provider({}) 315 316provider_label = Gtk.Label(label=loaded_provider.name) 317provider_label.set_halign(Gtk.Align.START) 318provider_label.set_valign(Gtk.Align.CENTER) 319font_desc = Pango.FontDescription() 320font_desc.set_weight(Pango.Weight.BOLD) 321provider_label.override_font(font_desc) 322provider_label.set_line_wrap(True) 323provider_label.set_justify(Gtk.Justification.LEFT) 324 325provider_description = Gtk.Label(label=loaded_provider.description) 326provider_description.set_halign(Gtk.Align.START) 327provider_description.set_valign(Gtk.Align.CENTER) 328provider_description.set_justify(Gtk.Justification.LEFT) 329provider_description.set_line_wrap(True) 330 331icon = Gtk.Image.new_from_icon_name(loaded_provider.icon, Gtk.IconSize.LARGE_TOOLBAR) 332icon.set_pixel_size(self.icon_size) 333 334switch = Gtk.Switch() 335switch.set_valign(Gtk.Align.CENTER) 336switch.set_active(provider.stem in self.permanently_enabled_providers) 337switch.connect("notify::active", self.update_permanently_enabled_providers, provider.stem) 338 339providers_table.attach(icon, 0, rows, 1, 1) 340providers_table.attach(provider_label, 1, rows, 1, 1) 341providers_table.attach(provider_description, 2, rows, 1, 1) 342providers_table.attach(switch, 3, rows, 1, 1) 343 344provider_label.show() 345provider_description.show() 346 347rows += 1 348 349providers_window.show_all() 350 351def preferences(self, widget): 352preferences_builder = Gtk.Builder() 353preferences_builder.add_from_file("preferences.ui") 354preferences_window = preferences_builder.get_object("preferences-window") 355preferences_window.connect("destroy", lambda _: preferences_window.destroy()) 356 357def update_appearance_preferences(widget, *args): 358self.icon_size = preferences_builder.get_object("icon-size").get_value() 359self.show_result_descriptions = preferences_builder.get_object("show-result-descriptions").get_active() 360 361with open(self.APPEARANCE_CONFIG_FILE, "w") as f: 362json.dump({"icon_size": self.icon_size, "show_result_descriptions": self.show_result_descriptions}, f) 363 364# Regenerate the results to reflect the changes 365if self.update_task is not None: 366self.update_task.cancel() 367self.update_task = asyncio.create_task(self.update_results(widget)) 368 369preferences_builder.connect_signals( 370{ 371"update-appearance": update_appearance_preferences, 372} 373) 374 375preferences_window.show_all() 376 377def update_permanently_enabled_providers(self, widget, param, provider): 378if widget.get_active(): 379self.permanently_enabled_providers[provider] = self.available_providers[provider] 380else: 381self.permanently_enabled_providers.pop(provider, None) 382 383with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 384f.write("\n".join(self.permanently_enabled_providers.keys())) 385 386 387async def update_results(self, widget): 388print("Updating results...") 389spinner = self.builder.get_object("spinner") 390spinner.start() 391generators = [] 392query = self.builder.get_object("search-query-buffer").get_text() 393# print(f"Query: {query}") 394for provider in self.enabled_providers.values(): 395spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 396module = importlib.util.module_from_spec(spec) 397spec.loader.exec_module(module) 398loaded_provider = module.Provider({}) 399# print(f"Searching with provider: {loaded_provider.name}") 400 401generators.append(loaded_provider.search) 402 403# Clear the results list 404results_list = self.builder.get_object("results-list") 405for row in results_list.get_children(): 406results_list.remove(row) 407 408async for result in merge_generators_with_params(generators, query): 409# print(result) 410result_box = ResultInfoWidget(result["name"], result["description"], result["image"], result["execute"], self.icon_size, self.show_result_descriptions) 411results_list.add(result_box) 412result_box.show_all() 413 414results_list.show_all() 415spinner.stop() 416 417 418if __name__ == "__main__": 419izvor = Izvor() 420izvor.window.show_all() 421try: 422izvor.loop.run_forever() 423finally: 424izvor.loop.close() 425