rvaiya / keyd

A key remapping daemon for linux.
MIT License
2.89k stars 171 forks source link

Dedicated syntax for timeouts #333

Closed nsbgn closed 2 years ago

nsbgn commented 2 years ago

Feel free to close this issue without comment if it doesn't feel right --- since it's primarily aesthetic, I don't think it warrants having long back-and-forths ;) That said, this suggestion could inform the decisions in #309 and #320.

@herrsimon's comment about required hold times in #320 gave me an idea: what if timeouts were given dedicated syntax in general?

That is, instead of this:

capslock = timeout(overload(control, esc), 500, overload(control, noop))

... you'd do something like this:

capslock = overload(control, esc)
capslock 500ms = overload(control, noop)

The default behaviour would be that intervening events interrupt the timeout. Queueing behaviour would be achieved with a slight modification, so that you could get the proposed f = overload2(shift, f, 200) with something like:

f = f
f 200ms queue = overload(shift, f)

In my opinion, this communicates intent rather well and needs minimal reference to the manual. It's succinct and the right-hand side of the configuration stays blissfully low on parentheses and number suffixes and timeout arguments. It has other benefits:

Exact syntax could be different, of course, but I like the general idea.

herrsimon commented 2 years ago

I think you really nailed it! What you propose kills multiple birds with one stone and I would even go a bit further:

On a given layer, every key can have a “regular” binding (<key>[+<key2>+...] = ...) and a “conditional” binding, or binding “with a timer attached” of the form (of course with exact syntax still up for discussion)

<key>[+<key2>+...] <timeout> [<timeout_type>] = ...

and by default a regular binding of each key to its own or the special layers exists (as is the case right now). Conditional bindings are only considered if they are in the topmost active layer on the stack, and if the condition is not met or in the absence of a conditional binding, a regular binding is searched in the layer stack as usual (starting at the topmost active layer).

Instead of

[main]
f = overload(shift, f)

one would now have to write

[main]
f 10s = layer(shift)

which might look more complicated at first and slightly changes functionality, but looking at the result in total it seems much more intuitive to me (as overloading is currently realized by overload{,2,3} and timeout, which would all be unified in a very expressive syntax). In addition to this and what @slakkenhuis already wrote, there are many more advantages:

I would even go farther and allow the right hand side of a binding to take one of the following forms:

<macro>
<macro>, <action>
<action>

with the comma of course up for discussion. This is not a sneaky attempt to finally get oneshot2 and layer2 functionality into keyd (see #328 for a use case), but instead those would come for free. The main argument here is that with the vanishing of toggle2 and swap2, the very compact set of remaining actions would all take a single and self-explanatory argument:

In total, one would thus have a very intuitive binding syntax with “condition = consequence” semantics, which, like the English language, is built up in layers (pun intended), in the sense that you can already speak with minimal knowledge of words and grammar. If you don't care about advanced features, just use <key> = <action> or <key> = <macro>.

Proposal for the values of <timeout_type>: fire (default), fire_on_tap, cancel, queue, idle. In case you don't understand the meaning, it means that the names are not good.

EDIT: Maybe one should sacrifice brevity for even more clarity: Instead of <timeout_type> one could call it <trigger_condition>, naming them hold_or_interrupt (default), hold_or_tap_interrupt, hold_uninterrupted, hold_queue_interrupts and idle_after_release, where the manual could state that in case of an unmet condition the regular binding is looked up.

Propsal for the values of <timeout>: a digit with an (optional?) suffix of ms or s (no suffix would mean milliseconds, seconds are there to cover a typical overload emulation, where one would otherwise probably write a four or five digit number)

Some examples:

  1. Space cadet shift
    [main]
    shift = timeout(S-9, 200, layer(shift)

    would become

    [main]
    shift = S-9
    shift 200ms cancel = layer(shift)

    which is much more expressive in my opinion.

  2. Nesting example from my config I often want to cancel the tap behaviour of an overload if held for a certain amount of time and hence write
    [main]
    timeout(overload(enter, enter), 300, layer(enter))

    This could now be achieved via

    [main]
    enter 300ms = layer(enter)

    again resulting in much more clarity.

  3. Oneshot which is only triggered if next keypress happens within a certain time Instead of
    [main]
    shift = timeout-idle(oneshot(shift), 200, noop)

    as proposed in #277, one could now write

    [main]
    shift = oneshot(shift)
    shift 200ms idle = noop

    which is self-explanatory.

I really hope that I didn't overlook some serious side-effects letting all of the above implode.

rvaiya commented 2 years ago

I haven't had time to read herrsimon's post yet, but it likely won't alter my position.

I don't think it warrants having long back-and-forths ;)

Thanks for making this nice and concise :).

The default behaviour would be that intervening events interrupt the timeout. Queueing behaviour would be achieved with a slight modification, so that you could get the proposed f = overload2(shift, f, 200) with something like:

My problem with this is that it introduces an implicit dependency between different bindings.

Specifically it's not clear how:

f = f f 200ms = overload(shift, f)

differs from:

f = f f 200ms queue = overload(shift, f)

at a glance.

Assuming the latter is identical to 'overload2', the second binding silently changes the interrupt logic. The behaviour of f is now split across multiple statements, the second of which can modify the behaviour of intervening keys in different ways.

This is actually quite unintuitive (imo). I think to those of us that have been steeping in the problem domain, it might at first appear simple, but in practice the user is going to have to develop an understanding of several different concepts by reading the man page.

The word queue is also a bit of a leaky abstraction, since the queueing is really an implementation detail. A dedicated name like overload2 (though I agree the name is suboptimal) is more consistent with the existing syntax and better communicates the intended purpose. Most users interested in overload2 will probably have experimented with overload and encountered its limitations. overload2 is the natural progression with a different set of tradeoffs (which can quickly be gleaned from the man page).

It's succinct and the right-hand side of the configuration stays blissfully low on parentheses and number suffixes and timeout arguments.

The parentheses are a feature, not a bug ;). Nesting is generally a bad sign, but if the user is doing something sufficiently complex, the cost of them is minimal.

f 200ms queue = layer(shift),

I'm not convinced this is significantly easier to understand than overload2(shift, f, 200).

It skirts the issue of naming ("the real problem is that I made the mistake of naming timeout, timeout"), and yet it has a fairly obvious (and extensible!) interpretation.

I have been meaning to revisit the naming issue, but that should probably be done in another issue.

Feel free to ping me on IRC if you want to iterate in person (or just post here).

nsbgn commented 2 years ago

The idea of also pulling sequential overloads into the condition = consequence paradigm did cross my mind (but surely, f = overload(shift, f) would necessitate something more than f 10s = layer(shift) unless you want shift activation to take 10 uninterrupted seconds...) Such things are good to keep in the back of the mind, and the reason I said 'extensible' - but I figured we had better tackle things one at a time. :P

EDIT: I'm on my phone now, will reply to the rest in a bit!

herrsimon commented 2 years ago

but surely, f = overload(shift, f) would necessitate something more than f 10s = layer(shift) unless you want shift activation to take 10 uninterrupted seconds...

The ten seconds are just some long dummy timeout, you could as well set it to one minute or so. Shift triggers as soon as another key is pressed before the timeout (or when you hold uninterrupted for the timeout). As I said, it slightly changes functionality, but I don't see situations where this could be problematic. It could also be seen as an advantage that on tapping shift less key events are produced.

but I figured we had better tackle things one at a time. :P

You are of course right, in this case I made an exception as everything is very connected (changes the way the overloads are achieved).

Feel free to ping me on IRC if you want to iterate in person (or just post here).

It would be nice if we could all discuss in realtime at some point. I have to get some sleep right now but for future planning: My current timezone is GMT+2.

nsbgn commented 2 years ago

With apologies for the back-and-forth that I promised to save you from!

it introduces an implicit dependency between different bindings

I realize this, and would have anticipated that response --- were it not for the fact that combos will also introduce this dependency, even without hold times. Also bear in mind that timeout cannot be used to cancel combos below a hold treshold, because you'd lose the ordering information of the original keys. [^2] All the user should know is that combos and timeouts create exceptional behaviour. Perhaps = could be replaced with -> or += to emphasize that it mutates another binding. I really feel that the tradeoff is worth it, but see your point.

[^2]: EDIT: Unless f+j is different from j+f, which isn't a good idea since the point of combos is that they're struck simultaneously.

in practice the user is going to have to develop an understanding of several different concepts

This is unavoidable once you allow the user access to timeouts. It's better to give it to 'em plain. With my suggestion, the crucial question will at least be confined to a single spot, with only one clear yes-or-no (while you hold this key, would pressing another key cancel the timeout or not?), as opposed to the question being hidden in primitives that end up forcing users to either reverse-engineer the question, or blindly try stuff without understanding the manual.

I suppose that you want users to pick an overload based on whether the key is often struck chorded or in sequence. But it's futile to try to hide from them that the solution is to add a timeout and queue. After all, they have to provide a timeout value! And where does this latency come from? Best to avoid mysterious primitives and instead provide short but sufficient directions in the manual.

[^1]: EDIT: By the way, it just occurred to me that you could even set a per-key default for whether a timeout on that key would be queued or not. That would be a tad too much magic for my tastes, but if you really want users to not think about the concept of queues...

I'm not convinced this is significantly easier to understand

On its own, I agree that it's not much better. But it should be easier to grok in the context of the whole manual, because it would use the same syntax every time.

Anyway, I just wanted to float the idea. In the end, it's a matter of taste. Let's reopen the issue once you've also seen in your mind's eye all the squeaky clean configs that could have been ;)


Shift triggers as soon as another key is pressed before the timeout

No, the timeout would be cancelled. For the purpose of this issue, overload is safe!

It would be nice if we could all discuss in realtime at some point.

+1

nsbgn commented 2 years ago

I know I closed the thread, but on second thought I'd make queue the default, so that

f = overload2(shift, f, 200)

becomes

f 200ms += overload(shift, f)

while

capslock = timeout(overload(control, esc), 500, layer(control))

becomes something like

capslock = overload(control, esc)
capslock alone 500ms += layer(control)

That should cause less confusion about purpose and alleviate concerns about leaking implementation details.

herrsimon commented 2 years ago

First of all, I really think that it would be much more efficient if we can all have a realtime chat about it in the next weeks. If you agree, let's collect our timezones first (again, mine is GMT+2) and then figure out a good time and date.

Also, for the sake of finally being somewhat concise, I'm not going to comment on everything what was written so far (and predict that I will yet fail in my endeavour).

@slakkenhuis

No, the timeout would be cancelled. For the purpose of this issue, overload is safe! You are of course completely right, I overlooked your first nested overload example and was always thinking about a single overload. My apologies!

So please forget my proposal of removing overload, it is still needed.

Please also forget my proposal of introducing the comma on the right hand side of a binding for this discussion, besides of not being optimal, it is purely distracting here.

The core of what we're discussing here is whether the timeout functionality should move from an action (on the right of a binding) to a trigger (on the left of a binding).

First of all, let me just throw functional syntax in the ring, so that the contenders now are

<key>[+<key2>+...] <timeout> <trigger> = <action>
trigger(<key>[+<key2>+...], <timeout>) = <action>

where trigger could for example be

or just the more concise wordings proposed by @slakkenhuis. Again, if the trigger condition is not met, a simple binding should be looked up in the layer stack, starting from the topmost active layer.

The functional syntax has the disadvantage of not being able to specify a default, but it is more flexible regarding future extensions and bindings would be symmetrical as the right hand side is already written in a functional way. I`m slightly leaning towards @slakkenhuis's proposed syntax at the moment, as it keeps the affected key at the very left and thus associated simple and trigger bindings can be singled out more easily in the configuration file. On the other hand, the functional syntax seems to better convey the fact that something is happening to the key (and thus the simple binding) in the first argument.

No matter what the syntax will be, the non-syntactical advantages would be:

Regarding @rvaiya`s criticism that the proposal creates a dependency between multiple bindings: This is true but the problem only occurs on the same layer, whose bindings typically fit in total in the editor window. So unless the user is very messy with the config, a trigger binding will typically appear in the vicinity of the simple binding it affects. If the trigger mechanism is introduced properly in the manual, I agree with @slakkenhuis that this should only be a minor issue, although the necessity of sometimes having to write the key or combo to assign to twice itches me a tiny bit.

In total, I'm still in favour of @slakkenhuis's proposal.

nsbgn commented 2 years ago

Extensions and syntactic variations are possible, but it was an intentional decision not to elaborate on them, because they would at this point overwhelm the question of whether splitting the timeout into multiple statements is intuitive in the first place.

I strongly feel that it is, primarily because it's consistent with combo notation, with many pleasing side effects (and you've noted additional ones, which make it more attractive). But beyond that, it's mostly about gut feeling, which led me to close the issue.

This is true but the problem only occurs on the same layer

That's not necessarily true. You could add a timeout to a key that inherited its mapping from another layer. I don't think that's a problem, but some way of emphasizing the dependency via += or your functional syntax would be a good idea.

realtime chat

Yeah, I should finally be in the channel on my phone now, so we can chat there.