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()
-