#!/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 configparser from pathlib import Path from collections import defaultdict import gi gi.require_version('Gtk', '3.0') gi.require_version('Atk', '1.0') from gi.repository import Gtk, Gdk, GLib, Atk def read_desktop_files(paths): desktopEntries = [] for path in paths: for file in Path(path).rglob('*.desktop'): config = configparser.ConfigParser(interpolation=None) config.read(file) desktopEntries.append(config) return desktopEntries userApplicationsPath = Path.home() / '.local/share/applications' systemApplicationsPath = Path('/usr/share/applications') userFlatpakApplicationsPath = Path.home() / '.local/share/flatpak/exports/share/applications' systemFlatpakApplicationsPath = Path('/var/lib/flatpak/exports/share/applications') desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath, userFlatpakApplicationsPath, systemFlatpakApplicationsPath]) # Combine some of the categories categoryMap = { "2DGraphics": "Office", "Accessibility": "Settings", "Audio": "Audio and Video", "AudioVideo": "Audio and Video", "AudioVideoEditing": "Audio and Video", "ActionGame": "Games", "Building": "Development", "Calendar": "Office", "Chat": "Communication", "Database": "Office", "DesktopSettings": "Settings", "Emulator": "Games", "FlowChart": "Office", "Game": "Games", "HardwareSettings": "Settings", "IDE": "Development", "InstantMessaging": "Communication", "Math": "Education", "Midi": "Audio and Video", "Mixer": "Audio and Video", "Player": "Audio and Video", "Presentation": "Office", "Recorder": "Audio and Video", "Science": "Education", "Spreadsheet": "Office", "Telephony": "Communication", "Terminal": "Utilities", "TerminalEmulator": "Utilities", "TV": "Audio and Video", "Utility": "Utilities", "VectorGraphics": "Office", "Video": "Audio and Video", "WebDevelopment": "Development", "WordProcessor": "Office", "X-Alsa": "Audio and Video", "X-Fedora": "Utilities", "X-Jack": "Audio and Video", "X-LXDE-Settings": "Settings", "X-MATE-NetworkSettings": "Settings", "X-MATE-PersonalSettings": "Settings", "X-Red-Hat-Base": "Utilities", "X-SuSE-Core-Office": "Office", "X-XFCE": "XFCE", "X-XFCE-HardwareSettings": "Settings", "X-XFCE-PersonalSettings": "Settings", "X-XFCE-SettingsDialog": "Settings", "X-Xfce": "XFCE", "X-Xfce-Toplevel": "XFCE", } # First, gather all applications by category categoryApps = defaultdict(dict) subcategories = defaultdict(set) for entry in desktopEntries: try: # Check if NoDisplay=true is set try: noDisplay = entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False) if noDisplay: continue except: pass name = entry.get('Desktop Entry', 'Name') execCommand = entry.get('Desktop Entry', 'Exec') entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';') # For applications with categories mainCategory = None for category in entryCategories: if category: # Skip empty strings mappedCategory = categoryMap.get(category, category) if mainCategory is None: mainCategory = mappedCategory # Check if this might be a subcategory for other in entryCategories: if other and other != category: mappedOther = categoryMap.get(other, other) if mappedCategory != mappedOther: subcategories[mappedOther].add(mappedCategory) # If we found a category, add the application if mainCategory: categoryApps[mainCategory][name] = execCommand else: # If no category was found, add to "Other" categoryApps["Other"][name] = execCommand except (configparser.NoOptionError, KeyError): continue # Ensure we have an "All Applications" category that contains everything allApps = {} for category, apps in categoryApps.items(): allApps.update(apps) categoryApps["All Applications"] = allApps class I38_Tab_Menu(Gtk.Window): def __init__(self): super().__init__(title="I38 Menu") self.set_default_size(500, 400) self.set_border_width(10) # Main container self.mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.add(self.mainBox) # Add search box at the top self.searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) # Create completion for the search entry self.completionStore = Gtk.ListStore(str, str) # Name, Exec self.completion = Gtk.EntryCompletion() self.completion.set_model(self.completionStore) self.completion.set_text_column(0) self.completion.set_minimum_key_length(1) self.completion.set_popup_completion(True) self.completion.set_inline_completion(False) self.completion.connect("match-selected", self.on_completion_match) self.searchEntry = Gtk.Entry() self.searchEntry.set_completion(self.completion) self.searchEntry.set_placeholder_text("Search applications...") self.searchEntry.connect("changed", self.on_search_changed) self.searchEntry.connect("key-press-event", self.on_search_key_press) self.searchBox.pack_start(self.searchEntry, True, True, 0) # Add search button for visual users self.searchButton = Gtk.Button.new_from_icon_name("search", Gtk.IconSize.BUTTON) self.searchButton.connect("clicked", self.on_search_activated) self.searchBox.pack_start(self.searchButton, False, False, 0) self.mainBox.pack_start(self.searchBox, False, False, 0) # Create notebook (tabbed interface) self.notebook = Gtk.Notebook() self.notebook.set_tab_pos(Gtk.PositionType.TOP) self.mainBox.pack_start(self.notebook, True, True, 0) # Flag for search mode self.inSearchMode = False # For incremental letter navigation self.typedText = "" self.typedTextTimer = None # Add a tab for each major category self.treeViews = {} # Store TreeViews for each tab self.stores = {} # Store models for each tab # Sort categories alphabetically, but ensure "All Applications" is first sortedCategories = sorted(categoryApps.keys()) if "All Applications" in sortedCategories: sortedCategories.remove("All Applications") sortedCategories.insert(0, "All Applications") # Create tabs for category in sortedCategories: if not categoryApps[category]: # Skip empty categories continue # Create a TreeStore for this category store = Gtk.TreeStore(str, str) # Columns: Name, Exec self.stores[category] = store # Add applications to this category's store sortedApps = sorted(categoryApps[category].items()) # Check for potential subcategories within this category categorySubcategories = {} for appName, appExec in sortedApps: subcatFound = False for subcat in subcategories.get(category, []): if appName in categoryApps.get(subcat, {}): if subcat not in categorySubcategories: categorySubcategories[subcat] = [] categorySubcategories[subcat].append((appName, appExec)) subcatFound = True break if not subcatFound: # Add directly to the category's root store.append(None, [appName, appExec]) # Add any subcategories for subcat, subcatApps in sorted(categorySubcategories.items()): subcatIter = store.append(None, [subcat, None]) for appName, appExec in sorted(subcatApps): store.append(subcatIter, [appName, appExec]) # Create TreeView for this category treeView = Gtk.TreeView(model=store) treeView.set_headers_visible(False) self.treeViews[category] = treeView # Add column for application names renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn("Applications", renderer, text=0) treeView.append_column(column) # Set up scrolled window scrolledWindow = Gtk.ScrolledWindow() scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolledWindow.add(treeView) # Connect signals treeView.connect("row-activated", self.on_row_activated) treeView.connect("key-press-event", self.on_key_press) # Create tab label tabLabel = Gtk.Label(label=category) # Add the tab self.notebook.append_page(scrolledWindow, tabLabel) # Set tab accessibility properties for screen readers tabChild = self.notebook.get_nth_page(self.notebook.get_n_pages() - 1) # Get the accessible object and set properties on it instead accessible = tabChild.get_accessible() accessible.set_name(f"{category} applications") # Use Atk role instead of Gtk.AccessibleRole which isn't available in GTK 3.0 accessible.set_role(Atk.Role.LIST) # Connect notebook signals self.notebook.connect("switch-page", self.on_switch_page) # Connect window signals self.connect("key-press-event", self.on_window_key_press) self.connect("focus-out-event", self.on_focus_out) # Add all applications to the completion store self.populate_completion_store() # Set accessibility properties windowAccessible = self.get_accessible() windowAccessible.set_name("I38 Application Menu") windowAccessible.set_description("Tab-based application launcher menu. Press slash to search, type app name and use down arrow to navigate results. Type letters to incrementally navigate to matching applications.") def populate_completion_store(self): """Populate completion store with all available applications""" self.completionStore.clear() # Add all applications from all categories for tabName, appDict in categoryApps.items(): for appName, execCommand in sorted(appDict.items()): self.completionStore.append([appName, execCommand]) def on_switch_page(self, notebook, page, pageNum): # Focus the TreeView in the newly selected tab tab = notebook.get_nth_page(pageNum) for child in tab.get_children(): if isinstance(child, Gtk.TreeView): child.grab_focus() break def on_row_activated(self, treeView, path, column): model = treeView.get_model() treeIter = model.get_iter(path) execCommand = model.get_value(treeIter, 1) if execCommand: # Launch the application cmdParts = execCommand.split() # Remove field codes like %f, %F, %u, %U cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)] cleanCmd = ' '.join(cleanCmd) # Use GLib.spawn_command_line_async for better process handling try: GLib.spawn_command_line_async(cleanCmd) Gtk.main_quit() except GLib.Error as e: dialog = Gtk.MessageDialog( transient_for=self, flags=0, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=f"Failed to launch application: {e.message}" ) dialog.run() dialog.destroy() def on_completion_match(self, completion, model, iterator): """Handle when a completion item is selected""" appName = model[iterator][0] execCommand = model[iterator][1] if execCommand: # Launch the application cmdParts = execCommand.split() # Remove field codes like %f, %F, %u, %U cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)] cleanCmd = ' '.join(cleanCmd) # Use GLib.spawn_command_line_async for better process handling try: GLib.spawn_command_line_async(cleanCmd) Gtk.main_quit() except GLib.Error as e: dialog = Gtk.MessageDialog( transient_for=self, flags=0, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=f"Failed to launch application: {e.message}" ) dialog.run() dialog.destroy() return True def on_search_changed(self, entry): """Handle search text changes""" searchText = entry.get_text().lower() self.inSearchMode = bool(searchText) # If entering search mode, make sure "All Applications" tab is active if self.inSearchMode and not self.notebook.get_current_page() == 0: self.notebook.set_current_page(0) currentTab = self.notebook.get_current_page() tabName = list(self.treeViews.keys())[currentTab] treeView = self.treeViews[tabName] store = self.stores[tabName] self.filter_applications(store, searchText) # Update the completion model with filtered results self.update_completion_results(searchText) # Set accessibility announcement if self.inSearchMode: treeView.get_accessible().set_name(f"Search results for {searchText}") else: treeView.get_accessible().set_name(f"{tabName} applications") def update_completion_results(self, searchText): """Update completion dropdown with matching applications""" if not searchText: # If empty, restore all applications self.populate_completion_store() return # Clear and repopulate with filtered results self.completionStore.clear() for tabName, appDict in categoryApps.items(): for appName, execCommand in sorted(appDict.items()): if searchText.lower() in appName.lower(): self.completionStore.append([appName, execCommand]) def filter_applications(self, store, searchText): """Show/hide applications based on search text""" def filterRow(model, iterator): if not self.inSearchMode: return True appName = model.get_value(iterator, 0).lower() # Always show categories (rows with no exec command) execCommand = model.get_value(iterator, 1) if execCommand is None: # Check if any children match childIter = model.iter_children(iterator) while childIter: childName = model.get_value(childIter, 0).lower() if searchText in childName: return True childIter = model.iter_next(childIter) return False # Show app if it matches search return searchText in appName for tabName, treeStore in self.stores.items(): # Replace store with filtered version filterStore = treeStore.filter_new() filterStore.set_visible_func(filterRow) treeView = self.treeViews[tabName] treeView.set_model(filterStore) # Expand all categories in search mode if self.inSearchMode: treeView.expand_all() else: treeView.collapse_all() def on_search_key_press(self, entry, event): """Handle keyboard input in search box""" keyval = event.keyval if keyval == Gdk.KEY_Down: # Check if we can navigate to completion results if self.completion.get_popup_completion() and self.inSearchMode: # Try to activate completion popup and navigate to it self.completion.complete() return True # If not in completion mode, move focus to the treeview currentTab = self.notebook.get_current_page() tabName = list(self.treeViews.keys())[currentTab] treeView = self.treeViews[tabName] treeView.grab_focus() # Set cursor to first item model = treeView.get_model() iterator = model.get_iter_first() if iterator: path = model.get_path(iterator) treeView.set_cursor(path) return True elif keyval == Gdk.KEY_Escape: if self.searchEntry.get_text(): # Clear search if there's text self.searchEntry.set_text("") return True else: # Otherwise close the app Gtk.main_quit() return True # Other keys pass through return False def on_search_activated(self, widget): """Process search when search button is clicked""" searchText = self.searchEntry.get_text() if searchText: # Focus on the first result if any currentTab = self.notebook.get_current_page() tabName = list(self.treeViews.keys())[currentTab] treeView = self.treeViews[tabName] model = treeView.get_model() # Find first visible row iterator = model.get_iter_first() if iterator: path = model.get_path(iterator) treeView.set_cursor(path) treeView.grab_focus() def on_key_press(self, widget, event): keyval = event.keyval if keyval == Gdk.KEY_Escape: # Reset incremental typing and close app if no ongoing search if self.typedText: self.typedText = "" # Announce reset current_tab = self.notebook.get_current_page() tab_name = list(self.treeViews.keys())[current_tab] treeView = self.treeViews[tab_name] treeView.get_accessible().set_name(f"Type reset. {tab_name} applications") return True else: Gtk.main_quit() return True elif keyval == Gdk.KEY_slash: # Forward slash activates search self.searchEntry.grab_focus() return True elif keyval == Gdk.KEY_Left: # If in a TreeView and Left key is pressed path = widget.get_cursor()[0] if path: model = widget.get_model() treeIter = model.get_iter(path) if widget.row_expanded(path): # Collapse the current row if it's expanded widget.collapse_row(path) return True else: # Move to parent if possible parentIter = model.iter_parent(treeIter) if parentIter: parentPath = model.get_path(parentIter) widget.set_cursor(parentPath) return True # If we couldn't handle it in the TreeView, try to switch tabs currentPage = self.notebook.get_current_page() if currentPage > 0 and not self.inSearchMode: self.notebook.set_current_page(currentPage - 1) return True elif keyval == Gdk.KEY_Right: # If in a TreeView and Right key is pressed path = widget.get_cursor()[0] if path: model = widget.get_model() treeIter = model.get_iter(path) # Check if this row has children if model.iter_has_child(treeIter): if not widget.row_expanded(path): widget.expand_row(path, False) # Move to the first child childPath = model.get_path(model.iter_children(treeIter)) widget.set_cursor(childPath) return True # If we couldn't handle it in the TreeView, try to switch tabs currentPage = self.notebook.get_current_page() if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode: self.notebook.set_current_page(currentPage + 1) return True elif keyval == Gdk.KEY_Tab: # Control tab navigation currentPage = self.notebook.get_current_page() if event.state & Gdk.ModifierType.SHIFT_MASK: # Shift+Tab -> previous tab if currentPage > 0 and not self.inSearchMode: self.notebook.set_current_page(currentPage - 1) else: self.notebook.set_current_page(self.notebook.get_n_pages() - 1) else: # Tab -> next tab if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode: self.notebook.set_current_page(currentPage + 1) else: self.notebook.set_current_page(0) return True # Incremental letter navigation elif Gdk.KEY_a <= keyval <= Gdk.KEY_z or Gdk.KEY_A <= keyval <= Gdk.KEY_Z: # Cancel any pending timer if self.typedTextTimer: GLib.source_remove(self.typedTextTimer) self.typedTextTimer = None # Add the new letter to the typed text letter = chr(keyval).lower() self.typedText += letter # Find item matching typed text found = self.find_incremental_match(widget, self.typedText) # Set timer to reset typed text after 1.5 seconds of inactivity self.typedTextTimer = GLib.timeout_add(1500, self.reset_typed_text) # Announce the letters being typed current_tab = self.notebook.get_current_page() tab_name = list(self.treeViews.keys())[current_tab] treeView = self.treeViews[tab_name] treeView.get_accessible().set_name(f"Typed: {self.typedText}") return True if found else False return False def reset_typed_text(self): """Reset the typed text after timeout""" self.typedText = "" self.typedTextTimer = None return False # Don't call again def find_incremental_match(self, treeView, text): """Find items matching the incrementally typed text""" if not text: return False model = treeView.get_model() found = False def search_tree(model, path, treeIter, user_data): nonlocal found if found: return True # Stop iteration if already found name = model.get_value(treeIter, 0) if name and name.lower().startswith(text.lower()): # Found a match treeView.set_cursor(path) treeView.scroll_to_cell(path, None, True, 0.5, 0.5) found = True return True # Stop iteration return False # Continue iteration # Search the entire model model.foreach(search_tree, None) return found def on_window_key_press(self, widget, event): # Handle window-level key events keyval = event.keyval if keyval == Gdk.KEY_Escape: Gtk.main_quit() return True elif keyval == Gdk.KEY_slash: # Forward slash activates search self.searchEntry.grab_focus() return True return False def on_focus_out(self, widget, event): # Quit when the window loses focus Gtk.main_quit() window = I38_Tab_Menu() window.connect("destroy", Gtk.main_quit) window.show_all() # Focus the first TreeView firstTab = window.notebook.get_nth_page(0) for child in firstTab.get_children(): if isinstance(child, Gtk.TreeView): child.grab_focus() break Gtk.main()