Skip to content

Instantly share code, notes, and snippets.

@snippins
Last active October 2, 2020 16:01
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save snippins/ee943f2b25db555ef12107f7cee20241 to your computer and use it in GitHub Desktop.
Save snippins/ee943f2b25db555ef12107f7cee20241 to your computer and use it in GitHub Desktop.
HUD Menu
#!/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()
@flexiondotorg
Copy link

@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