Last active
October 2, 2020 16:01
-
-
Save snippins/ee943f2b25db555ef12107f7cee20241 to your computer and use it in GitHub Desktop.
HUD Menu
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
import gi | |
gi.require_version("Gtk", "3.0") | |
import configparser | |
import dbus | |
import dbus.service | |
import logging | |
import os | |
import setproctitle | |
import subprocess | |
import sys | |
import time | |
import threading | |
from dbus.mainloop.glib import DBusGMainLoop | |
from gi.repository import Gio, GLib, Gtk, Gdk, GObject | |
from Xlib import display, protocol, X, Xatom, error | |
### Constants | |
# https://en.wikipedia.org/wiki/Whitespace_character | |
NOBREAKSPACE = '\u00A0' | |
EMSPACE = '\u2003' | |
# PATHARROW = '>' | |
# PATHARROW = '▶' | |
PATHARROW = '\u00BB' | |
# PATHSEPERATOR = NOBREAKSPACE + NOBREAKSPACE + PATHARROW + NOBREAKSPACE + NOBREAKSPACE | |
PATHSEPERATOR = EMSPACE + PATHARROW + EMSPACE | |
### Globals | |
rofi_process = None | |
show_shortcuts = True | |
### Classes / Util Functions | |
class EWMH: | |
"""This class provides the ability to get and set properties defined | |
by the EWMH spec. It was blanty ripped out of pyewmh | |
* https://github.com/parkouss/pyewmh | |
""" | |
def __init__(self, _display=None, root = None): | |
self.display = _display or display.Display() | |
self.root = root or self.display.screen().root | |
def getActiveWindow(self): | |
"""Get the current active (toplevel) window or None (property _NET_ACTIVE_WINDOW) | |
:return: Window object or None""" | |
active_window = self._getProperty('_NET_ACTIVE_WINDOW') | |
if active_window == None: | |
return None | |
return self._createWindow(active_window[0]) | |
def _getProperty(self, _type, win=None): | |
if not win: | |
win = self.root | |
atom = win.get_full_property(self.display.get_atom(_type), X.AnyPropertyType) | |
if atom: | |
return atom.value | |
def _setProperty(self, _type, data, win=None, mask=None): | |
"""Send a ClientMessage event to the root window""" | |
if not win: | |
win = self.root | |
if type(data) is str: | |
dataSize = 8 | |
else: | |
data = (data+[0]*(5-len(data)))[:5] | |
dataSize = 32 | |
ev = protocol.event.ClientMessage(window=win, client_type=self.display.get_atom(_type), data=(dataSize, data)) | |
if not mask: | |
mask = (X.SubstructureRedirectMask|X.SubstructureNotifyMask) | |
self.root.send_event(ev, event_mask=mask) | |
def _createWindow(self, wId): | |
if not wId: | |
return None | |
return self.display.create_resource_object('window', wId) | |
def format_path(path): | |
#logging.debug('Path:%s', path) | |
result = path.replace(PATHSEPERATOR, '', 1) | |
result = result.replace('Root' + PATHSEPERATOR, '') | |
result = result.replace('Label Empty' + PATHSEPERATOR, '') | |
result = result.replace('_', '') | |
# return result.replace(PATHARROW, u'\u0020\u0020\u00BB\u0020\u0020') | |
return result | |
def convert_alphanumeric_to_unicode(text): | |
out = '' | |
for c in text: | |
if c.isnumeric(): | |
c = chr(ord(c) + 120764) #convert numbers | |
elif c.islower(): | |
c = chr(ord(c) + 120205) #convert lowercase | |
elif c.isupper(): | |
c = chr(ord(c) + 120211) #convert uppercase | |
else: | |
pass | |
out += c | |
# print('{} => {}'.format(text, out)) | |
return out | |
def format_shortcut(text): | |
# GTK | |
text = text.replace('<Primary>', 'Ctrl+') | |
text = text.replace('<Shift>', 'Shift+') | |
text = text.replace('<Alt>', 'Alt+') | |
text = text.replace('<Mod4>', 'Meta+') | |
text = text.replace('bracketleft', '[') | |
text = text.replace('bracketright', ']') | |
text = text.replace('backslash', '\\') | |
text = text.replace('slash', '/') | |
text = text.replace('Return', '⏎') | |
# Qt | |
text = text.replace('Control+', 'Ctrl+') | |
text = "-- " + text | |
# Prevent shortcut from showing up in search | |
# text = convert_alphanumeric_to_unicode(text) | |
# text = text.replace('+', '+') # Full-width Plus (U+FF0B) | |
return text | |
def format_menuitem(path, shortcut): | |
result = format_path(path) | |
if show_shortcuts and shortcut: | |
shortcut = format_shortcut(shortcut) | |
result += EMSPACE + shortcut | |
# print('\t', result) | |
return result | |
def rgba_to_hex(color): | |
""" | |
Return hexadecimal string for :class:`Gdk.RGBA` `color`. | |
""" | |
return "#{0:02x}{1:02x}{2:02x}".format( | |
int(color.red * 255), | |
int(color.green * 255), | |
int(color.blue * 255)) | |
def get_color(style_context, preferred_color, fallback_color): | |
color = rgba_to_hex(style_context.lookup_color(preferred_color)[1]) | |
if color == '#000000': | |
color = rgba_to_hex(style_context.lookup_color(fallback_color)[1]) | |
return color | |
### Implementation | |
def get_menu(menuKeys): | |
""" | |
Generate menu of available menu items. | |
""" | |
global rofi_process, shortcut_fg_color | |
if not menuKeys: | |
return '' | |
menu_string, *menu_items = menuKeys | |
for menu_item in menu_items: | |
menu_string += '\n' + menu_item | |
logging.debug("get_menu.rofi_process: %s", rofi_process) | |
rofi_process = subprocess.Popen(['rofi-hud', '-dmenu', '-i', | |
'-p', 'HUD: ', | |
'-columns', '2' | |
'-location', '1', | |
'-monitor', '-1', | |
'-width', '100', | |
'-lines', '20', | |
'-fixed-num-lines' | |
'-separator-style', 'solid', | |
'-hide-scrollbar'], | |
stdout=subprocess.PIPE, stdin=subprocess.PIPE) | |
rofi_process.stdin.write(menu_string.encode('utf-8')) | |
menu_result = rofi_process.communicate()[0].decode('utf8').rstrip() | |
rofi_process.stdin.close() | |
return menu_result | |
""" | |
try_dbusmenu_interface | |
""" | |
def try_dbusmenu_interface(dbusmenu_bus, dbusmenu_object_path): | |
# --- Get Appmenu Registrar DBus interface | |
session_bus = dbus.SessionBus() | |
# --- Access dbusmenu items | |
try: | |
dbusmenu_object = session_bus.get_object(dbusmenu_bus, dbusmenu_object_path) | |
dbusmenu_object_iface = dbus.Interface(dbusmenu_object, 'com.canonical.dbusmenu') | |
except ValueError: | |
logging.info('Unable to access dbusmenu items.') | |
return False | |
dbusmenu_root_item = dbusmenu_object_iface.GetLayout(0, 0, ["label", "children-display"]) | |
dbusmenu_item_dict = dict() | |
#For excluding items which have no action | |
blacklist = [] | |
""" expanse_all_menu_with_dbus """ | |
def expanse_all_menu_with_dbus(item, root, path): | |
item_id = item[0] | |
item_props = item[1] | |
# expand if necessary | |
if 'children-display' in item_props: | |
dbusmenu_object_iface.AboutToShow(item_id) | |
dbusmenu_object_iface.Event(item_id, "opened", "not used", dbus.UInt32(time.time())) #fix firefox | |
try: | |
item = dbusmenu_object_iface.GetLayout(item_id, 1, ["label", "children-display"])[1] | |
except: | |
return | |
item_children = item[2] | |
if 'label' in item_props: | |
new_path = path + PATHSEPERATOR + item_props['label'] | |
else: | |
new_path = path | |
shortcut = None | |
if 'shortcut' in item_props: | |
shortcut = '+'.join(item_props['shortcut'][0]) | |
# logging.debug('shortcut', shortcut) | |
if len(item_children) == 0: | |
if new_path not in blacklist: | |
dbusmenu_item_dict[format_menuitem(new_path, shortcut)] = item_id | |
else: | |
blacklist.append(new_path) | |
for child in item_children: | |
expanse_all_menu_with_dbus(child, False, new_path) | |
expanse_all_menu_with_dbus(dbusmenu_root_item[1], True, "") | |
menuKeys = sorted(dbusmenu_item_dict.keys()) | |
menu_result = get_menu(menuKeys) | |
# --- Use dmenu result | |
if menu_result in dbusmenu_item_dict: | |
action = dbusmenu_item_dict[menu_result] | |
logging.debug('AppMenu Action : %s', str(action)) | |
dbusmenu_object_iface.Event(action, 'clicked', 0, 0) | |
# Firefox: | |
# Send closed events to level 1 items to make sure nothing weird happens | |
# Firefox will close the submenu items (luckily!) | |
# VimFx extension wont work without this | |
dbusmenu_level1_items = dbusmenu_object_iface.GetLayout(0, 1, ["label"])[1] | |
for item in dbusmenu_level1_items[2]: | |
item_id = item[0] | |
dbusmenu_object_iface.Event(item_id, "closed", "not used", dbus.UInt32(time.time())) | |
return True | |
""" | |
try_gtk_interface | |
""" | |
def try_gtk_interface(gtk_bus_name, gtk_menu_object_path, gtk_actions_paths_list): | |
session_bus = dbus.SessionBus() | |
# --- Ask for menus over DBus --- Credit @1931186 | |
try: | |
gtk_menu_object = session_bus.get_object(gtk_bus_name, gtk_menu_object_path) | |
gtk_menu_menus_iface = dbus.Interface(gtk_menu_object, dbus_interface='org.gtk.Menus') | |
# terminate_appmenu_registrar() | |
except dbus.exceptions.DBusException: | |
logging.info('Unable to connect with com.gtk.Menus.') | |
return False | |
# Here's the deal: The idea is to reduce the number of calls to the proxy and keep it as low as possible | |
# because the proxy is a potential bottleneck | |
# This means we ignore GMenus standard building model and just iterate over all the information one Start() provides at once | |
# Start() does these calls, returns the result and keeps track of all parents (the IDs used by org.gtk.Menus.Start()) we called | |
# queue() adds a parent to a potential_new_layers list; we'll use this later to avoid starting() some layers twice | |
# explore is for iterating over the information a Start() call provides | |
gtk_menubar_action_dict = dict() | |
gtk_menubar_action_target_dict = dict() | |
usedLayers = [] | |
def Start(i): | |
usedLayers.append(i) | |
return gtk_menu_menus_iface.Start([i]) | |
# --- Construct menu list --- | |
potential_new_layers = [] | |
def queue(potLayer, label, path): | |
# collects potentially new layers to check them against usedLayers | |
# potLayer: ID of potential layer, label: None if nondescript, path | |
potential_new_layers.append([potLayer, label, path]) | |
def explore(parent, path): | |
for node in parent: | |
content = node[2] | |
# node[0] = ID of parent | |
# node[1] = ID of node under parent | |
# node[2] = actuall content of a node; this is split up into several elements/ menu entries | |
for element in content: | |
# We distinguish between labeled entries and unlabeled ones | |
# Unlabeled sections/ submenus get added under to parent ({parent: {content}}), labeled under a key in parent (parent: {label: {content}}) | |
if 'label' in element: | |
if ':section' in element or ':submenu' in element: | |
# If there's a section we don't care about the action | |
# There theoretically could be a section that is also a submenu, so we have to handel this via queue | |
# submenus are more important than sections | |
if ':submenu' in element: | |
queue(element[':submenu'][0], None, path + PATHSEPERATOR + element['label']) | |
# We ignore whether or not a submenu points to a specific index, shouldn't matter because of the way the menu got exportet | |
# Worst that can happen are some duplicates | |
# Also we don't Start() directly which could mean we get nothing under this label but this shouldn't really happen because there shouldn't be two submenus | |
# that point to the same parent. Even if this happens it's not that big of a deal. | |
if ':section' in element: | |
if element[':section'][0] != node[0]: | |
queue(element['section'][0], element['label'], path) | |
# section points to other parent, we only want to add the elements if their parent isn't referenced anywhere else | |
# We do this because: | |
# a) It shouldn't happen anyways | |
# b) The worst that could happen is we fuck up the menu structure a bit and avoid double entries | |
elif 'action' in element: | |
# This is pretty straightforward: | |
menu_action = str(element['action']).split(".",1)[1] | |
action_path = path + PATHSEPERATOR + element['label'] | |
action_shortcut = None | |
if 'accel' in element: | |
action_shortcut = str(element['accel']) | |
# logging.debug('action', action_path, action_shortcut) | |
action_entry = format_menuitem(action_path, action_shortcut) | |
gtk_menubar_action_dict[action_entry] = menu_action | |
if 'target' in element: | |
gtk_menubar_action_target_dict[action_entry] = element['target'] | |
else: | |
if ':submenu' in element or ':section' in element: | |
if ':section' in element: | |
if element[':section'][0] != node[0] and element['section'][0] not in usedLayers: | |
queue(element[':section'][0], None, path) | |
# We will only queue a nondescript section if it points to a (potentially) new parent | |
if ':submenu' in element: | |
queue(element[':submenu'][0], None, path) | |
# We queue the submenu under the parent without a label | |
queue(0, None, "") | |
# We queue the first parent, [0] | |
# This means 0 gets added to potential_new_layers with a path of "" (it's the root node) | |
while len(potential_new_layers) > 0: | |
layer = potential_new_layers.pop() | |
# usedLayers keeps track of all the parents Start() already called | |
if layer[0] not in usedLayers: | |
explore(Start(layer[0]), layer[2]) | |
gtk_menu_menus_iface.End(usedLayers) | |
menuKeys = sorted(gtk_menubar_action_dict.keys()) | |
if menuKeys is not None and len(menuKeys) == 0: | |
logging.debug('gtk menubar has an empty menu') | |
return False | |
menu_result = get_menu(menuKeys) | |
# --- Use menu result | |
if menu_result in gtk_menubar_action_dict: | |
action = gtk_menubar_action_dict[menu_result] | |
target = [] | |
try: | |
target = gtk_menubar_action_target_dict[menu_result] | |
if (not isinstance(target, list)): | |
target = [target] | |
except: | |
pass | |
for action_entry in gtk_actions_paths_list: | |
try: | |
action_object = session_bus.get_object(gtk_bus_name, action_entry) | |
action_iface = dbus.Interface(action_object, dbus_interface='org.gtk.Actions') | |
not_use_platform_data = dict() | |
not_use_platform_data["not used"] = "not used" | |
logging.debug('GTK Action : %s', str(action)) | |
action_iface.Activate(action, target, not_use_platform_data) | |
except Exception as e: | |
logging.debug('action_entry: %s', str(action_entry)) | |
return True | |
def hud(): | |
logging.debug("hud()") | |
# Get Window properties and GTK MenuModel Bus name | |
ewmh = EWMH() | |
win = ewmh.getActiveWindow() | |
if win is None: | |
logging.debug('ewmh.getActiveWindow returned None, giving up') | |
return | |
window_id = hex(ewmh._getProperty('_NET_ACTIVE_WINDOW')[0]) | |
def get_prop_str(propKey): | |
value = ewmh._getProperty(propKey, win) | |
# print('get_prop_str', propKey, value) | |
if isinstance(value, bytes): | |
return value.decode('utf8') | |
else: | |
return value | |
gtk_bus_name = get_prop_str('_GTK_UNIQUE_BUS_NAME') | |
gtk_menubar_object_path = get_prop_str('_GTK_MENUBAR_OBJECT_PATH') | |
gtk_app_object_path = get_prop_str('_GTK_APPLICATION_OBJECT_PATH') | |
gtk_win_object_path = get_prop_str('_GTK_WINDOW_OBJECT_PATH') | |
gtk_unity_object_path = get_prop_str('_UNITY_OBJECT_PATH') | |
kde_appmenu_service_name = get_prop_str('_KDE_NET_WM_APPMENU_SERVICE_NAME') | |
kde_appmenu_object_path = get_prop_str('_KDE_NET_WM_APPMENU_OBJECT_PATH') | |
logging.debug('Window id is : %s', window_id) | |
logging.debug('_GTK_UNIQUE_BUS_NAME: %s', gtk_bus_name) | |
logging.debug('_GTK_MENUBAR_OBJECT_PATH: %s', gtk_menubar_object_path) | |
logging.debug('_GTK_APPLICATION_OBJECT_PATH: %s', gtk_app_object_path) | |
logging.debug('_GTK_WINDOW_OBJECT_PATH: %s', gtk_win_object_path) | |
logging.debug('_UNITY_OBJECT_PATH: %s', gtk_unity_object_path) | |
logging.debug('_KDE_NET_WM_APPMENU_SERVICE_NAME: %s', kde_appmenu_service_name) | |
logging.debug('_KDE_NET_WM_APPMENU_OBJECT_PATH: %s', kde_appmenu_object_path) | |
appmenu_success = False | |
if gtk_menubar_object_path: | |
# Many apps do not respect menu action groups, such as | |
# LibreOffice and gnome-mpv, so we have to include all action | |
# groups. Many other apps have these properties point to the | |
# same path, so we need to remove them. | |
logging.debug('Trying GTK interface') | |
gtk_actions_paths_list = list(set([gtk_win_object_path, | |
gtk_menubar_object_path, | |
gtk_app_object_path, | |
gtk_unity_object_path])) | |
appmenu_success = try_gtk_interface(gtk_bus_name, gtk_menubar_object_path, gtk_actions_paths_list) | |
if not appmenu_success: | |
if kde_appmenu_service_name and kde_appmenu_object_path: | |
logging.debug('Trying KDE AppMenu interface') | |
appmenu_success = try_dbusmenu_interface(kde_appmenu_service_name, kde_appmenu_object_path) | |
if not appmenu_success: | |
logging.debug('Giving up') | |
if __name__ == "__main__": | |
hud() |
@snippins I've merged the changes, I'd really appreciate if you could do some quick tests with mate-hud.py
(just the client) in-conjunction with vala-panel-appmenu and confirm it works as you'd expect.
@flexiondotorg Nice. Just a comment. For Firefox (with unity patches) to work you need to run it with: UBUNTU_MENUPROXY=0 firefox.
Edit: You need to add the format_path function and remove the format_label_list for it to work.
@snippins Yeah, I circled back to it last night and made some other improvement, current full version is here:
See the commit log for what was required to get it working. Interestingly UBUNTU_MENUPROXY=1
is being exported to the session and Firefox and Thunderbird work correctly. This is testing on Ubuntu MATE 17.10 daily.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@flexiondotorg I've been using this HUD code for quite a while and it seems to work flawlessly in all apps now. I think you should consider merging it yourself to mate-hud.py. Sorry my off days was over and now I am too lazy/busy to do it myself. Thanks.