dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/shell/gmenu.js

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());
}
};