123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647 |
- // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
- //
- // SPDX-License-Identifier: GPL-2.0-or-later
- import Atk from 'gi://Atk';
- import Clutter from 'gi://Clutter';
- import Gio from 'gi://Gio';
- import GObject from 'gi://GObject';
- import St from 'gi://St';
- import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
- import {getIcon} from './utils.js';
- import Tooltip from './tooltip.js';
- /**
- * 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) {
- const info = {
- target: null,
- links: [],
- };
- //
- let iter = model.iterate_item_attributes(index);
- while (iter.next()) {
- const name = iter.get_name();
- let value = iter.get_value();
- switch (name) {
- case 'icon':
- value = Gio.Icon.deserialize(value);
- if (value instanceof Gio.ThemedIcon)
- value = 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;
- }
- /**
- *
- */
- export 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) {
- const 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) {
- const 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
- );
- }
- item.connectObject(
- 'activate',
- this._onGMenuItemActivate.bind(this),
- this
- );
- return item;
- }
- _addGMenuSection(model) {
- const section = new ListBox({
- model: model,
- action_group: this.action_group,
- });
- this.addMenuItem(section);
- }
- _addGMenuSubmenu(model, item) {
- // Add an expander arrow to the item
- const 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++) {
- const info = getItemInfo(this.model, i);
- let item;
- // A regular item
- if (info.hasOwnProperty('label'))
- item = this._addGMenuItem(info);
- for (const 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
- const prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
- prev.label.style = 'font-weight: bold;';
- const prevArrow = PopupMenu.arrowIcon(St.Side.LEFT);
- prev.replace_child(prev._ornamentIcon, prevArrow);
- this.addMenuItem(prev, 0);
- prev.connectObject('activate', (item, event) => {
- this.emit('activate', item);
- this._parent.submenu = null;
- }, this);
- }
- }
- _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
- const allocation = this.actor.allocation;
- const 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
- */
- export const 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({
- parent: this,
- markup: params.info.label,
- });
- this.accessible_name = params.info.label;
- }
- if (params.info.hasOwnProperty('icon'))
- this.child = new St.Icon({gicon: params.info.icon});
- // Submenu
- for (const 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 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
- vfunc_clicked(clicked_button) {
- // Unless this has a submenu, activate the action and close the menu
- if (!this.toggle_mode) {
- this._parent._getTopMenu().close();
- this.action_group.activate_action(
- this.action_name,
- this.action_target
- );
- // StButton.checked has already been toggled so we're opening
- } else if (this.checked) {
- this._parent.submenu = this.submenu;
- // If this is the active submenu being closed, animate-close it
- } else if (this._parent.submenu === this.submenu) {
- this._parent.submenu = null;
- }
- }
- });
- export 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 (const 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;
- for (const button of this.box.get_children())
- button.checked = false;
- for (const submenu of this.sub.get_children())
- submenu.hide();
- }
- }
- _onActionChanged(group, name, enabled) {
- const 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) {
- const button = this.box.get_child_at_index(position);
- const action_name = button.action_name;
- if (button.submenu)
- button.submenu.destroy();
- button.destroy();
- this._menu_items.delete(action_name);
- removed--;
- }
- // Add items
- for (let i = 0; i < added; i++) {
- const index = position + i;
- // Create an iconic button
- const button = new IconButton({
- action_group: this.action_group,
- info: getItemInfo(model, index),
- // NOTE: 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) {
- const menu = actor._delegate;
- for (const button of menu.box.get_children()) {
- 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());
- }
- }
|