653 lines
19 KiB
JavaScript
653 lines
19 KiB
JavaScript
'use strict';
|
|
|
|
const Atk = imports.gi.Atk;
|
|
const Clutter = imports.gi.Clutter;
|
|
const Gio = imports.gi.Gio;
|
|
const GObject = imports.gi.GObject;
|
|
const St = imports.gi.St;
|
|
|
|
const PopupMenu = imports.ui.popupMenu;
|
|
|
|
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
|
|
|
const Tooltip = Extension.imports.shell.tooltip;
|
|
|
|
|
|
/**
|
|
* Get a dictionary of a GMenuItem's attributes
|
|
*
|
|
* @param {Gio.MenuModel} model - The menu model containing the item
|
|
* @param {number} index - The index of the item in @model
|
|
* @return {object} - A dictionary of the item's attributes
|
|
*/
|
|
function getItemInfo(model, index) {
|
|
let info = {
|
|
target: null,
|
|
links: []
|
|
};
|
|
|
|
//
|
|
let iter = model.iterate_item_attributes(index);
|
|
|
|
while (iter.next()) {
|
|
let name = iter.get_name();
|
|
let value = iter.get_value();
|
|
|
|
switch (name) {
|
|
case 'icon':
|
|
value = Gio.Icon.deserialize(value);
|
|
|
|
if (value instanceof Gio.ThemedIcon)
|
|
value = gsconnect.getIcon(value.names[0]);
|
|
|
|
info[name] = value;
|
|
break;
|
|
|
|
case 'target':
|
|
info[name] = value;
|
|
break;
|
|
|
|
default:
|
|
info[name] = value.unpack();
|
|
}
|
|
}
|
|
|
|
// Submenus & Sections
|
|
iter = model.iterate_item_links(index);
|
|
|
|
while (iter.next()) {
|
|
info.links.push({
|
|
name: iter.get_name(),
|
|
value: iter.get_value()
|
|
});
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
|
|
/**
|
|
*
|
|
*/
|
|
var ListBox = class ListBox extends PopupMenu.PopupMenuSection {
|
|
|
|
constructor(params) {
|
|
super();
|
|
Object.assign(this, params);
|
|
|
|
// Main Actor
|
|
this.actor = new St.BoxLayout({
|
|
x_expand: true,
|
|
clip_to_allocation: true
|
|
});
|
|
this.actor._delegate = this;
|
|
|
|
// Item Box
|
|
this.box.clip_to_allocation = true;
|
|
this.box.x_expand = true;
|
|
this.box.add_style_class_name('gsconnect-list-box');
|
|
this.box.set_pivot_point(1, 1);
|
|
this.actor.add_child(this.box);
|
|
|
|
// Submenu Container
|
|
this.sub = new St.BoxLayout({
|
|
clip_to_allocation: true,
|
|
vertical: false,
|
|
visible: false,
|
|
x_expand: true
|
|
});
|
|
this.sub.set_pivot_point(1, 1);
|
|
this.sub._delegate = this;
|
|
this.actor.add_child(this.sub);
|
|
|
|
// Handle transitions
|
|
this._boxTransitionsCompletedId = this.box.connect(
|
|
'transitions-completed',
|
|
this._onTransitionsCompleted.bind(this)
|
|
);
|
|
|
|
this._subTransitionsCompletedId = this.sub.connect(
|
|
'transitions-completed',
|
|
this._onTransitionsCompleted.bind(this)
|
|
);
|
|
|
|
// Handle keyboard navigation
|
|
this._submenuCloseKeyId = this.sub.connect(
|
|
'key-press-event',
|
|
this._onSubmenuCloseKey.bind(this)
|
|
);
|
|
|
|
// Refresh the menu when mapped
|
|
this._mappedId = this.actor.connect(
|
|
'notify::mapped',
|
|
this._onMapped.bind(this)
|
|
);
|
|
|
|
// Watch the model for changes
|
|
this._itemsChangedId = this.model.connect(
|
|
'items-changed',
|
|
this._onItemsChanged.bind(this)
|
|
);
|
|
this._onItemsChanged();
|
|
}
|
|
|
|
_onMapped(actor) {
|
|
if (actor.mapped) {
|
|
this._onItemsChanged();
|
|
|
|
// We use this instead of close() to avoid touching finalized objects
|
|
} else {
|
|
this.box.set_opacity(255);
|
|
this.box.set_width(-1);
|
|
this.box.set_height(-1);
|
|
this.box.visible = true;
|
|
|
|
this._submenu = null;
|
|
this.sub.set_opacity(0);
|
|
this.sub.set_width(0);
|
|
this.sub.set_height(0);
|
|
this.sub.visible = false;
|
|
this.sub.get_children().map(menu => menu.hide());
|
|
}
|
|
}
|
|
|
|
_onSubmenuCloseKey(actor, event) {
|
|
if (this.submenu && event.get_key_symbol() == Clutter.KEY_Left) {
|
|
this.submenu.submenu_for.setActive(true);
|
|
this.submenu = null;
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_onSubmenuOpenKey(actor, event) {
|
|
let item = actor._delegate;
|
|
|
|
if (item.submenu && event.get_key_symbol() == Clutter.KEY_Right) {
|
|
this.submenu = item.submenu;
|
|
item.submenu.firstMenuItem.setActive(true);
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_onGMenuItemActivate(item, event) {
|
|
this.emit('activate', item);
|
|
|
|
if (item.submenu) {
|
|
this.submenu = item.submenu;
|
|
} else if (item.action_name) {
|
|
this.action_group.activate_action(
|
|
item.action_name,
|
|
item.action_target
|
|
);
|
|
this.itemActivated();
|
|
}
|
|
}
|
|
|
|
_addGMenuItem(info) {
|
|
// TODO: Use an image menu item if there's an icon?
|
|
let item = new PopupMenu.PopupMenuItem(info.label);
|
|
this.addMenuItem(item);
|
|
|
|
if (info.action !== undefined) {
|
|
item.action_name = info.action.split('.')[1];
|
|
item.action_target = info.target;
|
|
|
|
item.actor.visible = this.action_group.get_action_enabled(
|
|
item.action_name
|
|
);
|
|
}
|
|
|
|
// Modify the ::activate callback to invoke the GAction or submenu
|
|
item.disconnect(item._activateId);
|
|
item._activateId = item.connect(
|
|
'activate',
|
|
this._onGMenuItemActivate.bind(this)
|
|
);
|
|
|
|
return item;
|
|
}
|
|
|
|
_addGMenuSection(model) {
|
|
let section = new ListBox({
|
|
model: model,
|
|
action_group: this.action_group
|
|
});
|
|
this.addMenuItem(section);
|
|
}
|
|
|
|
_addGMenuSubmenu(model, item) {
|
|
// Add an expander arrow to the item
|
|
let arrow = PopupMenu.arrowIcon(St.Side.RIGHT);
|
|
arrow.x_align = Clutter.ActorAlign.END;
|
|
arrow.x_expand = true;
|
|
item.actor.add_child(arrow);
|
|
|
|
// Mark it as an expandable and open on right-arrow
|
|
item.actor.add_accessible_state(Atk.StateType.EXPANDABLE);
|
|
|
|
item.actor.connect(
|
|
'key-press-event',
|
|
this._onSubmenuOpenKey.bind(this)
|
|
);
|
|
|
|
// Create the submenu
|
|
item.submenu = new ListBox({
|
|
model: model,
|
|
action_group: this.action_group,
|
|
submenu_for: item,
|
|
_parent: this
|
|
});
|
|
item.submenu.actor.hide();
|
|
|
|
// Add to the submenu container
|
|
this.sub.add_child(item.submenu.actor);
|
|
}
|
|
|
|
_onItemsChanged(model, position, removed, added) {
|
|
// Clear the menu
|
|
this.removeAll();
|
|
this.sub.get_children().map(child => child.destroy());
|
|
|
|
for (let i = 0, len = this.model.get_n_items(); i < len; i++) {
|
|
let info = getItemInfo(this.model, i);
|
|
let item;
|
|
|
|
// A regular item
|
|
if (info.hasOwnProperty('label')) {
|
|
item = this._addGMenuItem(info);
|
|
}
|
|
|
|
for (let link of info.links) {
|
|
// Submenu
|
|
if (link.name === 'submenu') {
|
|
this._addGMenuSubmenu(link.value, item);
|
|
|
|
// Section
|
|
} else if (link.name === 'section') {
|
|
this._addGMenuSection(link.value);
|
|
|
|
// len is length starting at 1
|
|
if (i + 1 < len) {
|
|
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this is a submenu of another item...
|
|
if (this.submenu_for) {
|
|
// Prepend an "<= Go Back" item, bold with a unicode arrow
|
|
let prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
|
|
prev.label.style = 'font-weight: bold;';
|
|
let prevArrow = PopupMenu.arrowIcon(St.Side.LEFT);
|
|
prev.replace_child(prev._ornamentLabel, prevArrow);
|
|
this.addMenuItem(prev, 0);
|
|
|
|
// Modify the ::activate callback to close the submenu
|
|
prev.disconnect(prev._activateId);
|
|
|
|
prev._activateId = prev.connect('activate', (item, event) => {
|
|
this.emit('activate', item);
|
|
this._parent.submenu = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
_onTransitionsCompleted(actor) {
|
|
if (this.submenu) {
|
|
this.box.visible = false;
|
|
} else {
|
|
this.sub.visible = false;
|
|
this.sub.get_children().map(menu => menu.hide());
|
|
}
|
|
}
|
|
|
|
get submenu() {
|
|
return this._submenu || null;
|
|
}
|
|
|
|
set submenu(submenu) {
|
|
// Get the current allocation to hold the menu width
|
|
let allocation = this.actor.allocation;
|
|
let width = Math.max(0, allocation.x2 - allocation.x1);
|
|
|
|
// Prepare the appropriate child for tweening
|
|
if (submenu) {
|
|
this.sub.set_opacity(0);
|
|
this.sub.set_width(0);
|
|
this.sub.set_height(0);
|
|
this.sub.visible = true;
|
|
} else {
|
|
this.box.set_opacity(0);
|
|
this.box.set_width(0);
|
|
this.sub.set_height(0);
|
|
this.box.visible = true;
|
|
}
|
|
|
|
// Setup the animation
|
|
this.box.save_easing_state();
|
|
this.box.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
|
this.box.set_easing_duration(250);
|
|
|
|
this.sub.save_easing_state();
|
|
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
|
this.sub.set_easing_duration(250);
|
|
|
|
if (submenu) {
|
|
submenu.actor.show();
|
|
|
|
this.sub.set_opacity(255);
|
|
this.sub.set_width(width);
|
|
this.sub.set_height(-1);
|
|
|
|
this.box.set_opacity(0);
|
|
this.box.set_width(0);
|
|
this.box.set_height(0);
|
|
} else {
|
|
this.box.set_opacity(255);
|
|
this.box.set_width(width);
|
|
this.box.set_height(-1);
|
|
|
|
this.sub.set_opacity(0);
|
|
this.sub.set_width(0);
|
|
this.sub.set_height(0);
|
|
}
|
|
|
|
// Reset the animation
|
|
this.box.restore_easing_state();
|
|
this.sub.restore_easing_state();
|
|
|
|
//
|
|
this._submenu = submenu;
|
|
}
|
|
|
|
destroy() {
|
|
this.actor.disconnect(this._mappedId);
|
|
this.box.disconnect(this._boxTransitionsCompletedId);
|
|
this.sub.disconnect(this._subTransitionsCompletedId);
|
|
this.sub.disconnect(this._submenuCloseKeyId);
|
|
this.model.disconnect(this._itemsChangedId);
|
|
|
|
super.destroy();
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* A St.Button subclass for iconic GMenu items
|
|
*/
|
|
var IconButton = GObject.registerClass({
|
|
GTypeName: 'GSConnectShellIconButton'
|
|
}, class Button extends St.Button {
|
|
|
|
_init(params) {
|
|
super._init({
|
|
style_class: 'gsconnect-icon-button',
|
|
can_focus: true
|
|
});
|
|
Object.assign(this, params);
|
|
|
|
// Item attributes
|
|
if (params.info.hasOwnProperty('action')) {
|
|
this.action_name = params.info.action.split('.')[1];
|
|
}
|
|
|
|
if (params.info.hasOwnProperty('target')) {
|
|
this.action_target = params.info.target;
|
|
}
|
|
|
|
if (params.info.hasOwnProperty('label')) {
|
|
this.tooltip = new Tooltip.Tooltip({
|
|
parent: this,
|
|
markup: params.info.label
|
|
});
|
|
}
|
|
|
|
if (params.info.hasOwnProperty('icon')) {
|
|
this.child = new St.Icon({gicon: params.info.icon});
|
|
}
|
|
|
|
// Submenu
|
|
for (let link of params.info.links) {
|
|
if (link.name === 'submenu') {
|
|
this.add_accessible_state(Atk.StateType.EXPANDABLE);
|
|
this.toggle_mode = true;
|
|
this.connect('notify::checked', this._onChecked);
|
|
|
|
this.submenu = new ListBox({
|
|
model: link.value,
|
|
action_group: this.action_group,
|
|
_parent: this._parent
|
|
});
|
|
|
|
this.submenu.actor.style_class = 'popup-sub-menu';
|
|
this.submenu.actor.visible = false;
|
|
}
|
|
}
|
|
|
|
this.connect('clicked', this._onClicked);
|
|
}
|
|
|
|
// This is (reliably?) emitted before ::clicked
|
|
_onChecked(button) {
|
|
if (button.checked) {
|
|
button.add_accessible_state(Atk.StateType.EXPANDED);
|
|
button.add_style_pseudo_class('active');
|
|
} else {
|
|
button.remove_accessible_state(Atk.StateType.EXPANDED);
|
|
button.remove_style_pseudo_class('active');
|
|
}
|
|
}
|
|
|
|
// This is (reliably?) emitted after notify::checked
|
|
_onClicked(button, clicked_button) {
|
|
// Unless this has submenu activate the action and close
|
|
if (!button.toggle_mode) {
|
|
button._parent._getTopMenu().close();
|
|
|
|
button.action_group.activate_action(
|
|
button.action_name,
|
|
button.action_target
|
|
);
|
|
|
|
// StButton.checked has already been toggled so we're opening
|
|
} else if (button.checked) {
|
|
button._parent.submenu = button.submenu;
|
|
|
|
// If this is the active submenu being closed, animate-close it
|
|
} else if (button._parent.submenu === button.submenu) {
|
|
button._parent.submenu = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
var IconBox = class IconBox extends PopupMenu.PopupMenuSection {
|
|
|
|
constructor(params) {
|
|
super();
|
|
Object.assign(this, params);
|
|
|
|
// Main Actor
|
|
this.actor = new St.BoxLayout({
|
|
vertical: true,
|
|
x_expand: true
|
|
});
|
|
this.actor._delegate = this;
|
|
|
|
// Button Box
|
|
this.box._delegate = this;
|
|
this.box.style_class = 'gsconnect-icon-box';
|
|
this.box.vertical = false;
|
|
this.actor.add_child(this.box);
|
|
|
|
// Submenu Container
|
|
this.sub = new St.BoxLayout({
|
|
clip_to_allocation: true,
|
|
vertical: true,
|
|
x_expand: true
|
|
});
|
|
this.sub.connect('transitions-completed', this._onTransitionsCompleted);
|
|
this.sub._delegate = this;
|
|
this.actor.add_child(this.sub);
|
|
|
|
// Track menu items so we can use ::items-changed
|
|
this._menu_items = new Map();
|
|
|
|
// PopupMenu
|
|
this._mappedId = this.actor.connect(
|
|
'notify::mapped',
|
|
this._onMapped.bind(this)
|
|
);
|
|
|
|
// GMenu
|
|
this._itemsChangedId = this.model.connect(
|
|
'items-changed',
|
|
this._onItemsChanged.bind(this)
|
|
);
|
|
|
|
// GActions
|
|
this._actionAddedId = this.action_group.connect(
|
|
'action-added',
|
|
this._onActionChanged.bind(this)
|
|
);
|
|
this._actionEnabledChangedId = this.action_group.connect(
|
|
'action-enabled-changed',
|
|
this._onActionChanged.bind(this)
|
|
);
|
|
this._actionRemovedId = this.action_group.connect(
|
|
'action-removed',
|
|
this._onActionChanged.bind(this)
|
|
);
|
|
}
|
|
|
|
destroy() {
|
|
this.actor.disconnect(this._mappedId);
|
|
this.model.disconnect(this._itemsChangedId);
|
|
this.action_group.disconnect(this._actionAddedId);
|
|
this.action_group.disconnect(this._actionEnabledChangedId);
|
|
this.action_group.disconnect(this._actionRemovedId);
|
|
|
|
super.destroy();
|
|
}
|
|
|
|
get submenu() {
|
|
return this._submenu || null;
|
|
}
|
|
|
|
set submenu(submenu) {
|
|
if (submenu) {
|
|
for (let button of this.box.get_children()) {
|
|
if (button.submenu && this._submenu && button.submenu !== submenu) {
|
|
button.checked = false;
|
|
button.submenu.actor.hide();
|
|
}
|
|
}
|
|
|
|
this.sub.set_height(0);
|
|
submenu.actor.show();
|
|
}
|
|
|
|
this.sub.save_easing_state();
|
|
this.sub.set_easing_duration(250);
|
|
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
|
|
|
this.sub.set_height(submenu ? submenu.actor.get_preferred_size()[1] : 0);
|
|
this.sub.restore_easing_state();
|
|
|
|
this._submenu = submenu;
|
|
}
|
|
|
|
_onMapped(actor) {
|
|
if (!actor.mapped) {
|
|
this._submenu = null;
|
|
this.box.get_children().map(button => button.checked = false);
|
|
this.sub.get_children().map(submenu => submenu.hide());
|
|
}
|
|
}
|
|
|
|
_onActionChanged(group, name, enabled) {
|
|
let menuItem = this._menu_items.get(name);
|
|
|
|
if (menuItem !== undefined) {
|
|
menuItem.visible = group.get_action_enabled(name);
|
|
}
|
|
}
|
|
|
|
_onItemsChanged(model, position, removed, added) {
|
|
// Remove items
|
|
while (removed > 0) {
|
|
let button = this.box.get_child_at_index(position);
|
|
let action_name = button.action_name;
|
|
|
|
(button.submenu) ? button.submenu.destroy() : null;
|
|
button.destroy();
|
|
|
|
this._menu_items.delete(action_name);
|
|
removed--;
|
|
}
|
|
|
|
// Add items
|
|
for (let i = 0; i < added; i++) {
|
|
let index = position + i;
|
|
|
|
// Create an iconic button
|
|
let button = new IconButton({
|
|
action_group: this.action_group,
|
|
info: getItemInfo(model, index),
|
|
// TODO: Because this doesn't derive from a PopupMenu class
|
|
// it lacks some things its parent will expect from it
|
|
_parent: this,
|
|
_delegate: null
|
|
});
|
|
|
|
// Set the visibility based on the enabled state
|
|
if (button.action_name !== undefined) {
|
|
button.visible = this.action_group.get_action_enabled(
|
|
button.action_name
|
|
);
|
|
}
|
|
|
|
// If it has a submenu, add it as a sibling
|
|
if (button.submenu) {
|
|
this.sub.add_child(button.submenu.actor);
|
|
}
|
|
|
|
// Track the item if it has an action
|
|
if (button.action_name !== undefined) {
|
|
this._menu_items.set(button.action_name, button);
|
|
}
|
|
|
|
// Insert it in the box at the defined position
|
|
this.box.insert_child_at_index(button, index);
|
|
}
|
|
}
|
|
|
|
_onTransitionsCompleted(actor) {
|
|
let menu = actor._delegate;
|
|
|
|
menu.box.get_children().map(button => {
|
|
if (button.submenu && button.submenu !== menu.submenu) {
|
|
button.checked = false;
|
|
button.submenu.actor.hide();
|
|
}
|
|
});
|
|
|
|
menu.sub.set_height(-1);
|
|
}
|
|
|
|
// PopupMenu.PopupMenuBase overrides
|
|
isEmpty() {
|
|
return (this.box.get_children().length === 0);
|
|
}
|
|
|
|
_setParent(parent) {
|
|
super._setParent(parent);
|
|
this._onItemsChanged(this.model, 0, 0, this.model.get_n_items());
|
|
}
|
|
};
|
|
|