gmenu.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Atk from 'gi://Atk';
  5. import Clutter from 'gi://Clutter';
  6. import Gio from 'gi://Gio';
  7. import GObject from 'gi://GObject';
  8. import St from 'gi://St';
  9. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  10. import {getIcon} from './utils.js';
  11. import Tooltip from './tooltip.js';
  12. /**
  13. * Get a dictionary of a GMenuItem's attributes
  14. *
  15. * @param {Gio.MenuModel} model - The menu model containing the item
  16. * @param {number} index - The index of the item in @model
  17. * @return {Object} A dictionary of the item's attributes
  18. */
  19. function getItemInfo(model, index) {
  20. const info = {
  21. target: null,
  22. links: [],
  23. };
  24. //
  25. let iter = model.iterate_item_attributes(index);
  26. while (iter.next()) {
  27. const name = iter.get_name();
  28. let value = iter.get_value();
  29. switch (name) {
  30. case 'icon':
  31. value = Gio.Icon.deserialize(value);
  32. if (value instanceof Gio.ThemedIcon)
  33. value = getIcon(value.names[0]);
  34. info[name] = value;
  35. break;
  36. case 'target':
  37. info[name] = value;
  38. break;
  39. default:
  40. info[name] = value.unpack();
  41. }
  42. }
  43. // Submenus & Sections
  44. iter = model.iterate_item_links(index);
  45. while (iter.next()) {
  46. info.links.push({
  47. name: iter.get_name(),
  48. value: iter.get_value(),
  49. });
  50. }
  51. return info;
  52. }
  53. /**
  54. *
  55. */
  56. export class ListBox extends PopupMenu.PopupMenuSection {
  57. constructor(params) {
  58. super();
  59. Object.assign(this, params);
  60. // Main Actor
  61. this.actor = new St.BoxLayout({
  62. x_expand: true,
  63. clip_to_allocation: true,
  64. });
  65. this.actor._delegate = this;
  66. // Item Box
  67. this.box.clip_to_allocation = true;
  68. this.box.x_expand = true;
  69. this.box.add_style_class_name('gsconnect-list-box');
  70. this.box.set_pivot_point(1, 1);
  71. this.actor.add_child(this.box);
  72. // Submenu Container
  73. this.sub = new St.BoxLayout({
  74. clip_to_allocation: true,
  75. vertical: false,
  76. visible: false,
  77. x_expand: true,
  78. });
  79. this.sub.set_pivot_point(1, 1);
  80. this.sub._delegate = this;
  81. this.actor.add_child(this.sub);
  82. // Handle transitions
  83. this._boxTransitionsCompletedId = this.box.connect(
  84. 'transitions-completed',
  85. this._onTransitionsCompleted.bind(this)
  86. );
  87. this._subTransitionsCompletedId = this.sub.connect(
  88. 'transitions-completed',
  89. this._onTransitionsCompleted.bind(this)
  90. );
  91. // Handle keyboard navigation
  92. this._submenuCloseKeyId = this.sub.connect(
  93. 'key-press-event',
  94. this._onSubmenuCloseKey.bind(this)
  95. );
  96. // Refresh the menu when mapped
  97. this._mappedId = this.actor.connect(
  98. 'notify::mapped',
  99. this._onMapped.bind(this)
  100. );
  101. // Watch the model for changes
  102. this._itemsChangedId = this.model.connect(
  103. 'items-changed',
  104. this._onItemsChanged.bind(this)
  105. );
  106. this._onItemsChanged();
  107. }
  108. _onMapped(actor) {
  109. if (actor.mapped) {
  110. this._onItemsChanged();
  111. // We use this instead of close() to avoid touching finalized objects
  112. } else {
  113. this.box.set_opacity(255);
  114. this.box.set_width(-1);
  115. this.box.set_height(-1);
  116. this.box.visible = true;
  117. this._submenu = null;
  118. this.sub.set_opacity(0);
  119. this.sub.set_width(0);
  120. this.sub.set_height(0);
  121. this.sub.visible = false;
  122. this.sub.get_children().map(menu => menu.hide());
  123. }
  124. }
  125. _onSubmenuCloseKey(actor, event) {
  126. if (this.submenu && event.get_key_symbol() === Clutter.KEY_Left) {
  127. this.submenu.submenu_for.setActive(true);
  128. this.submenu = null;
  129. return Clutter.EVENT_STOP;
  130. }
  131. return Clutter.EVENT_PROPAGATE;
  132. }
  133. _onSubmenuOpenKey(actor, event) {
  134. const item = actor._delegate;
  135. if (item.submenu && event.get_key_symbol() === Clutter.KEY_Right) {
  136. this.submenu = item.submenu;
  137. item.submenu.firstMenuItem.setActive(true);
  138. }
  139. return Clutter.EVENT_PROPAGATE;
  140. }
  141. _onGMenuItemActivate(item, event) {
  142. this.emit('activate', item);
  143. if (item.submenu) {
  144. this.submenu = item.submenu;
  145. } else if (item.action_name) {
  146. this.action_group.activate_action(
  147. item.action_name,
  148. item.action_target
  149. );
  150. this.itemActivated();
  151. }
  152. }
  153. _addGMenuItem(info) {
  154. const item = new PopupMenu.PopupMenuItem(info.label);
  155. this.addMenuItem(item);
  156. if (info.action !== undefined) {
  157. item.action_name = info.action.split('.')[1];
  158. item.action_target = info.target;
  159. item.actor.visible = this.action_group.get_action_enabled(
  160. item.action_name
  161. );
  162. }
  163. item.connectObject(
  164. 'activate',
  165. this._onGMenuItemActivate.bind(this),
  166. this
  167. );
  168. return item;
  169. }
  170. _addGMenuSection(model) {
  171. const section = new ListBox({
  172. model: model,
  173. action_group: this.action_group,
  174. });
  175. this.addMenuItem(section);
  176. }
  177. _addGMenuSubmenu(model, item) {
  178. // Add an expander arrow to the item
  179. const arrow = PopupMenu.arrowIcon(St.Side.RIGHT);
  180. arrow.x_align = Clutter.ActorAlign.END;
  181. arrow.x_expand = true;
  182. item.actor.add_child(arrow);
  183. // Mark it as an expandable and open on right-arrow
  184. item.actor.add_accessible_state(Atk.StateType.EXPANDABLE);
  185. item.actor.connect(
  186. 'key-press-event',
  187. this._onSubmenuOpenKey.bind(this)
  188. );
  189. // Create the submenu
  190. item.submenu = new ListBox({
  191. model: model,
  192. action_group: this.action_group,
  193. submenu_for: item,
  194. _parent: this,
  195. });
  196. item.submenu.actor.hide();
  197. // Add to the submenu container
  198. this.sub.add_child(item.submenu.actor);
  199. }
  200. _onItemsChanged(model, position, removed, added) {
  201. // Clear the menu
  202. this.removeAll();
  203. this.sub.get_children().map(child => child.destroy());
  204. for (let i = 0, len = this.model.get_n_items(); i < len; i++) {
  205. const info = getItemInfo(this.model, i);
  206. let item;
  207. // A regular item
  208. if (info.hasOwnProperty('label'))
  209. item = this._addGMenuItem(info);
  210. for (const link of info.links) {
  211. // Submenu
  212. if (link.name === 'submenu') {
  213. this._addGMenuSubmenu(link.value, item);
  214. // Section
  215. } else if (link.name === 'section') {
  216. this._addGMenuSection(link.value);
  217. // len is length starting at 1
  218. if (i + 1 < len)
  219. this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  220. }
  221. }
  222. }
  223. // If this is a submenu of another item...
  224. if (this.submenu_for) {
  225. // Prepend an "<= Go Back" item, bold with a unicode arrow
  226. const prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
  227. prev.label.style = 'font-weight: bold;';
  228. const prevArrow = PopupMenu.arrowIcon(St.Side.LEFT);
  229. prev.replace_child(prev._ornamentIcon, prevArrow);
  230. this.addMenuItem(prev, 0);
  231. prev.connectObject('activate', (item, event) => {
  232. this.emit('activate', item);
  233. this._parent.submenu = null;
  234. }, this);
  235. }
  236. }
  237. _onTransitionsCompleted(actor) {
  238. if (this.submenu) {
  239. this.box.visible = false;
  240. } else {
  241. this.sub.visible = false;
  242. this.sub.get_children().map(menu => menu.hide());
  243. }
  244. }
  245. get submenu() {
  246. return this._submenu || null;
  247. }
  248. set submenu(submenu) {
  249. // Get the current allocation to hold the menu width
  250. const allocation = this.actor.allocation;
  251. const width = Math.max(0, allocation.x2 - allocation.x1);
  252. // Prepare the appropriate child for tweening
  253. if (submenu) {
  254. this.sub.set_opacity(0);
  255. this.sub.set_width(0);
  256. this.sub.set_height(0);
  257. this.sub.visible = true;
  258. } else {
  259. this.box.set_opacity(0);
  260. this.box.set_width(0);
  261. this.sub.set_height(0);
  262. this.box.visible = true;
  263. }
  264. // Setup the animation
  265. this.box.save_easing_state();
  266. this.box.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
  267. this.box.set_easing_duration(250);
  268. this.sub.save_easing_state();
  269. this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
  270. this.sub.set_easing_duration(250);
  271. if (submenu) {
  272. submenu.actor.show();
  273. this.sub.set_opacity(255);
  274. this.sub.set_width(width);
  275. this.sub.set_height(-1);
  276. this.box.set_opacity(0);
  277. this.box.set_width(0);
  278. this.box.set_height(0);
  279. } else {
  280. this.box.set_opacity(255);
  281. this.box.set_width(width);
  282. this.box.set_height(-1);
  283. this.sub.set_opacity(0);
  284. this.sub.set_width(0);
  285. this.sub.set_height(0);
  286. }
  287. // Reset the animation
  288. this.box.restore_easing_state();
  289. this.sub.restore_easing_state();
  290. //
  291. this._submenu = submenu;
  292. }
  293. destroy() {
  294. this.actor.disconnect(this._mappedId);
  295. this.box.disconnect(this._boxTransitionsCompletedId);
  296. this.sub.disconnect(this._subTransitionsCompletedId);
  297. this.sub.disconnect(this._submenuCloseKeyId);
  298. this.model.disconnect(this._itemsChangedId);
  299. super.destroy();
  300. }
  301. }
  302. /**
  303. * A St.Button subclass for iconic GMenu items
  304. */
  305. export const IconButton = GObject.registerClass({
  306. GTypeName: 'GSConnectShellIconButton',
  307. }, class Button extends St.Button {
  308. _init(params) {
  309. super._init({
  310. style_class: 'gsconnect-icon-button',
  311. can_focus: true,
  312. });
  313. Object.assign(this, params);
  314. // Item attributes
  315. if (params.info.hasOwnProperty('action'))
  316. this.action_name = params.info.action.split('.')[1];
  317. if (params.info.hasOwnProperty('target'))
  318. this.action_target = params.info.target;
  319. if (params.info.hasOwnProperty('label')) {
  320. this.tooltip = new Tooltip({
  321. parent: this,
  322. markup: params.info.label,
  323. });
  324. this.accessible_name = params.info.label;
  325. }
  326. if (params.info.hasOwnProperty('icon'))
  327. this.child = new St.Icon({gicon: params.info.icon});
  328. // Submenu
  329. for (const link of params.info.links) {
  330. if (link.name === 'submenu') {
  331. this.add_accessible_state(Atk.StateType.EXPANDABLE);
  332. this.toggle_mode = true;
  333. this.connect('notify::checked', this._onChecked);
  334. this.submenu = new ListBox({
  335. model: link.value,
  336. action_group: this.action_group,
  337. _parent: this._parent,
  338. });
  339. this.submenu.actor.style_class = 'popup-sub-menu';
  340. this.submenu.actor.visible = false;
  341. }
  342. }
  343. }
  344. // This is (reliably?) emitted before ::clicked
  345. _onChecked(button) {
  346. if (button.checked) {
  347. button.add_accessible_state(Atk.StateType.EXPANDED);
  348. button.add_style_pseudo_class('active');
  349. } else {
  350. button.remove_accessible_state(Atk.StateType.EXPANDED);
  351. button.remove_style_pseudo_class('active');
  352. }
  353. }
  354. // This is (reliably?) emitted after notify::checked
  355. vfunc_clicked(clicked_button) {
  356. // Unless this has a submenu, activate the action and close the menu
  357. if (!this.toggle_mode) {
  358. this._parent._getTopMenu().close();
  359. this.action_group.activate_action(
  360. this.action_name,
  361. this.action_target
  362. );
  363. // StButton.checked has already been toggled so we're opening
  364. } else if (this.checked) {
  365. this._parent.submenu = this.submenu;
  366. // If this is the active submenu being closed, animate-close it
  367. } else if (this._parent.submenu === this.submenu) {
  368. this._parent.submenu = null;
  369. }
  370. }
  371. });
  372. export class IconBox extends PopupMenu.PopupMenuSection {
  373. constructor(params) {
  374. super();
  375. Object.assign(this, params);
  376. // Main Actor
  377. this.actor = new St.BoxLayout({
  378. vertical: true,
  379. x_expand: true,
  380. });
  381. this.actor._delegate = this;
  382. // Button Box
  383. this.box._delegate = this;
  384. this.box.style_class = 'gsconnect-icon-box';
  385. this.box.vertical = false;
  386. this.actor.add_child(this.box);
  387. // Submenu Container
  388. this.sub = new St.BoxLayout({
  389. clip_to_allocation: true,
  390. vertical: true,
  391. x_expand: true,
  392. });
  393. this.sub.connect('transitions-completed', this._onTransitionsCompleted);
  394. this.sub._delegate = this;
  395. this.actor.add_child(this.sub);
  396. // Track menu items so we can use ::items-changed
  397. this._menu_items = new Map();
  398. // PopupMenu
  399. this._mappedId = this.actor.connect(
  400. 'notify::mapped',
  401. this._onMapped.bind(this)
  402. );
  403. // GMenu
  404. this._itemsChangedId = this.model.connect(
  405. 'items-changed',
  406. this._onItemsChanged.bind(this)
  407. );
  408. // GActions
  409. this._actionAddedId = this.action_group.connect(
  410. 'action-added',
  411. this._onActionChanged.bind(this)
  412. );
  413. this._actionEnabledChangedId = this.action_group.connect(
  414. 'action-enabled-changed',
  415. this._onActionChanged.bind(this)
  416. );
  417. this._actionRemovedId = this.action_group.connect(
  418. 'action-removed',
  419. this._onActionChanged.bind(this)
  420. );
  421. }
  422. destroy() {
  423. this.actor.disconnect(this._mappedId);
  424. this.model.disconnect(this._itemsChangedId);
  425. this.action_group.disconnect(this._actionAddedId);
  426. this.action_group.disconnect(this._actionEnabledChangedId);
  427. this.action_group.disconnect(this._actionRemovedId);
  428. super.destroy();
  429. }
  430. get submenu() {
  431. return this._submenu || null;
  432. }
  433. set submenu(submenu) {
  434. if (submenu) {
  435. for (const button of this.box.get_children()) {
  436. if (button.submenu && this._submenu && button.submenu !== submenu) {
  437. button.checked = false;
  438. button.submenu.actor.hide();
  439. }
  440. }
  441. this.sub.set_height(0);
  442. submenu.actor.show();
  443. }
  444. this.sub.save_easing_state();
  445. this.sub.set_easing_duration(250);
  446. this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
  447. this.sub.set_height(submenu ? submenu.actor.get_preferred_size()[1] : 0);
  448. this.sub.restore_easing_state();
  449. this._submenu = submenu;
  450. }
  451. _onMapped(actor) {
  452. if (!actor.mapped) {
  453. this._submenu = null;
  454. for (const button of this.box.get_children())
  455. button.checked = false;
  456. for (const submenu of this.sub.get_children())
  457. submenu.hide();
  458. }
  459. }
  460. _onActionChanged(group, name, enabled) {
  461. const menuItem = this._menu_items.get(name);
  462. if (menuItem !== undefined)
  463. menuItem.visible = group.get_action_enabled(name);
  464. }
  465. _onItemsChanged(model, position, removed, added) {
  466. // Remove items
  467. while (removed > 0) {
  468. const button = this.box.get_child_at_index(position);
  469. const action_name = button.action_name;
  470. if (button.submenu)
  471. button.submenu.destroy();
  472. button.destroy();
  473. this._menu_items.delete(action_name);
  474. removed--;
  475. }
  476. // Add items
  477. for (let i = 0; i < added; i++) {
  478. const index = position + i;
  479. // Create an iconic button
  480. const button = new IconButton({
  481. action_group: this.action_group,
  482. info: getItemInfo(model, index),
  483. // NOTE: Because this doesn't derive from a PopupMenu class
  484. // it lacks some things its parent will expect from it
  485. _parent: this,
  486. _delegate: null,
  487. });
  488. // Set the visibility based on the enabled state
  489. if (button.action_name !== undefined) {
  490. button.visible = this.action_group.get_action_enabled(
  491. button.action_name
  492. );
  493. }
  494. // If it has a submenu, add it as a sibling
  495. if (button.submenu)
  496. this.sub.add_child(button.submenu.actor);
  497. // Track the item if it has an action
  498. if (button.action_name !== undefined)
  499. this._menu_items.set(button.action_name, button);
  500. // Insert it in the box at the defined position
  501. this.box.insert_child_at_index(button, index);
  502. }
  503. }
  504. _onTransitionsCompleted(actor) {
  505. const menu = actor._delegate;
  506. for (const button of menu.box.get_children()) {
  507. if (button.submenu && button.submenu !== menu.submenu) {
  508. button.checked = false;
  509. button.submenu.actor.hide();
  510. }
  511. }
  512. menu.sub.set_height(-1);
  513. }
  514. // PopupMenu.PopupMenuBase overrides
  515. isEmpty() {
  516. return (this.box.get_children().length === 0);
  517. }
  518. _setParent(parent) {
  519. super._setParent(parent);
  520. this._onItemsChanged(this.model, 0, 0, this.model.get_n_items());
  521. }
  522. }