I38/scripts/menu.py

660 lines
26 KiB
Python
Executable File

#!/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 <https://www.gnu.org/licenses/>.
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()