__init__.py
Python script, ASCII text executable
1""" 2MATE-like app menu applet for the Panorama panel. 3Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 4 5This program is free software: you can redistribute it and/or modify 6it under the terms of the GNU General Public Licence as published by 7the Free Software Foundation, either version 3 of the Licence, or 8(at your option) any later version. 9 10This program is distributed in the hope that it will be useful, 11but WITHOUT ANY WARRANTY; without even the implied warranty of 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13GNU General Public Licence for more details. 14 15You should have received a copy of the GNU General Public Licence 16along with this program. If not, see <https://www.gnu.org/licenses/>. 17""" 18 19import os 20from pathlib import Path 21import locale 22import panorama_panel 23 24import gi 25gi.require_version("Gtk", "4.0") 26 27from gi.repository import Gtk, GLib, Gio, Gdk 28 29 30module_directory = Path(__file__).resolve().parent 31 32 33CATEGORY_MAPPINGS = { 34"Utility": {"menu_name": "Accessories", "icon": "applications-accessories"}, 35"Development": {"menu_name": "Programming", "icon": "applications-development"}, 36"Game": {"menu_name": "Games", "icon": "applications-games"}, 37"Graphics": {"menu_name": "Graphics", "icon": "applications-graphics"}, 38"Network": {"menu_name": "Network", "icon": "applications-internet"}, 39"AudioVideo": {"menu_name": "Multimedia", "icon": "applications-multimedia"}, 40"Office": {"menu_name": "Office", "icon": "applications-office"}, 41"Science": {"menu_name": "Science", "icon": "applications-science"}, 42"Education": {"menu_name": "Education", "icon": "applications-education"}, 43"System": {"menu_name": "System", "icon": "applications-system"}, 44"Settings": {"menu_name": "Settings", "icon": "preferences-desktop"}, 45"Other": {"menu_name": "Other", "icon": "applications-other"}, 46} 47 48custom_css = """ 49.no-menu-item-padding:dir(ltr) { 50padding-left: 0; 51} 52 53.no-menu-item-padding:dir(rtl) { 54padding-right: 0; 55} 56""" 57 58css_provider = Gtk.CssProvider() 59css_provider.load_from_data(custom_css) 60Gtk.StyleContext.add_provider_for_display( 61Gdk.Display.get_default(), 62css_provider, 63Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 64) 65 66 67def force_visible_on_visible_notify(widget, *args): 68if not widget.get_visible(): 69widget.set_visible(True) 70 71 72def add_icons_to_menu(popover: Gtk.PopoverMenu): 73section = popover.get_child().get_first_child().get_first_child().get_first_child() 74while section is not None: 75child = section.get_first_child().get_first_child() 76 77while child is not None: 78gutter_box: Gtk.Box = child.get_first_child() 79if isinstance(gutter_box.get_next_sibling(), Gtk.Image): 80# For some reason GTK creates images but they're hidden? 81image: Gtk.Image = gutter_box.get_next_sibling() 82label: Gtk.Label = image.get_next_sibling() 83 84image.unparent() 85gutter_box.unparent() 86gutter_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 87gutter_box.insert_before(child, label) 88gutter_box.append(image) 89image.set_icon_size(Gtk.IconSize.LARGE) 90image.set_margin_start(8) 91image.set_margin_end(8) 92 93child.add_css_class("no-menu-item-padding") 94 95# Push the arrow to the left 96label.set_halign(Gtk.Align.FILL) 97label.set_hexpand(True) 98label.set_xalign(0) 99 100# GTK pushes its stance on icons so hard it makes them invisible multiple times; 101# force it visible 102image.set_visible(True) 103image.connect("notify::visible", force_visible_on_visible_notify) 104 105# Find the submenu if there is one 106subchild = child.get_first_child() 107submenu = None 108while subchild is not None: 109if isinstance(subchild, Gtk.PopoverMenu): 110submenu = subchild 111subchild = subchild.get_next_sibling() 112 113# Recursive 114if submenu is not None: 115add_icons_to_menu(submenu) 116 117child = child.get_next_sibling() 118 119section = section.get_next_sibling() 120 121 122locale.bindtextdomain("panorama-app-menu", module_directory / "locale") 123_ = lambda x: locale.dgettext("panorama-app-menu", x) 124 125 126class AppMenu(panorama_panel.Applet): 127name = _("App menu") 128description = _("Show apps installed on your system, grouped by category") 129 130def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 131super().__init__(orientation=orientation, config=config) 132locale.bindtextdomain("panorama-app-menu", module_directory / "locale") 133_ = lambda x: locale.dgettext("panorama-app-menu", x) 134if config is None: 135config = {} 136 137self.auto_refresh = config.get("auto_refresh", True) 138self.category_mappings = config.get("category_mappings", CATEGORY_MAPPINGS) 139self.trigger_name = config.get("trigger_name", "app-menu") 140self.icon_name = config.get("icon_name", "start-here-symbolic") 141 142self.button = Gtk.MenuButton() 143self.button.set_has_frame(False) # flat look 144self.icon = Gtk.Image.new_from_icon_name(self.icon_name) 145self.icon.set_pixel_size(config.get("icon_size", 24)) 146self.button.set_child(self.icon) 147self.apps_by_id: dict[int, Gio.AppInfo] = {} 148# Wait for the widget to be in a layer-shell window before doing this 149self.connect("realize", lambda *args: self.add_trigger_to_app()) 150 151self.menu = Gio.Menu() 152self.popover = Gtk.PopoverMenu.new_from_model_full(self.menu, Gtk.PopoverMenuFlags.NESTED) 153self.popover.set_has_arrow(False) 154panorama_panel.track_popover(self.popover) 155self.button.set_popover(self.popover) 156 157self.generate_app_menu() 158 159self.append(self.button) 160 161self.context_menu = self.make_context_menu() 162panorama_panel.track_popover(self.context_menu) 163 164right_click_controller = Gtk.GestureClick() 165right_click_controller.set_button(3) 166right_click_controller.connect("pressed", self.show_context_menu) 167 168self.add_controller(right_click_controller) 169 170action_group = Gio.SimpleActionGroup() 171options_action = Gio.SimpleAction.new("options", None) 172options_action.connect("activate", self.show_options) 173action_group.add_action(options_action) 174options_action = Gio.SimpleAction.new("launch-app", GLib.VariantType.new("s")) 175options_action.connect("activate", self.launch_app) 176action_group.add_action(options_action) 177self.insert_action_group("applet", action_group) 178 179if self.auto_refresh: 180self.app_info_monitor = Gio.AppInfoMonitor.get() 181self.app_info_monitor.connect("changed", self.generate_app_menu) 182 183self.options_window = None 184 185def add_trigger_to_app(self): 186app: Gtk.Application = self.get_root().get_application() 187action = Gio.SimpleAction.new(self.trigger_name, None) 188action.connect("activate", lambda *args: self.button.popup()) 189app.add_action(action) 190 191def launch_app(self, action, id: GLib.Variant): 192app = self.apps_by_id[int(id.get_string())] 193app.launch() 194 195def make_context_menu(self): 196menu = Gio.Menu() 197menu.append(_("Menu _options"), "applet.options") 198context_menu = Gtk.PopoverMenu.new_from_model(menu) 199context_menu.set_has_arrow(False) 200context_menu.set_parent(self) 201context_menu.set_halign(Gtk.Align.START) 202context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 203return context_menu 204 205def show_context_menu(self, gesture, n_presses, x, y): 206rect = Gdk.Rectangle() 207rect.x = int(x) 208rect.y = int(y) 209rect.width = 1 210rect.height = 1 211 212self.context_menu.set_pointing_to(rect) 213self.context_menu.popup() 214 215def show_options(self, _0=None, _1=None): 216... 217 218def shutdown(self): 219app: Gtk.Application = self.get_root().get_application() 220app.remove_action(self.trigger_name) 221 222def get_config(self): 223return { 224"category_mappings": self.category_mappings, 225"trigger_name": self.trigger_name, 226"icon_name": self.icon_name, 227"icon_size": self.icon_size, 228} 229 230def set_panel_position(self, position): 231self.popover.set_position(panorama_panel.OPPOSITE_POSITION[position]) 232self.button.set_direction(panorama_panel.POSITION_TO_ARROW[panorama_panel.OPPOSITE_POSITION[position]]) 233 234def generate_app_menu(self, app_info_monitor=None): 235self.menu = Gio.Menu() 236self.popover.set_menu_model(self.menu) 237self.apps_by_id = {} 238 239all_apps = Gio.AppInfo.get_all() 240apps_by_category: dict[str, list[Gio.AppInfo]] = {} 241for category, info in self.category_mappings.items(): 242apps_by_category[category] = [] 243 244for app in all_apps: 245category_found = False 246if isinstance(app, Gio.DesktopAppInfo): 247if app.get_categories() is not None: 248categories = app.get_categories().split(";") 249for category in categories: 250if category in apps_by_category: 251apps_by_category[category].append(app) 252category_found = True 253 254if not category_found: 255apps_by_category["Other"].append(app) 256 257for apps in apps_by_category.values(): 258apps.sort(key=lambda app: app.get_display_name()) 259 260for category, info in self.category_mappings.items(): 261if apps_by_category[category]: 262item = Gio.MenuItem.new(_(info["menu_name"])) 263item.set_icon(Gio.ThemedIcon.new(info["icon"])) 264submenu = Gio.Menu() 265 266for app in apps_by_category[category]: 267if isinstance(app, Gio.DesktopAppInfo) and (app.get_is_hidden() or app.get_nodisplay()): 268continue 269 270subitem = Gio.MenuItem.new(app.get_display_name(), f"applet.launch-app::{id(app)}") 271subitem.set_icon(app.get_icon() or Gio.ThemedIcon.new("image-missing")) 272self.apps_by_id[id(app)] = app 273submenu.append_item(subitem) 274 275item.set_submenu(submenu) 276self.menu.append_item(item) 277 278add_icons_to_menu(self.popover) 279