#!/usr/bin/env python3 # This file is part of I38. # I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, # either version 3 of the License, or (at your option) any later version. # I38 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR # PURPOSE. See the GNU General Public License for more details. # You should have received a copy of the GNU General Public License along with I38. If not, see . import os import re import shlex import shutil import subprocess from collections import namedtuple from pathlib import Path import gi gi.require_version("Atk", "1.0") gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gio, GLib, Gtk from shortcuts import desktop_directory try: gi.require_version("GioUnix", "2.0") from gi.repository import GioUnix DesktopAppInfo = GioUnix.DesktopAppInfo except (ImportError, ValueError): DesktopAppInfo = Gio.DesktopAppInfo APP_NAME = "I38 Desktop" DesktopItem = namedtuple("DesktopItem", ("name", "kind", "path", "command")) def build_launch_command(command, workspace): if not workspace or shutil.which("i3-msg") is None: return command if re.fullmatch(r"[0-9]+", workspace): return f"i3-msg workspace number {workspace} && {command}" return f"i3-msg workspace {shlex.quote(workspace)} && {command}" def read_shortcuts(): config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) shortcuts_file = config_home / "i3" / "desktop_shortcuts" if not shortcuts_file.is_file(): return [] items = [] for raw_line in shortcuts_file.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue fields = line.split("|") if len(fields) < 2: continue name = fields[0].strip() command = fields[1].strip() workspace = fields[2].strip() if len(fields) > 2 else "" if not name or not command: continue items.append( DesktopItem( name=name, kind="Shortcut", path=None, command=build_launch_command(command, workspace), ) ) return items def desktop_app_info(path): try: return DesktopAppInfo.new_from_filename(str(path)) except (TypeError, GLib.Error): return None def desktop_entry_name(path): app_info = desktop_app_info(path) if app_info is not None: name = app_info.get_display_name() if name: return name return path.name def read_desktop_items(): try: desktop_path = desktop_directory() except ValueError: return [] if not desktop_path.is_dir(): return [] directories = sorted( (path for path in desktop_path.iterdir() if path.is_dir()), key=lambda path: path.name.casefold(), ) files = sorted( (path for path in desktop_path.iterdir() if not path.is_dir()), key=lambda path: path.name.casefold(), ) items = [] for path in directories: items.append(DesktopItem(path.name, "Folder", path, None)) for path in files: kind = "Desktop entry" if path.suffix.lower() == ".desktop" else "File" name = desktop_entry_name(path) if kind == "Desktop entry" else path.name items.append(DesktopItem(name, kind, path, None)) return items class DesktopWindow(Gtk.Window): def __init__(self, items): super().__init__(title=APP_NAME) self.set_default_size(640, 420) self.set_border_width(12) self.set_position(Gtk.WindowPosition.CENTER) self.set_skip_taskbar_hint(True) self.set_skip_pager_hint(True) self.connect("destroy", Gtk.main_quit) self.connect("focus-out-event", self.on_focus_out) self.suppress_focus_close = False self.filter_text = "" self.store = Gtk.ListStore(str, str, str, str) self.filter_model = self.store.filter_new() self.filter_model.set_visible_func(self.visible_item) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.add(main_box) search_label = Gtk.Label(label="_Search desktop items:") search_label.set_xalign(0) main_box.pack_start(search_label, False, False, 0) self.search_entry = Gtk.SearchEntry() self.search_entry.set_placeholder_text("Search desktop items") self.search_entry.connect("changed", self.on_search_changed) self.search_entry.connect("key-press-event", self.on_search_key_press) search_label.set_mnemonic_widget(self.search_entry) main_box.pack_start(self.search_entry, False, False, 0) self.view = Gtk.TreeView(model=self.filter_model) self.view.set_headers_visible(True) self.view.set_enable_search(False) self.view.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.view.connect("row-activated", self.on_row_activated) self.view.connect("button-press-event", self.on_tree_button_press) self.view.connect("key-press-event", self.on_tree_key_press) name_column = Gtk.TreeViewColumn("Item", Gtk.CellRendererText(), text=0) kind_column = Gtk.TreeViewColumn("Type", Gtk.CellRendererText(), text=1) self.view.append_column(name_column) self.view.append_column(kind_column) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.add(self.view) main_box.pack_start(scrolled, True, True, 0) self.status = Gtk.Statusbar() self.status_context_id = self.status.get_context_id("desktop-items") main_box.pack_start(self.status, False, False, 0) button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL) button_box.set_layout(Gtk.ButtonBoxStyle.END) open_button = Gtk.Button.new_with_mnemonic("_Open") open_button.connect("clicked", self.on_open_clicked) delete_button = Gtk.Button.new_with_mnemonic("_Delete") delete_button.connect("clicked", self.on_delete_clicked) close_button = Gtk.Button.new_with_mnemonic("_Close") close_button.connect("clicked", lambda _button: self.close()) button_box.add(close_button) button_box.add(delete_button) button_box.add(open_button) main_box.pack_start(button_box, False, False, 0) self.get_accessible().set_name(APP_NAME) self.get_accessible().set_description( "Select a desktop item and press Enter to open it. " "Use Shift with Up or Down to select multiple items. " "Press Control+Space to clear the selection. " "Escape clears an active search, then closes the dialog. " "Press Shift+F10 for the context menu or Delete to permanently remove selected items." ) self.search_entry.get_accessible().set_name("Search desktop items") self.view.get_accessible().set_name("Desktop items") self.status.get_accessible().set_name("Desktop status") self.load_items(items) self.select_first_item() itemCount = len(items) self.set_status(f"{itemCount} {'item' if itemCount == 1 else 'items'} found.") self.show_all() self.view.grab_focus() def load_items(self, items): self.store.clear() for item in items: self.store.append( ( item.name, item.kind, str(item.path) if item.path is not None else "", item.command or "", ) ) def visible_item(self, model, tree_iter, _data=None): if not self.filter_text: return True name = model.get_value(tree_iter, 0).casefold() return name.startswith(self.filter_text) def select_first_item(self): tree_iter = self.filter_model.get_iter_first() if tree_iter is None: self.view.get_selection().unselect_all() return self.view.set_cursor(self.filter_model.get_path(tree_iter), None, False) def selected_items(self): model, paths = self.view.get_selection().get_selected_rows() items = [] for path in paths: tree_iter = model.get_iter(path) item_path = model.get_value(tree_iter, 2) child_path = self.filter_model.convert_path_to_child_path(path) items.append(( child_path.get_indices()[0], path.to_string(), DesktopItem( name=model.get_value(tree_iter, 0), kind=model.get_value(tree_iter, 1), path=Path(item_path) if item_path else None, command=model.get_value(tree_iter, 3) or None, ), )) return items def selected_item(self): items = self.selected_items() if not items: return None cursor_path, _column = self.view.get_cursor() if cursor_path is not None: for _index, path, item in items: if path == cursor_path.to_string(): return item return items[0][2] def set_status(self, message): self.status.remove_all(self.status_context_id) self.status.push(self.status_context_id, message) def show_error(self, message): self.suppress_focus_close = True dialog = Gtk.MessageDialog( transient_for=self, flags=Gtk.DialogFlags.MODAL, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text="I38 Desktop", ) dialog.format_secondary_text(message) dialog.run() dialog.destroy() GLib.idle_add(self.allow_focus_close) def open_selected(self): item = self.selected_item() if item is None: self.set_status("Select an item to open.") return try: if item.command is not None: subprocess.Popen( ["/usr/bin/env", "bash", "-c", item.command], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) elif item.path is not None and item.path.suffix.lower() == ".desktop": app_info = desktop_app_info(item.path) if app_info is None: subprocess.Popen( ["xdg-open", str(item.path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) elif not app_info.launch([], None): self.show_error(f"Could not open {item.name}.") return elif item.path is not None: subprocess.Popen( ["xdg-open", str(item.path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except (OSError, GLib.Error) as error: self.show_error(f"Could not open {item.name}.\n\n{error}") return self.close() def delete_selected(self): selected = self.selected_items() if not selected: self.set_status("Select a desktop item to delete.") return deletable = [(index, item) for index, _path, item in selected if item.path is not None] if not deletable: self.set_status("Shortcut entries are configured in desktop_shortcuts.") return self.suppress_focus_close = True dialog = Gtk.MessageDialog( transient_for=self, flags=Gtk.DialogFlags.MODAL, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text="Delete desktop item", ) if len(deletable) > 1: folder_selected = any( item.path.is_dir() and not item.path.is_symlink() for _index, item in deletable ) message = f"Permanently delete {len(deletable)} selected desktop items" if folder_selected: message += " and everything inside the selected folders" dialog.format_secondary_text(message + "?") elif deletable[0][1].path.is_dir() and not deletable[0][1].path.is_symlink(): item = deletable[0][1] dialog.format_secondary_text( f"Permanently delete {item.name} and everything inside it?" ) else: dialog.format_secondary_text(f"Permanently delete {deletable[0][1].name}?") dialog.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "_Delete", Gtk.ResponseType.OK, ) dialog.set_default_response(Gtk.ResponseType.CANCEL) response = dialog.run() dialog.destroy() GLib.idle_add(self.allow_focus_close) if response != Gtk.ResponseType.OK: return deleted = [] failures = [] for index, item in deletable: try: if item.path.is_dir() and not item.path.is_symlink(): shutil.rmtree(item.path) else: item.path.unlink() except OSError as error: failures.append((item.name, error)) else: deleted.append(index) for index in sorted(deleted, reverse=True): self.store.remove(self.store.get_iter_from_string(str(index))) if deleted: self.select_first_item() if failures: name, error = failures[0] self.show_error( f"Deleted {len(deleted)} items. Could not delete {len(failures)} items.\n\n" f"{name}: {error}" ) else: count = len(deleted) self.set_status(f"Deleted {count} {'item' if count == 1 else 'items'}.") def show_context_menu(self, event=None): item = self.selected_item() if item is None: self.set_status("Select an item to open its context menu.") return True menu = Gtk.Menu() open_item = Gtk.MenuItem.new_with_mnemonic("_Open") open_item.connect("activate", lambda _menu_item: self.open_selected()) menu.append(open_item) if any(selected.path is not None for _index, _path, selected in self.selected_items()): delete_item = Gtk.MenuItem.new_with_mnemonic("_Delete") delete_item.connect("activate", lambda _menu_item: self.delete_selected()) menu.append(delete_item) menu.attach_to_widget(self.view, None) menu.show_all() self.suppress_focus_close = True menu.connect("deactivate", lambda _menu: GLib.idle_add(self.allow_focus_close)) if event is not None: menu.popup_at_pointer(event) else: menu.popup_at_widget( self.view, Gdk.Gravity.CENTER, Gdk.Gravity.CENTER, None, ) return True def allow_focus_close(self): self.suppress_focus_close = False return False def on_focus_out(self, _widget, _event): if self.suppress_focus_close: return False self.close() return False def on_search_changed(self, _entry): self.filter_text = self.search_entry.get_text().strip().casefold() self.filter_model.refilter() self.select_first_item() visible_count = 0 tree_iter = self.filter_model.get_iter_first() while tree_iter is not None: visible_count += 1 tree_iter = self.filter_model.iter_next(tree_iter) noun = "item" if visible_count == 1 else "items" self.set_status(f"{visible_count} {noun} found.") def clear_search_or_close(self): if self.search_entry.get_text(): self.search_entry.set_text("") self.view.grab_focus() else: self.close() def on_search_key_press(self, _entry, event): if event.keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down): self.select_first_item() self.view.grab_focus() return True if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): self.open_selected() return True if event.keyval == Gdk.KEY_Escape: self.clear_search_or_close() return True return False def on_tree_key_press(self, _view, event): if event.keyval in (Gdk.KEY_space, Gdk.KEY_KP_Space) and event.state & Gdk.ModifierType.CONTROL_MASK: self.view.get_selection().unselect_all() self.set_status("Selection cleared.") return True if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): self.open_selected() return True if event.keyval == Gdk.KEY_F10 and event.state & Gdk.ModifierType.SHIFT_MASK: return self.show_context_menu() if event.keyval in (Gdk.KEY_Delete, Gdk.KEY_KP_Delete): self.delete_selected() return True if event.keyval == Gdk.KEY_Escape: self.clear_search_or_close() return True characterCode = Gdk.keyval_to_unicode(event.keyval) if (characterCode >= 32 and not event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK | Gdk.ModifierType.SUPER_MASK | Gdk.ModifierType.META_MASK)): character = chr(characterCode) self.search_entry.grab_focus() self.search_entry.insert_text(character, -1) self.search_entry.set_position(-1) return True return False def on_tree_button_press(self, view, event): if event.type != Gdk.EventType.BUTTON_PRESS or event.button != Gdk.BUTTON_SECONDARY: return False path_info = view.get_path_at_pos(int(event.x), int(event.y)) if path_info is not None: path, column = path_info[0], path_info[1] selection = view.get_selection() selected_paths = selection.get_selected_rows()[1] if selection.path_is_selected(path) else [] view.set_cursor(path, column, False) for selected_path in selected_paths: selection.select_path(selected_path) view.grab_focus() return self.show_context_menu(event) def on_row_activated(self, _view, _path, _column): self.open_selected() def on_open_clicked(self, _button): self.open_selected() def on_delete_clicked(self, _button): self.delete_selected() def main(): GLib.set_application_name(APP_NAME) items = read_shortcuts() + read_desktop_items() DesktopWindow(items) Gtk.main() if __name__ == "__main__": main()