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 230self.scroll_to_row(rows[next_index], results_list.get_adjustment()) 231return True 232 233if event.keyval == Gdk.KEY_Up: 234# Move to the previous row 235if current_row: 236current_index = rows.index(current_row) 237prev_index = (current_index - 1) % len(rows) # Wrap to the end if at the beginning 238else: 239prev_index = len(rows) - 1 240 241results_list.select_row(rows[prev_index]) 242search_entry.grab_focus() # Refocus the search entry 243self.scroll_to_row(rows[prev_index], results_list.get_adjustment()) 244return True 245 246if event.keyval == Gdk.KEY_Return and current_row: 247# Execute the selected row 248current_row.activate() 249return True 250 251return False 252 253def scroll_to_row(self, row, adjustment): 254row_geometry = row.get_allocation() 255row_y = row_geometry.y 256row_height = row_geometry.height 257adjustment_value = adjustment.get_value() 258adjustment_upper = adjustment.get_upper() - adjustment.get_page_size() 259 260if row_y + row_height > adjustment_value + adjustment.get_page_size(): 261adjustment.set_value(min(row_y + row_height - adjustment.get_page_size(), adjustment_upper)) 262elif row_y < adjustment_value: 263adjustment.set_value(max(row_y, 0)) 264 265def about(self, widget): 266about_builder = Gtk.Builder() 267about_builder.add_from_file("about.ui") 268about_window = about_builder.get_object("about-dialog") 269about_window.connect("destroy", lambda _: about_window.destroy()) 270about_window.connect("close", lambda _: about_window.destroy()) 271about_window.connect("response", lambda _, __: about_window.destroy()) 272about_window.show_all() 273 274def provider_menu(self, widget): 275providers_builder = Gtk.Builder() 276providers_builder.add_from_file("providers.ui") 277providers_window = providers_builder.get_object("providers-window") 278providers_window.connect("destroy", lambda _: providers_window.destroy()) 279 280providers_table = providers_builder.get_object("providers-table") # actually Gtk.Grid 281rows = 0 282for provider in self.available_providers.values(): 283spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 284module = importlib.util.module_from_spec(spec) 285spec.loader.exec_module(module) 286loaded_provider = module.Provider({}) 287 288provider_label = Gtk.Label(label=loaded_provider.name) 289provider_label.set_halign(Gtk.Align.START) 290provider_label.set_valign(Gtk.Align.CENTER) 291font_desc = Pango.FontDescription() 292font_desc.set_weight(Pango.Weight.BOLD) 293provider_label.override_font(font_desc) 294provider_label.set_line_wrap(True) 295provider_label.set_justify(Gtk.Justification.LEFT) 296 297provider_description = Gtk.Label(label=loaded_provider.description) 298provider_description.set_halign(Gtk.Align.START) 299provider_description.set_valign(Gtk.Align.CENTER) 300provider_description.set_justify(Gtk.Justification.LEFT) 301provider_description.set_line_wrap(True) 302 303icon = Gtk.Image.new_from_icon_name(loaded_provider.icon, Gtk.IconSize.LARGE_TOOLBAR) 304icon.set_pixel_size(ICON_SIZE) 305 306switch = Gtk.Switch() 307switch.set_valign(Gtk.Align.CENTER) 308switch.set_active(provider.stem in self.permanently_enabled_providers) 309switch.connect("notify::active", self.update_permanently_enabled_providers, provider.stem) 310 311providers_table.attach(icon, 0, rows, 1, 1) 312providers_table.attach(provider_label, 1, rows, 1, 1) 313providers_table.attach(provider_description, 2, rows, 1, 1) 314providers_table.attach(switch, 3, rows, 1, 1) 315 316provider_label.show() 317provider_description.show() 318 319rows += 1 320 321providers_window.show_all() 322 323 324def update_permanently_enabled_providers(self, widget, param, provider): 325if widget.get_active(): 326self.permanently_enabled_providers[provider] = self.available_providers[provider] 327else: 328self.permanently_enabled_providers.pop(provider, None) 329 330with open(self.ENABLED_PROVIDERS_FILE, "w") as f: 331f.write("\n".join(self.permanently_enabled_providers.keys())) 332 333 334async def update_results(self, widget): 335print("Updating results...") 336generators = [] 337query = self.builder.get_object("search-query-buffer").get_text() 338# print(f"Query: {query}") 339for provider in self.enabled_providers.values(): 340spec = importlib.util.spec_from_file_location(provider.stem, provider / "__init__.py") 341module = importlib.util.module_from_spec(spec) 342spec.loader.exec_module(module) 343loaded_provider = module.Provider({}) 344# print(f"Searching with provider: {loaded_provider.name}") 345 346generators.append(loaded_provider.search) 347 348# Clear the results list 349results_list = self.builder.get_object("results-list") 350for row in results_list.get_children(): 351results_list.remove(row) 352 353async for result in merge_generators_with_params(generators, query): 354# print(result) 355result_box = ResultInfoWidget(result["name"], result["description"], result["image"], result["execute"]) 356results_list.add(result_box) 357result_box.show_all() 358 359results_list.show_all() 360 361 362if __name__ == "__main__": 363izvor = Izvor() 364izvor.window.show_all() 365try: 366izvor.loop.run_forever() 367finally: 368izvor.loop.close() 369