__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 32locale.bindtextdomain("panorama-app-menu", module_directory / "locale") 33_ = lambda x: locale.dgettext("panorama-app-menu", x) 34 35 36CATEGORY_MAPPINGS = { 37"Utility": {"menu_name": _("Accessories"), "icon": "applications-accessories"}, 38"Development": {"menu_name": _("Programming"), "icon": "applications-development"}, 39"Game": {"menu_name": _("Games"), "icon": "applications-games"}, 40"Graphics": {"menu_name": _("Graphics"), "icon": "applications-graphics"}, 41"Network": {"menu_name": _("Network"), "icon": "applications-internet"}, 42"AudioVideo": {"menu_name": _("Multimedia"), "icon": "applications-multimedia"}, 43"Office": {"menu_name": _("Office"), "icon": "applications-office"}, 44"Science": {"menu_name": _("Science"), "icon": "applications-science"}, 45"Education": {"menu_name": _("Education"), "icon": "applications-education"}, 46"System": {"menu_name": _("System"), "icon": "applications-system"}, 47"Settings": {"menu_name": _("Settings"), "icon": "preferences-desktop"}, 48"Other": {"menu_name": _("Other"), "icon": "applications-other"}, 49} 50 51custom_css = """ 52.no-menu-item-padding:dir(ltr) { 53padding-left: 0; 54} 55 56.no-menu-item-padding:dir(rtl) { 57padding-right: 0; 58} 59""" 60 61css_provider = Gtk.CssProvider() 62css_provider.load_from_data(custom_css) 63Gtk.StyleContext.add_provider_for_display( 64Gdk.Display.get_default(), 65css_provider, 66Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 67) 68 69 70def force_visible_on_visible_notify(widget, *args): 71if not widget.get_visible(): 72widget.set_visible(True) 73 74 75def add_icons_to_menu(popover: Gtk.PopoverMenu): 76section = popover.get_child().get_first_child().get_first_child().get_first_child() 77while section is not None: 78child = section.get_first_child().get_first_child() 79 80while child is not None: 81gutter_box: Gtk.Box = child.get_first_child() 82if isinstance(gutter_box.get_next_sibling(), Gtk.Image): 83# For some reason GTK creates images but they're hidden? 84image: Gtk.Image = gutter_box.get_next_sibling() 85label: Gtk.Label = image.get_next_sibling() 86 87image.unparent() 88gutter_box.unparent() 89gutter_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 90gutter_box.insert_before(child, label) 91gutter_box.append(image) 92image.set_icon_size(Gtk.IconSize.LARGE) 93image.set_margin_start(8) 94image.set_margin_end(8) 95 96child.add_css_class("no-menu-item-padding") 97 98# Push the arrow to the left 99label.set_halign(Gtk.Align.FILL) 100label.set_hexpand(True) 101label.set_xalign(0) 102 103# GTK pushes its stance on icons so hard it makes them invisible multiple times; 104# force it visible 105image.set_visible(True) 106image.connect("notify::visible", force_visible_on_visible_notify) 107 108# Find the submenu if there is one 109subchild = child.get_first_child() 110submenu = None 111while subchild is not None: 112if isinstance(subchild, Gtk.PopoverMenu): 113submenu = subchild 114subchild = subchild.get_next_sibling() 115 116# Recursive 117if submenu is not None: 118add_icons_to_menu(submenu) 119 120child = child.get_next_sibling() 121 122section = section.get_next_sibling() 123 124 125class AppMenu(panorama_panel.Applet): 126name = _("App menu") 127description = _("Show apps installed on your system, grouped by category") 128 129def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None): 130super().__init__(orientation=orientation, config=config) 131if config is None: 132config = {} 133self.category_mappings = config.get("category_mappings", CATEGORY_MAPPINGS) 134self.trigger_name = config.get("trigger_name", "app-menu") 135self.button = Gtk.MenuButton() 136self.button.set_has_frame(False) # flat look 137self.icon_name = config.get("icon_name", "start-here-symbolic") 138self.icon = Gtk.Image.new_from_icon_name(self.icon_name) 139self.button.set_child(self.icon) 140self.apps_by_id: dict[int, Gio.AppInfo] = {} 141# Wait for the widget to be in a layer-shell window before doing this 142self.connect("realize", lambda *args: self.add_trigger_to_app()) 143 144self.menu = Gio.Menu() 145self.popover = Gtk.PopoverMenu.new_from_model_full(self.menu, Gtk.PopoverMenuFlags.NESTED) 146self.popover.set_has_arrow(False) 147panorama_panel.track_popover(self.popover) 148self.button.set_popover(self.popover) 149 150self.generate_app_menu() 151 152self.append(self.button) 153 154self.context_menu = self.make_context_menu() 155panorama_panel.track_popover(self.context_menu) 156 157right_click_controller = Gtk.GestureClick() 158right_click_controller.set_button(3) 159right_click_controller.connect("pressed", self.show_context_menu) 160 161self.add_controller(right_click_controller) 162 163action_group = Gio.SimpleActionGroup() 164options_action = Gio.SimpleAction.new("options", None) 165options_action.connect("activate", self.show_options) 166action_group.add_action(options_action) 167options_action = Gio.SimpleAction.new("launch-app", GLib.VariantType.new("s")) 168options_action.connect("activate", self.launch_app) 169action_group.add_action(options_action) 170self.insert_action_group("applet", action_group) 171 172self.options_window = None 173 174def add_trigger_to_app(self): 175app: Gtk.Application = self.get_root().get_application() 176action = Gio.SimpleAction.new(self.trigger_name, None) 177action.connect("activate", lambda *args: self.button.popup()) 178app.add_action(action) 179 180def launch_app(self, action, id: GLib.Variant): 181app = self.apps_by_id[int(id.get_string())] 182app.launch() 183 184def make_context_menu(self): 185menu = Gio.Menu() 186menu.append(_("Menu _options"), "applet.options") 187context_menu = Gtk.PopoverMenu.new_from_model(menu) 188context_menu.set_has_arrow(False) 189context_menu.set_parent(self) 190context_menu.set_halign(Gtk.Align.START) 191context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 192return context_menu 193 194def show_context_menu(self, gesture, n_presses, x, y): 195rect = Gdk.Rectangle() 196rect.x = int(x) 197rect.y = int(y) 198rect.width = 1 199rect.height = 1 200 201self.context_menu.set_pointing_to(rect) 202self.context_menu.popup() 203 204def show_options(self, _0=None, _1=None): 205... 206 207def shutdown(self): 208app: Gtk.Application = self.get_root().get_application() 209app.remove_action(self.trigger_name) 210 211def get_config(self): 212return {"category_mappings": self.category_mappings, "trigger_name": self.trigger_name, "icon_name": self.icon_name} 213 214def set_panel_position(self, position): 215self.popover.set_position(panorama_panel.OPPOSITE_POSITION[position]) 216self.button.set_direction(panorama_panel.POSITION_TO_ARROW[panorama_panel.OPPOSITE_POSITION[position]]) 217 218def generate_app_menu(self): 219self.menu.remove_all() 220self.apps_by_id = {} 221 222all_apps = Gio.AppInfo.get_all() 223apps_by_category: dict[str, list[Gio.AppInfo]] = {} 224for category, info in self.category_mappings.items(): 225apps_by_category[category] = [] 226 227for app in all_apps: 228category_found = False 229if isinstance(app, Gio.DesktopAppInfo): 230if app.get_categories() is not None: 231categories = app.get_categories().split(";") 232for category in categories: 233if category in apps_by_category: 234apps_by_category[category].append(app) 235category_found = True 236 237if not category_found: 238apps_by_category["Other"].append(app) 239 240for category, info in self.category_mappings.items(): 241if apps_by_category[category]: 242item = Gio.MenuItem.new(info["menu_name"]) 243item.set_icon(Gio.ThemedIcon.new(info["icon"])) 244submenu = Gio.Menu() 245 246for app in apps_by_category[category]: 247if isinstance(app, Gio.DesktopAppInfo) and (app.get_is_hidden() or app.get_nodisplay()): 248continue 249 250subitem = Gio.MenuItem.new(app.get_display_name(), f"applet.launch-app::{id(app)}") 251subitem.set_icon(app.get_icon() or Gio.ThemedIcon.new("image-missing")) 252self.apps_by_id[id(app)] = app 253submenu.append_item(subitem) 254 255item.set_submenu(submenu) 256self.menu.append_item(item) 257 258add_icons_to_menu(self.popover) 259