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