From 2f001021743dc91cf9c64f2799e9bf8377d033d9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 29 Oct 2014 07:42:03 +0530 Subject: [PATCH] A demo application using Unity's global menu bar --- src/calibre/gui2/dbus_export/demo.py | 43 ++++++++ src/calibre/gui2/dbus_export/menu.py | 50 +++++---- src/calibre/gui2/dbus_export/widgets.py | 140 ++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 src/calibre/gui2/dbus_export/demo.py create mode 100644 src/calibre/gui2/dbus_export/widgets.py diff --git a/src/calibre/gui2/dbus_export/demo.py b/src/calibre/gui2/dbus_export/demo.py new file mode 100644 index 0000000000..d1d5e728e8 --- /dev/null +++ b/src/calibre/gui2/dbus_export/demo.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Kovid Goyal ' + +from PyQt5.Qt import ( + QApplication, QMainWindow, QVBoxLayout, Qt, QKeySequence) + +from calibre.gui2.dbus_export.utils import setup_for_cli_run +from calibre.gui2.dbus_export.widgets import factory + +setup_for_cli_run() + +class MainWindow(QMainWindow): + + def action_triggered(self, checked=False): + self.statusBar().showMessage('Action triggered: %s' % self.sender().text()) + +app = QApplication([]) +f = factory() +mw = MainWindow() +mw.setWindowTitle('Demo of DBUS menu exporter and systray integration') +mw.statusBar().showMessage(mw.windowTitle()) +w = mw.centralWidget() +mw.l = l = QVBoxLayout(w) +mb = f.create_window_menubar(mw) +mw.setMenuBar(mb) +m = mb.addMenu('&One') +s = mw.style() +for i, icon in zip(xrange(3), map(s.standardIcon, (s.SP_DialogOkButton, s.SP_DialogCancelButton, s.SP_ArrowUp))): + ac = m.addAction('One - &%d' % (i + 1)) + ac.triggered.connect(mw.action_triggered) + k = getattr(Qt, 'Key_%d' % (i + 1)) + ac.setShortcut(QKeySequence(Qt.CTRL | (Qt.Key_1 + i), Qt.SHIFT | (Qt.Key_1 + i))) + ac.setIcon(icon) +m.addSeparator() +m.addAction('&Disabled action').setEnabled(False) +mw.show() +print ('DBUS connection unique name:', f.bus.get_unique_name()) +app.exec_() diff --git a/src/calibre/gui2/dbus_export/menu.py b/src/calibre/gui2/dbus_export/menu.py index 1181f77e94..190aa73d0c 100644 --- a/src/calibre/gui2/dbus_export/menu.py +++ b/src/calibre/gui2/dbus_export/menu.py @@ -11,8 +11,7 @@ __copyright__ = '2014, Kovid Goyal ' import dbus from PyQt5.Qt import ( - QApplication, QMenu, QIcon, QKeySequence, QObject, QAction, QMenuBar, - QEvent, QTimer) + QApplication, QMenu, QIcon, QKeySequence, QObject, QEvent, QTimer) 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 ( @@ -23,14 +22,6 @@ 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(): @@ -53,9 +44,13 @@ def create_properties_for_action(ac, previous=None): ans['toggle-state'] = int(ac.isChecked()) shortcuts = ac.shortcuts() if shortcuts: - ans['shortcut'] = sc = [] + sc = dbus.Array(signature='as') for s in shortcuts: - sc.extend(key_sequence_to_dbus_shortcut(s)) + if not s.isEmpty(): + for x in key_sequence_to_dbus_shortcut(s): + sc.append(dbus.Array(x, signature='s')) + if sc: + ans['shortcut'] = sc[:1] # Unity fails to display the shortcuts at all if more than one is specified if ac.isIconVisibleInMenu(): icon = ac.icon() if previous and previous.get('x-qt-icon-cache-key') == icon.cacheKey(): @@ -115,11 +110,10 @@ class DBusMenu(QObject): 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 + qmenu.destroyed.connect(lambda obj=None:self.publish_new_menu()) + ac = qmenu.menuAction() self.add_action(ac) + self.dbus_api.LayoutUpdated(self.dbus_api.revision, 0) def add_action(self, ac): ac_id = 0 if ac.menu() is self.qmenu else self.next_id @@ -222,6 +216,12 @@ class DBusMenu(QObject): ans.append((action_id, self.action_properties(action_id, property_names))) return ans + def handle_event(self, action_id, event, data, timestamp): + ac = self.id_to_action(action_id) + if event == 'clicked': + # TODO: Handle checkable actions + ac.triggered.emit() + class DBusMenuAPI(Object): IFACE = 'com.canonical.dbusmenu' @@ -266,18 +266,19 @@ class DBusMenuAPI(Object): @dbus_method(IFACE, in_signature='is', out_signature='v') def GetProperty(self, id, name): - return self.menu.action_properties(id).get(name) + return self.menu.action_properties(id).get(name, '') @dbus_method(IFACE, in_signature='isvu', out_signature='') def Event(self, id, eventId, data, timestamp): ''' This is called by the applet to notify the application an event happened on a - menu item. type can be one of the following:: + menu item. eventId can be one of the following:: * "clicked" * "hovered" * "opened" * "closed" Vendor specific events can be added by prefixing them with "x--"''' - pass + if self.menu.id_to_action(id) is not None: + self.menu.handle_event(id, eventId, data, timestamp) @dbus_method(IFACE, in_signature='a(isvu)', out_signature='ai') def EventGroup(self, events): @@ -285,7 +286,13 @@ class DBusMenuAPI(Object): several different menuitems. This is done to optimize DBus traffic. Should return a list of ids that are not found. events is a list of events in the same format as used for the Event method.''' - return dbus.Array(signature='u') + missing = dbus.Array(signature='u') + for id, eventId, data, timestamp in events: + if self.menu.id_to_action(id) is not None: + self.menu.handle_event(id, eventId, data, timestamp) + else: + missing.append(id) + return missing @dbus_method(IFACE, in_signature='i', out_signature='b') def AboutToShow(self, id): @@ -313,7 +320,8 @@ def test(): bus = dbus.SessionBus() dbus_name = BusName('com.calibre-ebook.TestDBusMenu', bus=bus, do_not_queue=True) m = QMenu() - m.addAction(QIcon(I('window-close.png')), 'Quit', app.quit).setShortcut(QKeySequence(QKeySequence.Quit)) + ac = m.addAction(QIcon(I('window-close.png')), 'Quit', app.quit) + ac.setShortcut(QKeySequence('Ctrl+Q')) menu = DBusMenu('/Menu', bus=bus) menu.publish_new_menu(m) app.exec_() diff --git a/src/calibre/gui2/dbus_export/widgets.py b/src/calibre/gui2/dbus_export/widgets.py new file mode 100644 index 0000000000..4de97d3ac5 --- /dev/null +++ b/src/calibre/gui2/dbus_export/widgets.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Kovid Goyal ' + +import time + +from PyQt5.Qt import QObject, QMenuBar, QAction, QEvent + +UNITY_WINDOW_REGISTRAR = ('com.canonical.AppMenu.Registrar', '/com/canonical/AppMenu/Registrar', 'com.canonical.AppMenu.Registrar') + +class MenuBarAction(QAction): + + def __init__(self, mb): + QAction.__init__(self, mb) + + def menu(self): + return self.parent() + +menu_counter = 0 + +class ExportedMenuBar(QMenuBar): + + def __init__(self, parent, menu_registrar, bus): + global menu_counter + if not parent.isWindow(): + raise ValueError('You must supply a top level window widget as the parent for an exported menu bar') + QMenuBar.__init__(self, parent) + QMenuBar.setVisible(self, False) + self.menu_action = MenuBarAction(self) + self.menu_registrar = menu_registrar + self.registered_window_id = None + self.bus = bus + menu_counter += 1 + import dbus + from calibre.gui2.dbus_export.menu import DBusMenu + self.object_path = dbus.ObjectPath('/MenuBar/%d' % menu_counter) + self.dbus_menu = DBusMenu(self.object_path) + self.dbus_menu.publish_new_menu(self) + self.register() + parent.installEventFilter(self) + + def register(self): + wid = self.parent().effectiveWinId() + if wid is not None: + self.registered_window_id = int(wid) + args = self.menu_registrar + ('RegisterWindow', 'uo', (self.registered_window_id, self.object_path)) + self.bus.call_blocking(*args) + + def unregister(self): + if self.registered_window_id is not None: + args = self.menu_registrar + ('UnregisterWindow', 'u', (self.registered_window_id,)) + self.registered_window_id = None + self.bus.call_blocking(*args) + + def setVisible(self, visible): + pass # no-op + + def isVisible(self): + return True + + def menuAction(self): + return self.menu_action + + def eventFilter(self, obj, ev): + etype = ev.type() + if etype == QEvent.WinIdChange: + self.unregister() + self.register() + return False + +class Factory(QObject): + + def __init__(self): + QObject.__init__(self) + try: + import dbus + self.dbus = dbus + except ImportError: + self.dbus = None + + self.menu_registrar = None + self._bus = None + + @property + def bus(self): + if self._bus is None: + try: + self._bus = self.dbus.SessionBus() + self._bus.call_on_disconnection(self.bus_disconnected) + except Exception as err: + print ('Failed to connect to DBUS session bus, with error:', str(err)) + self._bus = False + return self._bus or None + + @property + def has_global_menu(self): + if self.menu_registrar is None: + if self.dbus is None: + self.menu_registrar = False + else: + try: + self.detect_menu_registrar() + except Exception as err: + self.menu_registrar = False + print ('Failed to detect window menu registrar, with error:', str(err)) + return bool(self.menu_registrar) + + def detect_menu_registrar(self): + self.menu_registrar = False + if self.bus.name_has_owner(UNITY_WINDOW_REGISTRAR[0]): + self.menu_registrar = UNITY_WINDOW_REGISTRAR + + def create_window_menubar(self, parent): + if self.has_global_menu: + return ExportedMenuBar(parent, self.menu_registrar, self.bus) + return QMenuBar(parent) + + def bus_disconnected(self): + self._bus = None + for i in xrange(5): + try: + self.bus + except Exception: + time.sleep(1) + continue + break + else: + self.bus + # TODO: have the created widgets also handle bus disconnection + +_factory = None +def factory(): + global _factory + if _factory is None: + _factory = Factory() + return _factory