gmenu.js 19 KB

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