keyszer icon indicating copy to clipboard operation
keyszer copied to clipboard

Make nested keymaps an abstraction around top-level keymaps

Open joshgoebel opened this issue 3 years ago • 48 comments

If we could identify the differences and then make the software flexible enough to build nested keymaps on top of top-level keymaps that would be a big win. Key differences now:

  • nested keymaps ignore a single keypress that doesn't map to anything (then return to top)
  • nested keymaps also return to top after a valid combo is pressed
  • nested keymaps are "modal" (only one active at once)

joshgoebel avatar Aug 30 '22 12:08 joshgoebel

Not that they would literally be top-level... I'm more talking about cleaning up the abstrations a bit... like it seems like if we just go with "modality" alone and say it has the default behavior of "return to top after done/missed"... then now nested keymaps are:

  • just a keymap
  • nested (geography)
  • a modal keymap (state)

So modal is just a flag... something you could (theoretically) turn on/off for global keymaps also...

joshgoebel avatar Aug 30 '22 12:08 joshgoebel

If we went all the way with this then nested keymaps could have conditions as well... though having a condition on a modal (active) keymap seems perhaps problematic... :-)

joshgoebel avatar Aug 30 '22 12:08 joshgoebel

@RedBearAK

joshgoebel avatar Aug 31 '22 12:08 joshgoebel

I was also thinking perhaps a stack of keymaps... imagine we want to kill processes... KT (kill terminal), KC (kill chrome)

{
  # "Kill process" is a named "utility" keymap (not typically part of the global list)
  C("Ctrl-K"): push_keymap("Kill process", modal = true)
}

I think it's unclear how you'd escape from non-modals though... I feel like different use cases could want ALL the behaviors:

  • escape/stay after matching combo
  • escape/stay after non-matching combo

I'm not loving all those options.

Perhaps you escape a non-modal by triggering a combo in another keymap higher up the stack... so if you start a multi-step combo but then "alt-tab" the tabbing cancels the multi-step combo.

joshgoebel avatar Aug 31 '22 12:08 joshgoebel

Maybe (commands):

  • modal_keymap (make a keymap the only keymap)
  • prepend_keymap (push a keymap early on the list, so it get keys first, but not exclusively)
  • remove_keymap (remove a keymap from the active list)

Prepend and remove would work with the global list of keymaps and modal would "lock" you in, as it does now.

joshgoebel avatar Aug 31 '22 12:08 joshgoebel

@joshgoebel

Just trying to digest some of this. Basically you're looking at making keymaps a bit smarter in how they are used?

Don't really understand your example about pushing a keymap for killing processes.

RedBearAK avatar Aug 31 '22 12:08 RedBearAK

Don't really understand your example about pushing a keymap for killing processes.

I'm just showing you could have a key lead to any NAMED keymap... not a specific keymap... or you could have a variable determine what keymap you got.

These would be equivalent:

keymap("Kill process", { xyz })

{
  C("Ctrl-K"): push_keymap("Kill process", modal = true)
}

and

{
  C("Ctrl-K"): { xyz }
}

But push_keymap gives you a lot more power in what should happen...

joshgoebel avatar Aug 31 '22 13:08 joshgoebel

Python 3.10 introduced a structure like Switch/Case:

https://docs.python.org/3.10/whatsnew/3.10.html#pep-634-structural-pattern-matching

Wonder if that could be somehow supported as an alternative to the way nested keys are done now. Maybe it would be simpler.

RedBearAK avatar Aug 31 '22 13:08 RedBearAK

You could push/remove multiple keymaps with a combo, etc...

joshgoebel avatar Aug 31 '22 13:08 joshgoebel

So the push_keymap would literally put you inside the named keymap?

You could push/remove multiple keymaps with a combo, etc...

Yeah, interesting.

RedBearAK avatar Aug 31 '22 13:08 RedBearAK

Python 3.10 introduced a structure like Switch/Case:

That is program code, where-as the map data we need really needs to be furnished as a data structure that we can process and alter (to fill in right/left keys, etc).

joshgoebel avatar Aug 31 '22 13:08 joshgoebel

That is program code, where-as the map data we need really needs to be furnished as a data structure that we can process and alter (to fill in right/left keys, etc).

I kind of expected that.

RedBearAK avatar Aug 31 '22 13:08 RedBearAK

OK, so wait, would this mean if you had a "default" option like Key.ANY inside a keymap, that could be made to... push back to the original keymap? But there still needs to be a way to carry the input through so that it's still treated as "input" outside of the modal keymap. I'm not really seeing a solution to that discussed here.

RedBearAK avatar Aug 31 '22 13:08 RedBearAK

I think if you want outside combos to work then the nested keymap just shouldn't be modal. Do you have an example where you desire a modal keymap that can redirect input to other keymaps?

joshgoebel avatar Sep 01 '22 02:09 joshgoebel

I think if you want outside combos to work then the nested keymap just shouldn't be modal. Do you have an example where you desire a modal keymap that can redirect input to other keymaps?

Well... the way I'm doing it now in AHK, to fully imitate the macOS dead key experience, the default case from the Switch/Case needs to deselect the previously selected accent character. This means there needs to actually be an "exit" action before the input combo goes on to do whatever it would normally do. So just having the keymap be non-modal wouldn't quite do the trick in the same way.

It would need to work something like this, I think.

keymap("DK - Grave", {
    C("a"): C("x"),
    C("b"): C("y"),
    Key.ANY: C("Right"), remove_keymap(),
}

keymap("DK - Acute", {
    C("a"): C("b"),
    Key.ANY: C("Right"), remove_keymap(),
}

keymap("Option Key Special Characters", {
    C("Alt-Grave"): push_keymap("DK - Grave", modal=True),
    C("Alt-E"): push_keymap("DK - Acute", modal=True),
}

But if you activated one of the dead keys keymaps and then did something unrelated like Cmd+A, it would perform the "exit" actions (right arrow and remove keymap) and also let the Cmd+A go on to do "select all" as it normally would.

The question is if the "exit" action could be triggered without the keymap being modal. If it can, there's no problem. And the exit action(s) would need to happen prior to the input combo doing what it would normally do. This is explicit in the AHK version with the "%UserInput%" variable being sent out right at the end.

I assume that "remove_keymap" if it doesn't reference anything would just automatically remove the keymap it's inside of.

RedBearAK avatar Sep 01 '22 07:09 RedBearAK

What problem did you have the other day when trying to build using non-nested keymaps? Do you have your code from that? Looking at it now it should work - it just doesn't STOP evaluating the match conditions when it finds a matching key.... but I don't think that would matter for your use case.

keymap("DK - Grave", {
    C("a"): C("x"),
    C("b"): C("y"),
}, when = lambda: if_unicode_enabled("Grave"))

keymap("DK - Acute", {
    C("a"): C("b"),
}, when = lambda: if_unicode_enabled("Acute"))

# toggles the variable
keymap("disable unicode trigger", {
}, when = lambda: disable_all_unicode()
)

keymap("Option Key Special Characters", {
    C("Alt-Grave"): push_keymap("Grave"),
    C("Alt-E"): push_keymap("Acute"),
}

push_keymap would be your own helper function that sets the global state correctly and enabled the keymaps...

joshgoebel avatar Sep 02 '22 02:09 joshgoebel

A problem here is disable_all_unicode() runs after every keypress, but that should still allow the first two keymaps to be active briefly and trigger a single combo (isn't that all nested keymaps do anyways?)... (before being disabled on the next pass)

joshgoebel avatar Sep 02 '22 02:09 joshgoebel

A problem here is disable_all_unicode() runs after every keypress, but that should still allow the first two keymaps to be active briefly

In my testing the dead key keymap was getting disabled immediately. Because the thing that kills the keymap was also getting evaluated on every key press. What circumstance do you think would delay "disable_all_unicode" from running within a couple of milliseconds of the conditions that activate any of the dead keys keymaps? There's nothing that causes the code to pause inside the newly active keymap.

The dead key keymap would work fine for me as long as I disabled the tripwire keymap. But then of course the only way to disable the dead key keymap was to use one of the designated shortcuts contained within it, and have the same function kill the keymap on the way out.

I didn't actually progress to that point, but it would have looked something more like this.

keymap("DK - Grave", {
    C("a"): [C("x"), KDK()],
    C("b"): [C("y"), KDK()],
}, when = lambda: if_DK_enabled("Grave"))

keymap("DK - Acute", {
    C("a"): [C("b"), KDK()],
}, when = lambda: if_DK_enabled("Acute"))

# toggles the variable
keymap("disable unicode trigger", {
}, when = lambda: KDK()  # KDK = kill_dead_keys
)

keymap("Option Key Special Characters", {
    C("Alt-Grave"): push_keymap("Grave"),
    C("Alt-E"): push_keymap("Acute"),
}

But there's just no way I know of to keep the deactivation condition from killing the newly active keymap before you can even press another key.

RedBearAK avatar Sep 02 '22 02:09 RedBearAK

I got rid of the original because it seemed fairly pointless. But it was something like this. Populate the "ac_Chr" variable, use it to type the correct diacritic, select it, then the appropriate keystroke (from the keymap that was activated by ac_Chr having that value) should overwrite the selection with the full accented character coming from the relevant activated keymap.

Worked fine as long as I completely disabled the tripwire that would automatically kill it.

keymap("DK - Grave", {
    C("a"): [C("x"), KDK()],
    C("b"): [C("y"), KDK()],
}, when = lambda _: ac_Chr == 0x0060)

keymap("DK - Acute", {
    C("a"): [C("b"), KDK()],
}, when = lambda _: ac_Chr == 0x00B4)

# toggles the variable
keymap("disable dead keys trigger", {
}, when = lambda: KDK()  # KDK = kill_dead_keys
)

keymap("Option Key Special Characters", {
    C("Alt-Grave"): [set_dead_key_char(0x0060), UC(ac_Chr), C("Shift-Left")],
    C("Alt-E"): [set_dead_key_char(0x00B4), UC(ac_Chr), C("Shift-Left")],
}

RedBearAK avatar Sep 02 '22 03:09 RedBearAK

What should happen:

  • You Alt-Grave, set_dead_key_char is turned on to 0x60 (as the very last step)
  • the next key you press will SEARCH ALL, and execute one

So if you hit x (a non combo)...

  • DK - grave conditional runs
  • DK - Acture conditional runs
  • disable dead keys trigger conditional runs (and they are disabled)
  • Option Key Special Characters conditional runs
  • no matchs, so x is output without changes

If you hit b:

  • DK - grave conditional runs (match, added to active keymaps)
  • DK - Acture conditional runs
  • disable dead keys trigger conditional runs (match added to active keymaps) (and they are disabled for FUTURE keypresses)
  • Option Key Special Characters conditional runs (match added to active keymaps)

Step 3 does toggle the variable, but it doesn't matter because at that point DK - grace has already been checked AND added to the active keymaps...

The only trick is the "trigger" needs to come AFTER the "submaps" and before the "enable" commands... it must sit in the middle.

Now the active keymaps are searched in order:

    • DK - grave, match found, y is output.

If you want to whip up a test case again (and save it) and if it doesn't work pass it over to me to play with I'd be happy to take a look...

joshgoebel avatar Sep 02 '22 14:09 joshgoebel

The only trick is the "trigger" needs to come AFTER the "submaps" and before the "enable" commands... it must sit in the middle.

The way I laid mine out was just like that, in the order in the example above. But I will put together a test case again.

You Alt-Grave, set_dead_key_char is turned on to 0x60 (as the very last step)

Maybe this is the secret. I'm sending out key presses after setting the variable state. That just means I'll have to manually send the diacritic character out and select it before setting the variable, but that shouldn't be a big deal. I was trying to take too much of a shortcut. Makes sense.

RedBearAK avatar Sep 02 '22 21:09 RedBearAK

Maybe this is the secret

I was not referring to the order of the command sequence. That should be irrelevant since no input happens while commands are running anyways.

joshgoebel avatar Sep 02 '22 22:09 joshgoebel

That should be irrelevant since no input happens while commands are running anyways.

That's kind of what I thought originally.

Unfortunately, nothing that I'm trying is working the way I thought it would, at this point. Absolutely nothing. Either I can't even get the keymap to activate without the trigger keymap, or it activates inappropriately before I even type the input combo, and won't deactivate even with the trigger keymap enabled. I don't remember having nearly this much trouble the last time I tried to do this, but maybe my testing was too limited to display the same problems I'm having now.

I'm about at the stage where I want to run my laptop through an industrial paper shredder and just forget the whole thing. 🤣

_optspecialchars = True
ac_Chr = 0x0000


def set_dead_key_char(hex_unicode_addr):
    global ac_Chr
    ac_Chr = hex_unicode_addr
    # return ac_Chr


def get_dead_key_char(hex_unicode_addr):
    global ac_Chr
    if ac_Chr == hex_unicode_addr:
        return True
    else:
        return False

keymap("DK - Grave", {
        # C("A"):                     C("x"),                 # à Latin Small a with Grave
        C("A"):                     UC(0x00E0),# , set_dead_key_char(0x0000)],                # à Latin Small a with Grave
}, when = lambda ctx: ctx.wm_class not in remotes and _optspecialchars is True and get_dead_key_char(0x0060) is True)
# }, when = lambda ctx: ctx.wm_class not in remotes and _optspecialchars is True and ac_Chr == 0x0060)
# }, when = lambda _: ac_Chr == 0x0060)

# keymap("Disable Dead Keys",{
#     # Nothing here. Tripwire keymap to disable active dead keys keymap(s)
# }, when = lambda _: set_dead_key_char(0x0000))

keymap("Option key special characters US", {

    # Number keys row with Option
    ######################################################
    C("Alt-Grave"): [UC(0x0060), C("Shift-Left"), set_dead_key_char(0x0060)],
}, when = lambda ctx: ctx.wm_class not in remotes and _optspecialchars is True)

RedBearAK avatar Sep 03 '22 00:09 RedBearAK

I'll test tomorrow and see if I can get this working.

joshgoebel avatar Sep 03 '22 01:09 joshgoebel

Ok, it indeed works, but I'm not sure why you've started so complex. First lets get it working, then make it more complex.

I'm not sure if any state is needed for the alt-grave:

keymap("Option key special characters US", {
    C("Alt-Grave"): [UC(0x0060), C("Shift-Left"), set_dead_key_char(0x0060)],
})

I simplified the keymap to the variable check variant:

keymap("DK - Grave", {
        C("a"): UC(0x00E0), # à Latin Small a with Grave
}, when = lambda _: ac_Chr == 0x0060)

Now we need to remember that set_dead_key_char returns a function so we need to call it, THEN call the function it returns, hence the extra ().

keymap("Disable Dead Keys",{
    # Nothing here. Tripwire keymap to disable active dead keys keymap(s)
}, when = lambda _: set_dead_key_char(None)())

And the one BIG thing I think you're doing wrong is that helpers need to return FUNCTIONS... otherwise they only run once when the config is evaluate, and that accomplishes nothing:

def set_dead_key_char(hex_unicode_addr):
    global ac_Chr
    def fn():
        global ac_Chr
        debug("dead key set to", hex_unicode_addr)
        ac_Chr = hex_unicode_addr

    return fn

Not sure if both globals are necessary there or not. I just stopped when I got it working..

joshgoebel avatar Sep 03 '22 22:09 joshgoebel

IE, set_dead_key_char doesn't need to DO That... because doing that inside the configuration makes zero sense... it needs to return a function to do that LATER, while Keyszer is running.

joshgoebel avatar Sep 03 '22 22:09 joshgoebel

Oh, this dead key stuff is pretty nifty too! 💙💙💙

joshgoebel avatar Sep 03 '22 23:09 joshgoebel

is that helpers need to return FUNCTIONS...

I wonder if we could make this mistake hard to make somehow... hmmm...

joshgoebel avatar Sep 03 '22 23:09 joshgoebel

Oh, so if it returns a function, that's the kind of object that can be evaluated over and over again, but just returning a "True" or something is a logical dead end? Kind of makes sense, I guess.

Now we need to remember that set_dead_key_char returns a function so we need to call it, THEN call the function it returns, hence the extra ().

Putting an extra "()" after the initial set of parentheses with something inside is not something that would have EVER made sense to me to try. It still doesn't. I've never seen anything like that. That's just weird. But as long as it works. 🤷🏽‍♂️

Not sure if both globals are necessary there or not. I just stopped when I got it working..

I don't think the second one is necessary. Pretty sure the "globalness" of the variable from the first time carries over into any sub-functions. But I'll double-check. [Edit: Wrong. I think the global is necessary at each level. Good job. 👍🏽 ]

I simplified the keymap to the variable check variant:

I did try that, it was commented out in the sample because even that simple form wasn't behaving as I expected. Probably going wrong somewhere else.

IE, set_dead_key_char doesn't need to DO That... because doing that inside the configuration makes zero sense... it needs to return a function to do that LATER, while Keyszer is running.

Yeah, I kind of see the difference. It shouldn't just "do the thing" but make an object (a simple machine made of code) that can "do the thing" later as many times as it needs to.

I wonder if we could make this mistake hard to make somehow... hmmm...

I'm sure I won't be the only one that will have trouble grasping exactly what it is that when wants. But I don't have any idea of how to simplify or put a fence around it.

I'm not sure if any state is needed for the alt-grave:

The entire Option key scheme has to be disabled by default, because it covers the ENTIRE keyboard with alternate characters when using Option or Shift+Option. That means any shortcut combo that normally uses Alt+key or Shift+Alt+key will be blocked when the special character scheme is active. So I had to set it up to be inactive and then activated/deactivated by Shift+Opt+Cmd+O.

Unfortunately both Windows and Linux apps rely on too many Alt-based shortcuts for there to be any easy way around this.

Although, if you take Kinto's mimicry of the Apple keyboard to its logical conclusion, I guess you could argue that the Option key scheme should be active by default, and any Alt-based shortcuts that happen to be interfered with should actually be remapped to be based on Cmd (same physical location) or Ctrl rather than still trying to use the Alt/Option key. Hmmm... 🤔

I guess I've been thinking that the Option key scheme should "stay out of the way", but in reality none of those Alt-based shortcuts that it steps on would work the same way on macOS. So they should be fixed. This is really something to think about. That would actually kind of be neat if it was just active all the time by default. Like any Mac you sit down to use.

Oh, this dead key stuff is pretty nifty too!

Heck yeah, lots of useful characters even on the standard US layout. I lost count at about 600 characters on the "ABC Extended" layout. I don't know if I'll ever have the time and focus to complete that one. Kind of halfway through building the catalog that I'll need for the implementation process.

RedBearAK avatar Sep 04 '22 01:09 RedBearAK

In my imagination just now I had an idea that maybe the set_dead_key_char function could actually do the "exit" action I've been looking for, if the variable isn't "None". Am I losing it? I guess we'll see soon.

RedBearAK avatar Sep 04 '22 01:09 RedBearAK