diff --git a/data/dbus-interfaces/org.gnome.Shell.xml b/data/dbus-interfaces/org.gnome.Shell.xml
index 3ffb5c374..bc50f9569 100644
--- a/data/dbus-interfaces/org.gnome.Shell.xml
+++ b/data/dbus-interfaces/org.gnome.Shell.xml
@@ -17,9 +17,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js
index 3b99224ce..a85d3d518 100644
--- a/js/ui/shellDBus.js
+++ b/js/ui/shellDBus.js
@@ -18,6 +18,18 @@ import {ControlsState} from './overviewControls.js';
const GnomeShellIface = loadInterfaceXML('org.gnome.Shell');
const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
+
+function dbg_(v) {
+ console.error(v);
+ return v;
+}
+
+
+const eqSet = (xs, ys) =>
+ xs.size === ys.size &&
+ [...xs].every(x => ys.has(x));
+
+
export class GnomeShell {
constructor() {
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellIface, this);
@@ -34,10 +46,24 @@ export class GnomeShell {
this._grabbedAccelerators = new Map();
this._grabbers = new Map();
+ /* session: string → [
+ * sender: string,
+ * [(
+ * name: string,
+ * shortcuts: [trigger: string, action: int],
+ * )]
+ * ]
+ */
+ this._accelerators = new Map();
+
global.display.connect('accelerator-activated',
(display, action, device, timestamp) => {
this._emitAcceleratorActivated(action, device, timestamp);
});
+ global.display.connect('accelerator-deactivated',
+ (display, action, device, timestamp) => {
+ this._emitAcceleratorDeactivated(action, device, timestamp);
+ });
this._cachedOverviewVisible = false;
Main.overview.connect('showing',
@@ -194,6 +220,66 @@ export class GnomeShell {
invocation.return_value(null);
}
+ async BindShortcutsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let [groupId, shortcuts] = params;
+
+ let [remainingShortcuts, changed] = this._bindShortcuts(groupId, shortcuts, invocation.get_sender());
+
+ invocation.return_value(GLib.Variant.new(
+ '(a{sv}u)',
+ [
+ {'shortcuts': GLib.Variant.new('a(sa{sv})', remainingShortcuts)},
+ changed,
+ ]
+ ));
+ }
+
+
+ async UnbindShortcutsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+ let [groupId] = params;
+
+ this._bindShortcuts(groupId, [], invocation.get_sender());
+
+ invocation.return_value(null);
+ }
+
+
+ async ListShortcutsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let [groupId] = params;
+ if (this._accelerators.has(groupId)) {
+ invocation.return_value(GLib.Variant.new(
+ '(a{sv})',
+ [{'shortcuts': GLib.Variant.new('a(sa{sv})', this._listShortcuts(groupId))}]
+ ));
+ } else {
+ invocation.return_error_literal(
+ Gio.DBusError,
+ Gio.DBusError.INVALID_ARGS,
+ 'No such group'
+ );
+ }
+ }
+
async GrabAcceleratorAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
@@ -272,28 +358,206 @@ export class GnomeShell {
invocation.return_value(null);
}
- _emitAcceleratorActivated(action, device, timestamp) {
+ _bindShortcuts(groupId, shortcuts, sender) {
+ let cleanedShortcuts = shortcuts.map(([name, info]) => [
+ name,
+ info['shortcuts'] ? info['shortcuts'].unpack() : [],
+ info['description'] ? info['description'].unpack() : undefined,
+ ]);
+
+ let existing = [];
+
+ if (this._accelerators.has(groupId)) {
+ let [sender_, ex] = this._accelerators.get(groupId);
+ existing = ex;
+ }
+
+ // MDN says the last key is the one preserved in the final Map.
+ let descriptions = new Map([
+ ...existing.map(([name, _, desc]) => [name, desc]),
+ ...cleanedShortcuts.map(([name, _, desc]) => [name, desc]),
+ ]);
+
+ let requested = new Map(cleanedShortcuts);
+ // Shortcuts to (re)bind.
+ let changed = [];
+ // The following 2 mark things to unbind.
+ // Names may get immediately be bound again.
+ let removedNames = [];
+
+ for (let [name, actions, desc_] of existing) {
+ if (requested.has(name)) {
+ let reqTriggers = requested.get(name);
+ let triggers = actions.map(([trigger, _]) => trigger);
+ const unpack = arr => arr.map(t => t.unpack());
+ if (!eqSet(new Set(unpack(reqTriggers)), new Set(unpack(triggers)))) {
+ changed.push([name, reqTriggers]);
+ removedNames.push(name);
+ }
+ } else {
+ removedNames.push(name);
+ }
+ }
+
+ let present = new Map(existing);
+ for (let [name, triggers, desc_] of cleanedShortcuts) {
+ if (!present.has(name))
+ changed.push([name, triggers]);
+ }
+
+ let addedShortcuts = changed.map(([name, triggers]) => [name, triggers, descriptions.get(name)]);
+
+ // FIXME: is this a race condition? Can input events be processed between these calls?
+ if (removedNames.length > 0)
+ this._unbindShortcuts(groupId, removedNames);
+
+ this._bindShortcutsForSender(groupId, addedShortcuts, sender);
+
+ let kept = new Map(changed);
+ let removedIds = removedNames.filter(name => !kept.has(name));
+
+ let remainingShortcuts = this._listShortcuts(groupId);
+ if (remainingShortcuts.length === 0)
+ this._accelerators.delete(groupId);
+ return [remainingShortcuts, 0 + (changed.length + removedIds.length > 0)];
+ }
+
+ // Each shortcut is being bound separately and as a whole.
+ // If there's an existing bound shortcut with the same name,
+ // the new one will not take effect.
+ _bindShortcutsForSender(groupId, shortcuts, sender) {
+ let [sender_, existing] = this._accelerators.get(groupId) || [null, []];
+
+ let existingNames = new Set(existing.map(([name, _actions, _desc]) => name));
+
+ let addedShortcuts = [];
+ for (let [name, triggers, description] of shortcuts) {
+ if (existingNames.has(name)) {
+ console.error('Tried to bind over an existing shortcut', name, 'in group', groupId);
+ continue;
+ }
+ let actions = [];
+ for (let trigger of triggers) {
+ let bindingAction = this._grabAcceleratorForSender(
+ trigger.unpack(),
+ Shell.ActionMode.ALL,
+ Meta.KeyBindingFlags.TRIGGER_RELEASE,
+ sender
+ );
+ actions.push([trigger, bindingAction]);
+ }
+ addedShortcuts.push([name, actions, description]);
+ }
+
+ this._accelerators.set(groupId, [sender, existing.concat(addedShortcuts)]);
+ }
+
+ _listShortcuts(groupId) {
+ let [sender_, rawShortcuts] = this._accelerators.get(groupId);
+
+ function shortcutToDbus(name, triggers, description) {
+ let info = {'trigger_description': GLib.Variant.new('as', triggers.map(t => t.unpack()))};
+ if (description !== undefined)
+ info.description = GLib.Variant.new('s', description);
+
+ return [name, info];
+ }
+
+ return rawShortcuts.map(
+ ([name, shortcuts, description]) => shortcutToDbus(
+ name,
+ shortcuts.map(([trigger, _]) => trigger),
+ description
+ )
+ );
+ }
+
+ _unbindShortcuts(groupId, names) {
+ let namesSet = new Set(names);
+ let [sender, rawShortcuts] = this._accelerators.get(groupId);
+ let ungrabFailed = 0;
+ let newRaw = [];
+
+ for (let [name, shortcuts, desc] of rawShortcuts) {
+ if (namesSet.has(name)) {
+ for (let [trigger_, action] of shortcuts)
+ ungrabFailed += this._ungrabAcceleratorForSender(action, sender) ? 0 : 1;
+ } else {
+ newRaw.push([name, shortcuts, desc]);
+ }
+ }
+
+ if (ungrabFailed)
+ console.error(`Failed to remove ${ungrabFailed} shortcuts for ${groupId}`);
+
+
+ if (newRaw.length > 0)
+ this._accelerators.set(groupId, [sender, newRaw]);
+ else
+ this._accelerators.delete(groupId);
+
+ console.warn(`Some shortcuts were unbound in group ${groupId}`);
+ }
+
+ _emitAcceleratorTriggered(action, device, timestamp, activated) {
let destination = this._grabbedAccelerators.get(action);
if (!destination)
return;
let connection = this._dbusImpl.get_connection();
let info = this._dbusImpl.get_info();
- let params = {
- 'timestamp': GLib.Variant.new('u', timestamp),
- 'action-mode': GLib.Variant.new('u', Main.actionMode),
- };
+ let shortcutData;
+ for (let [group, data] of this._accelerators) {
+ let [sender, rawShortcuts] = data;
+ if (sender === destination) {
+ for (let [name, shortcuts] of rawShortcuts) {
+ for (let [_, savedAction] of shortcuts) {
+ if (action === savedAction) {
+ shortcutData = [group, name];
+ break;
+ }
+ }
+ }
+ }
+ }
- let deviceNode = device.get_device_node();
- if (deviceNode)
- params['device-node'] = GLib.Variant.new('s', deviceNode);
+ if (shortcutData === undefined) {
+ if (!activated)
+ return;
- connection.emit_signal(
- destination,
- this._dbusImpl.get_object_path(),
- info?.name ?? null,
- 'AcceleratorActivated',
- GLib.Variant.new('(ua{sv})', [action, params]));
+ let params = {
+ 'timestamp': GLib.Variant.new('u', timestamp),
+ 'action-mode': GLib.Variant.new('u', Main.actionMode),
+ };
+
+ let deviceNode = device.get_device_node();
+ if (deviceNode)
+ params['device-node'] = GLib.Variant.new('s', deviceNode);
+
+ connection.emit_signal(
+ destination,
+ this._dbusImpl.get_object_path(),
+ info?.name ?? null,
+ 'AcceleratorActivated',
+ GLib.Variant.new('(ua{sv})', [action, params]));
+ } else {
+ let [group, name] = shortcutData;
+
+ connection.emit_signal(
+ destination,
+ this._dbusImpl.get_object_path(),
+ info?.name ?? null,
+ activated ? 'Activated' : 'Deactivated',
+ GLib.Variant.new('(sst)', [group, name, timestamp]));
+ }
+ }
+
+ _emitAcceleratorActivated(action, device, timestamp) {
+ return this._emitAcceleratorTriggered(action, device, timestamp, true);
+ }
+
+ _emitAcceleratorDeactivated(action, device, timestamp) {
+ return this._emitAcceleratorTriggered(action, device, timestamp, false);
}
_grabAcceleratorForSender(accelerator, modeFlags, grabFlags, sender) {
@@ -337,6 +601,10 @@ export class GnomeShell {
if (sender === name)
this._ungrabAccelerator(action);
}
+ for (let [session, [sender, data_]] of this._accelerators) {
+ if (sender === name)
+ this._accelerators.delete(session);
+ }
Gio.bus_unwatch_name(this._grabbers.get(name));
this._grabbers.delete(name);
}