From 002c5ce1d70faa6705a768cd3c850b39778c5d5d Mon Sep 17 00:00:00 2001 From: Storm Dragon <stormdragon2976@gmail.com> Date: Tue, 8 Apr 2025 06:02:15 -0400 Subject: [PATCH] Totally revamped the alt+f1 menu system. I think this is much better than the old version, and it also fixes a bug where the old menu didn't close when something was selected. It is experimental, and can be removed if people prefer the old menu, just let me know. --- scripts/menu.py | 629 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 557 insertions(+), 72 deletions(-) diff --git a/scripts/menu.py b/scripts/menu.py index 31f1268..d92c7db 100755 --- a/scripts/menu.py +++ b/scripts/menu.py @@ -16,7 +16,8 @@ from pathlib import Path from collections import defaultdict import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk +gi.require_version('Atk', '1.0') +from gi.repository import Gtk, Gdk, GLib, Atk def read_desktop_files(paths): desktopEntries = [] @@ -85,90 +86,574 @@ categoryMap = { "X-Xfce-Toplevel": "XFCE", } -categories = defaultdict(set) +# First, gather all applications by category +categoryApps = defaultdict(dict) +subcategories = defaultdict(set) + for entry in desktopEntries: try: - entryCategories = entry.get('Desktop Entry', 'Categories').split(';') + # 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: - combinedCategory = categoryMap.get(category, category) - name = entry.get('Desktop Entry', 'Name') - execCommand = entry.get('Desktop Entry', 'Exec') - # Use a tuple of name and execCommand as a unique identifier - if (name, execCommand) not in categories[combinedCategory]: - categories[combinedCategory].add((name, execCommand)) - except configparser.NoOptionError: + 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 -class Xdg_Menu_Window(Gtk.Window): +# 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(400, 300) + self.set_default_size(500, 400) self.set_border_width(10) - - self.store = Gtk.TreeStore(str, str) # Columns: Category/Application Name, Exec Command - - sortedCategories = sorted(categories.items()) # Sort categories alphabetically - - for category, entries in sortedCategories: - if category == "": + + # 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 - categoryIter = self.store.append(parent=None, row=[category, None]) - sortedEntries = sorted(entries, key=lambda e: e[0]) # Sort entries by name - for name, execCommand in sortedEntries: - self.store.append(parent=categoryIter, row=[name, execCommand]) - - self.treeview = Gtk.TreeView(model=self.store) - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn("Applications", renderer, text=0) - self.treeview.append_column(column) - self.treeview.set_headers_visible(False) - - self.treeview.connect("row-activated", self.on_row_activated) - self.treeview.connect("key-press-event", self.on_key_press) - - self.add(self.treeview) - self.connect("key-press-event", self.on_key_press) - self.treeview.connect("focus-out-event", self.on_focus_out) - - self.treeview.grab_focus() - self.show_all() - - def on_row_activated(self, treeview, path, column): - model = treeview.get_model() - iter = model.get_iter(path) - execCommand = model.get_value(iter, 1) + + # 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: - os.system(execCommand) - - def on_focus_out(self, widget, event): - Gtk.main_quit() - + # 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 - state = event.state + + 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() - elif keyval == Gdk.KEY_Left: - path = self.treeview.get_cursor()[0] - if path: - iter = self.store.get_iter(path) - if self.treeview.row_expanded(path): - self.treeview.collapse_row(path) - else: - parent_iter = self.store.iter_parent(iter) - if parent_iter: - parent_path = self.store.get_path(parent_iter) - self.treeview.collapse_row(parent_path) - else: - self.treeview.collapse_row(path) - elif keyval == Gdk.KEY_Right: - path = self.treeview.get_cursor()[0] - if path: - if not self.treeview.row_expanded(path): - self.treeview.expand_row(path, open_all=False) + 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 -win = Xdg_Menu_Window() -win.connect("destroy", Gtk.main_quit) -win.show_all() Gtk.main() -