const Signals = imports.signals; const Main = imports.ui.main; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const Lang = imports.lang; const Util = imports.misc.util; const Meta = imports.gi.Meta; const Cinnamon = imports.gi.Cinnamon; const AppletManager = imports.ui.appletManager; const DeskletManager = imports.ui.deskletManager; const MK = imports.gi.CDesktopEnums.MediaKeyType; const CinnamonDesktop = imports.gi.CinnamonDesktop; const CUSTOM_KEYS_PARENT_SCHEMA = "org.cinnamon.desktop.keybindings"; const CUSTOM_KEYS_BASENAME = "/org/cinnamon/desktop/keybindings/custom-keybindings"; const CUSTOM_KEYS_SCHEMA = "org.cinnamon.desktop.keybindings.custom-keybinding"; const MEDIA_KEYS_SCHEMA = "org.cinnamon.desktop.keybindings.media-keys"; const MEDIA_KEYS = [ // Volume/audio { key: MK.MUTE, mode: Cinnamon.ActionMode.ALL }, { key: MK.MUTE_QUIET, mode: Cinnamon.ActionMode.ALL }, { key: MK.VOLUME_UP, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.VOLUME_UP_QUIET, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.VOLUME_DOWN, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.VOLUME_DOWN_QUIET, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.MIC_MUTE, mode: Cinnamon.ActionMode.ALL }, // Disc/media { key: MK.EJECT, mode: Cinnamon.ActionMode.ALL }, { key: MK.MEDIA, mode: Cinnamon.ActionMode.ALL }, // Playback { key: MK.PLAY, mode: Cinnamon.ActionMode.ALL }, { key: MK.PAUSE, mode: Cinnamon.ActionMode.ALL }, { key: MK.STOP, mode: Cinnamon.ActionMode.ALL }, { key: MK.PREVIOUS, mode: Cinnamon.ActionMode.ALL }, { key: MK.NEXT, mode: Cinnamon.ActionMode.ALL }, { key: MK.REWIND, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.FORWARD, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.REPEAT, mode: Cinnamon.ActionMode.ALL }, { key: MK.RANDOM, mode: Cinnamon.ActionMode.ALL }, // Screenshots { key: MK.SCREENSHOT, mode: Cinnamon.ActionMode.ALL }, { key: MK.SCREENSHOT_CLIP, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.WINDOW_SCREENSHOT, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.WINDOW_SCREENSHOT_CLIP, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.AREA_SCREENSHOT, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.AREA_SCREENSHOT_CLIP, mode: Cinnamon.ActionMode.NORMAL }, // Touchpad { key: MK.TOUCHPAD, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.TOUCHPAD_ON, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.TOUCHPAD_OFF, mode: Cinnamon.ActionMode.NORMAL }, // Session { key: MK.LOGOUT, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.SHUTDOWN, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.SUSPEND, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.HIBERNATE, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.SCREENSAVER, mode: Cinnamon.ActionMode.NORMAL }, // App launchers { key: MK.HOME, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.CALCULATOR, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.SEARCH, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.EMAIL, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.HELP, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.TERMINAL, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.WWW, mode: Cinnamon.ActionMode.NORMAL }, // Display { key: MK.ROTATE_VIDEO_LOCK, mode: Cinnamon.ActionMode.ALL }, // Accessibility { key: MK.SCREENREADER, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.ON_SCREEN_KEYBOARD, mode: Cinnamon.ActionMode.NORMAL | Cinnamon.ActionMode.POPUP | Cinnamon.ActionMode.UNLOCK_SCREEN | Cinnamon.ActionMode.SYSTEM_MODAL }, { key: MK.INCREASE_TEXT, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.DECREASE_TEXT, mode: Cinnamon.ActionMode.NORMAL }, { key: MK.TOGGLE_CONTRAST, mode: Cinnamon.ActionMode.NORMAL }, // Brightness { key: MK.SCREEN_BRIGHTNESS_UP, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.SCREEN_BRIGHTNESS_DOWN, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.KEYBOARD_BRIGHTNESS_UP, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.KEYBOARD_BRIGHTNESS_DOWN, mode: Cinnamon.ActionMode.ALL, repeatable: true }, { key: MK.KEYBOARD_BRIGHTNESS_TOGGLE, mode: Cinnamon.ActionMode.ALL }, // Battery { key: MK.BATTERY, mode: Cinnamon.ActionMode.NORMAL }, ]; const iface = "\ \ \ \ \ \ \ \ "; const proxy = Gio.DBusProxy.makeProxyWrapper(iface); function KeybindingManager() { this._init(); } KeybindingManager.prototype = { _init: function() { this._proxy = new proxy(Gio.DBus.session, 'org.cinnamon.SettingsDaemon.KeybindingHandler', '/org/cinnamon/SettingsDaemon/KeybindingHandler'); /* Keep track of bindings so we can a) check if they've change (and avoid the work * if the haven't), b) handle the callbacks when the keystrokes are captured by * Main._stageEventHandler. * * This dict will contain [name, bindings, callback] and keyed on the id returned by * add_custom_keybinding. */ this.bindings = new Map(); this.applet_bindings = new Map(); this.kb_schema = Gio.Settings.new(CUSTOM_KEYS_PARENT_SCHEMA); this.setup_custom_keybindings(); this.kb_schema.connect("changed::custom-list", Lang.bind(this, this.on_customs_changed)); this.media_key_settings = new Gio.Settings({ schema_id: MEDIA_KEYS_SCHEMA }); this.media_key_settings.connect("changed", Lang.bind(this, this.setup_media_keys)); this.setup_media_keys(); }, on_customs_changed: function(settings, key) { this.remove_custom_keybindings(); this.setup_custom_keybindings(); }, addHotKey: function(name, bindings_string, callback, flags, allowedModes) { if (!bindings_string) return false; return this.addHotKeyArray(name, bindings_string.split("::"), callback, flags, allowedModes); }, _makeXletKey: function(xlet, name, binding) { return `${xlet._uuid}::${name}::${binding}`; }, _uuidFromXletKey: function(xlet_key) { return xlet_key.split("::")[0]; }, /* Menu applet example * * uuid: menu@cinnamon.org * binding name: overlay-key * instances: * 49: super-l, super-r * 52: super-l, ctrl-shift-f7 * * is in applet_bindings as: * * { * "menu@cinnamon.org::overlay-key:super-l" : { * "49": callback49, * "52": callback52 * }, * "menu@cinnamon.org::overlay-key:super-r" : { * "49": callback49 * }, * "menu@cinnamon.org::overlay-key:ctrl-shift-f7" : { * "52": callback52 * } * } */ addXletHotKey: function(xlet, name, bindings_string, callback, flags, allowedModes) { this._removeMatchingXletBindings(xlet, name); if (!bindings_string) return false; let xlet_set = null; const instanceId = xlet.instance_id || 0; // extensions == undefined const binding_array = bindings_string.split("::"); for (const binding of binding_array) { const xlet_key = this._makeXletKey(xlet, name, binding); xlet_set = this.applet_bindings.get(xlet_key); if (xlet_set === undefined) { xlet_set = new Map([ ["commitTimeoutId", 0] ]); this.applet_bindings.set(xlet_key, xlet_set); } xlet_set.set(instanceId, callback); this._queueCommitXletHotKey(xlet_key, binding, xlet_set, flags, allowedModes); } }, _removeMatchingXletBindings: function(xlet, name) { // This sucks, but since the individual binding string is part of the name // name we send to muffin, we can't just call display.remove_keybinding(name), // and need to iterate thru the list finding our matching uuid and instance ids. const key_prefix = `${xlet._uuid}::${name}::`; const instanceId = xlet.instance_id || 0; const iter = this.applet_bindings.keys(); for (const xlet_key of iter) { if (xlet_key.startsWith(key_prefix)) { const xlet_set = this.applet_bindings.get(xlet_key); if (xlet_set.has(instanceId)) { xlet_set.delete(instanceId); if (xlet_set.size === 1) { // only commitTimeoutId left this.applet_bindings.delete(xlet_key); this.removeHotKey(xlet_key); } } } } }, _xletCallback: function(lookup_key, display, window, kb, action) { const xlet_set = this.applet_bindings.get(lookup_key); if (!xlet_set) { return; } /* This should catch extensions also. The minimum size is 2 - 1 will be a * binding, plus the commitTimeoutId. */ if (xlet_set.size === 2) { const iter = xlet_set.keys(); for (const instanceId of iter) { if (instanceId === "commitTimeoutId") { continue; } const callback = xlet_set.get(instanceId); callback(display, window, kb, action); break; } return; } const iter = xlet_set.keys(); const uuid = this._uuidFromXletKey(lookup_key); const currentMonitor = Main.layoutManager.currentMonitor.index; let xlet = null let xletMonitor = 0; let primary_callback = null; let current_callback = null; for (instanceId of iter) { current_callback = xlet_set.get(instanceId); xlet = AppletManager.get_object_for_uuid(uuid, instanceId); if (!xlet) { xlet = DeskletManager.get_object_for_uuid(uuid, instanceId); } if (xlet) { const actor = xlet.actor; if (actor) { xletMonitor = Main.layoutManager.findMonitorIndexForActor(actor); if (xletMonitor === Main.layoutManager.primaryMonitor.index) { primary_callback = current_callback; } if (xletMonitor == currentMonitor) { current_callback(display, window, kb, action); return; } } } } // No match... more monitors than instances? Prefer the primary monitor's if we encountered it. if (primary_callback) { primary_callback(display, window, kb, action); } else { // Fallback to the last one we looked at otherwise. current_callback(display, window, kb, action); } }, removeXletHotKey: function(xlet, name) { this._removeMatchingXletBindings(xlet, name); }, _queueCommitXletHotKey: function(xlet_key, binding, xlet_set, flags, allowedModes) { let id = xlet_set.get("commitTimeoutId") ?? 0; if (id > 0) { GLib.source_remove(id); } id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.addHotKeyArray(xlet_key, [binding], this._xletCallback.bind(this, xlet_key), flags, allowedModes); xlet_set.set("commitTimeoutId", 0); return GLib.SOURCE_REMOVE; }); xlet_set.set("commitTimeoutId", id); }, _lookupEntry: function(name) { let found = 0; for (const action_id of this.bindings.keys()) { let entry = this.bindings.get(action_id); if (entry !== undefined && entry.name === name) { return [action_id, entry]; } } return [Meta.KeyBindingAction.NONE, undefined]; }, getBindingById: function(action_id) { return this.bindings.get(action_id); }, addHotKeyArray: function(name, bindings, callback, flags=Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, allowedModes=Cinnamon.ActionMode.NORMAL | Cinnamon.ActionMode.POPUP) { let [existing_action_id, entry] = this._lookupEntry(name); if (entry !== undefined) { if (entry.bindings.toString() === bindings.toString()) { return true; } global.display.remove_keybinding(name); this.bindings.delete(existing_action_id); } if (!bindings) { global.logError("Missing bindings array for keybinding: " + name); return false; } let empty = true; for (let i = 0; empty && (i < bindings.length); i++) { empty = bindings[i].toString().trim() == ""; } if (empty) { return true; } action_id = global.display.add_custom_keybinding_full(name, bindings, flags, callback); // log(`set keybinding: ${name}, bindings: ${bindings}, flags: ${flags}, allowedModes: ${allowedModes} - action id: ${action_id}`); if (action_id === Meta.KeyBindingAction.NONE) { global.logError("Warning, unable to bind hotkey with name '" + name + "'. The selected keybinding could already be in use."); return false; } this.bindings.set(action_id, { "name" : name, "bindings" : bindings, "callback" : callback, "allowedModes": allowedModes }); return true; }, removeHotKey: function(name) { let [action_id, entry] = this._lookupEntry(name); if (entry === undefined) { return; } global.display.remove_keybinding(name); this.bindings.delete(action_id); }, setup_custom_keybindings: function() { let list = this.kb_schema.get_strv("custom-list"); for (let i = 0; i < list.length; i++) { if (list[i] === "__dummy__") { continue; } let custom_path = CUSTOM_KEYS_BASENAME + "/" + list[i] + "/"; let schema = Gio.Settings.new_with_path(CUSTOM_KEYS_SCHEMA, custom_path); let command = schema.get_string("command"); let binding = schema.get_strv("binding"); let name = list[i]; this.addHotKeyArray(name, binding, Lang.bind(this, function() { Util.spawnCommandLine(command); })) } }, remove_custom_keybindings: function() { for (const action_id of this.bindings.keys()) { name = this.bindings.get(action_id).name; if (name && name.indexOf("custom") > -1) { global.display.remove_keybinding(name); this.bindings.delete(action_id); } } }, setup_media_keys: function() { for (const mk of MEDIA_KEYS) { let flags = mk.repeatable ? Meta.KeyBindingFlags.NONE : Meta.KeyBindingFlags.IGNORE_AUTOREPEAT; let bindings = this.media_key_settings.get_strv( CinnamonDesktop.desktop_get_media_key_string(mk.key) ); this.addHotKeyArray( "media-keys-" + mk.key.toString(), bindings, Lang.bind(this, this.on_media_key_pressed, mk.key), flags, mk.mode ); } return true; }, on_media_key_pressed: function(display, window, kb, action) { let [, entry] = this._lookupEntry("media-keys-" + action.toString()); if (Main._shouldFilterKeybinding(entry)) return; // The screensaver key always goes through the controller - it resolves // custom-screensaver-command, internal shield, and external daemon modes. if (action === MK.SCREENSAVER) { GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { Main.screensaverController.lockScreen(false); return GLib.SOURCE_REMOVE; }); return; } this._proxy.HandleKeybindingRemote(action); }, _onBuiltinKeyPressed: function(display, window, binding, actionId) { let entry = this.bindings.get(actionId); if (entry === undefined || Main._shouldFilterKeybinding(entry)) return; entry.callback(display, window, binding); }, setBuiltinHandler: function(name, actionId, callback, allowedModes=Cinnamon.ActionMode.NORMAL) { Meta.keybindings_set_custom_handler(name, (display, window, binding) => this._onBuiltinKeyPressed(display, window, binding, actionId) ); this.bindings.set(actionId, { name: name, bindings: [], callback: callback, allowedModes: allowedModes }); }, invoke_keybinding_action_by_id: function(id, mask=0) { const entry = this.bindings.get(id); if (entry !== undefined) { entry.callback(global.display, global.display.get_focus_window(), { get_name: () => entry.name, get_mask: () => mask, is_reversed: () => entry.name.endsWith('-backward') }); } } }; Signals.addSignalMethods(KeybindingManager.prototype);