From 8dc9812c7bb54eb8cf4816155050bc7427bb5fea Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Tue, 1 May 2018 20:58:16 -0400 Subject: [PATCH 01/17] Update karabiner.json --- karabiner/karabiner.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/karabiner/karabiner.json b/karabiner/karabiner.json index 5ee60c33..7dceb2bc 100644 --- a/karabiner/karabiner.json +++ b/karabiner/karabiner.json @@ -8,8 +8,10 @@ { "complex_modifications": { "parameters": { + "basic.simultaneous_threshold_milliseconds": 50, "basic.to_delayed_action_delay_milliseconds": 500, - "basic.to_if_alone_timeout_milliseconds": 1000 + "basic.to_if_alone_timeout_milliseconds": 1000, + "basic.to_if_held_down_threshold_milliseconds": 500 }, "rules": [ { @@ -219,6 +221,7 @@ ], "virtual_hid_keyboard": { "caps_lock_delay_milliseconds": 0, + "country_code": 0, "keyboard_type": "ansi" } } From b6e7d24ff0366275873d5c918160147c09f00231 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Tue, 1 May 2018 20:58:16 -0400 Subject: [PATCH 02/17] Implement Hyper Mode exclusively with Hammerspoon - Hyper Mode is now activated by simultaneously pressing the 'k' and 'l' keys and holding them down. This is similar to activating (S)uper (D)uper Mode by simultaneously pressing the 's' and 'd' keys and holding them down. - Hyper Mode is no longer activated by pressing the right option key. - The format of the Hyper Mode key-to-app mappings has changed. See hammerspoon/hyper-apps-defaults.lua. --- hammerspoon/hyper-apps-defaults.lua | 18 +-- hammerspoon/hyper.lua | 35 ++++- hammerspoon/simultaneous-keypress-modal.lua | 151 ++++++++++++++++++++ karabiner/karabiner.json | 29 +--- 4 files changed, 189 insertions(+), 44 deletions(-) create mode 100644 hammerspoon/simultaneous-keypress-modal.lua diff --git a/hammerspoon/hyper-apps-defaults.lua b/hammerspoon/hyper-apps-defaults.lua index ea4bdd7d..31079394 100644 --- a/hammerspoon/hyper-apps-defaults.lua +++ b/hammerspoon/hyper-apps-defaults.lua @@ -4,13 +4,13 @@ -- this file, save it as `hyper-apps.lua`, and edit the table below to configure -- your preferred shortcuts. return { - { 'a', 'iTunes' }, -- "A" for "Apple Music" - { 'b', 'Google Chrome' }, -- "B" for "Browser" - { 'c', 'Slack' }, -- "C for "Chat" - { 'd', 'Remember The Milk' }, -- "D" for "Do!" ... or "Done!" - { 'e', 'Atom' }, -- "E" for "Editor" - { 'f', 'Finder' }, -- "F" for "Finder" - { 'g', 'Mailplane 3' }, -- "G" for "Gmail" - { 's', 'Slack' }, -- "S" for "Slack" - { 't', 'iTerm' }, -- "T" for "Terminal" + a = 'iTunes', -- "A" for "Apple Music" + b = 'Google Chrome', -- "B" for "Browser" + c = 'Slack', -- "C for "Chat" + d = 'Remember The Milk', -- "D" for "Do!" ... or "Done!" + e = 'Atom', -- "E" for "Editor" + f = 'Finder', -- "F" for "Finder" + g = 'Mailplane 3', -- "G" for "Gmail" + s = 'Slack', -- "S" for "Slack" + t = 'iTerm', -- "T" for "Terminal" } diff --git a/hammerspoon/hyper.lua b/hammerspoon/hyper.lua index 7f99fe8c..ccc775c4 100644 --- a/hammerspoon/hyper.lua +++ b/hammerspoon/hyper.lua @@ -1,13 +1,30 @@ -local status, hyperModeAppMappings = pcall(require, 'keyboard.hyper-apps') +local eventtap = hs.eventtap +local eventTypes = hs.eventtap.event.types +local simultaneousKeypressModal = require('keyboard.simultaneous-keypress-modal') +-- Look for custom Hyper Mode app mappings. If there are none, then use the +-- default mappings. +local status, hyperModeAppMappings = pcall(require, 'keyboard.hyper-apps') if not status then hyperModeAppMappings = require('keyboard.hyper-apps-defaults') end -for i, mapping in ipairs(hyperModeAppMappings) do - local key = mapping[1] - local app = mapping[2] - hs.hotkey.bind({'shift', 'ctrl', 'alt', 'cmd'}, key, function() +-- Create a hotkey that will enter Hyper Mode when 'k' and 'l' are pressed +-- simultaneously. +hyperMode = simultaneousKeypressModal.new('Hyper Mode', 'k', 'l') + +-------------------------------------------------------------------------------- +-- Watch for key-down events in Hyper Mode, and trigger app associated with the +-- given key. +-------------------------------------------------------------------------------- +hyperModeKeyListener = eventtap.new({ eventTypes.keyDown }, function(event) + if not hyperMode:isActive() then + return false + end + + local app = hyperModeAppMappings[event:getCharacters(true):lower()] + + if app then if (type(app) == 'string') then hs.application.open(app) elseif (type(app) == 'function') then @@ -15,5 +32,9 @@ for i, mapping in ipairs(hyperModeAppMappings) do else hs.logger.new('hyper'):e('Invalid mapping for Hyper +', key) end - end) -end + return true + end +end):start() + +--- We're all set. Now we just enable Hyper Mode and get to work. 👔 +hyperMode:enable() diff --git a/hammerspoon/simultaneous-keypress-modal.lua b/hammerspoon/simultaneous-keypress-modal.lua new file mode 100644 index 00000000..7fe1a7e6 --- /dev/null +++ b/hammerspoon/simultaneous-keypress-modal.lua @@ -0,0 +1,151 @@ +local eventtap = hs.eventtap +local eventTypes = hs.eventtap.event.types +local message = require('keyboard.status-message') + +local modal={} + +modal.new = function(name, key1, key2) + local instance = { + -- The two keys that must be pressed simultaneously to enter the modal state + key1 = key1, + key2 = key2, + + -- If key1 and key2 are *both* pressed within this time period, consider + -- this to mean that they've been pressed simultaneously, and therefore we + -- should enter the modal state. + maxTimeBetweenSimultaneousKeypresses = 0.04, -- 40 milliseconds + + -- The status message to display when the modal state is active + statusMessage = message.new(name), + + -- Resets object to initial state + reset = function(self) + self.active = false + self.isKey1Down = false + self.isKey2Down = false + self.ignoreNextKey1 = false + self.ignoreNextKey2 = false + self.statusMessage:hide() + + return self + end, + + -- Are we in the modal state? + isActive = function(self) + return self.active + end, + + -- Enters the modal state + -- + -- Mimics hs.hotkey.modal:enter() + enter = function(self) + if self.active then return end + + self.statusMessage:show() + self.active = true + self:entered() + end, + + -- Exits the modal state + -- + -- Mimics hs.hotkey.modal:exit() + exit = function(self) + if not self.active then return end + + self:reset() + self:exited() + end, + + -- Optional callback for when a modal state is entered + -- + -- Mimics hs.hotkey.modal:entered() + entered = function(self) end, + + -- Optional callback for when a modal state is exited + -- + -- Mimics hs.hotkey.modal:exited() + exited = function(self) end, + + -- Enable the simultaneous keypress hotkey + -- + -- Mimics hs.hotkey:enable() + enable = function(self) + self.activationListener:start() + self.deactivationListener:start() + end, + + -- Diable the simultaneous keypress hotkey + -- + -- Mimics hs.hotkey:disable() + disable = function(self) + self:exit() + self.activationListener:stop() + self.deactivationListener:stop() + end, + } + + instance.activationListener = eventtap.new({ eventTypes.keyDown }, function(event) + -- If key1 or key2 is pressed in conjuction with any modifier keys + -- (e.g., command + key1), then we're not activating the modal state. + if not (next(event:getFlags()) == nil) then + return false + end + + local characters = event:getCharacters() + + if characters == instance.key1 then + if instance.ignoreNextKey1 then + instance.ignoreNextKey1 = false + return false + end + -- Temporarily suppress this key1 keystroke. At this point, we're not sure + -- if the user intends to type key1, or if the user is attempting to + -- activate the modal state. If key2 is pressed by the time the following + -- function executes, then activate the modal state. Otherwise, trigger an + -- ordinary key1 keystroke. + instance.isKey1Down = true + hs.timer.doAfter(instance.maxTimeBetweenSimultaneousKeypresses, function() + if instance.isKey2Down then + instance:enter() + else + instance.ignoreNextKey1 = true + keyUpDown({}, instance.key1) + return false + end + end) + return true + elseif characters == instance.key2 then + if instance.ignoreNextKey2 then + instance.ignoreNextKey2 = false + return false + end + -- Temporarily suppress this key2 keystroke. At this point, we're not sure + -- if the user intends to type key2, or if the user is attempting to + -- activate the modal state. If key1 is pressed by the time the following + -- function executes, then activate the modal state. Otherwise, trigger an + -- ordinary key2 keystroke. + instance.isKey2Down = true + hs.timer.doAfter(instance.maxTimeBetweenSimultaneousKeypresses, function() + if instance.isKey1Down then + instance:enter() + else + instance.ignoreNextKey2 = true + keyUpDown({}, instance.key2) + return false + end + end) + return true + end + end) + + instance.deactivationListener = eventtap.new({ eventTypes.keyUp }, function(event) + local characters = event:getCharacters() + if characters == instance.key1 or characters == instance.key2 then + instance:reset() + end + end) + + return instance:reset() +end + +return modal diff --git a/karabiner/karabiner.json b/karabiner/karabiner.json index 7dceb2bc..509f830a 100644 --- a/karabiner/karabiner.json +++ b/karabiner/karabiner.json @@ -13,34 +13,7 @@ "basic.to_if_alone_timeout_milliseconds": 1000, "basic.to_if_held_down_threshold_milliseconds": 500 }, - "rules": [ - { - "manipulators": [ - { - "description": "Change right option to Hyper (i.e., command+control+option+shift)", - "from": { - "key_code": "right_option", - "modifiers": { - "optional": [ - "any" - ] - } - }, - "to": [ - { - "key_code": "left_shift", - "modifiers": [ - "left_control", - "left_option", - "left_command" - ] - } - ], - "type": "basic" - } - ] - } - ] + "rules": [] }, "devices": [ { From 6af578c6d788c83c3ead13499aa9589d50e63a48 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sat, 5 May 2018 09:03:28 -0400 Subject: [PATCH 03/17] :fire: Karabiner-Elements --- Brewfile | 1 - karabiner/karabiner.json | 202 --------------------------------------- script/setup | 15 ++- 3 files changed, 6 insertions(+), 212 deletions(-) delete mode 100644 karabiner/karabiner.json diff --git a/Brewfile b/Brewfile index a3d7d608..e130bbc6 100644 --- a/Brewfile +++ b/Brewfile @@ -1,4 +1,3 @@ tap 'caskroom/cask' -cask 'karabiner-elements' cask 'hammerspoon' diff --git a/karabiner/karabiner.json b/karabiner/karabiner.json deleted file mode 100644 index 509f830a..00000000 --- a/karabiner/karabiner.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "global": { - "check_for_updates_on_startup": true, - "show_in_menu_bar": false, - "show_profile_name_in_menu_bar": false - }, - "profiles": [ - { - "complex_modifications": { - "parameters": { - "basic.simultaneous_threshold_milliseconds": 50, - "basic.to_delayed_action_delay_milliseconds": 500, - "basic.to_if_alone_timeout_milliseconds": 1000, - "basic.to_if_held_down_threshold_milliseconds": 500 - }, - "rules": [] - }, - "devices": [ - { - "disable_built_in_keyboard_if_exists": false, - "fn_function_keys": [], - "identifiers": { - "is_keyboard": true, - "is_pointing_device": false, - "product_id": 276, - "vendor_id": 4176 - }, - "ignore": true, - "manipulate_caps_lock_led": false, - "simple_modifications": [] - }, - { - "disable_built_in_keyboard_if_exists": false, - "fn_function_keys": [], - "identifiers": { - "is_keyboard": true, - "is_pointing_device": false, - "product_id": 1031, - "vendor_id": 4176 - }, - "ignore": true, - "manipulate_caps_lock_led": false, - "simple_modifications": [] - }, - { - "disable_built_in_keyboard_if_exists": false, - "fn_function_keys": [], - "identifiers": { - "is_keyboard": true, - "is_pointing_device": false, - "product_id": 631, - "vendor_id": 1452 - }, - "ignore": false, - "manipulate_caps_lock_led": false, - "simple_modifications": [] - }, - { - "disable_built_in_keyboard_if_exists": false, - "fn_function_keys": [], - "identifiers": { - "is_keyboard": true, - "is_pointing_device": false, - "product_id": 34304, - "vendor_id": 1452 - }, - "ignore": true, - "manipulate_caps_lock_led": false, - "simple_modifications": [] - }, - { - "disable_built_in_keyboard_if_exists": false, - "fn_function_keys": [], - "identifiers": { - "is_keyboard": true, - "is_pointing_device": false, - "product_id": 0, - "vendor_id": 0 - }, - "ignore": false, - "manipulate_caps_lock_led": false, - "simple_modifications": [] - } - ], - "fn_function_keys": [ - { - "from": { - "key_code": "f1" - }, - "to": { - "key_code": "vk_consumer_brightness_down" - } - }, - { - "from": { - "key_code": "f2" - }, - "to": { - "key_code": "vk_consumer_brightness_up" - } - }, - { - "from": { - "key_code": "f3" - }, - "to": { - "key_code": "vk_mission_control" - } - }, - { - "from": { - "key_code": "f4" - }, - "to": { - "key_code": "vk_launchpad" - } - }, - { - "from": { - "key_code": "f5" - }, - "to": { - "key_code": "vk_consumer_illumination_down" - } - }, - { - "from": { - "key_code": "f6" - }, - "to": { - "key_code": "vk_consumer_illumination_up" - } - }, - { - "from": { - "key_code": "f7" - }, - "to": { - "key_code": "vk_consumer_previous" - } - }, - { - "from": { - "key_code": "f8" - }, - "to": { - "key_code": "vk_consumer_play" - } - }, - { - "from": { - "key_code": "f9" - }, - "to": { - "key_code": "vk_consumer_next" - } - }, - { - "from": { - "key_code": "f10" - }, - "to": { - "key_code": "mute" - } - }, - { - "from": { - "key_code": "f11" - }, - "to": { - "key_code": "volume_down" - } - }, - { - "from": { - "key_code": "f12" - }, - "to": { - "key_code": "volume_up" - } - } - ], - "name": "Default profile", - "selected": true, - "simple_modifications": [ - { - "from": { - "key_code": "caps_lock" - }, - "to": { - "key_code": "left_control" - } - } - ], - "virtual_hid_keyboard": { - "caps_lock_delay_milliseconds": 0, - "country_code": 0, - "keyboard_type": "ansi" - } - } - ] -} diff --git a/script/setup b/script/setup index 276d6402..d5d865a9 100755 --- a/script/setup +++ b/script/setup @@ -6,10 +6,6 @@ which -s brew || (echo "Homebrew is required: http://brew.sh/" && exit 1) brew bundle check || brew bundle -# Prepare custom settings for Karabiner-Elements -# https://github.com/tekezo/Karabiner-Elements/issues/597#issuecomment-282760186 -ln -sfn $PWD/karabiner ~/.config/ - # Prepare custom settings for Hammerspoon mkdir -p ~/.hammerspoon if ! grep -sq "require('keyboard')" ~/.hammerspoon/init.lua; then @@ -27,12 +23,13 @@ defaults write org.hammerspoon.Hammerspoon MJShowDockIconKey -bool FALSE # when opening Hammerspoon below killall Hammerspoon || true -# Open Apps +# Open Hammerspoon open /Applications/Hammerspoon.app -open /Applications/Karabiner-Elements.app -# Enable apps at startup +# Enable Hammerspoon at startup osascript -e 'tell application "System Events" to make login item at end with properties {path:"/Applications/Hammerspoon.app", hidden:true}' > /dev/null -osascript -e 'tell application "System Events" to make login item at end with properties {path:"/Applications/Karabiner-Elements.app", hidden:true}' > /dev/null -echo "Done! Remember to enable Accessibility for Hammerspoon." +echo "Done!" +echo "" +echo "🎗 Remember to enable Accessibility for Hammerspoon." +echo "🎗 Remember to remap Caps Lock to Control." From c02f6fe8147b2fd886c8d97c0a7b9fabf03d229d Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sat, 5 May 2018 09:36:34 -0400 Subject: [PATCH 04/17] =?UTF-8?q?Tap=20=E2=8C=A5=20(AKA=20'alt'=20or=20'op?= =?UTF-8?q?tion')=20to=20enter=20Hyper=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b6e7d24ff0366275873d5c918160147c09f00231 removed the use of Karabiner-Elements for triggering Hyper Mode, and opted to trigger Hyper Mode with a simultaneous keypress of 'k' and 'l'. After using that behavior for a few days, it's not the experience I'm looking for. I think tapping 'alt' will be closer to the experience I'm looking for. Tapping 'alt' is super easy to do. (It's easier than ensuring that you hit k and l simultaneously.) Also, when typing, you use 'alt' a lot less often than you use 'k' and 'l', so using 'alt' reduces the chance that you'll accidentally trigger Hyper Mode when you're just trying to type. - Remove the new code introduced in b6e7d24ff0366275873d5c918160147c09f00231 - Add logic to enter Hyper Mode when you press and then release the 'alt' key - Restore the original format of the Hyper Mode key-to-app mappings. 😅 See hammerspoon/hyper-apps-defaults.lua. --- hammerspoon/hyper-apps-defaults.lua | 18 +-- hammerspoon/hyper.lua | 45 +++--- hammerspoon/simultaneous-keypress-modal.lua | 151 ------------------ hammerspoon/tap-modifier-for-hotkey.lua | 163 ++++++++++++++++++++ 4 files changed, 194 insertions(+), 183 deletions(-) delete mode 100644 hammerspoon/simultaneous-keypress-modal.lua create mode 100644 hammerspoon/tap-modifier-for-hotkey.lua diff --git a/hammerspoon/hyper-apps-defaults.lua b/hammerspoon/hyper-apps-defaults.lua index 31079394..ea4bdd7d 100644 --- a/hammerspoon/hyper-apps-defaults.lua +++ b/hammerspoon/hyper-apps-defaults.lua @@ -4,13 +4,13 @@ -- this file, save it as `hyper-apps.lua`, and edit the table below to configure -- your preferred shortcuts. return { - a = 'iTunes', -- "A" for "Apple Music" - b = 'Google Chrome', -- "B" for "Browser" - c = 'Slack', -- "C for "Chat" - d = 'Remember The Milk', -- "D" for "Do!" ... or "Done!" - e = 'Atom', -- "E" for "Editor" - f = 'Finder', -- "F" for "Finder" - g = 'Mailplane 3', -- "G" for "Gmail" - s = 'Slack', -- "S" for "Slack" - t = 'iTerm', -- "T" for "Terminal" + { 'a', 'iTunes' }, -- "A" for "Apple Music" + { 'b', 'Google Chrome' }, -- "B" for "Browser" + { 'c', 'Slack' }, -- "C for "Chat" + { 'd', 'Remember The Milk' }, -- "D" for "Do!" ... or "Done!" + { 'e', 'Atom' }, -- "E" for "Editor" + { 'f', 'Finder' }, -- "F" for "Finder" + { 'g', 'Mailplane 3' }, -- "G" for "Gmail" + { 's', 'Slack' }, -- "S" for "Slack" + { 't', 'iTerm' }, -- "T" for "Terminal" } diff --git a/hammerspoon/hyper.lua b/hammerspoon/hyper.lua index ccc775c4..4b2a1b7b 100644 --- a/hammerspoon/hyper.lua +++ b/hammerspoon/hyper.lua @@ -1,7 +1,3 @@ -local eventtap = hs.eventtap -local eventTypes = hs.eventtap.event.types -local simultaneousKeypressModal = require('keyboard.simultaneous-keypress-modal') - -- Look for custom Hyper Mode app mappings. If there are none, then use the -- default mappings. local status, hyperModeAppMappings = pcall(require, 'keyboard.hyper-apps') @@ -9,22 +5,16 @@ if not status then hyperModeAppMappings = require('keyboard.hyper-apps-defaults') end --- Create a hotkey that will enter Hyper Mode when 'k' and 'l' are pressed --- simultaneously. -hyperMode = simultaneousKeypressModal.new('Hyper Mode', 'k', 'l') - --------------------------------------------------------------------------------- --- Watch for key-down events in Hyper Mode, and trigger app associated with the --- given key. --------------------------------------------------------------------------------- -hyperModeKeyListener = eventtap.new({ eventTypes.keyDown }, function(event) - if not hyperMode:isActive() then - return false - end +-- Create a hotkey that will enter Hyper Mode when 'alt' is tapped (i.e., +-- when 'alt' is pressed and then released in quick succession). +local hotkey = require('keyboard.tap-modifier-for-hotkey') +hyperMode = hotkey.new('alt') - local app = hyperModeAppMappings[event:getCharacters(true):lower()] - - if app then +-- Bind the hotkeys that will be active when we're in Hyper Mode +for i, mapping in ipairs(hyperModeAppMappings) do + local key = mapping[1] + local app = mapping[2] + hyperMode:bind(key, function() if (type(app) == 'string') then hs.application.open(app) elseif (type(app) == 'function') then @@ -32,9 +22,18 @@ hyperModeKeyListener = eventtap.new({ eventTypes.keyDown }, function(event) else hs.logger.new('hyper'):e('Invalid mapping for Hyper +', key) end - return true - end -end):start() + end) +end + +-- Show a status message when we're in Hyper Mode +local message = require('keyboard.status-message') +hyperMode.statusMessage = message.new('Hyper Mode') +hyperMode.entered = function() + hyperMode.statusMessage:show() +end +hyperMode.exited = function() + hyperMode.statusMessage:hide() +end ---- We're all set. Now we just enable Hyper Mode and get to work. 👔 +-- We're all set. Now we just enable Hyper Mode and get to work. 👔 hyperMode:enable() diff --git a/hammerspoon/simultaneous-keypress-modal.lua b/hammerspoon/simultaneous-keypress-modal.lua deleted file mode 100644 index 7fe1a7e6..00000000 --- a/hammerspoon/simultaneous-keypress-modal.lua +++ /dev/null @@ -1,151 +0,0 @@ -local eventtap = hs.eventtap -local eventTypes = hs.eventtap.event.types -local message = require('keyboard.status-message') - -local modal={} - -modal.new = function(name, key1, key2) - local instance = { - -- The two keys that must be pressed simultaneously to enter the modal state - key1 = key1, - key2 = key2, - - -- If key1 and key2 are *both* pressed within this time period, consider - -- this to mean that they've been pressed simultaneously, and therefore we - -- should enter the modal state. - maxTimeBetweenSimultaneousKeypresses = 0.04, -- 40 milliseconds - - -- The status message to display when the modal state is active - statusMessage = message.new(name), - - -- Resets object to initial state - reset = function(self) - self.active = false - self.isKey1Down = false - self.isKey2Down = false - self.ignoreNextKey1 = false - self.ignoreNextKey2 = false - self.statusMessage:hide() - - return self - end, - - -- Are we in the modal state? - isActive = function(self) - return self.active - end, - - -- Enters the modal state - -- - -- Mimics hs.hotkey.modal:enter() - enter = function(self) - if self.active then return end - - self.statusMessage:show() - self.active = true - self:entered() - end, - - -- Exits the modal state - -- - -- Mimics hs.hotkey.modal:exit() - exit = function(self) - if not self.active then return end - - self:reset() - self:exited() - end, - - -- Optional callback for when a modal state is entered - -- - -- Mimics hs.hotkey.modal:entered() - entered = function(self) end, - - -- Optional callback for when a modal state is exited - -- - -- Mimics hs.hotkey.modal:exited() - exited = function(self) end, - - -- Enable the simultaneous keypress hotkey - -- - -- Mimics hs.hotkey:enable() - enable = function(self) - self.activationListener:start() - self.deactivationListener:start() - end, - - -- Diable the simultaneous keypress hotkey - -- - -- Mimics hs.hotkey:disable() - disable = function(self) - self:exit() - self.activationListener:stop() - self.deactivationListener:stop() - end, - } - - instance.activationListener = eventtap.new({ eventTypes.keyDown }, function(event) - -- If key1 or key2 is pressed in conjuction with any modifier keys - -- (e.g., command + key1), then we're not activating the modal state. - if not (next(event:getFlags()) == nil) then - return false - end - - local characters = event:getCharacters() - - if characters == instance.key1 then - if instance.ignoreNextKey1 then - instance.ignoreNextKey1 = false - return false - end - -- Temporarily suppress this key1 keystroke. At this point, we're not sure - -- if the user intends to type key1, or if the user is attempting to - -- activate the modal state. If key2 is pressed by the time the following - -- function executes, then activate the modal state. Otherwise, trigger an - -- ordinary key1 keystroke. - instance.isKey1Down = true - hs.timer.doAfter(instance.maxTimeBetweenSimultaneousKeypresses, function() - if instance.isKey2Down then - instance:enter() - else - instance.ignoreNextKey1 = true - keyUpDown({}, instance.key1) - return false - end - end) - return true - elseif characters == instance.key2 then - if instance.ignoreNextKey2 then - instance.ignoreNextKey2 = false - return false - end - -- Temporarily suppress this key2 keystroke. At this point, we're not sure - -- if the user intends to type key2, or if the user is attempting to - -- activate the modal state. If key1 is pressed by the time the following - -- function executes, then activate the modal state. Otherwise, trigger an - -- ordinary key2 keystroke. - instance.isKey2Down = true - hs.timer.doAfter(instance.maxTimeBetweenSimultaneousKeypresses, function() - if instance.isKey1Down then - instance:enter() - else - instance.ignoreNextKey2 = true - keyUpDown({}, instance.key2) - return false - end - end) - return true - end - end) - - instance.deactivationListener = eventtap.new({ eventTypes.keyUp }, function(event) - local characters = event:getCharacters() - if characters == instance.key1 or characters == instance.key2 then - instance:reset() - end - end) - - return instance:reset() -end - -return modal diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua new file mode 100644 index 00000000..269a0875 --- /dev/null +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -0,0 +1,163 @@ +local eventtap = require('hs.eventtap') +local events = eventtap.event.types + +local modal={} + +-- Return an object whose behavior is inspired by hs.hotkey.modal. In this case, +-- the modal state is entered when the specified modifier key is tapped (i.e., +-- pressed and then released in quick succession). +modal.new = function(modifier) + local instance = { + modifier = modifier, + + tapTimeoutInSeconds = 0.5, + + modalStateTimeoutInSeconds = 0.5, + + modalKeybindings = {}, + + reset = function(self) + self.inModalState = false + self.modifierDownHappened = false + + return self + end, + + -- Enable the modal + -- + -- Mimics hs.modal:enable() + enable = function(self) + self.watcher:start() + end, + + -- Disable the modal + -- + -- Mimics hs.modal:disable() + disable = function(self) + self.watcher:stop() + self.watcher:reset() + end, + + -- Temporarily enter the modal state in which the modal's hotkeys are + -- active. The modal state will terminate after `modalStateTimeoutInSeconds` + -- or after the first keydown event, whichever comes first. + -- + -- Mimics hs.modal.modal:enter() + enter = function(self) + self.inModalState = true + self:entered() + + hs.timer.doAfter(self.modalStateTimeoutInSeconds, + function() self:exit() end + ) + end, + + -- Exit the modal state in which the modal's hotkey are active + -- + -- Mimics hs.modal.modal:exit() + exit = function(self) + if not self.inModalState then return end + + self:reset() + self:exited() + end, + + -- Optional callback for when modal state is entered + -- + -- Mimics hs.modal.modal:entered() + entered = function(self) end, + + -- Optional callback for when modal state is exited + -- + -- Mimics hs.modal.modal:exited() + exited = function(self) end, + + -- Bind hotkey that will be enabled/disabled as modal state is + -- entered/exited + bind = function(self, key, fn) + self.modalKeybindings[key] = fn + end, + } + + isNoModifiers = function(flagsChangedEvent) + local isFalsey = function(value) + return not value + end + + return hs.fnutils.every(flagsChangedEvent:getFlags(), isFalsey) + end + + isOnlyModifier = function(flagsChangedEvent) + return hs.fnutils.every(flagsChangedEvent:getFlags(), function(isDown, modifierName) + return (isDown and modifierName == modifier) or + (not isDown and not modifierName == modifier) + end) + end + + onModifierChange = function(event) + + -- If it's only the modifier that we care about, then this could be the + -- start of a tap, so start watching for the modifier to be released. + if isOnlyModifier(event) and not instance.modifierDownHappened then + + instance.modifierDownHappened = true + + -- If the modifier isn't released before the timeout, then it doesn't seem + -- like the user is intending to *tap* the modifier key. So, start over. + hs.timer.doAfter(instance.tapTimeoutInSeconds, function() + if not instance.inModalState then instance:reset() end + end) + + return + end + + -- If we've seen one press of the modifier we care about, and now no + -- modifiers are down, then this is the key-up event for the modifier we care + -- about. So, enter the modal state. + if isNoModifiers(event) and instance.modifierDownHappened then + instance:enter() + + return + end + + -- If we get there, then this isn't the sequence of events we were looking + -- for, so start over. + instance:reset() + + -- Allow the event to propagate + return false + end + + onKeyDown = function(event) + if instance.inModalState then + local fn = instance.modalKeybindings[event:getCharacters():lower()] + if fn then fn() end + + instance:exit() + + -- Delete the event so that we're the sole consumer of it + return true + else + -- Since we're not in the modal state, this event isn't part of a sequence + -- of events that represents the modifier being tapped, so start over. + instance:reset() + + -- Allow the event to propagate + return false + end + end + + instance.watcher = eventtap.new({events.flagsChanged, events.keyDown}, + function(event) + if event:getType() == events.flagsChanged then + return onModifierChange(event) + else + return onKeyDown(event) + end + end + ) + + return instance:reset() +end + +return modal From 96a25fb33c61991056990e05afc66026f299d296 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 09:13:13 -0400 Subject: [PATCH 05/17] :bug: Fix isOnlyModifier fn --- hammerspoon/tap-modifier-for-hotkey.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 269a0875..af810276 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -88,10 +88,14 @@ modal.new = function(modifier) end isOnlyModifier = function(flagsChangedEvent) - return hs.fnutils.every(flagsChangedEvent:getFlags(), function(isDown, modifierName) - return (isDown and modifierName == modifier) or - (not isDown and not modifierName == modifier) + local flags = flagsChangedEvent:getFlags() + + isPrimaryModiferDown = flags[modifier] + areOtherModifiersDown = hs.fnutils.some(flags, function(isDown, modifierName) + return isDown and not modifierName == modifier end) + + return isPrimaryModiferDown and not areOtherModifiersDown end onModifierChange = function(event) From 20398ccb95478258be8d264e6c42eb54c357a8ae Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 09:21:35 -0400 Subject: [PATCH 06/17] Improve Hyper Mode's handling of apps that take a long time to open --- hammerspoon/tap-modifier-for-hotkey.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index af810276..6c85e676 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -103,7 +103,6 @@ modal.new = function(modifier) -- If it's only the modifier that we care about, then this could be the -- start of a tap, so start watching for the modifier to be released. if isOnlyModifier(event) and not instance.modifierDownHappened then - instance.modifierDownHappened = true -- If the modifier isn't released before the timeout, then it doesn't seem @@ -135,7 +134,16 @@ modal.new = function(modifier) onKeyDown = function(event) if instance.inModalState then local fn = instance.modalKeybindings[event:getCharacters():lower()] - if fn then fn() end + + -- Some actions may take a while to perform (e.g., opening Slack when it's + -- not yet running). We don't want to keep the modal state active while we + -- wait for a long-running action to complete. So, we schedule the action + -- to run in the background so that we can exit the modal state and let + -- the user go on about their business. + local delayInSeconds = 0.001 -- 1 millisecond + hs.timer.doAfter(delayInSeconds, function() + if fn then fn() end + end) instance:exit() From 5ef1f80898f5715d3a7c72491e1cd2c04f561910 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 09:25:20 -0400 Subject: [PATCH 07/17] Bump Hammerspoon dependency to 0.9.66 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f5e7ead3..57ac2476 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ This setup is honed and tested with the following dependencies. - macOS High Sierra, 10.13 - [Karabiner-Elements 11.4.0][karabiner] -- [Hammerspoon 0.9.57][hammerspoon] +- [Hammerspoon 0.9.66][hammerspoon] ## Installation From 1b6468e8cbb19cbef9b1cc3b840405b1abfdd242 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 09:57:10 -0400 Subject: [PATCH 08/17] Update README to reflect use of option key for Hyper Mode --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 57ac2476..5a5a4702 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ While I find that these customizations yield a more-useful keyboard for me, they - [Arrange windows via the home row](#window-layout-mode) - [Enable other commonly-used actions on or near the home row](#miscellaneous-goodness) - [Format text as Markdown](#markdown-mode) -- [Launch commonly-used apps via global keyboard shortcuts](#hyper-key-for-quickly-launching-apps) +- [Launch commonly-used apps via global keyboard shortcuts](#hyper-mode-for-quickly-launching-apps) - [And more...](#miscellaneous-goodness) ### A more useful caps lock key @@ -128,11 +128,14 @@ Use control + m to turn on Markdown Mode. Then, use any sh - Use control + m to exit Markdown Mode without performing any actions -### Hyper key for quickly launching apps +### Hyper Mode for quickly launching apps -macOS doesn't have a native hyper key. But thanks to Karabiner-Elements, we can [create our own](karabiner/karabiner.json). In this setup, we'll use the right option key as our hyper key. +Launch your favorite apps with global shortcuts, and do so without interfering with existing macOS shortcuts or application-specific shortcuts. 😅 -With a new modifier key defined, we open a whole world of possibilities. I find it especially useful for providing global shortcuts for launching apps. +Tap option (AKA alt) to enter Hyper Mode and then press any shortcut to focus the associated application. For example, if you're using the default keybindings shown below to open the Finder, you would: + +1. Tap the option key (i.e., press and then release it in quick succession) to enter Hyper Mode +2. Then, press f for Finder #### Choose your own apps @@ -140,15 +143,15 @@ Hyper Mode ships with the default keybindings below, but you'll likely want to p #### Default app keybindings -- hyper + a to open iTunes ("A" for "Apple Music") -- hyper + b to open Google Chrome ("B" for "Browser") -- hyper + c to open Slack ("C for "Chat") -- hyper + d to open [Remember The Milk](https://www.rememberthemilk.com/) ("D" for "Do!" ... or "Done!") -- hyper + e to open [Atom](https://atom.io) ("E" for "Editor") -- hyper + f to open Finder ("F" for "Finder") -- hyper + g to open [Mailplane](http://mailplaneapp.com/) ("G" for "Gmail") -- hyper + s to open [Slack](https://slack.com/downloads/osx) ("S" for "Slack") -- hyper + t to open [iTerm2](https://www.iterm2.com/) ("T" for "Terminal") +- a to open iTunes ("A" for "Apple Music") +- b to open Google Chrome ("B" for "Browser") +- c to open Slack ("C for "Chat") +- d to open [Remember The Milk](https://www.rememberthemilk.com/) ("D" for "Do!" ... or "Done!") +- e to open [Atom](https://atom.io) ("E" for "Editor") +- f to open Finder ("F" for "Finder") +- g to open [Mailplane](http://mailplaneapp.com/) ("G" for "Gmail") +- s to open [Slack](https://slack.com/downloads/osx) ("S" for "Slack") +- t to open [iTerm2](https://www.iterm2.com/) ("T" for "Terminal") ### Miscellaneous goodness @@ -164,7 +167,6 @@ Hyper Mode ships with the default keybindings below, but you'll likely want to p This setup is honed and tested with the following dependencies. - macOS High Sierra, 10.13 -- [Karabiner-Elements 11.4.0][karabiner] - [Hammerspoon 0.9.66][hammerspoon] ## Installation @@ -189,7 +191,6 @@ This setup is honed and tested with the following dependencies. [customize]: http://dictionary.reference.com/browse/customize [don't-make-me-think]: http://en.wikipedia.org/wiki/Don't_Make_Me_Think -[karabiner]: https://github.com/tekezo/Karabiner-Elements [hammerspoon]: http://www.hammerspoon.org [hammerspoon-releases]: https://github.com/Hammerspoon/hammerspoon/releases [modern-space-cadet]: http://stevelosh.com/blog/2012/10/a-modern-space-cadet From c5d2f336d8578e3a3236ddadd39f32c744dcaad5 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 10:06:29 -0400 Subject: [PATCH 09/17] Update setup notes re: mapping caps lock to control --- README.md | 2 ++ script/setup | 1 + 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 5a5a4702..cf084be2 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ This setup is honed and tested with the following dependencies. 2. Enable accessibility to allow Hammerspoon to do its thing [[screenshot]](screenshots/accessibility-permissions-for-hammerspoon.png) +3. Give yourself a [more useful caps lock key](#a-more-useful-caps-lock-key): Open *System Preferences*, navigate to *Keyboard > Modifier Keys*, and set the caps lock key to control [[screenshot]](https://user-images.githubusercontent.com/2988/27111039-7f620442-507b-11e7-9bcf-93d46e14af13.png) + ## TODO - Add [#13](https://github.com/jasonrudolph/keyboard/pull/13) to [features](#features): diff --git a/script/setup b/script/setup index d5d865a9..8c1596c1 100755 --- a/script/setup +++ b/script/setup @@ -33,3 +33,4 @@ echo "Done!" echo "" echo "🎗 Remember to enable Accessibility for Hammerspoon." echo "🎗 Remember to remap Caps Lock to Control." +echo "🎗 See details at https://github.com/jasonrudolph/keyboard#installation" From 6e2b472c1baa17b303f3dbe84de6841288cec3bc Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Sun, 6 May 2018 14:42:10 -0400 Subject: [PATCH 10/17] Only alter modal state in enter() and exit() --- hammerspoon/tap-modifier-for-hotkey.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 6c85e676..6edc64f4 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -16,8 +16,9 @@ modal.new = function(modifier) modalKeybindings = {}, + inModalState = false, + reset = function(self) - self.inModalState = false self.modifierDownHappened = false return self @@ -58,6 +59,7 @@ modal.new = function(modifier) exit = function(self) if not self.inModalState then return end + self.inModalState = false self:reset() self:exited() end, From c548e90122a004116ac68f264b920554f8a72597 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 16 May 2018 19:51:24 -0400 Subject: [PATCH 11/17] Bump the modal timeout to 1 second --- hammerspoon/tap-modifier-for-hotkey.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 6edc64f4..1fe7633f 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -12,7 +12,7 @@ modal.new = function(modifier) tapTimeoutInSeconds = 0.5, - modalStateTimeoutInSeconds = 0.5, + modalStateTimeoutInSeconds = 1.0, modalKeybindings = {}, From 9321be2e721bee9a697ddf6b49369c345b40aaf8 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 16 May 2018 20:49:12 -0400 Subject: [PATCH 12/17] Fix typo --- hammerspoon/tap-modifier-for-hotkey.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 1fe7633f..ef839117 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -125,7 +125,7 @@ modal.new = function(modifier) return end - -- If we get there, then this isn't the sequence of events we were looking + -- If we get here, then this isn't the sequence of events we were looking -- for, so start over. instance:reset() From debc89e9c5c9fcd4f7067ba16747abd208cc909a Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 16 May 2018 20:52:10 -0400 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=90=9B=20Improve=20modifier=20tap?= =?UTF-8?q?=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this change, simultaneously tapping option and another modifier would sometimes enter Hyper Mode. With this change, we should only enter Hyper Mode when this exact sequence occurs: 1. No modifiers are down 2. Only the Option key is down 3. No modifiers are down --- hammerspoon/tap-modifier-for-hotkey.lua | 36 ++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index ef839117..ff2c95cd 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -10,6 +10,8 @@ modal.new = function(modifier) local instance = { modifier = modifier, + lastFlagsSeen = {}, + tapTimeoutInSeconds = 0.5, modalStateTimeoutInSeconds = 1.0, @@ -19,7 +21,7 @@ modal.new = function(modifier) inModalState = false, reset = function(self) - self.modifierDownHappened = false + self.tapStarted = false return self end, @@ -81,31 +83,35 @@ modal.new = function(modifier) end, } - isNoModifiers = function(flagsChangedEvent) + isNoModifiers = function(flags) local isFalsey = function(value) return not value end - return hs.fnutils.every(flagsChangedEvent:getFlags(), isFalsey) + return hs.fnutils.every(flags, isFalsey) end - isOnlyModifier = function(flagsChangedEvent) - local flags = flagsChangedEvent:getFlags() - + isOnlyModifier = function(flags) isPrimaryModiferDown = flags[modifier] areOtherModifiersDown = hs.fnutils.some(flags, function(isDown, modifierName) - return isDown and not modifierName == modifier + local isPrimaryModifier = modifierName == modifier + return isDown and not isPrimaryModifier end) return isPrimaryModiferDown and not areOtherModifiersDown end onModifierChange = function(event) + local oldFlags = instance.lastFlagsSeen + local newFlags = event:getFlags() + + instance.lastFlagsSeen = newFlags - -- If it's only the modifier that we care about, then this could be the - -- start of a tap, so start watching for the modifier to be released. - if isOnlyModifier(event) and not instance.modifierDownHappened then - instance.modifierDownHappened = true + -- If we've transitioned from no modifiers to just the modifier that we care + -- about, then this could be the start of a tap, so start watching for the + -- modifier to be released. + if isNoModifiers(oldFlags) and isOnlyModifier(newFlags) then + instance.tapStarted = true -- If the modifier isn't released before the timeout, then it doesn't seem -- like the user is intending to *tap* the modifier key. So, start over. @@ -116,10 +122,10 @@ modal.new = function(modifier) return end - -- If we've seen one press of the modifier we care about, and now no - -- modifiers are down, then this is the key-up event for the modifier we care - -- about. So, enter the modal state. - if isNoModifiers(event) and instance.modifierDownHappened then + -- If we saw the modifier we care about get pressed, and now no modifiers + -- are down, then this is the key-up event for the modifier we care about. + -- So, enter the modal state. + if instance.tapStarted and isNoModifiers(newFlags) then instance:enter() return From 9acd75b6d1d52999e0de6f8f5e6e76d3866da1c5 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Thu, 17 May 2018 09:04:16 -0400 Subject: [PATCH 14/17] :art: --- hammerspoon/tap-modifier-for-hotkey.lua | 49 ++++++++----------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index ff2c95cd..f8f674eb 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -10,10 +10,6 @@ modal.new = function(modifier) local instance = { modifier = modifier, - lastFlagsSeen = {}, - - tapTimeoutInSeconds = 0.5, - modalStateTimeoutInSeconds = 1.0, modalKeybindings = {}, @@ -21,7 +17,14 @@ modal.new = function(modifier) inModalState = false, reset = function(self) - self.tapStarted = false + -- Keep track of the last flags from the three most recent flagsChanged + -- events. + self.flagsHistory = { {}, {}, {} } + self.flagsHistory.push = function(self, flags) + self[1] = self[2] + self[2] = self[3] + self[3] = flags + end return self end, @@ -102,39 +105,19 @@ modal.new = function(modifier) end onModifierChange = function(event) - local oldFlags = instance.lastFlagsSeen - local newFlags = event:getFlags() - - instance.lastFlagsSeen = newFlags - - -- If we've transitioned from no modifiers to just the modifier that we care - -- about, then this could be the start of a tap, so start watching for the - -- modifier to be released. - if isNoModifiers(oldFlags) and isOnlyModifier(newFlags) then - instance.tapStarted = true + instance.flagsHistory:push(event:getFlags()) - -- If the modifier isn't released before the timeout, then it doesn't seem - -- like the user is intending to *tap* the modifier key. So, start over. - hs.timer.doAfter(instance.tapTimeoutInSeconds, function() - if not instance.inModalState then instance:reset() end - end) - - return - end + local flags3 = instance.flagsHistory[3] -- the current flags + local flags2 = instance.flagsHistory[2] -- the previous flags + local flags1 = instance.flagsHistory[1] -- the flags before the previous flags - -- If we saw the modifier we care about get pressed, and now no modifiers - -- are down, then this is the key-up event for the modifier we care about. - -- So, enter the modal state. - if instance.tapStarted and isNoModifiers(newFlags) then + -- If we've transitioned from 1) no modifiers being pressed to 2) just the + -- modifier that we care about being pressed, to 3) no modifiers being + -- pressed, then enter the modal state. + if isNoModifiers(flags1) and isOnlyModifier(flags2) and isNoModifiers(flags3) then instance:enter() - - return end - -- If we get here, then this isn't the sequence of events we were looking - -- for, so start over. - instance:reset() - -- Allow the event to propagate return false end From 0fbe656c12566cb05d4f66a53bdda7df06be08b0 Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 20 Jun 2018 10:10:29 -0400 Subject: [PATCH 15/17] Terminate timer when exiting modal state due to keypress Prior to this change, if you used Hyper Mode multiple times in quick succession, the timer from the _previous_ Hyper Mode invocation could cause the _current_ Hyper Mode invocation to exit. Whoops. --- hammerspoon/tap-modifier-for-hotkey.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index f8f674eb..4bda7721 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -52,10 +52,7 @@ modal.new = function(modifier) enter = function(self) self.inModalState = true self:entered() - - hs.timer.doAfter(self.modalStateTimeoutInSeconds, - function() self:exit() end - ) + self.autoExitTimer:setNextTrigger(self.modalStateTimeoutInSeconds) end, -- Exit the modal state in which the modal's hotkey are active @@ -64,6 +61,7 @@ modal.new = function(modifier) exit = function(self) if not self.inModalState then return end + self.autoExitTimer:stop() self.inModalState = false self:reset() self:exited() @@ -150,6 +148,8 @@ modal.new = function(modifier) end end + instance.autoExitTimer = hs.timer.new(0, function() instance:exit() end) + instance.watcher = eventtap.new({events.flagsChanged, events.keyDown}, function(event) if event:getType() == events.flagsChanged then From fc4535f7b43defdadffe6c787248cd3f772221fe Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 20 Jun 2018 10:13:10 -0400 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=90=9B=20Fix=20bug=20where=20certai?= =?UTF-8?q?n=20sequence=20would=20wrongly=20enter=20Hyper=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the following bug: 1. Hold down option+shift 2. Press left arrow 3. Release shift 4. Release option 5. Observe that it wrongly enters Hyper Mode --- hammerspoon/tap-modifier-for-hotkey.lua | 118 +++++++++++++----------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 4bda7721..0cb80425 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -17,14 +17,20 @@ modal.new = function(modifier) inModalState = false, reset = function(self) - -- Keep track of the last flags from the three most recent flagsChanged - -- events. - self.flagsHistory = { {}, {}, {} } - self.flagsHistory.push = function(self, flags) - self[1] = self[2] - self[2] = self[3] - self[3] = flags - end + -- Keep track of the three most recent events. + self.eventHistory = { + fetch = function(self, index) + if self[index] then + return eventtap.event.newEventFromData(self[index]) + end + end, + + push = function(self, event) + self[3] = self[2] + self[2] = self[1] + self[1] = event:asData() + end + } return self end, @@ -102,61 +108,67 @@ modal.new = function(modifier) return isPrimaryModiferDown and not areOtherModifiersDown end - onModifierChange = function(event) - instance.flagsHistory:push(event:getFlags()) - - local flags3 = instance.flagsHistory[3] -- the current flags - local flags2 = instance.flagsHistory[2] -- the previous flags - local flags1 = instance.flagsHistory[1] -- the flags before the previous flags - - -- If we've transitioned from 1) no modifiers being pressed to 2) just the - -- modifier that we care about being pressed, to 3) no modifiers being - -- pressed, then enter the modal state. - if isNoModifiers(flags1) and isOnlyModifier(flags2) and isNoModifiers(flags3) then - instance:enter() - end + isFlagsChangedEvent = function(event) + return event and event:getType() == events.flagsChanged + end - -- Allow the event to propagate - return false + isFlagsChangedEventWithNoModifiers = function(event) + return isFlagsChangedEvent(event) and isNoModifiers(event:getFlags()) end - onKeyDown = function(event) - if instance.inModalState then - local fn = instance.modalKeybindings[event:getCharacters():lower()] - - -- Some actions may take a while to perform (e.g., opening Slack when it's - -- not yet running). We don't want to keep the modal state active while we - -- wait for a long-running action to complete. So, we schedule the action - -- to run in the background so that we can exit the modal state and let - -- the user go on about their business. - local delayInSeconds = 0.001 -- 1 millisecond - hs.timer.doAfter(delayInSeconds, function() - if fn then fn() end - end) - - instance:exit() - - -- Delete the event so that we're the sole consumer of it - return true - else - -- Since we're not in the modal state, this event isn't part of a sequence - -- of events that represents the modifier being tapped, so start over. - instance:reset() - - -- Allow the event to propagate - return false - end + isFlagsChangedEventWithOnlyModifier = function(event) + return isFlagsChangedEvent(event) and isOnlyModifier(event:getFlags()) end instance.autoExitTimer = hs.timer.new(0, function() instance:exit() end) instance.watcher = eventtap.new({events.flagsChanged, events.keyDown}, function(event) - if event:getType() == events.flagsChanged then - return onModifierChange(event) - else - return onKeyDown(event) + -- If we're in the modal state, and we got a keydown event, then trigger + -- the function associated with the key. + if (event:getType() == events.keyDown and instance.inModalState) then + local fn = instance.modalKeybindings[event:getCharacters():lower()] + + -- Some actions may take a while to perform (e.g., opening Slack when + -- it's not yet running). We don't want to keep the modal state active + -- while we wait for a long-running action to complete. So, we schedule + -- the action to run in the background so that we can exit the modal + -- state and let the user go on about their business. + local delayInSeconds = 0.001 -- 1 millisecond + hs.timer.doAfter(delayInSeconds, function() + if fn then fn() end + end) + + instance:exit() + + -- Delete the event so that we're the sole consumer of it + return true end + + -- Otherwise, determine if this event should cause us to enter the modal + -- state. + + local currentEvent = event + local lastEvent = instance.eventHistory:fetch(1) + local secondToLastEvent = instance.eventHistory:fetch(2) + + instance.eventHistory:push(currentEvent) + + -- If we've observed the following sequence of events, then enter the + -- modal state: + -- + -- 1. No modifiers are down + -- 2. Modifiers changed, and now only the primary modifier is down + -- 3. Modifiers changed, and now no modifiers are down + if (secondToLastEvent == nil or isNoModifiers(secondToLastEvent:getFlags())) and + isFlagsChangedEventWithOnlyModifier(lastEvent) and + isFlagsChangedEventWithNoModifiers(currentEvent) then + + instance:enter() + end + + -- Let the event propagate + return false end ) From 1799676b6056558eb256548b1f4a9ea268dfddcb Mon Sep 17 00:00:00 2001 From: Jason Rudolph Date: Wed, 20 Jun 2018 10:18:14 -0400 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=8E=A8=E2=9E=95=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hammerspoon/tap-modifier-for-hotkey.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/hammerspoon/tap-modifier-for-hotkey.lua b/hammerspoon/tap-modifier-for-hotkey.lua index 0cb80425..7dbff408 100644 --- a/hammerspoon/tap-modifier-for-hotkey.lua +++ b/hammerspoon/tap-modifier-for-hotkey.lua @@ -19,16 +19,18 @@ modal.new = function(modifier) reset = function(self) -- Keep track of the three most recent events. self.eventHistory = { - fetch = function(self, index) - if self[index] then - return eventtap.event.newEventFromData(self[index]) - end - end, - + -- Serialize the event and push it into the history push = function(self, event) self[3] = self[2] self[2] = self[1] self[1] = event:asData() + end, + + -- Fetch the event (if any) at the given index + fetch = function(self, index) + if self[index] then + return eventtap.event.newEventFromData(self[index]) + end end }