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.

This commit is contained in:
Storm Dragon 2025-04-08 06:02:15 -04:00
parent 70f3cc749e
commit 002c5ce1d7

View File

@ -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(';')
for category in entryCategories:
combinedCategory = categoryMap.get(category, category)
# 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')
# 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:
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
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
# Main container
self.mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add(self.mainBox)
sortedCategories = sorted(categories.items()) # Sort categories alphabetically
# Add search box at the top
self.searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
for category, entries in sortedCategories:
if category == "":
# 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)
# 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)
self.treeview.append_column(column)
self.treeview.set_headers_visible(False)
treeView.append_column(column)
self.treeview.connect("row-activated", self.on_row_activated)
self.treeview.connect("key-press-event", self.on_key_press)
# Set up scrolled window
scrolledWindow = Gtk.ScrolledWindow()
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolledWindow.add(treeView)
self.add(self.treeview)
self.connect("key-press-event", self.on_key_press)
self.treeview.connect("focus-out-event", self.on_focus_out)
# Connect signals
treeView.connect("row-activated", self.on_row_activated)
treeView.connect("key-press-event", self.on_key_press)
self.treeview.grab_focus()
self.show_all()
# 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)
def on_row_activated(self, treeview, path, column):
model = treeview.get_model()
iter = model.get_iter(path)
execCommand = model.get_value(iter, 1)
if execCommand:
os.system(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)
def on_focus_out(self, widget, event):
# 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()