feat(q5_max): add chord-based unicode/emoji input system

Implement a chord-mode unicode entry system activated by Fn1+LeftAlt
(CHORD_KEY). Supports two activation styles: tap CHORD_KEY then type
the sequence within a 2-second window, or hold CHORD_KEY, type the
sequence, and release to commit.

- Add chord_unicode.c/h with a ~110-entry table covering math symbols
  (°²³√≈≠≤≥±÷×∞π), Greek letters, currency, fractions, arrows,
  typography, and a broad emoji set
- Prefix-aware matching with a 300ms disambiguation timer handles
  same-prefix alias pairs (e.g. lte/lteq→≤, inf/infty→∞) cleanly
- Backspace deletes, Enter confirms, Escape cancels while in chord mode
- Modifier and layer key-up events pass through so TT(FN1) release
  correctly deactivates the layer while chord mode is active
- Enable UNICODE_ENABLE and wire chord_unicode.c into the build
This commit is contained in:
2026-04-06 12:18:08 -04:00
parent 893fd02ec2
commit c819c83b80
4 changed files with 529 additions and 1 deletions
@@ -0,0 +1,440 @@
// Copyright 2024 rootiest
// SPDX-License-Identifier: GPL-2.0-or-later
#include "chord_unicode.h"
#include <string.h>
// ============================================================================
// Chord table
// ============================================================================
// Rules:
// • Sequences are lowercase ASCII letters and digits only.
// • Two entries whose sequences share a prefix are fine as long as they
// produce the SAME output (aliases). If they produce DIFFERENT output,
// they must differ before the shorter one ends — otherwise the
// disambiguation timer handles it (CHORD_DISAMBIG_MS).
// • Max sequence length is CHORD_MAX_LEN (8).
//
// NOTE: ARM targets (this keyboard) store const data in flash regardless of
// PROGMEM; no pgm_read_* helpers are needed here.
// ============================================================================
typedef struct {
const char *seq; // ASCII sequence to type after CHORD_KEY
const char *out; // UTF-8 string to output
} chord_entry_t;
// clang-format off
static const chord_entry_t chord_table[] = {
// ---- Math / Science ----------------------------------------------------
{"deg", "°"}, // degree sign U+00B0
{"sqrt", ""}, // square root U+221A
{"sqrd", "²"}, // superscript 2 U+00B2
{"cubd", "³"}, // superscript 3 U+00B3
{"aprx", ""}, // almost equal U+2248
{"apx", ""}, // alias
{"neq", ""}, // not equal U+2260
{"lteq", ""}, // less-or-equal U+2264
{"lte", ""}, // alias
{"gteq", ""}, // greater-or-equal U+2265
{"gte", ""}, // alias
{"pm", "±"}, // plus-minus U+00B1
{"div", "÷"}, // division sign U+00F7
{"times", "×"}, // multiplication sign U+00D7
{"mult", "×"}, // alias
{"infty", ""}, // infinity U+221E
{"inf", ""}, // alias (prefix of "infty" → same output, OK)
{"pi", "π"}, // pi U+03C0
{"micro", "µ"}, // micro sign U+00B5
{"sigma", "Σ"}, // capital sigma U+03A3
{"delta", "Δ"}, // capital delta U+0394
{"theta", "θ"}, // small theta U+03B8
{"omega", "Ω"}, // capital omega U+03A9
{"alpha", "α"}, // small alpha U+03B1
{"beta", "β"}, // small beta U+03B2
// ---- Currency ----------------------------------------------------------
{"euro", ""}, // euro U+20AC
{"pound", "£"}, // pound sterling U+00A3
{"yen", "¥"}, // yen / yuan U+00A5
{"cent", "¢"}, // cent sign U+00A2
// ---- Fractions ---------------------------------------------------------
{"half", "½"}, // 1/2 U+00BD
{"qtr", "¼"}, // 1/4 U+00BC
{"3qtr", "¾"}, // 3/4 U+00BE
// ---- Legal / IP --------------------------------------------------------
{"copy", "©"}, // copyright U+00A9
{"reg", "®"}, // registered U+00AE
{"tm", ""}, // trademark U+2122
// ---- Arrows ------------------------------------------------------------
// Use "arr{l,r,u,d}" for the four cardinal arrows and
// "arr{h,v}" for the bidirectional pair. This avoids "arrl" being a
// prefix of a different-output entry.
{"arrl", ""}, // left U+2190
{"arrr", ""}, // right U+2192
{"arru", ""}, // up U+2191
{"arrd", ""}, // down U+2193
{"arrh", ""}, // left↔right U+2194
{"arrv", ""}, // up↕down U+2195
{"dbl", ""}, // double right arrow U+21D2
// ---- Typography --------------------------------------------------------
{"bull", ""}, // bullet U+2022
{"mdash", ""}, // em dash U+2014
{"ndash", ""}, // en dash U+2013
{"ellip", ""}, // horizontal ellipsis U+2026
// ---- Emoji : Faces -----------------------------------------------------
{"smile", "🙂"}, // slightly smiling face U+1F642
{"grin", "😁"}, // beaming face U+1F601
{"lol", "😂"}, // face with tears of joy U+1F602
{"cry", "😭"}, // loudly crying face U+1F62D
{"sad", "🙁"}, // slightly frowning face U+1F641
{"wink", "😉"}, // winking face U+1F609
{"cool", "😎"}, // smiling face w/ sunglasses U+1F60E
{"think", "🤔"}, // thinking face U+1F914
{"shrug", "🤷"}, // person shrugging U+1F937
{"ugh", "😤"}, // face with steam U+1F624
{"wow", "😮"}, // face with open mouth U+1F62E
{"zip", "🤐"}, // zipper-mouth face U+1F910
{"nerdy", "🤓"}, // nerd face U+1F913
// ---- Emoji : Gestures --------------------------------------------------
{"thup", "👍"}, // thumbs up U+1F44D
{"thdn", "👎"}, // thumbs down U+1F44E
{"wave", "👋"}, // waving hand U+1F44B
{"clap", "👏"}, // clapping U+1F44F
{"fist", ""}, // raised fist U+270A
{"pray", "🙏"}, // folded hands U+1F64F
{"ok", "👌"}, // ok hand U+1F44C
{"point", "👉"}, // backhand index pointing right U+1F449
// ---- Emoji : Symbols ---------------------------------------------------
// Variation selectors (U+FE0F) are omitted for cross-app compatibility;
// modern renderers apply emoji presentation automatically.
{"heart", ""}, // heavy black heart U+2764
{"check", ""}, // check mark U+2713
{"cross", ""}, // ballot X U+2717
{"warn", ""}, // warning sign U+26A0
{"stop", "🛑"}, // stop sign U+1F6D1
{"yes", ""}, // white heavy check mark U+2705
{"nope", ""}, // cross mark U+274C
{"ques", ""}, // question mark U+2753
{"excl", ""}, // exclamation mark U+2757
{"help", ""}, // information source U+2139
{"fire", "🔥"}, // fire U+1F525
{"star", ""}, // star U+2B50
{"tada", "🎉"}, // party popper U+1F389
{"100", "💯"}, // hundred points U+1F4AF
{"zzz", "💤"}, // zzz U+1F4A4
{"skull", "💀"}, // skull U+1F480
{"poop", "💩"}, // pile of poo U+1F4A9
{"eyes", "👀"}, // eyes U+1F440
{"bell", "🔔"}, // bell U+1F514
{"mute", "🔇"}, // muted speaker U+1F507
{"loud", "🔊"}, // loud speaker U+1F50A
{"bulb", "💡"}, // light bulb U+1F4A1
{"tack", "📌"}, // pushpin / thumbtack U+1F4CC
{"key", "🔑"}, // key U+1F511
{"lock", "🔒"}, // lock U+1F512
{"robot", "🤖"}, // robot U+1F916
{"alien", "👽"}, // alien U+1F47D
// ---- Emoji : Nature ----------------------------------------------------
{"sun", ""}, // black sun U+2600
{"moon", "🌙"}, // crescent moon U+1F319
{"snow", ""}, // snowflake U+2744
{"rain", "🌧"}, // cloud with rain U+1F327
{"bolt", ""}, // high voltage / lightning U+26A1
{"cat", "🐱"}, // cat face U+1F431
{"dog", "🐶"}, // dog face U+1F436
{"fox", "🦊"}, // fox face U+1F98A
{"bear", "🐻"}, // bear face U+1F43B
// ---- Emoji : Food & Objects --------------------------------------------
{"coffee",""}, // hot beverage U+2615
{"beer", "🍺"}, // beer mug U+1F37A
{"pizza", "🍕"}, // pizza U+1F355
{"cake", "🎂"}, // birthday cake U+1F382
{"gift", "🎁"}, // wrapped gift U+1F381
{"mike", "🎤"}, // microphone U+1F3A4
{"mus", "🎵"}, // musical note U+1F3B5
{"phone", "📱"}, // mobile phone U+1F4F1
{"pc", "💻"}, // laptop U+1F4BB
{"book", "📖"}, // open book U+1F4D6
{"mail", "📧"}, // e-mail U+1F4E7
{"money", "💰"}, // money bag U+1F4B0
{"gem", "💎"}, // gem stone U+1F48E
{"sword", ""}, // crossed swords U+2694
{"shield","🛡"}, // shield U+1F6E1
{"rocket","🚀"}, // rocket U+1F680
{"tools", "🔧"}, // wrench / tools U+1F527
{"trash", "🗑"}, // wastebasket U+1F5D1
{"clock", "🕐"}, // one o'clock U+1F550
{"hour", ""}, // hourglass with flowing sand U+23F3
};
// clang-format on
#define CHORD_TABLE_LEN (sizeof(chord_table) / sizeof(chord_table[0]))
// ============================================================================
// State machine
// ============================================================================
typedef enum {
CS_IDLE,
CS_ACTIVE,
} chord_state_t;
static chord_state_t chord_state = CS_IDLE;
static bool chord_held = false; // true while CHORD_KEY is still held
static char chord_buf[CHORD_MAX_LEN + 1];
static uint8_t chord_buf_len = 0;
static uint16_t chord_timer = 0; // last activity timestamp
// Disambiguation: pending exact match that is also a prefix of a longer entry
// whose output differs.
static bool chord_disambig = false;
static uint16_t chord_disambig_t = 0;
static uint8_t chord_disambig_i = 0;
// ============================================================================
// Internal helpers
// ============================================================================
static void chord_reset(void) {
chord_state = CS_IDLE;
chord_held = false;
chord_buf_len = 0;
chord_buf[0] = '\0';
chord_disambig = false;
}
static void chord_output(uint8_t idx) {
send_unicode_string(chord_table[idx].out);
chord_reset();
}
/*
* Scan the chord table against the current buffer.
*
* Returns:
* -2 — no match at all (not even a prefix) → cancel chord mode
* -1 — buffer is a prefix of ≥1 entry but no exact match yet → keep going
* ≥ 0 — index of the first exact match; *is_prefix is set if the buffer is
* also a prefix of a DIFFERENT-output longer entry
*/
static int chord_check(bool *is_prefix) {
*is_prefix = false;
int exact_idx = -1;
bool any_match = false;
for (uint8_t i = 0; i < CHORD_TABLE_LEN; i++) {
uint8_t slen = (uint8_t)strlen(chord_table[i].seq);
// Entry shorter than current buffer → can't match
if (slen < chord_buf_len) continue;
// Must be a prefix match at minimum
if (memcmp(chord_table[i].seq, chord_buf, chord_buf_len) != 0) continue;
any_match = true;
if (slen == chord_buf_len) {
// Exact match — take the first one found
if (exact_idx < 0) exact_idx = (int)i;
} else {
// Buffer is a strict prefix of this longer entry
// Flag as a prefix conflict only when output differs from the
// already-found exact match (same-output aliases are harmless).
if (exact_idx >= 0) {
if (strcmp(chord_table[i].out, chord_table[exact_idx].out) != 0) {
*is_prefix = true;
}
} else {
// No exact match found yet; mark the prefix tentatively.
// We'll re-evaluate the flag after finding an exact match, but
// we need any_match = true to avoid returning -2.
*is_prefix = true; // tentative; may be cleared below
}
}
}
if (!any_match) return -2;
if (exact_idx < 0) return -1; // prefix(es) only
// Re-verify is_prefix: we may have set it before seeing the exact match.
// Walk again only for the is_prefix refinement.
if (*is_prefix) {
*is_prefix = false;
for (uint8_t i = 0; i < CHORD_TABLE_LEN; i++) {
uint8_t slen = (uint8_t)strlen(chord_table[i].seq);
if (slen <= chord_buf_len) continue;
if (memcmp(chord_table[i].seq, chord_buf, chord_buf_len) != 0) continue;
// Longer entry that shares our prefix
if (strcmp(chord_table[i].out, chord_table[exact_idx].out) != 0) {
*is_prefix = true;
break;
}
}
}
return exact_idx;
}
// ============================================================================
// Public API
// ============================================================================
void chord_init(void) {
chord_reset();
}
void chord_activate(void) {
chord_reset();
chord_state = CS_ACTIVE;
chord_held = true;
chord_timer = timer_read();
}
void chord_key_released(void) {
if (chord_state != CS_ACTIVE) return;
if (chord_buf_len == 0) {
// Chord key tapped with nothing typed yet → switch to tap mode.
// Keep collecting; idle timeout will cancel if nothing arrives.
chord_held = false;
chord_timer = timer_read();
return;
}
// Hold mode: key released with a partial or complete sequence.
if (chord_disambig) {
chord_output(chord_disambig_i);
return;
}
bool is_prefix;
int idx = chord_check(&is_prefix);
if (idx >= 0) {
chord_output(idx);
} else {
chord_reset();
}
}
bool process_chord(uint16_t keycode, keyrecord_t *record) {
if (chord_state == CS_IDLE) return true;
// Pass through key-up events while chord mode is active.
// Basic key-ups (a-z, 0-9) are no-ops on the host since we never sent
// the corresponding key-down. Layer/quantum key-ups (TT, MO, LT, etc.)
// must reach QMK's layer system so layers deactivate correctly.
if (!record->event.pressed) return true;
// Any keypress resets the idle timer.
chord_timer = timer_read();
// --- Special keys -------------------------------------------------------
if (keycode == KC_ESC) {
chord_reset();
return false;
}
if (keycode == KC_BSPC) {
if (chord_disambig) {
chord_disambig = false;
} else if (chord_buf_len > 0) {
chord_buf[--chord_buf_len] = '\0';
}
return false;
}
if (keycode == KC_ENT) {
// Enter explicitly confirms the current buffer.
if (chord_disambig) {
chord_output(chord_disambig_i);
} else {
bool is_prefix;
int idx = chord_check(&is_prefix);
if (idx >= 0) {
chord_output(idx);
} else {
chord_reset();
}
}
return false;
}
// Modifier keys (Ctrl, Shift, Alt, GUI) pass through without disturbing
// chord mode — the user may have them held for unrelated reasons.
if (keycode >= KC_LCTL && keycode <= KC_RGUI) {
return true;
}
// --- Build the sequence buffer ------------------------------------------
char c = 0;
if (keycode >= KC_A && keycode <= KC_Z) {
c = 'a' + (keycode - KC_A);
} else if (keycode >= KC_1 && keycode <= KC_9) {
c = '1' + (keycode - KC_1);
} else if (keycode == KC_0) {
c = '0';
} else {
// Unrecognised key: cancel chord mode silently.
chord_reset();
return false;
}
if (chord_buf_len >= CHORD_MAX_LEN) {
chord_reset();
return false;
}
chord_buf[chord_buf_len++] = c;
chord_buf[chord_buf_len] = '\0';
chord_disambig = false;
// --- Check the table ----------------------------------------------------
bool is_prefix;
int idx = chord_check(&is_prefix);
if (idx == -2) {
// Dead end — no entry can ever match this buffer.
chord_reset();
} else if (idx >= 0 && !is_prefix) {
// Clean exact match: output immediately.
chord_output(idx);
} else if (idx >= 0 && is_prefix) {
// Exact match, but a longer different-output entry shares the prefix.
// Start the disambiguation timer; output fires when it expires.
chord_disambig = true;
chord_disambig_t = timer_read();
chord_disambig_i = (uint8_t)idx;
}
// idx == -1: only prefix match(es) so far → keep collecting.
return false;
}
void chord_scan(void) {
if (chord_state == CS_IDLE) return;
// Disambiguation timeout: no longer input arrived → commit exact match.
if (chord_disambig && timer_elapsed(chord_disambig_t) > CHORD_DISAMBIG_MS) {
chord_output(chord_disambig_i);
return;
}
// Idle timeout (tap mode only): cancel if nothing typed for too long.
if (!chord_held && timer_elapsed(chord_timer) > CHORD_TIMEOUT_MS) {
chord_reset();
}
}
@@ -0,0 +1,60 @@
// Copyright 2024 rootiest
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include QMK_KEYBOARD_H
/*
* Chord Unicode System
* ====================
* Activate chord mode with CHORD_KEY (Fn1 + Left Alt).
*
* Two activation styles:
* Tap - tap CHORD_KEY, release it, then type the sequence within
* CHORD_TIMEOUT_MS. Output fires on an unambiguous exact match.
* Hold - hold CHORD_KEY, type the sequence, then release CHORD_KEY.
* Output fires on exact match or on key release if buffer matches.
*
* While collecting:
* - Alpha / digit keys append to the sequence buffer (always lowercase).
* - Backspace deletes the last character.
* - Enter confirms the current buffer (useful when a shorter sequence is
* a prefix of a longer one with different output).
* - Escape cancels chord mode.
* - Any other key cancels chord mode silently.
*
* Prefix disambiguation:
* If the current buffer is an exact match AND a prefix of a longer entry
* that maps to a DIFFERENT character, a CHORD_DISAMBIG_MS timer fires
* and commits the shorter match if no more keys arrive in time.
* Aliases (different sequences → same output) coexist without conflict.
*/
/* Maximum sequence length (characters). */
#define CHORD_MAX_LEN 8
/* Milliseconds of idle time before tap-mode chord is cancelled. */
#define CHORD_TIMEOUT_MS 2000
/* Milliseconds to wait for more input when an exact match is also a prefix
* of a longer entry with different output. */
#define CHORD_DISAMBIG_MS 300
/* Called once at startup to prepare the chord subsystem.
* The caller (keyboard_post_init_user in keymap.c) is responsible for
* setting the unicode input mode via set_unicode_input_mode(). */
void chord_init(void);
/* Call from process_record_user when CHORD_KEY is pressed. */
void chord_activate(void);
/* Call from process_record_user when CHORD_KEY is released. */
void chord_key_released(void);
/* Call from process_record_user for every other key event.
* Returns false when the key was consumed by the chord subsystem (do not
* pass through to the host), true otherwise. */
bool process_chord(uint16_t keycode, keyrecord_t *record);
/* Call from matrix_scan_user to drive timeouts. */
void chord_scan(void);
@@ -16,6 +16,7 @@
#include QMK_KEYBOARD_H #include QMK_KEYBOARD_H
#include "keychron_common.h" #include "keychron_common.h"
#include "chord_unicode.h"
// Tap Dance declarations // Tap Dance declarations
enum { enum {
@@ -26,6 +27,7 @@ enum {
enum custom_keycodes { enum custom_keycodes {
ALT_TAB_FWD = SAFE_RANGE, // Alt+Tab (forward) ALT_TAB_FWD = SAFE_RANGE, // Alt+Tab (forward)
ALT_TAB_BWD, // Alt+Shift+Tab (backward) ALT_TAB_BWD, // Alt+Shift+Tab (backward)
CHORD_KEY, // Fn1+LeftAlt → chord/unicode entry mode
}; };
// Alt-Tab cycling state // Alt-Tab cycling state
@@ -58,7 +60,7 @@ const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
KC_TAB, KC_Q, KC_W, KC_E, KC_R, KC_T, KC_Y, KC_U, KC_I, KC_O, KC_P, KC_LBRC, KC_RBRC, KC_BSLS, KC_PGDN, KC_P7, KC_P8, KC_P9, KC_TAB, KC_Q, KC_W, KC_E, KC_R, KC_T, KC_Y, KC_U, KC_I, KC_O, KC_P, KC_LBRC, KC_RBRC, KC_BSLS, KC_PGDN, KC_P7, KC_P8, KC_P9,
KC_CAPS, KC_A, KC_S, KC_D, KC_F, KC_G, KC_H, KC_J, KC_K, KC_L, KC_SCLN, KC_QUOT, KC_ENT, KC_END, KC_P4, KC_P5, KC_P6, KC_PPLS, KC_CAPS, KC_A, KC_S, KC_D, KC_F, KC_G, KC_H, KC_J, KC_K, KC_L, KC_SCLN, KC_QUOT, KC_ENT, KC_END, KC_P4, KC_P5, KC_P6, KC_PPLS,
KC_LSFT, KC_Z, KC_X, KC_C, KC_V, KC_B, KC_N, KC_M, KC_COMM, KC_DOT, KC_SLSH, KC_RSFT, KC_UP, KC_P1, KC_P2, KC_P3, KC_LSFT, KC_Z, KC_X, KC_C, KC_V, KC_B, KC_N, KC_M, KC_COMM, KC_DOT, KC_SLSH, KC_RSFT, KC_UP, KC_P1, KC_P2, KC_P3,
KC_LCTL, KC_LGUI, KC_LALT, KC_SPC, TT(FN3), TG(FN1), OSL(KEEB_CTL), KC_HOME, KC_DOWN, KC_END, KC_P0, KC_PDOT, KC_PENT), KC_LCTL, KC_LGUI, CHORD_KEY, KC_SPC, TT(FN3), TG(FN1), OSL(KEEB_CTL), KC_HOME, KC_DOWN, KC_END, KC_P0, KC_PDOT, KC_PENT),
[FN2] = LAYOUT_ansi_101( [FN2] = LAYOUT_ansi_101(
KC_PWR, KC_F13, KC_F14, KC_F15, KC_F16, KC_F17, KC_F18, KC_F19, KC_F20, KC_F21, KC_F22, KC_F23, KC_F24, KC_DEL, KC_PSCR, KC_CALC, KC_FIND, KC_MPLY, KC_PWR, KC_F13, KC_F14, KC_F15, KC_F16, KC_F17, KC_F18, KC_F19, KC_F20, KC_F21, KC_F22, KC_F23, KC_F24, KC_DEL, KC_PSCR, KC_CALC, KC_FIND, KC_MPLY,
@@ -105,10 +107,33 @@ const uint16_t PROGMEM encoder_map[][NUM_ENCODERS][2] = {
#endif // ENCODER_MAP_ENABLE #endif // ENCODER_MAP_ENABLE
// clang-format on // clang-format on
void keyboard_post_init_user(void) {
chord_init();
// Use the Linux unicode input method (Ctrl+Shift+U → hex → Enter).
set_unicode_input_mode(UNICODE_MODE_LINUX);
}
bool process_record_user(uint16_t keycode, keyrecord_t *record) { bool process_record_user(uint16_t keycode, keyrecord_t *record) {
if (!process_record_keychron_common(keycode, record)) { if (!process_record_keychron_common(keycode, record)) {
return false; return false;
} }
// Chord key: activate/deactivate chord unicode mode.
if (keycode == CHORD_KEY) {
if (record->event.pressed) {
chord_activate();
} else {
chord_key_released();
}
return false;
}
// While chord mode is active, let it consume the key event.
if (!process_chord(keycode, record)) {
return false;
}
switch (keycode) { switch (keycode) {
case ALT_TAB_FWD: case ALT_TAB_FWD:
if (record->event.pressed) { if (record->event.pressed) {
@@ -141,6 +166,7 @@ void matrix_scan_user(void) {
unregister_code(KC_LALT); unregister_code(KC_LALT);
alt_tab_active = false; alt_tab_active = false;
} }
chord_scan();
} }
// Tap Dance definitions // Tap Dance definitions
@@ -1,2 +1,4 @@
VIA_ENABLE = yes VIA_ENABLE = yes
TAP_DANCE_ENABLE = yes TAP_DANCE_ENABLE = yes
UNICODE_ENABLE = yes
SRC += chord_unicode.c