Hammerspoon / hammerspoon

Staggeringly powerful macOS desktop automation with Lua

Home Page:http://www.hammerspoon.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How do I disable a modal hotkey?

NightMachinery opened this issue · comments

The reason I want this is to work around hs.osascript possibly blocking hs.hotkey events? · Issue #1178 · Hammerspoon/hammerspoon. I can detect when repeatfn is being called mistakenly, and I need some way to stop repeatfn from further executing. Currently, I accomplish this by throwing an exception, which indeed does what I want, but I would prefer to do this properly.

Here is my code:

hyper_modality = hs.hotkey.modal.new()
-- ...

function hyper_bind_v2(o)
  local hotkey_holder = { my_hotkey = nil }

  if o.auto_trigger_p == nil then
    o.auto_trigger_p = true
  end

  if o.auto_trigger_p then
    if o.pressedfn == nil then
      h_pressedfn = nil
    else
      function h_pressedfn()
        hyper_triggered()

        o.pressedfn()
      end
    end

    if o.releasedfn == nil then
      h_releasedfn = nil
    else
      function h_releasedfn()
        hyper_triggered()

        o.releasedfn()
      end
    end

    if o.repeatfn == nil then
      h_repeatfn = nil
    else
      function h_repeatfn()
        -- hs.alert("repeating: " .. o.key)
        -- hs.alert("repeating: " .. o.key .. "down: " .. tostring(hyper_modality.down_p) .. "hotkey: " .. tostring(hotkey_holder.my_hotkey))

        if hyper_modality.down_p == false then
          -- @upstreamBug @raceCondition
          if not (hotkey_holder.my_hotkey == nil) then
            -- hs.alert("disabling")

            -- Disable the hotkey
            hotkey_holder.my_hotkey:disable()
            -- This throws an exception which successfully stops the repeat loop.
            -- `callback: /Users/evar/.hammerspoon/init.lua:209: attempt to call a nil value (method 'disable')`

            hotkey_holder.my_hotkey:enable()
            -- We should re-enable the hotkey so that it can be triggered again. Since an exception happens, everything magically works out even without this line.

            hs.alert("disabled")
          end

          hs.alert("Repeat Bug Encountered (id: NIGHT_817123)")
          return
        end

        hyper_triggered()

        o.repeatfn()
      end
    end
  else
    h_pressedfn = o.pressedfn
    h_releasedfn = o.releasedfn
    h_repeatfn = o.repeatfn
  end

   hotkey_holder.my_hotkey = hyper_modality:bind(o.mods or {}, o.key, h_pressedfn, h_releasedfn, h_repeatfn)
  -- hs.hotkey.bind(mods, key, [message,] pressedfn, releasedfn, repeatfn) -> hs.hotkey object

  return hotkey_holder.my_hotkey
end

My current workaround with an explicit exception:

function hyper_bind_v2(o)
  local hotkey_holder = { my_hotkey = nil }

  if o.auto_trigger_p == nil then
    o.auto_trigger_p = true
  end

  if o.auto_trigger_p then
    if o.pressedfn == nil then
      h_pressedfn = nil
    else
      function h_pressedfn()
        hyper_triggered()

        o.pressedfn()
      end
    end

    if o.releasedfn == nil then
      h_releasedfn = nil
    else
      function h_releasedfn()
        hyper_triggered()

        o.releasedfn()
      end
    end

    if o.repeatfn == nil then
      h_repeatfn = nil
    else
      function h_repeatfn()
        -- hs.alert("repeating: " .. o.key)
        -- hs.alert("repeating: " .. o.key .. "down: " .. tostring(hyper_modality.down_p) .. "hotkey: " .. tostring(hotkey_holder.my_hotkey))

        if hyper_modality.down_p == false then
          -- @upstreamBug @raceCondition [[id:c27a51c9-d4ea-4714-8c15-72840c1fb933][=repeatfn= is buggy]]

          error("workaround for infinitely repeating hotkeys (id: NIGHT_817124)")
          -- No need for the buggy code down, we can just throw an explicit error ourselves.

        end

        hyper_triggered()

        o.repeatfn()
      end
    end
  else
    h_pressedfn = o.pressedfn
    h_releasedfn = o.releasedfn
    h_repeatfn = o.repeatfn
  end

   hotkey_holder.my_hotkey = hyper_modality:bind(o.mods or {}, o.key, h_pressedfn, h_releasedfn, h_repeatfn)
  -- hs.hotkey.bind(mods, key, [message,] pressedfn, releasedfn, repeatfn) -> hs.hotkey object

  return hotkey_holder.my_hotkey
end

This creates an infinite amount of log messages of:

2024-01-10 09:02:51: 09:02:51 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:02:51: 09:02:51 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:02:51: 09:02:51 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:02:51: 09:02:51 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:02:51: 09:02:51 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0

I was able to achieve what I wanted (though not what the issue initially asked for) by just entering and exiting the modality manually:

            hyper_enter()
            hyper_exit()

in

function hyper_enter()
  hyper_modality:enter()
end

function hyper_exit()
  hyper_modality:exit()
end

function hyper_bind_v2(o)
  local hotkey_holder = { my_hotkey = nil }

  if o.auto_trigger_p == nil then
    o.auto_trigger_p = true
  end

  if o.auto_trigger_p then
    if o.pressedfn == nil then
      h_pressedfn = nil
    else
      function h_pressedfn()
        hyper_triggered()

        o.pressedfn()
      end
    end

    if o.releasedfn == nil then
      h_releasedfn = nil
    else
      function h_releasedfn()
        hyper_triggered()

        o.releasedfn()
      end
    end

    if o.repeatfn == nil then
      h_repeatfn = nil
    else
      function h_repeatfn()
        -- hs.alert("repeating: " .. o.key)
        -- hs.alert("repeating: " .. o.key .. "down: " .. tostring(hyper_modality.down_p) .. "hotkey: " .. tostring(hotkey_holder.my_hotkey))

        if hyper_modality.down_p == false then
            hyper_enter()
            hyper_exit()
            return
        end

        hyper_triggered()

        o.repeatfn()
      end
    end
  else
    h_pressedfn = o.pressedfn
    h_releasedfn = o.releasedfn
    h_repeatfn = o.repeatfn
  end

  hotkey_holder.my_hotkey = hyper_modality:bind(o.mods or {}, o.key, h_pressedfn, h_releasedfn, h_repeatfn)
  -- hs.hotkey.bind(mods, key, [message,] pressedfn, releasedfn, repeatfn) -> hs.hotkey object

  return hotkey_holder.my_hotkey
end

But I still keep getting these infinite amount log messages:

2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0
2024-01-10 09:22:11: 09:22:11 ** Warning:   LuaSkin: hs.hotkey system callback for an eventUID we don't know about: 0

Any ideas on what to do about these warnings?

I don't really care about this issue anymore, I can just ignore the warnings in the log.

@NightMachinery I was hoping to get back to you sooner on this, but there ended up being a lot to write up. TL;DR: There's another way of working around #1178 that shouldn't cause the issues you're having (stuck hotkey repeat, and those eventUID we don't know about warnings), meaning you shouldn't need to disable the hotkeys. If you do need to disable modal hotkeys for this or any other reason, though, I've included info on how to do that too - just skip to the end of this comment.

A (probably) better workaround for #1178

Instead of putting the code with the long-running hs.osascript call directly inside the pressed callback, have that callback just start a 0-second timer that will call another function that does all the real work.

-- If you're okay with the pressedFn not being called when it's already running 
-- from a previous keypress, you can use the same timer each time:
local function timerifyPressedFn(pressedFn)
	local timer = hs.timer.new(0, pressedFn)
	return function()
		timer:start()
	end
end
-- Otherwise, you need to create a new timer for each call/press and make sure it
-- doesn't get garbage-collected:
local function timerifyPressedFn(pressedFn)
	return function()
		local timer
		timer = hs.timer.doAfter(0, function()
			timer = nil
			pressedFn()
		end)
	end
end

Now you need to wrap each pressed function with timerifyPressedFn(...), probably in hyper_bind_v2 (…v3?) so you don't have to remember it for each key. That keeps osascript from being called until after the hotkey callback has already returned, which seems to keep it from getting stuck repeating.

What's actually going on

I think the reason hs.osascript.applescript 'delay 1' triggers #1178 and hs.usleep(1000000) doesn't is that osascript doesn't hang Hammerspoon completely: while it does block the calling function, it also runs (a version of) HS's main event loop itself until the script is done. (That sort of makes sense - AppleScript/OSA is largely built around AppleEvents, so it would have to be able to receive those for the script to run properly.) The result is that Hammerspoon can receive the released event while the pressed function is still running!

hs.hotkey.bind(
	'ctrl', 't', -- 't' for 'test'
	function() -- pressed
		print 'pressed BEGIN'
		hs.osascript.applescript 'delay 1'
		print 'pressed END'
	end,
	function() -- released
		print 'released'
	end,
	function() -- repeat
		print 'repeat'
	end
)

With that hotkey in place, try pressing ctrl-T, and releasing it before the 1 second is up. You'll see:

pressed BEGIN
released
pressed END
repeat
repeat
repeat…

(Press it again for longer than 1 second to unstick it.)

If I'm understanding the code correctly, Hammerspoon doesn't actually receive repeat events for hotkeys - instead, it has to fake them using its own timers. When Hammerspoon receives the released event, it tries to stop the repeat timer… except that it hasn't been started yet, because that only happens after the 'pressed' function returns. Then the pressed function finishes, and Hammerspoon starts the repeat timer… while the key isn't being held anymore, and with nothing to stop it from repeating forever.

Using the timerifyPressedFn workaround above allows Hammerspoon to get around to starting the repeat timer before entering the osascript call, so it's there to be stopped when the released event happens.

What's actually going on, part II: The eventUID we don't know about warnings

This warning seems to be unrelated to #1178. It happens any time the repeat function ends up throwing an error on the very first repeat - like the fake error you're throwing in your second comment to skip handling fake repeats. (Is there a reason you can't just do if hyper_modality.down_p == false then return end instead of throwing an error?)

There are actually two timers involved in hs.hotkey's simulated key repeat: a longer, one-time 'delay' timer (corresponding to the 'Delay Until Repeat' slider) and - once that goes off - a shorter 'repeat' timer (corresponding to the 'Key Repeat' slider).

I'm not sure what's causing that warning in your third comment, however, unless that version of the repeat callback is getting some other error that I'm not seeing in that log snippet.

Actually answering the original question: 'How do I disable a modal hotkey?'

There's not an officially-supported way to do this. As you found, the :bind(...) method on a modal doesn't return a hotkey object, but rather the modal itself (so that you can chain multiple :bind(...):bind(...):bind(...) calls or similar). If you're willing to mess with the modal's implementation details a bit, you can get a hold of the hotkey objects. They're stored in the someModal.keys table, and the last one you created with :bind(...) will be at someModal.keys[#someModal.keys]. However, hs.hotkey.modals are basically just a shortcut for enabling and disabling a bunch of hotkeys at once, so trying to :enable()/:disable() individual hotkeys will just end up changing their current state and getting them out of sync with the modal until the next someModal:enter()/:exit().

You can try using this code:

~/.hammerspoon/disableModalKeys.lua
---------- In ~/.hammerspoon/disableModalKeys.lua: ----------
local enteredModals = setmetatable({}, {__mode = 'k'})
local tremove = table.remove
local indexOf = hs.fnutils.indexOf
local modalKeyMT = {}
modalKeyMT.__index = modalKeyMT
function modalKeyMT:disable()
	if self.enabled then
		local key = self.key
		key:disable(true) -- secret 'isModal' parameter to hide log messages
		local keys = self.modal.keys
		local i = indexOf(keys, key)
		if i then
			tremove(keys, i)
		end
		self.enabled = false
	end
	return self
end
function modalKeyMT:enable()
	if not self.enabled then
		local modal = self.modal
		local key = self.key
		local keys = modal.keys
		if not indexOf(keys, key) then
			keys[#keys+1] = key
		end
		if enteredModals[modal] then
			key:enable(
				nil, -- secret 'force' parameter
				true -- secret 'isModal' parameter to hide log messages
			)
		end
		self.enabled = true
	end
	return self
end

-- Need to keep track of whether a modal has been entered so that we know whether the key should really be enabled
local origEnter, origExit = hs.hotkey.modal.enter, hs.hotkey.modal.exit
function hs.hotkey.modal:enter(...)
	enteredModals[self] = true
	return origEnter(self, ...)
end
function hs.hotkey.modal:exit(...)
	enteredModals[self] = nil
	return origExit(self, ...)
end

function hs.hotkey.modal:bindAndGet(...)
	self:bind(...)
	local key = self.keys[#self.keys]
	return setmetatable({key = key, modal = self, enabled = true}, modalKeyMT)
end
Example usage in ~/.hammerspoon/init.lua
require('disableModalKeys')

-- Now you should be able to do things like:

local someKey = hyper_modality:bindAndGet({}, 'x', function()
end)

someKey:disable()
someKey:enable()

This code basically adds a :bindAndGet(...) method to modal objects that acts like the normal :bind(...), except that it returns an object representing the key. (I'm giving this a different name from the original bind to avoid breaking code that relies on bind's normal bahavior.) That 'modal key' object is actually a wrapper with :enable() and :disable() methods that combine the key's own state with that of the wrapper to decide whether the underlying hs.hotkey should be enabled or not.

Thanks, that was very helpful.

One related question: I have a hotkey bound in my hyper modality to z which sends cmd+z. This doen't work, unless I add a timer with a good delay (e.g., 0.1 seconds) or use another key such as q for the hotkey. I.e., the hotkey's bound key cannot be the same as the key it sends out. Can this be worked around shomehow? I don't want the 0.1 delays.

Yeah, from what I understand trying to do hs.eventtap.keyStroke and similar from within a hotkey's pressed callback has always been twitchy, especially if it's trying to press the same key that's already being pressed. A few options you could try:

  • Change it to a released callback, so the real key isn't being held anymore.
    Simple, but still adds a bit of delay because it waits until the key is up, and doesn't let you hold it down for key repeat (if that matters to you for this use case).
  • Use an hs.eventtap instead of hs.hotkey. Start/stop it from the modal's entered/exited callbacks. Watch for keyDown and keyUp events for the Z key, and add the ⌘ Command key using event:setFlags{cmd=true}.
    Makes the Cmd-Z happen immediately and repeat properly, but is more involved to set up. Also won't work if an app has secure input mode turned on, although you could combine it with the first option as a fallback.
  • Remap it using Karabiner-Elements instead.
    Handles the events much earlier in the process, so it shouldn't have any of the problems of those options. However, moving your hyper key setup from HS to KE would take a while. What you could do instead is leave HS handling most of the hyper key stuff, but use hs.execute to call KE's CLI from the modal's entered/exited callbacks to set a variable that enables/disables the ZCmd-Z mapping in KE.

See also:

Tagging @NightMachinery since I'm not sure if GitHub notified you of my reply on a closed issue.