PopupMenu: handle submenus inline

Instead of showing submenus on the left side, make PopupSubMenuMenuItem
act like an expander. The sub menu is toggled on click, opened on
right/enter/space on the parent item, closed on left on any item
or when closing the parent menu.

https://bugzilla.gnome.org/show_bug.cgi?id=633476
This commit is contained in:
Giovanni Campagna 2010-11-01 16:03:28 +01:00
parent 59b1aa26bb
commit 6024b87d27
3 changed files with 266 additions and 210 deletions

View File

@ -105,6 +105,10 @@ StTooltip StLabel {
min-width: 200px; min-width: 200px;
} }
.popup-sub-menu {
background-color: #606060;
}
/* The remaining popup-menu sizing is all done in ems, so that if you /* The remaining popup-menu sizing is all done in ems, so that if you
* override .popup-menu.font-size, everything else will scale with it. * override .popup-menu.font-size, everything else will scale with it.
*/ */

View File

@ -50,7 +50,7 @@ Button.prototype = {
if (open) { if (open) {
this.actor.add_style_pseudo_class('pressed'); this.actor.add_style_pseudo_class('pressed');
let focus = global.stage.get_key_focus(); let focus = global.stage.get_key_focus();
if (!focus || (focus != this.actor && !menu.contains(focus))) if (!focus || (focus != this.actor && !menu.actor.contains(focus)))
this.actor.grab_key_focus(); this.actor.grab_key_focus();
} else } else
this.actor.remove_style_pseudo_class('pressed'); this.actor.remove_style_pseudo_class('pressed');

View File

@ -87,7 +87,7 @@ PopupBaseMenuItem.prototype = {
}, },
_onButtonReleaseEvent: function (actor, event) { _onButtonReleaseEvent: function (actor, event) {
this.emit('activate', event); this.activate(event);
return true; return true;
}, },
@ -95,7 +95,7 @@ PopupBaseMenuItem.prototype = {
let symbol = event.get_key_symbol(); let symbol = event.get_key_symbol();
if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) {
this.emit('activate', event); this.activate(event);
return true; return true;
} }
return false; return false;
@ -132,11 +132,6 @@ PopupBaseMenuItem.prototype = {
this.emit('destroy'); this.emit('destroy');
}, },
// true if non descendant content includes @actor
contains: function(actor) {
return false;
},
// adds an actor to the menu item; @params can contain %span // adds an actor to the menu item; @params can contain %span
// (column span; defaults to 1, -1 means "all the remaining width"), // (column span; defaults to 1, -1 means "all the remaining width"),
// %expand (defaults to #false), and %align (defaults to // %expand (defaults to #false), and %align (defaults to
@ -612,76 +607,21 @@ function findNextInCycle(items, current, direction) {
return items[mod(cur + direction, items.length)]; return items[mod(cur + direction, items.length)];
} }
function PopupMenu() { function PopupMenuBase() {
this._init.apply(this, arguments); throw new TypeError('Trying to instantiate abstract class PopupMenuBase');
} }
PopupMenu.prototype = { PopupMenuBase.prototype = {
_init: function(sourceActor, alignment, arrowSide, gap) { _init: function(sourceActor, styleClass) {
this.sourceActor = sourceActor; this.sourceActor = sourceActor;
this._alignment = alignment;
this._arrowSide = arrowSide;
this._gap = gap;
this._boxPointer = new BoxPointer.BoxPointer(arrowSide, this.box = new St.BoxLayout({ style_class: styleClass,
{ x_fill: true, vertical: true });
y_fill: true,
x_align: St.Align.START });
this.actor = this._boxPointer.actor;
this.actor.style_class = 'popup-menu-boxpointer';
this._boxWrapper = new Shell.GenericContainer();
this._boxWrapper.connect('get-preferred-width', Lang.bind(this, this._boxGetPreferredWidth));
this._boxWrapper.connect('get-preferred-height', Lang.bind(this, this._boxGetPreferredHeight));
this._boxWrapper.connect('allocate', Lang.bind(this, this._boxAllocate));
this._boxPointer.bin.set_child(this._boxWrapper);
this._box = new St.BoxLayout({ style_class: 'popup-menu-content',
vertical: true });
this._boxWrapper.add_actor(this._box);
this.actor.add_style_class_name('popup-menu');
global.focus_manager.add_group(this.actor);
if (sourceActor._delegate instanceof PopupSubMenuMenuItem) {
this._isSubMenu = true;
this.actor.reactive = true;
this.actor.connect('key-press-event', Lang.bind(this, this._onKeyPressEvent));
}
this.isOpen = false; this.isOpen = false;
this._activeMenuItem = null; this._activeMenuItem = null;
}, },
_boxGetPreferredWidth: function (actor, forHeight, alloc) {
// Update the menuitem column widths
let columnWidths = [];
let items = this._box.get_children();
for (let i = 0; i < items.length; i++) {
if (items[i]._delegate instanceof PopupBaseMenuItem) {
let itemColumnWidths = items[i]._delegate.getColumnWidths();
for (let j = 0; j < itemColumnWidths.length; j++) {
if (j >= columnWidths.length || itemColumnWidths[j] > columnWidths[j])
columnWidths[j] = itemColumnWidths[j];
}
}
}
for (let i = 0; i < items.length; i++) {
if (items[i]._delegate instanceof PopupBaseMenuItem)
items[i]._delegate.setColumnWidths(columnWidths);
}
// Now they will request the right sizes
[alloc.min_size, alloc.natural_size] = this._box.get_preferred_width(forHeight);
},
_boxGetPreferredHeight: function (actor, forWidth, alloc) {
[alloc.min_size, alloc.natural_size] = this._box.get_preferred_height(forWidth);
},
_boxAllocate: function (actor, box, flags) {
this._box.allocate(box, flags);
},
addAction: function(title, callback) { addAction: function(title, callback) {
var menuItem = new PopupMenuItem(title); var menuItem = new PopupMenuItem(title);
this.addMenuItem(menuItem); this.addMenuItem(menuItem);
@ -692,9 +632,29 @@ PopupMenu.prototype = {
addMenuItem: function(menuItem, position) { addMenuItem: function(menuItem, position) {
if (position == undefined) if (position == undefined)
this._box.add(menuItem.actor); this.box.add(menuItem.actor);
else else
this._box.insert_actor(menuItem.actor, position); this.box.insert_actor(menuItem.actor, position);
if (menuItem instanceof PopupSubMenuMenuItem) {
if (position == undefined)
this.box.add(menuItem.menu.actor);
else
this.box.insert_actor(menuItem.menu.actor, position + 1);
menuItem._subMenuActivateId = menuItem.menu.connect('activate', Lang.bind(this, function() {
this.emit('activate');
this.close();
}));
menuItem._subMenuActiveChangeId = menuItem.menu.connect('active-changed', Lang.bind(this, function(submenu, submenuItem) {
if (this._activeMenuItem && this._activeMenuItem != submenuItem)
this._activeMenuItem.setActive(false);
this._activeMenuItem = submenuItem;
this.emit('active-changed', submenuItem);
}));
menuItem._closingId = this.connect('open-state-changed', function(self, open) {
if (!open)
menuItem.menu.immediateClose();
});
}
menuItem._activeChangeId = menuItem.connect('active-changed', Lang.bind(this, function (menuItem, active) { menuItem._activeChangeId = menuItem.connect('active-changed', Lang.bind(this, function (menuItem, active) {
if (active && this._activeMenuItem != menuItem) { if (active && this._activeMenuItem != menuItem) {
if (this._activeMenuItem) if (this._activeMenuItem)
@ -713,17 +673,45 @@ PopupMenu.prototype = {
menuItem.connect('destroy', Lang.bind(this, function(emitter) { menuItem.connect('destroy', Lang.bind(this, function(emitter) {
menuItem.disconnect(menuItem._activateId); menuItem.disconnect(menuItem._activateId);
menuItem.disconnect(menuItem._activeChangeId); menuItem.disconnect(menuItem._activeChangeId);
if (menuItem.menu) {
menuItem.menu.disconnect(menuItem._subMenuActivateId);
menuItem.menu.disconnect(menuItem._subMenuActiveChangeId);
this.disconnect(menuItem._closingId);
}
if (menuItem == this._activeMenuItem) if (menuItem == this._activeMenuItem)
this._activeMenuItem = null; this._activeMenuItem = null;
})); }));
}, },
getColumnWidths: function() {
let columnWidths = [];
let items = this.box.get_children();
for (let i = 0; i < items.length; i++) {
if (items[i]._delegate instanceof PopupBaseMenuItem || items[i]._delegate instanceof PopupMenuBase) {
let itemColumnWidths = items[i]._delegate.getColumnWidths();
for (let j = 0; j < itemColumnWidths.length; j++) {
if (j >= columnWidths.length || itemColumnWidths[j] > columnWidths[j])
columnWidths[j] = itemColumnWidths[j];
}
}
}
return columnWidths;
},
setColumnWidths: function(widths) {
let items = this.box.get_children();
for (let i = 0; i < items.length; i++) {
if (items[i]._delegate instanceof PopupBaseMenuItem || items[i]._delegate instanceof PopupMenuBase)
items[i]._delegate.setColumnWidths(widths);
}
},
addActor: function(actor) { addActor: function(actor) {
this._box.add(actor); this.box.add(actor);
}, },
getMenuItems: function() { getMenuItems: function() {
return this._box.get_children().map(function (actor) { return actor._delegate; }); return this.box.get_children().map(function (actor) { return actor._delegate; }).filter(function(item) { return item instanceof PopupBaseMenuItem; });
}, },
removeAll: function() { removeAll: function() {
@ -735,76 +723,16 @@ PopupMenu.prototype = {
}, },
activateFirst: function() { activateFirst: function() {
let children = this._box.get_children(); let children = this.box.get_children();
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
let actor = children[i]; let actor = children[i];
if (actor._delegate && actor.visible && actor.reactive) { if (actor._delegate && actor._delegate instanceof PopupBaseMenuItem && actor.visible && actor.reactive) {
actor._delegate.setActive(true); actor._delegate.setActive(true);
break; break;
} }
} }
}, },
open: function() {
if (this.isOpen)
return;
let primary = global.get_primary_monitor();
// We need to show it now to force an allocation,
// so that we can query the correct size.
this.actor.show();
// Position correctly relative to the sourceActor
let [sourceX, sourceY] = this.sourceActor.get_transformed_position();
let [sourceWidth, sourceHeight] = this.sourceActor.get_transformed_size();
let [minWidth, minHeight, natWidth, natHeight] = this.actor.get_preferred_size();
let menuWidth = natWidth, menuHeight = natHeight;
// Position the non-pointing axis
if (this._isSubmenu) {
if (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM) {
// vertical submenu
if (sourceY + sourceHeigth + menuHeight + this._gap < primary.y + primary.height)
this._boxPointer._arrowSide = this._arrowSide = St.Side.TOP;
else if (primary.y + menuHeight + this._gap < sourceY)
this._boxPointer._arrowSide = this._arrowSide = St.Side.BOTTOM;
else
this._boxPointer._arrowSide = this._arrowSide = St.Side.TOP;
} else {
// horizontal submenu
if (sourceX + sourceWidth + menuWidth + this._gap < primary.x + primary.width)
this._boxPointer._arrowSide = this._arrowSide = St.Side.LEFT;
else if (primary.x + menuWidth + this._gap < sourceX)
this._boxPointer._arrowSide = this._arrowSide = St.Side.RIGHT;
else
this._boxPointer._arrowSide = this._arrowSide = St.Side.LEFT;
}
}
this._boxPointer.setPosition(this.sourceActor, this._gap, this._alignment);
// Now show it
this.actor.reactive = true;
this._boxPointer.animateAppear();
this.isOpen = true;
this.emit('open-state-changed', true);
},
close: function() {
if (!this.isOpen)
return;
if (this._activeMenuItem)
this._activeMenuItem.setActive(false);
this.actor.reactive = false;
this._boxPointer.animateDisappear();
this.isOpen = false;
this.emit('open-state-changed', false);
},
toggle: function() { toggle: function() {
if (this.isOpen) if (this.isOpen)
this.close(); this.close();
@ -812,30 +740,6 @@ PopupMenu.prototype = {
this.open(); this.open();
}, },
_onKeyPressEvent: function(actor, event) {
// Move focus back to parent menu if the user types Left.
// (This handler is only connected if the PopupMenu is a
// submenu.)
if (this.isOpen &&
this._activeMenuItem &&
event.get_key_symbol() == Clutter.KEY_Left) {
this._activeMenuItem.setActive(false);
return true;
}
return false;
},
// return true if the actor is inside the menu or
// any actor related to the active submenu
contains: function(actor) {
if (this.actor.contains(actor))
return true;
if (this._activeMenuItem)
return this._activeMenuItem.contains(actor);
return false;
},
destroy: function() { destroy: function() {
this.removeAll(); this.removeAll();
this.actor.destroy(); this.actor.destroy();
@ -843,7 +747,186 @@ PopupMenu.prototype = {
this.emit('destroy'); this.emit('destroy');
} }
}; };
Signals.addSignalMethods(PopupMenu.prototype); Signals.addSignalMethods(PopupMenuBase.prototype);
function PopupMenu() {
this._init.apply(this, arguments);
}
PopupMenu.prototype = {
__proto__: PopupMenuBase.prototype,
_init: function(sourceActor, alignment, arrowSide, gap) {
PopupMenuBase.prototype._init.call (this, sourceActor, 'popup-menu-content');
this._alignment = alignment;
this._arrowSide = arrowSide;
this._gap = gap;
this._boxPointer = new BoxPointer.BoxPointer(arrowSide,
{ x_fill: true,
y_fill: true,
x_align: St.Align.START });
this.actor = this._boxPointer.actor;
this.actor._delegate = this;
this.actor.style_class = 'popup-menu-boxpointer';
this._boxWrapper = new Shell.GenericContainer();
this._boxWrapper.connect('get-preferred-width', Lang.bind(this, this._boxGetPreferredWidth));
this._boxWrapper.connect('get-preferred-height', Lang.bind(this, this._boxGetPreferredHeight));
this._boxWrapper.connect('allocate', Lang.bind(this, this._boxAllocate));
this._boxPointer.bin.set_child(this._boxWrapper);
this._boxWrapper.add_actor(this.box);
this.actor.add_style_class_name('popup-menu');
global.focus_manager.add_group(this.actor);
this.actor.reactive = true;
},
_boxGetPreferredWidth: function (actor, forHeight, alloc) {
let columnWidths = this.getColumnWidths();
this.setColumnWidths(columnWidths);
// Now they will request the right sizes
[alloc.min_size, alloc.natural_size] = this.box.get_preferred_width(forHeight);
},
_boxGetPreferredHeight: function (actor, forWidth, alloc) {
[alloc.min_size, alloc.natural_size] = this.box.get_preferred_height(forWidth);
},
_boxAllocate: function (actor, box, flags) {
this.box.allocate(box, flags);
},
setArrowOrigin: function(origin) {
this._boxPointer.setArrowOrigin(origin);
},
open: function() {
if (this.isOpen)
return;
this.isOpen = true;
this._boxPointer.setPosition(this.sourceActor, this._gap, this._alignment);
this._boxPointer.animateAppear();
this.emit('open-state-changed', true);
},
close: function() {
if (!this.isOpen)
return;
if (this._activeMenuItem)
this._activeMenuItem.setActive(false);
this._boxPointer.animateDisappear();
this.isOpen = false;
this.emit('open-state-changed', false);
}
};
function PopupSubMenu() {
this._init.apply(this, arguments);
}
PopupSubMenu.prototype = {
__proto__: PopupMenuBase.prototype,
_init: function(sourceActor, sourceArrow) {
PopupMenuBase.prototype._init.call(this, sourceActor, 'popup-sub-menu');
this._arrow = sourceArrow;
this._arrow.rotation_center_z_gravity = Clutter.Gravity.CENTER;
this.actor = this.box;
this.actor._delegate = this;
this.actor.clip_to_allocation = true;
this.actor.connect('key-press-event', Lang.bind(this, this._onKeyPressEvent));
this.actor.hide();
},
open: function() {
if (this.isOpen)
return;
this.isOpen = true;
let [naturalHeight, minHeight] = this.actor.get_preferred_height(-1);
this.actor.height = 0;
this.actor.show();
this.actor._arrow_rotation = this._arrow.rotation_angle_z;
Tweener.addTween(this.actor,
{ _arrow_rotation: 90,
height: naturalHeight,
time: 0.25,
onUpdateScope: this,
onUpdate: function() {
this._arrow.rotation_angle_z = this.actor._arrow_rotation;
},
onCompleteScope: this,
onComplete: function() {
this.actor.set_height(-1);
this.emit('open-state-changed', true);
}
});
},
close: function() {
if (!this.isOpen)
return;
this.isOpen = false;
if (this._activeMenuItem)
this._activeMenuItem.setActive(false);
this.actor._arrow_rotation = this._arrow.rotation_angle_z;
Tweener.addTween(this.actor,
{ _arrow_rotation: 0,
height: 0,
time: 0.25,
onCompleteScope: this,
onComplete: function() {
this.actor.hide();
this.actor.set_height(-1);
this.emit('open-state-changed', false);
},
onUpdateScope: this,
onUpdate: function() {
this._arrow.rotation_angle_z = this.actor._arrow_rotation;
}
});
},
immediateClose: function() {
if (!this.isOpen)
return;
if (this._activeMenuItem)
this._activeMenuItem.setActive(false);
this.actor.hide();
this.isOpen = false;
this.emit('open-state-changed', false);
},
_onKeyPressEvent: function(actor, event) {
// Move focus back to parent menu if the user types Left.
if (this.isOpen && event.get_key_symbol() == Clutter.KEY_Left) {
this.close();
this.sourceActor._delegate.setActive(true);
return true;
}
return false;
}
};
function PopupSubMenuMenuItem() { function PopupSubMenuMenuItem() {
this._init.apply(this, arguments); this._init.apply(this, arguments);
@ -853,77 +936,46 @@ PopupSubMenuMenuItem.prototype = {
__proto__: PopupBaseMenuItem.prototype, __proto__: PopupBaseMenuItem.prototype,
_init: function(text) { _init: function(text) {
PopupBaseMenuItem.prototype._init.call(this, { activate: false, hover: false }); PopupBaseMenuItem.prototype._init.call(this);
this.actor.connect('enter-event', Lang.bind(this, this._mouseEnter));
this.actor.connect('key-press-event', Lang.bind(this, this._onKeyPressEvent)); this.actor.add_style_class_name('popup-submenu-menu-item');
this.label = new St.Label({ text: text }); this.label = new St.Label({ text: text });
this.addActor(this.label); this.addActor(this.label);
this.addActor(new St.Label({ text: '>' }), { align: St.Align.END }); this._triangle = new St.Label({ text: '\u25B8' });
this.addActor(this._triangle, { align: St.Align.END });
this.menu = new PopupMenu(this.actor, St.Align.MIDDLE, St.Side.LEFT, 0, true); this.menu = new PopupSubMenu(this.actor, this._triangle);
Main.chrome.addActor(this.menu.actor, { visibleInOverview: true, this.menu.connect('open-state-changed', Lang.bind(this, this._subMenuOpenStateChanged));
affectsStruts: false });
this.menu.actor.hide();
this._openStateChangedId = this.menu.connect('open-state-changed', Lang.bind(this, this._subMenuOpenStateChanged));
this._activateId = this.menu.connect('activate', Lang.bind(this, this._subMenuActivate));
}, },
_subMenuOpenStateChanged: function(menu, open) { _subMenuOpenStateChanged: function(menu, open) {
PopupBaseMenuItem.prototype.setActive.call(this, open); if (open)
}, this.actor.add_style_pseudo_class('open');
else
_subMenuActivate: function(menu, menuItem) { this.actor.remove_style_pseudo_class('open');
this.emit('activate', null);
},
setMenu: function(newmenu) {
if (this.menu) {
this.menu.close();
this.menu.disconnect(this._openStateChangedId);
this.menu.disconnect(this._activateId);
}
if (newmenu) {
this._openStateChangedId = newmenu.connect('open-state-changed', Lang.bind(this, this._subMenuOpenStateChanged));
this._activateId = newmenu.connect('activate', Lang.bind(this, this._subMenuActivate));
}
this.menu = newmenu;
}, },
destroy: function() { destroy: function() {
if (this.menu) this.menu.destroy();
this.menu.destroy();
PopupBaseMenuItem.prototype.destroy.call(this); PopupBaseMenuItem.prototype.destroy.call(this);
}, },
setActive: function(active) {
if (this.menu) {
if (active)
this.menu.open();
else
this.menu.close();
}
PopupBaseMenuItem.prototype.setActive.call(this, active);
},
_onKeyPressEvent: function(actor, event) { _onKeyPressEvent: function(actor, event) {
if (!this.menu)
return false;
if (event.get_key_symbol() == Clutter.KEY_Right) { if (event.get_key_symbol() == Clutter.KEY_Right) {
this.menu.open();
this.menu.activateFirst(); this.menu.activateFirst();
return true; return true;
} }
return false; return PopupBaseMenuItem.prototype._onKeyPressEvent.call(this, actor, event);
}, },
contains: function(actor) { activate: function(event) {
return this.menu && this.menu.contains(actor); this.menu.open();
}, },
_mouseEnter: function(event) { _onButtonReleaseEvent: function(actor) {
this.setActive(true); this.menu.toggle();
} }
}; };
@ -1052,7 +1104,7 @@ PopupMenuManager.prototype = {
_eventIsOnActiveMenu: function(event) { _eventIsOnActiveMenu: function(event) {
let src = event.get_source(); let src = event.get_source();
return this._activeMenu != null return this._activeMenu != null
&& (this._activeMenu.contains(src) || && (this._activeMenu.actor.contains(src) ||
(this._activeMenu.sourceActor && this._activeMenu.sourceActor.contains(src))); (this._activeMenu.sourceActor && this._activeMenu.sourceActor.contains(src)));
}, },