From 4debedb275a66731474ea1a20d61c845caedcb63 Mon Sep 17 00:00:00 2001 From: Giovanni Campagna Date: Sun, 15 May 2011 18:55:23 +0200 Subject: [PATCH] Application Menu: add support for showing GApplication actions Use the new GApplication support in ShellApp to create the application menu. Supports plain (no state), boolean and double actions. Includes a test application (as no other application uses GApplication for actions) https://bugzilla.gnome.org/show_bug.cgi?id=621203 --- js/ui/panel.js | 52 ++++++--- js/ui/panelMenu.js | 34 ++++-- js/ui/popupMenu.js | 242 +++++++++++++++++++++++++++++++++++++++ src/test-gapplication.js | 7 +- 4 files changed, 310 insertions(+), 25 deletions(-) diff --git a/js/ui/panel.js b/js/ui/panel.js index a9344bc76..78a14a104 100644 --- a/js/ui/panel.js +++ b/js/ui/panel.js @@ -3,6 +3,7 @@ const Cairo = imports.cairo; const Clutter = imports.gi.Clutter; const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; const Lang = imports.lang; const Mainloop = imports.mainloop; const Pango = imports.gi.Pango; @@ -235,10 +236,12 @@ const AppMenuButton = new Lang.Class({ Name: 'AppMenuButton', Extends: PanelMenu.Button, - _init: function() { - this.parent(0.0); + _init: function(menuManager) { + this.parent(0.0, true); + this._startingApps = []; + this._menuManager = menuManager; this._targetApp = null; let bin = new St.Bin({ name: 'appMenu' }); @@ -264,10 +267,6 @@ const AppMenuButton = new Lang.Class({ this._iconBottomClip = 0; - this._quitMenu = new PopupMenu.PopupMenuItem(''); - this.menu.addMenuItem(this._quitMenu); - this._quitMenu.connect('activate', Lang.bind(this, this._onQuit)); - this._visible = !Main.overview.visible; if (!this._visible) this.actor.hide(); @@ -446,12 +445,6 @@ const AppMenuButton = new Lang.Class({ } }, - _onQuit: function() { - if (this._targetApp == null) - return; - this._targetApp.request_quit(); - }, - _onAppStateChanged: function(appSys, app) { let state = app.state; if (state != Shell.AppState.STARTING) { @@ -513,8 +506,10 @@ const AppMenuButton = new Lang.Class({ } if (targetApp == this._targetApp) { - if (targetApp && targetApp.get_state() != Shell.AppState.STARTING) + if (targetApp && targetApp.get_state() != Shell.AppState.STARTING) { this.stopAnimation(); + this._maybeSetMenu(); + } return; } @@ -528,16 +523,40 @@ const AppMenuButton = new Lang.Class({ let icon = targetApp.get_faded_icon(2 * PANEL_ICON_SIZE); this._label.setText(targetApp.get_name()); - // TODO - _quit() doesn't really work on apps in state STARTING yet - this._quitMenu.label.set_text(_("Quit %s").format(targetApp.get_name())); this._iconBox.set_child(icon); this._iconBox.show(); if (targetApp.get_state() == Shell.AppState.STARTING) this.startAnimation(); + else + this._maybeSetMenu(); this.emit('changed'); + }, + + _maybeSetMenu: function() { + let menu; + + if (this._targetApp.action_group) { + if (this.menu instanceof PopupMenu.RemoteMenu && + this.menu.actionGroup == this._targetApp.action_group) + return; + + menu = new PopupMenu.RemoteMenu(this.actor, this._targetApp.menu, this._targetApp.action_group); + } else { + if (this.menu && !(this.menu instanceof PopupMenu.RemoteMenu)) + return; + + // fallback to older menu + menu = new PopupMenu.PopupMenu(this.actor, 0.0, St.Side.TOP, 0); + menu.addAction(_("Quit"), Lang.bind(this, function() { + this._targetApp.request_quit(); + })); + } + + this.setMenu(menu); + this._menuManager.addMenu(menu); } }); @@ -924,9 +943,8 @@ const Panel = new Lang.Class({ // more cleanly with the rest of the panel this._menus.addMenu(this._activitiesButton.menu); - this._appMenu = new AppMenuButton(); + this._appMenu = new AppMenuButton(this._menus); this._leftBox.add(this._appMenu.actor); - this._menus.addMenu(this._appMenu.menu); } /* center */ diff --git a/js/ui/panelMenu.js b/js/ui/panelMenu.js index 927311b0e..126d2cc26 100644 --- a/js/ui/panelMenu.js +++ b/js/ui/panelMenu.js @@ -96,22 +96,39 @@ const Button = new Lang.Class({ Name: 'PanelMenuButton', Extends: ButtonBox, - _init: function(menuAlignment) { + _init: function(menuAlignment, dontCreateMenu) { this.parent({ reactive: true, can_focus: true, track_hover: true }); this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress)); this.actor.connect('key-press-event', Lang.bind(this, this._onSourceKeyPress)); - this.menu = new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP); - this.menu.actor.add_style_class_name('panel-menu'); - this.menu.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged)); - this.menu.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress)); - Main.uiGroup.add_actor(this.menu.actor); - this.menu.actor.hide(); + + if (dontCreateMenu) + this.menu = null; + else + this.setMenu(new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP, 0)); + }, + + setMenu: function(menu) { + if (this.menu) + this.menu.destroy(); + + this.menu = menu; + if (this.menu) { + this.menu.actor.add_style_class_name('panel-menu'); + this.menu.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged)); + this.menu.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress)); + + Main.uiGroup.add_actor(this.menu.actor); + this.menu.actor.hide(); + } }, _onButtonPress: function(actor, event) { + if (!this.menu) + return; + if (!this.menu.isOpen) { // Setting the max-height won't do any good if the minimum height of the // menu is higher then the screen; it's useful if part of the menu is @@ -125,6 +142,9 @@ const Button = new Lang.Class({ }, _onSourceKeyPress: function(actor, event) { + if (!this.menu) + return false; + let symbol = event.get_key_symbol(); if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { this.menu.toggle(); diff --git a/js/ui/popupMenu.js b/js/ui/popupMenu.js index 5652fc4ac..c6dc3df31 100644 --- a/js/ui/popupMenu.js +++ b/js/ui/popupMenu.js @@ -2,7 +2,9 @@ const Cairo = imports.cairo; const Clutter = imports.gi.Clutter; +const GLib = imports.gi.GLib; const Gtk = imports.gi.Gtk; +const Gio = imports.gi.Gio; const Lang = imports.lang; const Shell = imports.gi.Shell; const Signals = imports.signals; @@ -1692,6 +1694,246 @@ const PopupComboBoxMenuItem = new Lang.Class({ } }); +/** + * RemoteMenu: + * + * A PopupMenu that tracks a GMenuModel and shows its actions + * (exposed by GApplication/GActionGroup) + */ +const RemoteMenu = new Lang.Class({ + Name: 'RemoteMenu', + Extends: PopupMenu, + + _init: function(sourceActor, model, actionGroup) { + this.parent(sourceActor, 0.0, St.Side.TOP); + + this.model = model; + this.actionGroup = actionGroup; + + this._actions = { }; + this._modelChanged(this.model, 0, 0, this.model.get_n_items(), this); + + this._actionStateChangeId = this.actionGroup.connect('action-state-changed', Lang.bind(this, this._actionStateChanged)); + this._actionEnableChangeId = this.actionGroup.connect('action-enabled-changed', Lang.bind(this, this._actionEnabledChanged)); + }, + + destroy: function() { + if (this._actionStateChangeId) { + this.actionGroup.disconnect(this._actionStateChangeId); + this._actionStateChangeId = 0; + } + + if (this._actionEnableChangeId) { + this.actionGroup.disconnect(this._actionEnableChangeId); + this._actionEnableChangeId = 0; + } + + this.parent(); + }, + + _createMenuItem: function(model, index) { + let section_link = model.get_item_link(index, Gio.MENU_LINK_SECTION); + if (section_link) { + let item = new PopupMenuSection(); + this._modelChanged(section_link, 0, 0, section_link.get_n_items(), item); + return [item, true, '']; + } + + // labels are not checked for existance, as they're required for all items + let label = model.get_item_attribute_value(index, Gio.MENU_ATTRIBUTE_LABEL, null).deep_unpack(); + // remove all underscores that are not followed by another underscore + label = label.replace(/_([^_])/, '$1'); + let submenu_link = model.get_item_link(index, Gio.MENU_LINK_SUBMENU); + + if (submenu_link) { + let item = new PopupSubMenuMenuItem(label); + this._modelChanged(submenu_link, 0, 0, submenu_link.get_n_items(), item.menu); + return [item, false, '']; + } + + let action_id = model.get_item_attribute_value(index, Gio.MENU_ATTRIBUTE_ACTION, null).deep_unpack(); + if (!this.actionGroup.has_action(action_id)) { + // the action may not be there yet, wait for action-added + return [null, false, 'action-added']; + } + + if (!this._actions[action_id]) + this._actions[action_id] = { enabled: this.actionGroup.get_action_enabled(action_id), + state: this.actionGroup.get_action_state(action_id), + items: [ ], + }; + let action = this._actions[action_id]; + let item, target, destroyId, specificSignalId; + + if (action.state) { + // Docs have get_state_hint(), except that the DBus protocol + // has no provision for it (so ShellApp does not implement it, + // and neither GApplication), and g_action_get_state_hint() + // always returns null + // Funny :) + + switch (String.fromCharCode(action.state.classify())) { + case 'b': + item = new PopupSwitchMenuItem(label, action.state.get_boolean()); + action.items.push(item); + specificSignalId = item.connect('toggled', Lang.bind(this, function(item) { + this.actionGroup.activate_action(action_id, null); + })); + break; + case 's': + item = new PopupMenuItem(label); + item._remoteTarget = model.get_item_attribute_value(index, Gio.MENU_ATTRIBUTE_TARGET, null).deep_unpack(); + action.items.push(item); + item.setShowDot(action.state.deep_unpack() == item._remoteTarget); + specificSignalId = item.connect('activate', Lang.bind(this, function(item) { + this.actionGroup.activate_action(action_id, GLib.Variant.new_string(item._remoteTarget)); + })); + break; + default: + log('Action "%s" has state of type %s, which is not supported'.format(action_id, action.state.get_type_string())); + return [null, false, 'action-state-changed']; + } + } else { + target = model.get_item_attribute_value(index, Gio.MENU_ATTRIBUTE_TARGET, null); + item = new PopupMenuItem(label); + action.items.push(item); + specificSignalId = item.connect('activate', Lang.bind(this, function() { + this.actionGroup.activate_action(action_id, target); + })); + } + + item.actor.reactive = item.actor.can_focus = action.enabled; + if (action.enabled) + item.actor.remove_style_pseudo_class('insensitive'); + else + item.actor.add_style_pseudo_class('insensitive'); + + destroyId = item.connect('destroy', Lang.bind(this, function() { + item.disconnect(destroyId); + item.disconnect(specificSignalId); + + let pos = action.items.indexOf(item); + if (pos != -1) + action.items.splice(pos, 1); + })); + + return [item, false, '']; + }, + + _modelChanged: function(model, position, removed, added, target) { + let j, k; + let j0, k0; + + let currentItems = target._getMenuItems(); + + for (j0 = 0, k0 = 0; j0 < position; j0++, k0++) { + if (currentItems[k0] instanceof PopupSeparatorMenuItem) + k0++; + } + + if (removed == -1) { + // special flag to indicate we should destroy everything + for (k = k0; k < currentItems.length; k++) + currentItems[k].destroy(); + } else { + for (j = j0, k = k0; j < j0 + removed; j++, k++) { + currentItems[k].destroy(); + + if (currentItems[k] instanceof PopupSeparatorMenuItem) + j--; + } + } + + for (j = j0, k = k0; j < j0 + added; j++, k++) { + let [item, addSeparator, changeSignal] = this._createMenuItem(model, j); + + if (item) { + // separators must be added in the parent to make autohiding work + if (addSeparator) { + target.addMenuItem(new PopupSeparatorMenuItem(), k+1); + k++; + } + + target.addMenuItem(item, k); + + if (addSeparator) { + target.addMenuItem(new PopupSeparatorMenuItem(), k+1); + k++; + } + } else if (changeSignal) { + let signalId = this.actionGroup.connect(changeSignal, Lang.bind(this, function() { + this.actionGroup.disconnect(signalId); + + // force a full update + this._modelChanged(model, 0, -1, model.get_n_items(), target); + })); + } + } + + if (!model._changedId) { + model._changedId = model.connect('items-changed', Lang.bind(this, this._modelChanged, target)); + model._destroyId = target.connect('destroy', function() { + if (model._changedId) + model.disconnect(model._changedId); + if (model._destroyId) + target.disconnect(model._destroyId); + model._changedId = 0; + model._destroyId = 0; + }); + } + + if (target instanceof PopupMenuSection) { + target.actor.visible = target.numMenuItems != 0; + } else { + let sourceItem = target.sourceActor._delegate; + if (sourceItem instanceof PopupSubMenuMenuItem) + sourceItem.actor.visible = target.numMenuItems != 0; + } + }, + + _actionStateChanged: function(actionGroup, action_id) { + let action = this._actions[action_id]; + if (!action) + return; + + action.state = actionGroup.get_action_state(action_id); + if (action.items.length) { + switch (String.fromCharCode(action.state.classify())) { + case 'b': + for (let i = 0; i < action.items.length; i++) + action.items[i].setToggleState(action.state.get_boolean()); + break; + case 'd': + for (let i = 0; i < action.items.length; i++) + action.items[i].setValue(action.state.get_double()); + break; + case 's': + for (let i = 0; i < action.items.length; i++) + action.items[i].setShowDot(action.items[i]._remoteTarget == action.state.deep_unpack()); + } + } + }, + + _actionEnabledChanged: function(actionGroup, action_id) { + let action = this._actions[action_id]; + if (!action) + return; + + action.enabled = actionGroup.get_action_enabled(action_id); + if (action.items.length) { + for (let i = 0; i < action.items.length; i++) { + let item = action.items[i]; + item.actor.reactive = item.actor.can_focus = action.enabled; + + if (action.enabled) + item.actor.remove_style_pseudo_class('insensitive'); + else + item.actor.add_style_pseudo_class('insensitive'); + } + } + } +}); + /* Basic implementation of a menu manager. * Call addMenu to add menus */ diff --git a/src/test-gapplication.js b/src/test-gapplication.js index 98728895e..5f58b2dd5 100755 --- a/src/test-gapplication.js +++ b/src/test-gapplication.js @@ -13,6 +13,11 @@ function do_action_param(action, parameter) { print ("Action '" + action.name + "' invoked with parameter " + parameter.print(true)); } +function do_action_toggle(action) { + action.set_state(GLib.Variant.new('b', !action.state.deep_unpack())); + print ("Toggled"); +} + function do_action_state_change(action) { print ("Action '" + action.name + "' has now state '" + action.state.deep_unpack() + "'"); } @@ -36,7 +41,7 @@ function main() { group.insert(action); let action = Gio.SimpleAction.new_stateful('toggle', null, GLib.Variant.new('b', false)); - action.connect('activate', do_action); + action.connect('activate', do_action_toggle); action.connect('notify::state', do_action_state_change); group.insert(action);