Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Core] Add Chordal Hold, an "opposite hands rule" tap-hold option similar to Achordion, Bilateral Combinations. #24560

Open
wants to merge 40 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fa857db
Chordal Hold: restrict what chords settle as hold
getreuer Nov 2, 2024
e0f648d
Chordal Hold: docs and further improvements
getreuer Nov 3, 2024
352f4fa
Fix formatting.
getreuer Nov 3, 2024
ed53b7d
Doc rewording and minor edit.
getreuer Nov 4, 2024
3fdbb07
Support Chordal Hold of multiple tap-hold keys.
getreuer Nov 8, 2024
99d49ac
Fix formatting.
getreuer Nov 8, 2024
da8ccf0
Simplification and additional test.
getreuer Nov 8, 2024
1606d67
Fix formatting.
getreuer Nov 8, 2024
bd7e54a
Tighten tests.
getreuer Nov 11, 2024
6b55824
Add test two_mod_taps_same_hand_hold_til_timeout.
getreuer Nov 14, 2024
e924a0c
Revise handing of pairs of tap-hold keys.
getreuer Nov 16, 2024
fb6c2d8
Generate a default chordal_hold_layout.
getreuer Nov 17, 2024
8f86425
Merge branch 'develop' into chordal_hold
getreuer Nov 17, 2024
5b5ff41
Document chordal_hold_handedness().
getreuer Nov 20, 2024
60e8288
Add license notice to new and branched files in PR.
getreuer Nov 23, 2024
4e46c16
Merge branch 'develop' into chordal_hold
getreuer Nov 23, 2024
a525048
Add `tapping.chordal_hold` property for info.json.
getreuer Nov 23, 2024
4f3f5b3
Update docs/reference_info_json.md
getreuer Nov 28, 2024
234fb97
Merge branch 'develop' into chordal_hold
getreuer Nov 28, 2024
2014205
Revise "hand" jsonschema.
getreuer Nov 28, 2024
355f9f9
Chordal Hold: Improved layout handedness heuristic.
getreuer Dec 5, 2024
788f4aa
Use Optional instead of `| None`.
getreuer Dec 5, 2024
da32973
Refactor to avoid lambdas.
getreuer Dec 5, 2024
ae9fa23
Merge branch 'develop' into chordal_hold
getreuer Dec 5, 2024
c4d9180
Remove trailing comma in chordal_hold_layout.
getreuer Dec 7, 2024
503752a
Minor docs edits.
getreuer Dec 13, 2024
5af4ae7
Merge branch 'develop' into chordal_hold
getreuer Dec 13, 2024
2b1eff2
Revise to allow combining multiple same-hand mods.
getreuer Dec 24, 2024
bb7a3c3
Merge branch 'develop' into chordal_hold
getreuer Dec 24, 2024
4c5f603
Fix formatting.
getreuer Dec 24, 2024
a2bbbfa
Merge branch 'develop' into chordal_hold
getreuer Jan 3, 2025
c0cf45c
Add a couple tests with LT keys.
getreuer Jan 3, 2025
36fdeb3
Remove stale use of CHORDAL_HOLD_LAYOUT.
getreuer Jan 8, 2025
2aacc99
Fix misspelling lastest -> latest
getreuer Jan 8, 2025
11cc997
Merge branch 'develop' into chordal_hold
getreuer Jan 8, 2025
e6ecff4
Merge branch 'develop' into chordal_hold
getreuer Jan 9, 2025
ae88194
Handling tweak for LTs and tests.
getreuer Jan 15, 2025
d23fd44
Fix formatting.
getreuer Jan 15, 2025
f649bc4
More tests with LT keys.
getreuer Jan 16, 2025
3cefbaf
Fix formatting.
getreuer Jan 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions data/mappings/info_config.hjson
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"SPLIT_WPM_ENABLE": {"info_key": "split.transport.sync.wpm", "value_type": "flag"},

// Tapping
"CHORDAL_HOLD": {"info_key": "tapping.chordal_hold", "value_type": "flag"},
"HOLD_ON_OTHER_KEY_PRESS": {"info_key": "tapping.hold_on_other_key_press", "value_type": "flag"},
"HOLD_ON_OTHER_KEY_PRESS_PER_KEY": {"info_key": "tapping.hold_on_other_key_press_per_key", "value_type": "flag"},
"PERMISSIVE_HOLD": {"info_key": "tapping.permissive_hold", "value_type": "flag"},
Expand Down
7 changes: 6 additions & 1 deletion data/schemas/keyboard.jsonschema
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,11 @@
"h": {"$ref": "qmk.definitions.v1#/key_unit"},
"w": {"$ref": "qmk.definitions.v1#/key_unit"},
"x": {"$ref": "qmk.definitions.v1#/key_unit"},
"y": {"$ref": "qmk.definitions.v1#/key_unit"}
"y": {"$ref": "qmk.definitions.v1#/key_unit"},
"hand": {
"type": "string",
"enum": ["L", "R", "*"]
}
}
}
}
Expand Down Expand Up @@ -915,6 +919,7 @@
"tapping": {
"type": "object",
"properties": {
"chordal_hold": {"type": "boolean"},
"force_hold": {"type": "boolean"},
"force_hold_per_key": {"type": "boolean"},
"ignore_mod_tap_interrupt": {"type": "boolean"},
Expand Down
4 changes: 4 additions & 0 deletions docs/reference_info_json.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ You can create `info.json` files at every level under `qmk_firmware/keyboards/<k
* The delay between keydown and keyup for tap events in milliseconds.
* Default: `0` (no delay)
* `tapping`
* `chordal_hold` <Badge type="info">Boolean</Badge>
* Default: `false`
* `hold_on_other_key_press` <Badge type="info">Boolean</Badge>
* Default: `false`
* `hold_on_other_key_press_per_key` <Badge type="info">Boolean</Badge>
Expand Down Expand Up @@ -328,6 +330,8 @@ The ISO enter key is represented by a 1.25u×2uh key. Renderers which utilize in
* `h` <Badge type="info">KeyUnit</Badge>
* The height of the key, in key units.
* Default: `1` (1u)
* `hand` <Badge type="info">String</Badge>
* The handedness of the key for Chordal Hold, either `"L"` (left hand), `"R"` (right hand), or `"*"` (either or exempted handedness).
* `label` <Badge type="info">String</Badge>
* What to name the key. This is *not* a key assignment as in the keymap, but should usually correspond to the keycode for the first layer of the default keymap.
* Example: `"Escape"`
Expand Down
163 changes: 163 additions & 0 deletions docs/tap_hold.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,169 @@ uint16_t get_quick_tap_term(uint16_t keycode, keyrecord_t *record) {
If `QUICK_TAP_TERM` is set higher than `TAPPING_TERM`, it will default to `TAPPING_TERM`.
:::

## Chordal Hold

Chordal Hold is intended to be used together with either Permissive Hold or Hold
On Other Key Press. Chordal Hold is enabled by adding to your `config.h`:

```c
#define CHORDAL_HOLD
```

Chordal Hold implements, by default, an "opposite hands" rule. Suppose a
tap-hold key is pressed and then, before the tapping term, another key is
pressed. With Chordal Hold, the tap-hold key is settled as tapped if the two
keys are on the same hand.

Otherwise, if the keys are on opposite hands, Chordal Hold introduces no new
behavior. Hold On Other Key Press or Permissive Hold may be used together with
Chordal Hold to configure the behavior in the opposite hands case. With Hold On
Other Key Press, an opposite hands chord is settled immediately as held. Or with
Permissive Hold, an opposite hands chord is settled as held provided the other
key is pressed and released (nested press) before releasing the tap-hold key.

Chordal Hold may be useful to avoid accidental modifier activation with
mod-taps, particularly in rolled keypresses when using home row mods.

Notes:

* Chordal Hold has no effect after the tapping term.

* Combos are exempt from the opposite hands rule, since "handedness" is
ill-defined in this case. Even so, Chordal Hold's behavior involving combos
may be customized through the `get_chordal_hold()` callback.

An example of a sequence that is affected by “chordal hold”:

- `SFT_T(KC_A)` Down
- `KC_C` Down
- `KC_C` Up
- `SFT_T(KC_A)` Up

```
TAPPING_TERM
+---------------------------|--------+
| +----------------------+ | |
| | SFT_T(KC_A) | | |
| +----------------------+ | |
| +--------------+ | |
| | KC_C | | |
| +--------------+ | |
+---------------------------|--------+
```

If the two keys are on the same hand, then this will produce `ac` with
`SFT_T(KC_A)` settled as tapped the moment that `KC_C` is pressed.

If the two keys are on opposite hands and the `HOLD_ON_OTHER_KEY_PRESS` option
enabled, this will produce `C` with `SFT_T(KC_A)` settled as held when `KC_C` is
pressed.

Or if the two keys are on opposite hands and the `PERMISSIVE_HOLD` option is
enabled, this will produce `C` with `SFT_T(KC_A)` settled as held when that
`KC_C` is released.

### Chordal Hold Handedness

Determining whether keys are on the same or opposite hands involves defining the
"handedness" of each key position. By default, if nothing is specified,
handedness is guessed based on keyboard geometry.

Handedness may be specified with `chordal_hold_layout`. In keymap.c, define
`chordal_hold_layout` in the following form:

```c
const char chordal_hold_layout[MATRIX_ROWS][MATRIX_COLS] PROGMEM =
getreuer marked this conversation as resolved.
Show resolved Hide resolved
LAYOUT(
'L', 'L', 'L', 'L', 'L', 'L', 'R', 'R', 'R', 'R', 'R', 'R',
'L', 'L', 'L', 'L', 'L', 'L', 'R', 'R', 'R', 'R', 'R', 'R',
'L', 'L', 'L', 'L', 'L', 'L', 'R', 'R', 'R', 'R', 'R', 'R',
'L', 'L', 'L', 'R', 'R', 'R'
);
```

Use the same `LAYOUT` macro as used to define your keymap layers. Each entry is
a character indicating the handedness of one key, either `'L'` for left, `'R'`
for right, or `'*'` to exempt keys from the "opposite hands rule." A key with
`'*'` handedness may settle as held in chords with any other key. This could be
used perhaps on thumb keys or other places where you want to allow same-hand
chords.

Keyboard makers may specify handedness in keyboard.json. Under `"layouts"`,
specify the handedness of a key by adding a `"hand"` field with a value of
either `"L"`, `"R"`, or `"*"`. Note that if `"layouts"` contains multiple
layouts, only the first one is read. For example:

```json
{"matrix": [5, 6], "x": 0, "y": 5.5, "w": 1.25, "hand", "*"},
```

Alternatively, handedness may be defined functionally with
`chordal_hold_handedness()`. For example, in keymap.c define:

```c
char chordal_hold_handedness(keypos_t key) {
if (key.col == 0 || key.col == MATRIX_COLS - 1) {
return '*'; // Exempt the outer columns.
}
// On split keyboards, typically, the first half of the rows are on the
// left, and the other half are on the right.
return key.row < MATRIX_ROWS / 2 ? 'L' : 'R';
}
```

Given the matrix position of a key, the function should return `'L'`, `'R'`, or
`'*'`. Adapt the logic in this function according to the keyboard's matrix.

getreuer marked this conversation as resolved.
Show resolved Hide resolved
::: warning
Note the matrix may have irregularities around larger keys, around the edges of
the board, and around thumb clusters. You may find it helpful to use [this
debugging example](faq_debug#which-matrix-position-is-this-keypress) to
correspond physical keys to matrix positions.
:::

::: tip If you define both `chordal_hold_layout[MATRIX_ROWS][MATRIX_COLS]` and
`chordal_hold_handedness(keypos_t key)` for handedness, the latter takes
precedence.
:::


### Per-chord customization

Beyond the per-key configuration possible through handedness, Chordal Hold may
be configured at a *per-chord* granularity for detailed tuning. In keymap.c,
define `get_chordal_hold()`. Returning `true` allows the chord to be held, while
returning `false` settles as tapped.

For example:

```c
bool get_chordal_hold(uint16_t tap_hold_keycode, keyrecord_t* tap_hold_record,
uint16_t other_keycode, keyrecord_t* other_record) {
// Exceptionally allow some one-handed chords for hotkeys.
switch (tap_hold_keycode) {
case LCTL_T(KC_Z):
if (other_keycode == KC_C || other_keycode == KC_V) {
return true;
}
break;

case RCTL_T(KC_SLSH):
if (other_keycode == KC_N) {
return true;
}
break;
}
// Otherwise defer to the opposite hands rule.
return get_chordal_hold_default(tap_hold_record, other_record);
}
```

As shown in the last line above, you may use
`get_chordal_hold_default(tap_hold_record, other_record)` to get the default tap
vs. hold decision according to the opposite hands rule.


## Retro Tapping

To enable `retro tapping`, add the following to your `config.h`:
Expand Down
138 changes: 133 additions & 5 deletions lib/python/qmk/cli/generate/keyboard_c.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Used by the make system to generate keyboard.c from info.json.
"""
import bisect
import dataclasses
from typing import Optional

from milc import cli

from qmk.info import info_json
Expand Down Expand Up @@ -87,6 +91,7 @@ def _gen_matrix_mask(info_data):
lines.append(f' 0b{"".join(reversed(mask[i]))},')
lines.append('};')
lines.append('#endif')
lines.append('')

return lines

Expand Down Expand Up @@ -122,6 +127,128 @@ def _gen_joystick_axes(info_data):

lines.append('};')
lines.append('#endif')
lines.append('')

return lines


@dataclasses.dataclass
class LayoutKey:
"""Geometric info for one key in a layout."""
row: int
col: int
x: float
y: float
w: float = 1.0
h: float = 1.0
hand: Optional[str] = None

@staticmethod
def from_json(key_json):
row, col = key_json['matrix']
return LayoutKey(
row=row,
col=col,
x=key_json['x'],
y=key_json['y'],
w=key_json.get('w', 1.0),
h=key_json.get('h', 1.0),
hand=key_json.get('hand', None),
)

@property
def cx(self):
"""Center x coordinate of the key."""
return self.x + self.w / 2.0

@property
def cy(self):
"""Center y coordinate of the key."""
return self.y + self.h / 2.0


class Layout:
"""Geometric info of a layout."""
def __init__(self, layout_json):
self.keys = [LayoutKey.from_json(key_json) for key_json in layout_json['layout']]
self.x_min = min(key.cx for key in self.keys)
self.x_max = max(key.cx for key in self.keys)
self.x_mid = (self.x_min + self.x_max) / 2
# If there is one key with width >= 6u, it is probably the spacebar.
i = [i for i, key in enumerate(self.keys) if key.w >= 6.0]
self.spacebar = self.keys[i[0]] if len(i) == 1 else None

def is_symmetric(self, tol: float = 0.02):
"""Whether the key positions are symmetric about x_mid."""
x = sorted([key.cx for key in self.keys])
for i in range(len(x)):
x_i_mirrored = 2.0 * self.x_mid - x[i]
# Find leftmost x element greater than or equal to (x_i_mirrored - tol).
j = bisect.bisect_left(x, x_i_mirrored - tol)
if j == len(x) or abs(x[j] - x_i_mirrored) > tol:
return False

return True

def widest_horizontal_gap(self):
"""Finds the x midpoint of the widest horizontal gap between keys."""
x = sorted([key.cx for key in self.keys])
x_mid = self.x_mid
max_sep = 0
for i in range(len(x) - 1):
sep = x[i + 1] - x[i]
if sep > max_sep:
max_sep = sep
x_mid = (x[i + 1] + x[i]) / 2

return x_mid


def _gen_chordal_hold_layout(info_data):
"""Convert info.json content to chordal_hold_layout
"""
# NOTE: If there are multiple layouts, only the first is read.
for layout_name, layout_json in info_data['layouts'].items():
layout = Layout(layout_json)
break

if layout.is_symmetric():
# If the layout is symmetric (e.g. most split keyboards), guess the
# handedness based on the sign of (x - layout.x_mid).
hand_signs = [key.x - layout.x_mid for key in layout.keys]
elif layout.spacebar is not None:
# If the layout has a spacebar, form a dividing line through the spacebar,
# nearly vertical but with a slight angle to follow typical row stagger.
x0 = layout.spacebar.cx - 0.05
y0 = layout.spacebar.cy - 1.0
hand_signs = [(key.x - x0) - (key.y - y0) / 3.0 for key in layout.keys]
else:
# Fallback: assume handedness based on the widest horizontal separation.
x_mid = layout.widest_horizontal_gap()
hand_signs = [key.x - x_mid for key in layout.keys]

for key, hand_sign in zip(layout.keys, hand_signs):
if key.hand is None:
if key == layout.spacebar or abs(hand_sign) <= 0.02:
key.hand = '*'
else:
key.hand = 'L' if hand_sign < 0.0 else 'R'

lines = []
lines.append('#ifdef CHORDAL_HOLD')
line = ('__attribute__((weak)) const char chordal_hold_layout[MATRIX_ROWS][MATRIX_COLS] PROGMEM = ' + layout_name + '(')

x_prev = None
for key in layout.keys:
if x_prev is None or key.x < x_prev:
lines.append(line)
line = ' '
line += f"'{key.hand}', "
x_prev = key.x

lines.append(line[:-2])
lines.append(');')
lines.append('#endif')

return lines

Expand All @@ -136,11 +263,12 @@ def generate_keyboard_c(cli):
kb_info_json = info_json(cli.args.keyboard)

# Build the layouts.h file.
keyboard_h_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#include QMK_KEYBOARD_H', '']
keyboard_c_lines = [GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE, '#include QMK_KEYBOARD_H', '']

keyboard_h_lines.extend(_gen_led_configs(kb_info_json))
keyboard_h_lines.extend(_gen_matrix_mask(kb_info_json))
keyboard_h_lines.extend(_gen_joystick_axes(kb_info_json))
keyboard_c_lines.extend(_gen_led_configs(kb_info_json))
keyboard_c_lines.extend(_gen_matrix_mask(kb_info_json))
keyboard_c_lines.extend(_gen_joystick_axes(kb_info_json))
keyboard_c_lines.extend(_gen_chordal_hold_layout(kb_info_json))

# Show the results
dump_lines(cli.args.output, keyboard_h_lines, cli.args.quiet)
dump_lines(cli.args.output, keyboard_c_lines, cli.args.quiet)
Loading
Loading