mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Implement API to get menu layout and properties
This commit is contained in:
parent
9eba0872fc
commit
d3e7c92e70
@ -10,24 +10,230 @@ __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
|||||||
# dbus-menu.xml from the libdbusmenu project https://launchpad.net/libdbusmenu
|
# dbus-menu.xml from the libdbusmenu project https://launchpad.net/libdbusmenu
|
||||||
|
|
||||||
import dbus
|
import dbus
|
||||||
from PyQt5.Qt import QApplication, QMenu, QIcon, QKeySequence
|
from PyQt5.Qt import (
|
||||||
|
QApplication, QMenu, QIcon, QKeySequence, QObject, QAction, QMenuBar,
|
||||||
|
QEvent, QTimer)
|
||||||
|
|
||||||
from calibre.utils.dbus_service import Object, BusName, method as dbus_method, dbus_property, signal as dbus_signal
|
from calibre.utils.dbus_service import Object, BusName, method as dbus_method, dbus_property, signal as dbus_signal
|
||||||
from calibre.gui2.dbus_export.utils import setup_for_cli_run
|
from calibre.gui2.dbus_export.utils import (
|
||||||
|
setup_for_cli_run, swap_mnemonic_char, key_sequence_to_dbus_shortcut, icon_to_dbus_menu_icon)
|
||||||
|
|
||||||
class DBusMenu(Object):
|
null = object()
|
||||||
|
|
||||||
|
def PropDict(mapping=()):
|
||||||
|
return dbus.Dictionary(mapping, signature='sv')
|
||||||
|
|
||||||
|
class MenuBarAction(QAction):
|
||||||
|
|
||||||
|
def __init__(self, mb):
|
||||||
|
QAction.__init__(self, mb)
|
||||||
|
|
||||||
|
def menu(self):
|
||||||
|
return self.parent()
|
||||||
|
|
||||||
|
def create_properties_for_action(ac, previous=None):
|
||||||
|
ans = PropDict()
|
||||||
|
if ac.isSeparator():
|
||||||
|
ans['type'] = 'separator'
|
||||||
|
if not ac.isVisible():
|
||||||
|
ans['visible'] = False
|
||||||
|
return ans
|
||||||
|
text = ac.text() or ac.iconText()
|
||||||
|
if text:
|
||||||
|
ans['label'] = swap_mnemonic_char(text)
|
||||||
|
if not ac.isEnabled():
|
||||||
|
ans['enabled'] = False
|
||||||
|
if not ac.isVisible():
|
||||||
|
ans['visible'] = False
|
||||||
|
if ac.menu() is not None:
|
||||||
|
ans['children-display'] = 'submenu'
|
||||||
|
if ac.isCheckable():
|
||||||
|
exclusive = ac.actionGroup() is not None and ac.actionGroup().isExclusive()
|
||||||
|
ans['toggle-type'] = 'radio' if exclusive else 'checkmark'
|
||||||
|
ans['toggle-state'] = int(ac.isChecked())
|
||||||
|
shortcuts = ac.shortcuts()
|
||||||
|
if shortcuts:
|
||||||
|
ans['shortcut'] = sc = []
|
||||||
|
for s in shortcuts:
|
||||||
|
sc.extend(key_sequence_to_dbus_shortcut(s))
|
||||||
|
if ac.isIconVisibleInMenu():
|
||||||
|
icon = ac.icon()
|
||||||
|
if previous and previous.get('x-qt-icon-cache-key') == icon.cacheKey():
|
||||||
|
for x in 'icon-data x-qt-icon-cache-key'.split():
|
||||||
|
ans[x] = previous[x]
|
||||||
|
else:
|
||||||
|
data = icon_to_dbus_menu_icon(ac.icon())
|
||||||
|
if data is not None:
|
||||||
|
ans['icon-data'] = data
|
||||||
|
ans['x-qt-icon-cache-key'] = icon.cacheKey()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
class DBusMenu(QObject):
|
||||||
|
|
||||||
|
def __init__(self, object_path, **kw):
|
||||||
|
QObject.__init__(self, kw.get('parent'))
|
||||||
|
self.dbus_api = DBusMenuAPI(self, object_path, **kw)
|
||||||
|
self.set_status = self.dbus_api.set_status
|
||||||
|
self._next_id = 0
|
||||||
|
self.action_changed_timer = t = QTimer(self)
|
||||||
|
t.setInterval(0), t.setSingleShot(True), t.timeout.connect(self.actions_changed)
|
||||||
|
self.layout_changed_timer = t = QTimer(self)
|
||||||
|
t.setInterval(0), t.setSingleShot(True), t.timeout.connect(self.layouts_changed)
|
||||||
|
self.init_maps()
|
||||||
|
|
||||||
|
def init_maps(self, qmenu=None):
|
||||||
|
self.action_changes = set()
|
||||||
|
self.layout_changes = set()
|
||||||
|
self.qmenu = qmenu
|
||||||
|
self._id_to_action, self._action_to_id = {}, {}
|
||||||
|
self._action_properties = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next_id(self):
|
||||||
|
self._next_id += 1
|
||||||
|
return self._next_id
|
||||||
|
|
||||||
|
def id_to_action(self, action_id):
|
||||||
|
if self.qmenu is None:
|
||||||
|
return None
|
||||||
|
return self._id_to_action.get(action_id)
|
||||||
|
|
||||||
|
def action_to_id(self, action):
|
||||||
|
if self.qmenu is None:
|
||||||
|
return None
|
||||||
|
return self._action_to_id.get(action)
|
||||||
|
|
||||||
|
def action_properties(self, action_id, restrict_to=None):
|
||||||
|
if self.qmenu is None:
|
||||||
|
return {}
|
||||||
|
ans = self._action_properties.get(action_id, PropDict())
|
||||||
|
if restrict_to:
|
||||||
|
ans = PropDict({k:v for k, v in ans.iteritems() if k in restrict_to})
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def publish_new_menu(self, qmenu=None):
|
||||||
|
self.init_maps(qmenu)
|
||||||
|
if qmenu is not None:
|
||||||
|
qmenu.destroyed.connect(self.publish_new_menu)
|
||||||
|
ac = MenuBarAction(qmenu) if isinstance(qmenu, QMenuBar) else qmenu.menuAction()
|
||||||
|
if isinstance(qmenu, QMenuBar):
|
||||||
|
qmenu.menuAction = lambda : ac
|
||||||
|
self.add_action(ac)
|
||||||
|
|
||||||
|
def add_action(self, ac):
|
||||||
|
ac_id = 0 if ac.menu() is self.qmenu else self.next_id
|
||||||
|
self._id_to_action[ac_id] = ac
|
||||||
|
self._action_to_id[ac] = ac_id
|
||||||
|
self._action_properties[ac_id] = create_properties_for_action(ac)
|
||||||
|
if ac.menu() is not None:
|
||||||
|
self.add_menu(ac.menu())
|
||||||
|
|
||||||
|
def add_menu(self, menu):
|
||||||
|
menu.installEventFilter(self)
|
||||||
|
for ac in menu.actions():
|
||||||
|
self.add_action(ac)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, ev):
|
||||||
|
ac = getattr(obj, 'menuAction', lambda : None)()
|
||||||
|
ac_id = self.action_to_id(ac)
|
||||||
|
if ac_id is not None:
|
||||||
|
etype = ev.type()
|
||||||
|
if etype == QEvent.ActionChanged:
|
||||||
|
ac_id = self.action_to_id(ev.action())
|
||||||
|
self.action_changes.add(ac_id)
|
||||||
|
self.action_changed_timer.start()
|
||||||
|
elif etype == QEvent.ActionAdded:
|
||||||
|
self.layout_changes.add(ac_id)
|
||||||
|
self.layout_changed_timer.start()
|
||||||
|
self.add_action(ev.action())
|
||||||
|
elif etype == QEvent.ActionRemoved:
|
||||||
|
self.layout_changes.add(ac_id)
|
||||||
|
self.layout_changed_timer.start()
|
||||||
|
self.action_removed(ev.action())
|
||||||
|
return False
|
||||||
|
|
||||||
|
def actions_changed(self):
|
||||||
|
updated_props = dbus.Array(signature='(ia{sv})')
|
||||||
|
removed_props = dbus.Array(signature='(ias)')
|
||||||
|
for ac_id in self.action_changes:
|
||||||
|
ac = self.id_to_action(ac_id)
|
||||||
|
if ac is None:
|
||||||
|
continue
|
||||||
|
old_props = self.action_properties(ac_id)
|
||||||
|
new_props = self._action_properties[ac_id] = create_properties_for_action(ac, old_props)
|
||||||
|
removed = set(new_props) - set(old_props)
|
||||||
|
if removed:
|
||||||
|
removed_props.append((ac_id, dbus.Array(removed, signature='as')))
|
||||||
|
updated = PropDict({k:v for k, v in new_props.iteritems() if v != old_props.get(k, null)})
|
||||||
|
if updated:
|
||||||
|
updated_props.append((ac_id, updated))
|
||||||
|
self.action_changes = set()
|
||||||
|
if updated_props or removed_props:
|
||||||
|
self.dbus_api.ItemsPropertiesUpdated(updated_props, removed_props)
|
||||||
|
return updated_props, removed_props
|
||||||
|
|
||||||
|
def layouts_changed(self):
|
||||||
|
changes = set()
|
||||||
|
for ac_id in self.layout_changes:
|
||||||
|
if ac_id in self._id_to_action:
|
||||||
|
changes.add(ac_id)
|
||||||
|
self.layout_changes = set()
|
||||||
|
if changes:
|
||||||
|
self.dbus_api.revision += 1
|
||||||
|
for change in changes:
|
||||||
|
self.dbus_api.LayoutUpdated(self.dbus_api.revision, change)
|
||||||
|
return changes
|
||||||
|
|
||||||
|
def action_is_in_a_menu(self, ac):
|
||||||
|
all_menus = {ac.menu() for ac in self._action_to_id}
|
||||||
|
all_menus.discard(None)
|
||||||
|
return bool(set(ac.associatedWidgets()).intersection(all_menus))
|
||||||
|
|
||||||
|
def action_removed(self, ac):
|
||||||
|
if not self.action_is_in_a_menu(ac):
|
||||||
|
ac_id = self._action_to_id.pop(ac, None)
|
||||||
|
self._id_to_action.pop(ac_id, None)
|
||||||
|
self._action_properties.pop(ac_id, None)
|
||||||
|
|
||||||
|
def get_layout(self, parent_id, depth, property_names):
|
||||||
|
# Ensure any pending updates are done, as they are needed now
|
||||||
|
self.actions_changed()
|
||||||
|
self.layouts_changed()
|
||||||
|
property_names = property_names or None
|
||||||
|
props = self.action_properties(parent_id, property_names)
|
||||||
|
return parent_id, props, self.get_layout_children(parent_id, depth, property_names)
|
||||||
|
|
||||||
|
def get_layout_children(self, parent_id, depth, property_names):
|
||||||
|
ans = dbus.Array(signature='(ia{sv}av)')
|
||||||
|
ac = self.id_to_action(parent_id)
|
||||||
|
if ac is not None and depth != 0 and ac.menu() is not None:
|
||||||
|
for child in ac.menu().actions():
|
||||||
|
child_id = self.action_to_id(child)
|
||||||
|
if child_id is not None:
|
||||||
|
props = self.action_properties(child_id, property_names)
|
||||||
|
ans.append((child_id, props, self.get_layout_children(child_id, depth - 1, property_names)))
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_properties(self, ids=None, property_names=None):
|
||||||
|
property_names = property_names or None
|
||||||
|
ans = dbus.Array(signature='(ia{sv})')
|
||||||
|
for action_id in (ids or self._id_to_action):
|
||||||
|
ans.append((action_id, self.action_properties(action_id, property_names)))
|
||||||
|
return ans
|
||||||
|
|
||||||
|
class DBusMenuAPI(Object):
|
||||||
|
|
||||||
IFACE = 'com.canonical.dbusmenu'
|
IFACE = 'com.canonical.dbusmenu'
|
||||||
|
|
||||||
def __init__(self, object_path, **kw):
|
def __init__(self, menu, object_path, **kw):
|
||||||
bus = kw.get('bus')
|
bus = kw.get('bus')
|
||||||
if bus is None:
|
if bus is None:
|
||||||
bus = kw['bus'] = dbus.SessionBus()
|
bus = kw['bus'] = dbus.SessionBus()
|
||||||
Object.__init__(self, bus, object_path)
|
Object.__init__(self, bus, object_path)
|
||||||
self.status = 'normal'
|
self.status = 'normal'
|
||||||
|
self.menu = menu
|
||||||
def publish_new_menu(self, qmenu):
|
self.revision = 0
|
||||||
self.qmenu = qmenu
|
|
||||||
|
|
||||||
@dbus_property(IFACE, signature='u')
|
@dbus_property(IFACE, signature='u')
|
||||||
def Version(self):
|
def Version(self):
|
||||||
@ -43,7 +249,7 @@ class DBusMenu(Object):
|
|||||||
|
|
||||||
@dbus_property(IFACE, signature='s')
|
@dbus_property(IFACE, signature='s')
|
||||||
def TextDirection(self):
|
def TextDirection(self):
|
||||||
return 'ltr'
|
return 'ltr' if QApplication.instance().isLeftToRight() else 'rtl'
|
||||||
|
|
||||||
@dbus_property(IFACE, signature='as')
|
@dbus_property(IFACE, signature='as')
|
||||||
def IconThemePath(self):
|
def IconThemePath(self):
|
||||||
@ -51,15 +257,16 @@ class DBusMenu(Object):
|
|||||||
|
|
||||||
@dbus_method(IFACE, in_signature='iias', out_signature='u(ia{sv}av)')
|
@dbus_method(IFACE, in_signature='iias', out_signature='u(ia{sv}av)')
|
||||||
def GetLayout(self, parentId, recursionDepth, propertyNames):
|
def GetLayout(self, parentId, recursionDepth, propertyNames):
|
||||||
pass
|
layout = self.menu.get_layout(parentId, recursionDepth, propertyNames)
|
||||||
|
return self.revision, layout
|
||||||
|
|
||||||
@dbus_method(IFACE, in_signature='aias', out_signature='a(ia{sv})')
|
@dbus_method(IFACE, in_signature='aias', out_signature='a(ia{sv})')
|
||||||
def GetGroupProperties(self, ids, propertyNames):
|
def GetGroupProperties(self, ids, propertyNames):
|
||||||
pass
|
return self.menu.get_properties(ids, propertyNames)
|
||||||
|
|
||||||
@dbus_method(IFACE, in_signature='is', out_signature='v')
|
@dbus_method(IFACE, in_signature='is', out_signature='v')
|
||||||
def GetProperty(self, id, name):
|
def GetProperty(self, id, name):
|
||||||
pass
|
return self.menu.action_properties(id).get(name)
|
||||||
|
|
||||||
@dbus_method(IFACE, in_signature='isvu', out_signature='')
|
@dbus_method(IFACE, in_signature='isvu', out_signature='')
|
||||||
def Event(self, id, eventId, data, timestamp):
|
def Event(self, id, eventId, data, timestamp):
|
||||||
|
@ -6,11 +6,11 @@ from __future__ import (unicode_literals, division, absolute_import,
|
|||||||
__license__ = 'GPL v3'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
import sys, array, socket
|
import sys, array, socket, re
|
||||||
|
|
||||||
import dbus
|
import dbus
|
||||||
|
|
||||||
from PyQt5.Qt import QSize, QImage
|
from PyQt5.Qt import QSize, QImage, Qt, QKeySequence, QBuffer, QByteArray
|
||||||
|
|
||||||
def log(*args, **kw):
|
def log(*args, **kw):
|
||||||
kw['file'] = sys.stderr
|
kw['file'] = sys.stderr
|
||||||
@ -38,6 +38,41 @@ def qicon_to_sni_image_list(qicon):
|
|||||||
ans.append((w, h, dbus.ByteArray(data)))
|
ans.append((w, h, dbus.ByteArray(data)))
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
|
def swap_mnemonic_char(text, from_char='&', to_char='_'):
|
||||||
|
text = text.replace(to_char, to_char * 2) # Escape to_char
|
||||||
|
# Replace the first occurence of an unescaped from_char with to_char
|
||||||
|
text = re.sub(r'(?<!{0}){0}(?!$)'.format(from_char), to_char, text, count=1)
|
||||||
|
# Remove any remaining unescaped from_char
|
||||||
|
text = re.sub(r'(?<!{0}){0}(?!$)'.format(from_char), '', text)
|
||||||
|
# Unescape from_char
|
||||||
|
text = text.replace(from_char * 2, from_char)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def key_sequence_to_dbus_shortcut(qks):
|
||||||
|
for key in qks:
|
||||||
|
if key == -1 or key == Qt.Key_unknown:
|
||||||
|
continue
|
||||||
|
items = []
|
||||||
|
for mod, name in {Qt.META:'Super', Qt.CTRL:'Control', Qt.ALT:'Alt', Qt.SHIFT:'Shift'}.iteritems():
|
||||||
|
if key & mod == mod:
|
||||||
|
items.append(name)
|
||||||
|
key &= int(~(Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier))
|
||||||
|
text = QKeySequence(key).toString()
|
||||||
|
if text:
|
||||||
|
text = {'+':'plus', '-':'minus'}.get(text, text)
|
||||||
|
items.append(text)
|
||||||
|
if items:
|
||||||
|
yield items
|
||||||
|
|
||||||
|
def icon_to_dbus_menu_icon(icon, size=32):
|
||||||
|
if icon.isNull():
|
||||||
|
return None
|
||||||
|
ba = QByteArray()
|
||||||
|
buf = QBuffer(ba)
|
||||||
|
buf.open(QBuffer.WriteOnly)
|
||||||
|
icon.pixmap(32).save(buf, 'PNG')
|
||||||
|
return dbus.ByteArray(bytes((ba.data())))
|
||||||
|
|
||||||
def setup_for_cli_run():
|
def setup_for_cli_run():
|
||||||
import signal
|
import signal
|
||||||
from dbus.mainloop.glib import DBusGMainLoop, threads_init
|
from dbus.mainloop.glib import DBusGMainLoop, threads_init
|
||||||
|
Loading…
x
Reference in New Issue
Block a user