From 4a4929f12918a2ab2fa995542b8d4822f555e991 Mon Sep 17 00:00:00 2001 From: Kirill Elagin Date: Sun, 12 Apr 2020 13:35:56 +0300 Subject: [PATCH 1/3] Add support for configuring system shortcuts This commit implements the necessary functions, but only some of the shortcuts are supported as adding them is a little boring. --- modules/module-list.nix | 1 + modules/system/activation-scripts.nix | 2 + modules/system/shortcuts.nix | 298 ++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 modules/system/shortcuts.nix diff --git a/modules/module-list.nix b/modules/module-list.nix index e87f696ca..ce5eb6fa9 100644 --- a/modules/module-list.nix +++ b/modules/module-list.nix @@ -39,6 +39,7 @@ ./system/patches.nix ./system/shells.nix ./system/startup.nix + ./system/shortcuts.nix ./system/version.nix ./time ./networking diff --git a/modules/system/activation-scripts.nix b/modules/system/activation-scripts.nix index 1495a7b23..875cf35ff 100644 --- a/modules/system/activation-scripts.nix +++ b/modules/system/activation-scripts.nix @@ -109,6 +109,8 @@ in ${cfg.activationScripts.userLaunchd.text} ${cfg.activationScripts.homebrew.text} + ${cfg.activationScripts.shortcuts.text} + ${cfg.activationScripts.postUserActivation.text} exit $_status diff --git a/modules/system/shortcuts.nix b/modules/system/shortcuts.nix new file mode 100644 index 000000000..4499c563b --- /dev/null +++ b/modules/system/shortcuts.nix @@ -0,0 +1,298 @@ +# +# When this module is enabled it will override system shortcuts, +# but only those that it knows about. Defaults are the same as in system. +# +# Only some of the shortcuts have been implemented. Please, add more! +# +# To add a new shortcut, you need to know: +# +# * its numeric id, +# * default value (enabled or not and the key combo). +# +# The shorcuts are stored in `~/Library/Preferences/com.apple.symbolichotkeys.plist`. +# This file is a binary plist, so the first thing you need to do is +# convert it to XML: +# +# * plutil -convert xml1 ~/Library/Preferences/com.apple.symbolichotkeys.plist +# +# Now copy this file somewhere. +# +# Next go to System Preferences → Keyboard → Shortcuts, find the shortcut you +# are interested in, change something in it. Convert the file above to XML again +# and diff with the saved copy. The `key` of the changed entry is the numeric id +# of the shortcut. Press “Restore Defaults” in preferences to find out the default +# key combo. +# +# After you are done, copy your saved plist back and re-login just in case. +# + +{ config, lib, pkgs, ... }: + +let + inherit (lib) attrsets lists options types; + + cfg = config.system.keyboard.shortcuts; + + modNames = attrsets.genAttrs ["shift" "control" "option" "command"] (x: x); + + # NOTE: + # What comes below does not seem to be documented, so these are merely + # reverse-engineered guesses. + + # /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/IOKit.framework/Headers/hidsystem/IOLLEvent.h + modMasks = { + shift = 131072; # 0x00020000 + control = 262144; # 0x00040000 + option = 524288; # 0x00080000 + command = 1048576; # 0x00100000 + }; + + # /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Frameworks/HIToolbox.framework/Headers/Events.h + keyCodes = { + "A" = 0; # 0x00 + "S" = 1; # 0x01 + "D" = 2; # 0x02 + "F" = 3; # 0x03 + "H" = 4; # 0x04 + "G" = 5; # 0x05 + "Z" = 6; # 0x06 + "X" = 7; # 0x07 + "C" = 8; # 0x08 + "V" = 9; # 0x09 + "B" = 11; # 0x0B + "Q" = 12; # 0x0C + "W" = 13; # 0x0D + "E" = 14; # 0x0E + "R" = 15; # 0x0F + "Y" = 16; # 0x10 + "T" = 17; # 0x11 + "1" = 18; # 0x12 + "2" = 19; # 0x13 + "3" = 20; # 0x14 + "4" = 21; # 0x15 + "6" = 22; # 0x16 + "5" = 23; # 0x17 + "=" = 24; # 0x18 + "9" = 25; # 0x19 + "7" = 26; # 0x1A + "-" = 27; # 0x1B + "8" = 28; # 0x1C + "0" = 29; # 0x1D + "]" = 30; # 0x1E + "O" = 31; # 0x1F + "U" = 32; # 0x20 + "[" = 33; # 0x21 + "I" = 34; # 0x22 + "P" = 35; # 0x23 + "L" = 37; # 0x25 + "J" = 38; # 0x26 + "'" = 39; # 0x27 + "K" = 40; # 0x28 + ";" = 41; # 0x29 + "\\" = 42; # 0x2A + "," = 43; # 0x2B + "/" = 44; # 0x2C + "N" = 45; # 0x2D + "M" = 46; # 0x2E + "." = 47; # 0x2F + "`" = 50; # 0x32 + + "return" = 36; # 0x24 + "tab" = 48; # 0x30 + "space" = 49; # 0x31 + "delete" = 51; # 0x33 + "escape" = 53; # 0x35 + "left" = 123; # 0x7B + "right" = 124; # 0x7C + "down" = 125; # 0x7D + "up" = 126; # 0x7E + + "f17" = 64; # 0x40 + "f18" = 79; # 0x4F + "f19" = 80; # 0x50 + "f20" = 90; # 0x5A + "f5" = 96; # 0x60 + "f6" = 97; # 0x61 + "f7" = 98; # 0x62 + "f3" = 99; # 0x63 + "f8" = 100; # 0x64 + "f9" = 101; # 0x65 + "f11" = 103; # 0x67 + "f13" = 105; # 0x69 + "f16" = 106; # 0x6A + "f14" = 107; # 0x6B + "f10" = 109; # 0x6D + "f12" = 111; # 0x6F + "f15" = 113; # 0x71 + "f4" = 118; # 0x76 + "f2" = 120; # 0x78 + "f1" = 122; # 0x7A + + + "keypad." = 65; # 0x41 + "keypad*" = 67; # 0x43 + "keypad+" = 69; # 0x45 + "keypadClear" = 71; # 0x47 + "keypad/" = 75; # 0x4B + "keypadEnter" = 76; # 0x4C + "keypad-" = 78; # 0x4E + "keypad=" = 81; # 0x51 + "keypad0" = 82; # 0x52 + "keypad1" = 83; # 0x53 + "keypad2" = 84; # 0x54 + "keypad3" = 85; # 0x55 + "keypad4" = 86; # 0x56 + "keypad5" = 87; # 0x57 + "keypad6" = 88; # 0x58 + "keypad7" = 89; # 0x59 + "keypad8" = 91; # 0x5B + "keypad9" = 92; # 0x5C + }; + + modsOptions = attrsets.genAttrs (attrsets.attrNames modNames) (modName: + options.mkOption { + type = types.bool; + default = false; + example = true; + description = "Use the `${modName}` modifier in the combination"; + }); + + shortcutOptions = id: enable: mods: key: { + id = options.mkOption { + internal = true; + visible = false; + readOnly = true; + default = id; + type = types.int; + description = "Shortcut numeric key in the plist dict"; + }; + enable = options.mkOption { + type = types.bool; + default = enable; + example = true; + description = "Whether to enable this shortcut"; + }; + mods = options.mkOption { + type = types.submodule { options = modsOptions; }; + default = attrsets.genAttrs mods (_: true); + description = "Modifiers for this combination"; + }; + key = options.mkOption { + type = types.nullOr (types.enum (attrsets.attrNames keyCodes)); + example = "delete"; + description = "Final key of the combination"; + default = key; + apply = val: if val == null then 65535 else attrsets.getAttr val keyCodes; + }; + }; + + mkShortcut = id: description: enable: mods: key: + options.mkOption { + inherit description; + type = types.submodule { options = shortcutOptions id enable mods key; }; + default = {}; + example = options.literalExample '' + { + enable = true; + mods = { + option = true; + control = true; + }; + key = "delete"; + } + ''; + }; + + encodeShortcut = config: { + name = toString config.id; + value = { + enabled = config.enable; + value = { + parameters = [ + 65535 # This one doesn’t seem to be used + config.key + (lib.pipe modMasks [ + (attrsets.filterAttrs (mod: _: attrsets.getAttr mod config.mods)) + attrsets.attrValues + (lists.foldl' lib.add 0) + ]) + ]; + type = "standard"; # No idea what other possible values are + }; + }; + }; + + encodeShortcuts = shortcuts: + builtins.toJSON (builtins.listToAttrs (map encodeShortcut shortcuts)); +in + +{ + options.system.keyboard.shortcuts = with modNames; { + enable = options.mkEnableOption "keyboard shorcuts"; + + launchpadDock = { + dockHiding = mkShortcut 52 "Turn Dock hiding on/off" true [option command] "D"; + showLaunchpad = mkShortcut 160 "Show Launchpad" false [] null; + }; + + # TODO: missionControl + + # TODO: keyboard + + inputSources = { + prev = mkShortcut 60 "Select previous input source" true [control] "space"; + next = mkShortcut 61 "Select next input source" true [control option] "space"; + }; + + # TODO: screenshots + + # TODO: services + + spotlight = { + search = mkShortcut 64 "Show Spotlight search" true [command] "space"; + finderSearch = mkShortcut 65 "Show Finder search" true [option command] "space"; + }; + + # TODO: accessibility + + # TODO: appShortcuts + }; + + config = + let + # The shortcuts plist uses nested dicts and updating those is _really_ + # tricky without having a real programming language at hand. + # In particular, `defaults` can’t make sure the nested types are correct + # and PlistBuddy cannot do “update or create”. + updateShortcuts = pkgs.writeScript "updateShortcuts.py" '' + #!${pkgs.python3.interpreter} + + import json + from os.path import expanduser + import plistlib + import sys + + path = expanduser('~/Library/Preferences/com.apple.symbolichotkeys.plist') + + with open(path, 'rb') as f: + plist = plistlib.load(f) + + with open(sys.argv[1], 'rb') as f: + updates = json.load(f) + + plist['AppleSymbolicHotKeys'].update(updates) + + with open(path, 'wb') as f: + plistlib.dump(plist, f) + ''; + shortcutsSpec = pkgs.writeTextFile { + name = "shortcutsSpec.json"; + text = encodeShortcuts (attrsets.collect (s: s ? id) cfg); + }; + in { + system.activationScripts.shortcuts.text = lib.optionalString cfg.enable '' + # Configuring system shortcuts + "${updateShortcuts}" "${shortcutsSpec}" + ''; + }; +} From 811c32cac848b80fb184ab1c5b3c0d5536d66294 Mon Sep 17 00:00:00 2001 From: Jun Matsushita Date: Tue, 20 Jun 2023 11:56:17 +0200 Subject: [PATCH 2/3] Fix deprecated litteralExample --- modules/system/shortcuts.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/system/shortcuts.nix b/modules/system/shortcuts.nix index 4499c563b..e7e50d6df 100644 --- a/modules/system/shortcuts.nix +++ b/modules/system/shortcuts.nix @@ -191,7 +191,7 @@ let inherit description; type = types.submodule { options = shortcutOptions id enable mods key; }; default = {}; - example = options.literalExample '' + example = options.literalExpression '' { enable = true; mods = { From b761f7bd80d615bab9a5049c8600be535f73e963 Mon Sep 17 00:00:00 2001 From: Jun Matsushita Date: Tue, 20 Jun 2023 15:07:29 +0200 Subject: [PATCH 3/3] Fix ascii code param and finesse activation --- modules/system/shortcuts.nix | 50 +++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/modules/system/shortcuts.nix b/modules/system/shortcuts.nix index e7e50d6df..5f3d9cfe3 100644 --- a/modules/system/shortcuts.nix +++ b/modules/system/shortcuts.nix @@ -203,13 +203,46 @@ let ''; }; - encodeShortcut = config: { + encodeShortcut = config: let + reverseLookup = val: let + keys = builtins.attrNames keyCodes; + matchingKeys = builtins.filter (k: keyCodes.${k} == val) keys; + in + if matchingKeys == [] then null else builtins.head matchingKeys; + + # TODO: this is brittle, probably incorrect and based on this comment https://stackoverflow.com/a/23318003 + # > It is the ascii code of the letter on the key, or -1 (65535) if there is no ascii code. Note that letters are lowercase, so D is 100 (lowercase d). + # > Sometimes a key that would normally have an ascii code uses 65535 instead. This appears to happen when the control key modifier is used, for example with hot keys for specific spaces. + keyCodeToAscii = config : + let + code = config.key; + mods = config.mods; + isAsciish = code : (code >= 0 && code < 36) + || (code >= 37 && code < 48) + || (code == 50 ); + in + # Apart from the control modifier, it seems that for instance command option d is 65535 + # deal with ascii-ish keycodes and convert them to an ascii code. + if (!mods.control && !(mods.command && mods.option) && isAsciish code) then + (lib.strings.charToInt (lib.strings.toLower (reverseLookup code))) + # "return" + else if (!mods.control && code == 36) then 10 + # "tab" + else if (!mods.control && code == 48) then 9 + # "space" + else if (!mods.control && code == 49) then 32 + # "delete" + else if (!mods.control && code == 51) then 127 + # assume (probably incorrectly) that the rest map to the magic code 65535 + else 65535; + + in { name = toString config.id; value = { enabled = config.enable; value = { parameters = [ - 65535 # This one doesn’t seem to be used + (keyCodeToAscii config) config.key (lib.pipe modMasks [ (attrsets.filterAttrs (mod: _: attrsets.getAttr mod config.mods)) @@ -230,6 +263,9 @@ in options.system.keyboard.shortcuts = with modNames; { enable = options.mkEnableOption "keyboard shorcuts"; + # the otherwise undocumented complete list of magic numbers for system hotkeys + # is here https://gist.github.com/mkhl/455002#file-ctrl-f1-c-L12 + launchpadDock = { dockHiding = mkShortcut 52 "Turn Dock hiding on/off" true [option command] "D"; showLaunchpad = mkShortcut 160 "Show Launchpad" false [] null; @@ -249,7 +285,9 @@ in # TODO: services spotlight = { - search = mkShortcut 64 "Show Spotlight search" true [command] "space"; + # search = mkShortcut 64 "Show Spotlight search" true [command] "space"; + # until I learn how to override this correctly + search = mkShortcut 64 "Show Spotlight search" false [command] "space"; finderSearch = mkShortcut 65 "Show Finder search" true [option command] "space"; }; @@ -293,6 +331,12 @@ in system.activationScripts.shortcuts.text = lib.optionalString cfg.enable '' # Configuring system shortcuts "${updateShortcuts}" "${shortcutsSpec}" + + # https://zameermanji.com/blog/2021/6/8/applying-com-apple-symbolichotkeys-changes-instantaneously/ + # write to a (hopefully also in the future) unused magic number, so that some hidden state gets updated + # and activateSettings will reload the plist + defaults write com.apple.symbolichotkeys.plist AppleSymbolicHotKeys -dict-add 999 "enabled" + /System/Library/PrivateFrameworks/SystemAdministration.framework/Resources/activateSettings -u ''; }; }