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