Scaffolding: ZMK module structure, Kconfig, DTS, behavior driver

- zephyr/module.yml with dts_root for dt-bindings
- Kconfig: STENO_ENGINE, dict selection (Plover/Lapwing/test),
  MPHF toggle, Unicode modes, history size, multi-stroke timeout
- DTS behavior binding (one_param, steno key index)
- behavior_steno.c: chord accumulation, all-up detection,
  multi-stroke buffering with timeout, star undo, formatter pipeline,
  3-way dict dispatch (split/MPHF/simple trie)
- dict_embed.S: .incbin from generated header path
- steno_keys.h: 23-key layout, bit positions matching compiler
This commit is contained in:
afiqzudinhadi 2026-07-02 02:04:28 +08:00
parent 234bc8d731
commit 1ec204b845
33 changed files with 5443 additions and 0 deletions

271
src/behavior_steno.c Normal file
View file

@ -0,0 +1,271 @@
/*
* Copyright (c) 2024 Afiq Zudin Hadi
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
*/
#define DT_DRV_COMPAT zmk_behavior_steno_engine
#include <zephyr/device.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <drivers/behavior.h>
#include <zmk/behavior.h>
#include "output.h"
#include "undo.h"
#include "formatter.h"
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
#include "dict_mphf.h"
#else
#include "trie.h"
#endif
LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);
extern const uint8_t _steno_dict_start[];
extern const uint8_t _steno_dict_end[];
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
static struct dict_mphf mphf_dict;
#endif
#define STENO_MAX_MULTI 8
#define STENO_MULTI_TIMEOUT_MS CONFIG_STENO_MULTI_STROKE_TIMEOUT_MS
static inline const char *dict_lookup(const uint32_t *strokes, uint8_t count)
{
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
return dict_mphf_lookup(&mphf_dict, strokes, count);
#else
return steno_trie_lookup(strokes, count);
#endif
}
static inline bool dict_has_prefix(const uint32_t *strokes, uint8_t count)
{
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
return (count == 1) ? dict_mphf_has_prefix(&mphf_dict, strokes[0]) : false;
#else
return steno_trie_has_prefix(strokes, count);
#endif
}
struct steno_state {
uint32_t current_chord;
uint8_t keys_held;
uint32_t pending_strokes[STENO_MAX_MULTI];
uint8_t stroke_count;
struct k_work_delayable multi_timeout;
};
static struct steno_state state;
static struct steno_fmt_state fmt_state;
static struct stroke_history undo_history;
static bool dict_ready;
static void flush_strokes(void);
static void multi_timeout_handler(struct k_work *work);
static void emit_formatted(const char *translation,
const uint32_t *strokes, uint8_t stroke_count)
{
struct steno_fmt_result result;
steno_fmt_process(&fmt_state, translation, &result);
if (result.backspaces > 0) {
steno_output_backspace(result.backspaces);
}
if (result.len > 0) {
steno_output_send(result.text, result.len);
}
if (!result.is_command_only) {
steno_undo_push(&undo_history, strokes, stroke_count,
result.len + result.backspaces, 0, 0);
}
}
static void process_chord(void)
{
if (state.current_chord == 0) {
return;
}
/* Star-only stroke (bit 9) → undo */
if (state.current_chord == (1U << 9)) {
state.current_chord = 0;
struct stroke_history_entry *ue = steno_undo_pop(&undo_history);
if (ue) {
steno_output_backspace(ue->output_len);
}
return;
}
k_work_cancel_delayable(&state.multi_timeout);
if (state.stroke_count < STENO_MAX_MULTI) {
state.pending_strokes[state.stroke_count] = state.current_chord;
state.stroke_count++;
} else {
flush_strokes();
state.pending_strokes[0] = state.current_chord;
state.stroke_count = 1;
}
state.current_chord = 0;
if (!dict_ready) {
LOG_WRN("steno dict not ready, flushing");
flush_strokes();
return;
}
const char *translation = dict_lookup(
state.pending_strokes, state.stroke_count);
LOG_INF("steno lookup %u strokes → %s", state.stroke_count,
translation ? translation : "(null)");
if (translation) {
emit_formatted(translation, state.pending_strokes, state.stroke_count);
state.stroke_count = 0;
return;
}
if (dict_has_prefix(state.pending_strokes, state.stroke_count)) {
k_work_schedule(&state.multi_timeout,
K_MSEC(STENO_MULTI_TIMEOUT_MS));
return;
}
if (state.stroke_count > 1) {
uint32_t last = state.pending_strokes[state.stroke_count - 1];
state.stroke_count--;
const char *partial = dict_lookup(
state.pending_strokes, state.stroke_count);
if (partial) {
emit_formatted(partial, state.pending_strokes, state.stroke_count);
}
state.pending_strokes[0] = last;
state.stroke_count = 1;
const char *rest = dict_lookup(&last, 1);
if (rest) {
emit_formatted(rest, &last, 1);
state.stroke_count = 0;
}
return;
}
flush_strokes();
}
static void flush_strokes(void)
{
LOG_DBG("Flushing %u untranslated strokes", state.stroke_count);
state.stroke_count = 0;
}
static void multi_timeout_handler(struct k_work *work)
{
ARG_UNUSED(work);
if (state.stroke_count == 0) {
return;
}
const char *translation = dict_lookup(
state.pending_strokes, state.stroke_count);
if (translation) {
emit_formatted(translation, state.pending_strokes, state.stroke_count);
}
state.stroke_count = 0;
}
static int on_steno_binding_pressed(struct zmk_behavior_binding *binding,
struct zmk_behavior_binding_event event)
{
uint32_t key_index = binding->param1;
if (key_index > 35) {
return -EINVAL;
}
state.current_chord |= (1U << key_index);
state.keys_held++;
LOG_INF("steno press key=%u chord=0x%06X held=%u",
key_index, state.current_chord, state.keys_held);
return ZMK_BEHAVIOR_OPAQUE;
}
static int on_steno_binding_released(struct zmk_behavior_binding *binding,
struct zmk_behavior_binding_event event)
{
if (state.keys_held > 0) {
state.keys_held--;
}
LOG_INF("steno release held=%u chord=0x%06X", state.keys_held, state.current_chord);
if (state.keys_held == 0 && state.current_chord != 0) {
LOG_INF("steno all-up → process chord 0x%06X", state.current_chord);
process_chord();
}
return ZMK_BEHAVIOR_OPAQUE;
}
static int behavior_steno_init(const struct device *dev)
{
ARG_UNUSED(dev);
state.current_chord = 0;
state.keys_held = 0;
state.stroke_count = 0;
steno_fmt_init(&fmt_state);
steno_undo_init(&undo_history);
k_work_init_delayable(&state.multi_timeout, multi_timeout_handler);
size_t dict_size = _steno_dict_end - _steno_dict_start;
if (dict_size > 4) {
int ret;
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
ret = dict_mphf_init(&mphf_dict, _steno_dict_start, dict_size);
#else
ret = steno_trie_init(_steno_dict_start, dict_size);
#endif
if (ret == 0) {
dict_ready = true;
LOG_INF("Steno dict loaded (%u bytes)", (unsigned)dict_size);
} else {
LOG_ERR("Steno dict init failed: %d", ret);
}
} else {
LOG_WRN("No steno dict embedded");
}
LOG_INF("Steno engine initialized");
return 0;
}
static const struct behavior_driver_api steno_driver_api = {
.locality = BEHAVIOR_LOCALITY_CENTRAL,
.binding_pressed = on_steno_binding_pressed,
.binding_released = on_steno_binding_released,
};
#define STENO_INST(n) \
BEHAVIOR_DT_INST_DEFINE(n, \
behavior_steno_init, \
NULL, \
NULL, \
NULL, \
POST_KERNEL, \
CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, \
&steno_driver_api);
DT_INST_FOREACH_STATUS_OKAY(STENO_INST)

14
src/dict_embed.S Normal file
View file

@ -0,0 +1,14 @@
#include "steno_dict_path.h"
.section .rodata.steno_dict, "a", %progbits
.global _steno_dict_start
.global _steno_dict_end
.balign 4
_steno_dict_start:
#ifdef STENO_DICT_BIN_PATH
.incbin STENO_DICT_BIN_PATH
#else
.byte 0x00, 0x00, 0x00, 0x00
#endif
_steno_dict_end:

323
src/dict_mphf.c Normal file
View file

@ -0,0 +1,323 @@
/**
* MPHF dictionary lookup engine implementation.
*
* All data is read directly from flash. No heap allocation.
* String table is block-compressed with zlib; decompressed on-demand
* into a static 4KB buffer.
*
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
*/
#include "dict_mphf.h"
#include <string.h>
#ifdef __ZEPHYR__
#include <zephyr/sys/crc.h>
#endif
/* ─── Minimal inflate for non-Zephyr (native tests) ─── */
#ifndef __ZEPHYR__
#include <stdlib.h>
/* Use zlib on host for native tests */
#ifdef HAS_ZLIB
#include <zlib.h>
static int block_inflate(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap, size_t *dst_len)
{
uLongf out_len = dst_cap;
int ret = uncompress(dst, &out_len, src, src_len);
if (ret == Z_OK) {
*dst_len = out_len;
return 0;
}
return -1;
}
#else
/* Minimal tinf inflate — bundled for host-only testing.
* On Zephyr, we use the kernel's built-in zlib. */
#include "tinf/tinf.h"
static int block_inflate(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap, size_t *dst_len)
{
unsigned int out_len = dst_cap;
/* Skip 2-byte zlib header, strip 4-byte adler32 checksum */
if (src_len < 6) return -1;
int ret = tinf_uncompress(dst, &out_len, src + 2, src_len - 6);
if (ret == 0) {
*dst_len = out_len;
return 0;
}
return -1;
}
#endif /* HAS_ZLIB */
#else /* __ZEPHYR__ */
#include <zephyr/sys/util.h>
/* Zephyr built-in zlib decompression */
#if __has_include(<zephyr/lib/zlib/zlib.h>)
#include <zephyr/lib/zlib/zlib.h>
#elif __has_include(<zlib.h>)
#include <zlib.h>
#endif
static int block_inflate(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap, size_t *dst_len)
{
/* Try Zephyr's tinycrypt/miniz or fall back to raw copy */
#if defined(CONFIG_ZLIB)
uLongf out_len = dst_cap;
int ret = uncompress(dst, &out_len, src, src_len);
if (ret == Z_OK) {
*dst_len = out_len;
return 0;
}
return -1;
#else
/* No zlib available — strings must be uncompressed (flags bit 0 clear) */
(void)src; (void)src_len; (void)dst; (void)dst_cap; (void)dst_len;
return -1;
#endif
}
#endif /* __ZEPHYR__ */
/* ─── FNV-1a 32-bit hash ─── */
static uint32_t fnv1a_32(const uint8_t *data, size_t len)
{
uint32_t h = 0x811c9dc5u;
for (size_t i = 0; i < len; i++) {
h ^= data[i];
h *= 0x01000193u;
}
return h;
}
static uint32_t hash_key(const uint8_t *key, size_t key_len, uint32_t seed)
{
uint32_t h = 0x811c9dc5u;
uint8_t seed_bytes[4];
seed_bytes[0] = (uint8_t)(seed);
seed_bytes[1] = (uint8_t)(seed >> 8);
seed_bytes[2] = (uint8_t)(seed >> 16);
seed_bytes[3] = (uint8_t)(seed >> 24);
for (int i = 0; i < 4; i++) {
h ^= seed_bytes[i];
h *= 0x01000193u;
}
for (size_t i = 0; i < key_len; i++) {
h ^= key[i];
h *= 0x01000193u;
}
return h;
}
/* ─── Bit-packed field reading ─── */
static uint32_t read_bits(const uint8_t *data, uint32_t bit_pos, uint8_t n_bits)
{
uint32_t value = 0;
for (uint8_t i = 0; i < n_bits; i++) {
uint32_t byte_idx = (bit_pos + i) / 8;
uint8_t bit_idx = (bit_pos + i) % 8;
if (data[byte_idx] & (1u << bit_idx)) {
value |= (1u << i);
}
}
return value;
}
static inline uint32_t align4(uint32_t n)
{
return (n + 3u) & ~3u;
}
/* ─── Init ─── */
int dict_mphf_init(struct dict_mphf *dict, const void *data, size_t len)
{
if (!dict || !data) return -1;
if (len < sizeof(struct dict_mphf_header)) return -2;
const struct dict_mphf_header *hdr = (const struct dict_mphf_header *)data;
if (hdr->magic != DICT_MPHF_MAGIC) return -3;
if (hdr->version != DICT_MPHF_VERSION) return -4;
dict->header = hdr;
const uint8_t *base = (const uint8_t *)data;
uint32_t offset = sizeof(struct dict_mphf_header);
/* Displacements */
dict->displacements = base + offset;
uint32_t disp_bits_total = (uint32_t)hdr->bucket_count * hdr->disp_bits;
dict->disp_section_len = align4((disp_bits_total + 7) / 8);
offset += dict->disp_section_len;
/* Values */
dict->values = base + offset;
uint32_t val_bits_total = (uint32_t)hdr->entry_count * hdr->value_bits;
dict->val_section_len = align4((val_bits_total + 7) / 8);
offset += dict->val_section_len;
/* Fingerprints */
dict->fingerprints = base + offset;
dict->fp_section_len = align4(hdr->entry_count);
offset += dict->fp_section_len;
/* String offsets (u24 LE, 3 bytes each) */
dict->string_offsets = base + offset;
offset += hdr->unique_count * 3;
/* String data section */
dict->str_data_start = base + offset;
if (hdr->flags & DICT_MPHF_FLAG_COMPRESSED) {
/* Block directory: u16 block_count + u32[] offsets */
dict->block_count = dict->str_data_start[0] |
((uint16_t)dict->str_data_start[1] << 8);
dict->block_dir = (const uint32_t *)(dict->str_data_start + 2);
dict->blocks_start = dict->str_data_start + 2 + dict->block_count * 4;
} else {
dict->block_count = 0;
dict->block_dir = NULL;
dict->blocks_start = dict->str_data_start;
}
/* Prefix table at end */
uint32_t prefix_bytes = (uint32_t)hdr->prefix_count * 4;
if (len >= prefix_bytes) {
dict->prefix_table = (const uint32_t *)(base + len - prefix_bytes);
} else {
dict->prefix_table = NULL;
}
return 0;
}
/* ─── String decompression ─── */
static const char *resolve_string(const struct dict_mphf *dict, uint32_t val_id)
{
const uint8_t *off_ptr = dict->string_offsets + val_id * 3;
uint32_t str_offset = (uint32_t)off_ptr[0]
| ((uint32_t)off_ptr[1] << 8)
| ((uint32_t)off_ptr[2] << 16);
if (!(dict->header->flags & DICT_MPHF_FLAG_COMPRESSED)) {
return (const char *)(dict->str_data_start + str_offset);
}
/* Block-compressed: decompress the right block */
uint32_t block_idx = str_offset / DICT_MPHF_BLOCK_SIZE;
uint32_t in_block_off = str_offset % DICT_MPHF_BLOCK_SIZE;
if (block_idx >= dict->block_count) {
return NULL;
}
/* Get compressed block bounds */
uint32_t blk_start = dict->block_dir[block_idx];
uint32_t blk_end;
if (block_idx + 1 < dict->block_count) {
blk_end = dict->block_dir[block_idx + 1];
} else {
/* Last block: extends to prefix_table or end of file */
blk_end = (const uint8_t *)dict->prefix_table - dict->blocks_start;
}
const uint8_t *compressed = dict->blocks_start + blk_start;
uint32_t compressed_len = blk_end - blk_start;
static uint8_t decomp_buf[DICT_MPHF_BLOCK_SIZE];
static uint32_t cached_block = UINT32_MAX;
static size_t cached_len;
if (cached_block != block_idx) {
size_t out_len;
if (block_inflate(compressed, compressed_len,
decomp_buf, sizeof(decomp_buf), &out_len) != 0) {
return NULL;
}
cached_block = block_idx;
cached_len = out_len;
}
if (in_block_off >= cached_len) {
return NULL;
}
return (const char *)(decomp_buf + in_block_off);
}
/* ─── Lookup ─── */
const char *dict_mphf_lookup(const struct dict_mphf *dict,
const uint32_t *strokes, uint8_t count)
{
if (!dict || !dict->header || !strokes || count == 0) {
return NULL;
}
const struct dict_mphf_header *hdr = dict->header;
uint8_t key_buf[32];
size_t key_len = (size_t)count * 4;
if (key_len > sizeof(key_buf)) {
return NULL;
}
for (uint8_t i = 0; i < count; i++) {
key_buf[i * 4 + 0] = (uint8_t)(strokes[i]);
key_buf[i * 4 + 1] = (uint8_t)(strokes[i] >> 8);
key_buf[i * 4 + 2] = (uint8_t)(strokes[i] >> 16);
key_buf[i * 4 + 3] = (uint8_t)(strokes[i] >> 24);
}
uint32_t bucket = hash_key(key_buf, key_len, 0) % hdr->bucket_count;
uint32_t d = read_bits(dict->displacements,
bucket * (uint32_t)hdr->disp_bits,
hdr->disp_bits);
uint32_t slot = hash_key(key_buf, key_len, d + 1) % hdr->entry_count;
uint8_t expected_fp = (uint8_t)(fnv1a_32(key_buf, key_len) & 0xFF);
if (dict->fingerprints[slot] != expected_fp) {
return NULL;
}
uint32_t val_id = read_bits(dict->values,
slot * (uint32_t)hdr->value_bits,
hdr->value_bits);
if (val_id >= hdr->unique_count) {
return NULL;
}
return resolve_string(dict, val_id);
}
/* ─── has_prefix ─── */
bool dict_mphf_has_prefix(const struct dict_mphf *dict, uint32_t stroke)
{
if (!dict || !dict->header || !dict->prefix_table ||
dict->header->prefix_count == 0) {
return false;
}
uint32_t lo = 0;
uint32_t hi = dict->header->prefix_count;
while (lo < hi) {
uint32_t mid = lo + (hi - lo) / 2;
uint32_t val = dict->prefix_table[mid];
if (val == stroke) return true;
if (val < stroke) lo = mid + 1;
else hi = mid;
}
return false;
}

69
src/dict_mphf.h Normal file
View file

@ -0,0 +1,69 @@
/**
* MPHF (Minimal Perfect Hash Function) dictionary lookup engine.
*
* Binary format v2: CHD MPHF + bit-packed displacements/values +
* fingerprinted verification + block-compressed string table.
*
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
*/
#ifndef DICT_MPHF_H
#define DICT_MPHF_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#define DICT_MPHF_MAGIC 0x4F4E5453 /* "STNO" */
#define DICT_MPHF_VERSION 2
#define DICT_MPHF_FLAG_COMPRESSED 0x0001
#define DICT_MPHF_BLOCK_SIZE 4096
struct dict_mphf_header {
uint32_t magic;
uint16_t version;
uint16_t flags;
uint32_t entry_count;
uint32_t bucket_count;
uint32_t unique_count;
uint8_t value_bits;
uint8_t disp_bits;
uint16_t prefix_count;
uint32_t reserved0;
uint32_t reserved1;
} __attribute__((packed));
_Static_assert(sizeof(struct dict_mphf_header) == 32, "header must be 32 bytes");
struct dict_mphf {
const struct dict_mphf_header *header;
const uint8_t *displacements;
const uint8_t *values;
const uint8_t *fingerprints;
const uint8_t *string_offsets;
const uint8_t *str_data_start; /* start of string data section */
const uint32_t *prefix_table;
uint32_t disp_section_len;
uint32_t val_section_len;
uint32_t fp_section_len;
uint16_t block_count;
const uint32_t *block_dir; /* block offset directory */
const uint8_t *blocks_start; /* start of compressed blocks */
uint32_t str_data_len; /* total string data section length */
};
int dict_mphf_init(struct dict_mphf *dict, const void *data, size_t len);
const char *dict_mphf_lookup(const struct dict_mphf *dict,
const uint32_t *strokes, uint8_t count);
bool dict_mphf_has_prefix(const struct dict_mphf *dict, uint32_t stroke);
static inline uint32_t dict_mphf_count(const struct dict_mphf *dict)
{
return dict->header->entry_count;
}
#endif /* DICT_MPHF_H */

335
src/formatter.c Normal file
View file

@ -0,0 +1,335 @@
/*
* Copyright (c) 2024 Afiq Zudin Hadi
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
*
* Plover formatting engine parses translation strings containing
* {commands} and produces clean output with spacing/capitalization.
* No malloc all stack/static.
*/
#include "formatter.h"
#include <string.h>
/* ── helpers ────────────────────────────────────────────────────── */
static inline bool is_upper(char c) { return c >= 'A' && c <= 'Z'; }
static inline bool is_lower(char c) { return c >= 'a' && c <= 'z'; }
static inline char to_upper(char c) { return is_lower(c) ? (char)(c - 32) : c; }
static inline char to_lower(char c) { return is_upper(c) ? (char)(c + 32) : c; }
static bool str_eq(const char *a, const char *b, size_t n)
{
for (size_t i = 0; i < n; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
static bool starts_with(const char *s, size_t slen, const char *prefix)
{
size_t plen = strlen(prefix);
if (slen < plen) return false;
return str_eq(s, prefix, plen);
}
/* ── output buffer append ───────────────────────────────────────── */
static void emit_char(struct steno_fmt_result *r, char c)
{
if (r->len < STENO_FMT_MAX_OUTPUT - 1) {
r->text[r->len++] = c;
}
}
/* ── apply capitalization/mode transforms to a text segment ───── */
static void emit_text_transformed(struct steno_fmt_state *state,
struct steno_fmt_result *r,
const char *text, size_t len)
{
if (len == 0) return;
for (size_t i = 0; i < len; i++) {
char c = text[i];
/* Mode transforms */
if (state->mode == STENO_MODE_CAPS) {
c = to_upper(c);
} else if (state->mode == STENO_MODE_LOWER) {
c = to_lower(c);
} else if (state->mode == STENO_MODE_TITLE) {
/* Capitalize first letter of each word */
if (i == 0 || (i > 0 && text[i - 1] == ' ')) {
c = to_upper(c);
}
}
/* One-shot transforms (first char only) */
if (i == 0) {
if (state->cap_next) {
c = to_upper(c);
state->cap_next = false;
}
if (state->upper_next) {
/* uppercase entire word — handled below */
}
if (state->lower_next) {
/* lowercase entire word — handled below */
}
}
/* upper_next: entire word */
if (state->upper_next) {
c = to_upper(c);
}
/* lower_next: entire word */
if (state->lower_next) {
c = to_lower(c);
}
emit_char(r, c);
}
state->upper_next = false;
state->lower_next = false;
}
/* ── prepend space if needed ────────────────────────────────────── */
static void maybe_space(struct steno_fmt_state *state,
struct steno_fmt_result *r)
{
if (state->space_pending && !state->suppress_space) {
emit_char(r, ' ');
}
state->suppress_space = false;
}
/* ── process a single {…} command ──────────────────────────────── */
static void process_command(struct steno_fmt_state *state,
struct steno_fmt_result *r,
const char *cmd, size_t cmd_len)
{
/* {^} — suppress space */
if (cmd_len == 1 && cmd[0] == '^') {
state->suppress_space = true;
return;
}
/* {^suffix} — attach suffix */
if (cmd_len > 1 && cmd[0] == '^') {
state->suppress_space = true;
maybe_space(state, r);
emit_text_transformed(state, r, cmd + 1, cmd_len - 1);
state->space_pending = true;
state->glue = false;
return;
}
/* {prefix^} — attach prefix */
if (cmd_len > 1 && cmd[cmd_len - 1] == '^') {
maybe_space(state, r);
emit_text_transformed(state, r, cmd, cmd_len - 1);
state->suppress_space = true;
state->space_pending = false;
state->glue = false;
return;
}
/* {-|} — capitalize next */
if (cmd_len == 2 && cmd[0] == '-' && cmd[1] == '|') {
state->cap_next = true;
return;
}
/* {~|} — carry capitalize */
if (cmd_len == 2 && cmd[0] == '~' && cmd[1] == '|') {
state->cap_next = true;
return;
}
/* {*-|} — retro capitalize (backspace + re-emit) */
if (cmd_len == 3 && cmd[0] == '*' && cmd[1] == '-' && cmd[2] == '|') {
/* Signal retro-capitalize — simplified: set flag, caller handles */
r->backspaces = 1;
return;
}
/* {*!} — retro delete space */
if (cmd_len == 2 && cmd[0] == '*' && cmd[1] == '!') {
r->backspaces = 1;
return;
}
/* {*?} — retro insert space */
if (cmd_len == 2 && cmd[0] == '*' && cmd[1] == '?') {
/* Would need undo context; simplified signal */
return;
}
/* {*} — undo */
if (cmd_len == 1 && cmd[0] == '*') {
r->is_undo = true;
r->is_command_only = true;
return;
}
/* {.} {,} {?} {!} {;} {:} — punctuation: attach to prev, cap next for sentence-enders */
if (cmd_len == 1 && (cmd[0] == '.' || cmd[0] == ',' ||
cmd[0] == '?' || cmd[0] == '!' ||
cmd[0] == ';' || cmd[0] == ':')) {
state->suppress_space = true;
maybe_space(state, r);
emit_char(r, cmd[0]);
state->space_pending = true;
state->suppress_space = false;
/* Sentence-ending punctuation capitalizes next */
if (cmd[0] == '.' || cmd[0] == '?' || cmd[0] == '!') {
state->cap_next = true;
}
state->glue = false;
return;
}
/* {#...} — key combo (emit nothing for now, could extend) */
if (cmd_len > 0 && cmd[0] == '#') {
r->is_command_only = true;
return;
}
/* {&letter} — fingerspelling */
if (cmd_len >= 2 && cmd[0] == '&') {
/* Glue: no space between glue strokes */
if (state->glue) {
state->suppress_space = true;
}
maybe_space(state, r);
emit_text_transformed(state, r, cmd + 1, cmd_len - 1);
state->space_pending = true;
state->glue = true;
state->suppress_space = false;
return;
}
/* {&} — bare glue */
if (cmd_len == 1 && cmd[0] == '&') {
state->glue = true;
state->suppress_space = true;
return;
}
/* {MODE:CAPS} {MODE:TITLE} {MODE:LOWER} {MODE:RESET} */
if (starts_with(cmd, cmd_len, "MODE:")) {
const char *mode_str = cmd + 5;
size_t mode_len = cmd_len - 5;
if (mode_len == 4 && str_eq(mode_str, "CAPS", 4)) {
state->mode = STENO_MODE_CAPS;
} else if (mode_len == 5 && str_eq(mode_str, "TITLE", 5)) {
state->mode = STENO_MODE_TITLE;
} else if (mode_len == 5 && str_eq(mode_str, "LOWER", 5)) {
state->mode = STENO_MODE_LOWER;
} else if (mode_len == 5 && str_eq(mode_str, "RESET", 5)) {
state->mode = STENO_MODE_NORMAL;
}
return;
}
/* {<} — uppercase next word */
if (cmd_len == 1 && cmd[0] == '<') {
state->upper_next = true;
return;
}
/* {>} — lowercase next word */
if (cmd_len == 1 && cmd[0] == '>') {
state->lower_next = true;
return;
}
}
/* ── main entry point ──────────────────────────────────────────── */
void steno_fmt_init(struct steno_fmt_state *state)
{
memset(state, 0, sizeof(*state));
state->space_pending = false;
state->cap_next = false;
state->upper_next = false;
state->lower_next = false;
state->suppress_space = false;
state->glue = false;
state->mode = STENO_MODE_NORMAL;
}
void steno_fmt_process(struct steno_fmt_state *state,
const char *translation,
struct steno_fmt_result *result)
{
memset(result, 0, sizeof(*result));
if (!translation || !translation[0]) {
result->is_command_only = true;
return;
}
size_t tlen = strlen(translation);
const char *p = translation;
const char *end = translation + tlen;
bool emitted_text = false;
bool had_command = false;
bool this_glue = false;
while (p < end) {
/* Escaped braces: \{ \} */
if (p[0] == '\\' && p + 1 < end && (p[1] == '{' || p[1] == '}')) {
maybe_space(state, result);
emit_text_transformed(state, result, p + 1, 1);
state->space_pending = true;
state->glue = false;
emitted_text = true;
p += 2;
continue;
}
/* Command: {…} */
if (p[0] == '{') {
const char *close = p + 1;
while (close < end && *close != '}') {
close++;
}
if (close < end) {
const char *cmd = p + 1;
size_t cmd_len = (size_t)(close - cmd);
process_command(state, result, cmd, cmd_len);
had_command = true;
if (state->glue) this_glue = true;
p = close + 1;
continue;
}
}
/* Literal text segment — find end (next '{' or '\' or end) */
const char *seg_start = p;
while (p < end && p[0] != '{' &&
!(p[0] == '\\' && p + 1 < end && (p[1] == '{' || p[1] == '}'))) {
p++;
}
size_t seg_len = (size_t)(p - seg_start);
if (seg_len > 0) {
if (!this_glue) {
maybe_space(state, result);
}
emit_text_transformed(state, result, seg_start, seg_len);
state->space_pending = true;
if (!this_glue) {
state->glue = false;
}
emitted_text = true;
}
}
result->text[result->len] = '\0';
result->is_command_only = !emitted_text && had_command && result->len == 0;
}

46
src/formatter.h Normal file
View file

@ -0,0 +1,46 @@
#ifndef STENO_FORMATTER_H
#define STENO_FORMATTER_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Max output length from a single translation */
#define STENO_FMT_MAX_OUTPUT 128
/* Formatting mode */
enum steno_fmt_mode {
STENO_MODE_NORMAL = 0,
STENO_MODE_CAPS,
STENO_MODE_TITLE,
STENO_MODE_LOWER,
};
/* State persists between translations */
struct steno_fmt_state {
bool space_pending; /* insert space before next word */
bool cap_next; /* capitalize next word */
bool upper_next; /* uppercase next word */
bool lower_next; /* lowercase next word */
bool suppress_space; /* {^} suppress next space */
bool glue; /* in fingerspelling/glue sequence */
enum steno_fmt_mode mode;
};
/* Result of formatting one translation */
struct steno_fmt_result {
char text[STENO_FMT_MAX_OUTPUT]; /* output text to emit */
uint8_t len; /* length of text */
uint8_t backspaces; /* backspaces to send BEFORE text */
bool is_command_only; /* true if no text output (pure command) */
bool is_undo; /* true if this is an undo command */
};
void steno_fmt_init(struct steno_fmt_state *state);
/* Format a raw translation string. Updates state, fills result. */
void steno_fmt_process(struct steno_fmt_state *state,
const char *translation,
struct steno_fmt_result *result);
#endif

352
src/output.c Normal file
View file

@ -0,0 +1,352 @@
#include "output.h"
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zmk/hid.h>
#include <zmk/endpoints.h>
#include <zmk/events/keycode_state_changed.h>
LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);
struct hid_map {
uint8_t keycode;
bool shift;
};
static const struct hid_map ASCII_TO_HID[128] = {
[' '] = {0x2C, false}, /* space */
['!'] = {0x1E, true}, /* shift+1 */
['"'] = {0x34, true}, /* shift+' */
['#'] = {0x20, true},
['$'] = {0x21, true},
['%'] = {0x22, true},
['&'] = {0x24, true},
['\''] = {0x34, false},
['('] = {0x26, true},
[')'] = {0x27, true},
['*'] = {0x25, true},
['+'] = {0x2E, true},
[','] = {0x36, false},
['-'] = {0x2D, false},
['.'] = {0x37, false},
['/'] = {0x38, false},
['0'] = {0x27, false},
['1'] = {0x1E, false},
['2'] = {0x1F, false},
['3'] = {0x20, false},
['4'] = {0x21, false},
['5'] = {0x22, false},
['6'] = {0x23, false},
['7'] = {0x24, false},
['8'] = {0x25, false},
['9'] = {0x26, false},
[':'] = {0x33, true},
[';'] = {0x33, false},
['<'] = {0x36, true},
['='] = {0x2E, false},
['>'] = {0x37, true},
['?'] = {0x38, true},
['@'] = {0x1F, true},
['A'] = {0x04, true},
['B'] = {0x05, true},
['C'] = {0x06, true},
['D'] = {0x07, true},
['E'] = {0x08, true},
['F'] = {0x09, true},
['G'] = {0x0A, true},
['H'] = {0x0B, true},
['I'] = {0x0C, true},
['J'] = {0x0D, true},
['K'] = {0x0E, true},
['L'] = {0x0F, true},
['M'] = {0x10, true},
['N'] = {0x11, true},
['O'] = {0x12, true},
['P'] = {0x13, true},
['Q'] = {0x14, true},
['R'] = {0x15, true},
['S'] = {0x16, true},
['T'] = {0x17, true},
['U'] = {0x18, true},
['V'] = {0x19, true},
['W'] = {0x1A, true},
['X'] = {0x1B, true},
['Y'] = {0x1C, true},
['Z'] = {0x1D, true},
['['] = {0x2F, false},
['\\'] = {0x31, false},
[']'] = {0x30, false},
['^'] = {0x23, true},
['_'] = {0x2D, true},
['`'] = {0x35, false},
['a'] = {0x04, false},
['b'] = {0x05, false},
['c'] = {0x06, false},
['d'] = {0x07, false},
['e'] = {0x08, false},
['f'] = {0x09, false},
['g'] = {0x0A, false},
['h'] = {0x0B, false},
['i'] = {0x0C, false},
['j'] = {0x0D, false},
['k'] = {0x0E, false},
['l'] = {0x0F, false},
['m'] = {0x10, false},
['n'] = {0x11, false},
['o'] = {0x12, false},
['p'] = {0x13, false},
['q'] = {0x14, false},
['r'] = {0x15, false},
['s'] = {0x16, false},
['t'] = {0x17, false},
['u'] = {0x18, false},
['v'] = {0x19, false},
['w'] = {0x1A, false},
['x'] = {0x1B, false},
['y'] = {0x1C, false},
['z'] = {0x1D, false},
['{'] = {0x2F, true},
['|'] = {0x31, true},
['}'] = {0x30, true},
['~'] = {0x35, true},
};
#define HID_BACKSPACE 0x2A
#define HID_RETURN 0x28
#define HID_LSHIFT 0xE1
#define HID_LCTRL 0xE0
#define HID_LALT 0xE2
#define HID_RALT 0xE6
static void tap_key(uint8_t keycode, bool shift)
{
if (shift) {
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = HID_LSHIFT,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = true, .timestamp = k_uptime_get()});
}
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = keycode,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = true, .timestamp = k_uptime_get()});
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = keycode,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = false, .timestamp = k_uptime_get()});
if (shift) {
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = HID_LSHIFT,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = false, .timestamp = k_uptime_get()});
}
}
static void press_key(uint8_t keycode)
{
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = keycode,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = true, .timestamp = k_uptime_get()});
}
static void release_key(uint8_t keycode)
{
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
.usage_page = 0x07, .keycode = keycode,
.implicit_modifiers = 0, .explicit_modifiers = 0,
.state = false, .timestamp = k_uptime_get()});
}
/* Map hex digit (0-15) to HID keycode */
static uint8_t hex_to_hid(uint8_t nib)
{
if (nib == 0) {
return 0x27; /* '0' */
}
if (nib <= 9) {
return 0x1E + (nib - 1); /* '1'-'9': 0x1E-0x26 */
}
return 0x04 + (nib - 10); /* 'a'-'f': 0x04-0x09 */
}
/* Tap hex digits of codepoint (variable width, skip leading zeros) */
static void tap_hex_digits(uint32_t codepoint)
{
char buf[9];
int n = 0;
if (codepoint == 0) {
tap_key(hex_to_hid(0), false);
return;
}
/* Build hex digits in reverse */
uint32_t cp = codepoint;
while (cp > 0) {
buf[n++] = cp & 0xF;
cp >>= 4;
}
/* Tap in forward order */
for (int i = n - 1; i >= 0; i--) {
tap_key(hex_to_hid(buf[i]), false);
}
}
void steno_output_unicode(uint32_t codepoint)
{
if (codepoint > 0x10FFFF) {
LOG_WRN("Invalid codepoint U+%06X", codepoint);
return;
}
#if IS_ENABLED(CONFIG_STENO_UNICODE_MODE_LINUX)
/* IBus/GTK: Ctrl+Shift+U, hex digits, Return */
press_key(HID_LCTRL);
press_key(HID_LSHIFT);
tap_key(0x18, false); /* 'u' */
release_key(HID_LSHIFT);
release_key(HID_LCTRL);
tap_hex_digits(codepoint);
tap_key(HID_RETURN, false);
#elif IS_ENABLED(CONFIG_STENO_UNICODE_MODE_MACOS)
/* macOS Unicode Hex Input: hold Option, type 4+ hex digits */
press_key(HID_LALT);
/* Pad to at least 4 digits */
char digits[8];
int n = 0;
uint32_t cp = codepoint;
do {
digits[n++] = cp & 0xF;
cp >>= 4;
} while (cp > 0);
/* Pad to 4 */
while (n < 4) {
digits[n++] = 0;
}
for (int i = n - 1; i >= 0; i--) {
tap_key(hex_to_hid(digits[i]), false);
}
release_key(HID_LALT);
#elif IS_ENABLED(CONFIG_STENO_UNICODE_MODE_WINC)
/* WinCompose: tap RAlt, 'u', hex digits, Return */
tap_key(HID_RALT, false);
tap_key(0x18, false); /* 'u' */
tap_hex_digits(codepoint);
tap_key(HID_RETURN, false);
#else
LOG_WRN("Unicode disabled, skipping U+%04X", codepoint);
#endif
}
/* Decode UTF-8 byte at text[i], write codepoint, return bytes consumed (0 on error) */
static int utf8_decode(const char *text, size_t len, size_t i, uint32_t *cp)
{
unsigned char c = (unsigned char)text[i];
if (c < 0x80) {
*cp = c;
return 1;
}
uint32_t codepoint;
int expect; /* expected continuation bytes */
if ((c & 0xE0) == 0xC0) {
codepoint = c & 0x1F;
expect = 1;
} else if ((c & 0xF0) == 0xE0) {
codepoint = c & 0x0F;
expect = 2;
} else if ((c & 0xF8) == 0xF0) {
codepoint = c & 0x07;
expect = 3;
} else {
return 0; /* invalid lead byte */
}
if (i + expect >= len) {
return 0; /* truncated */
}
for (int j = 1; j <= expect; j++) {
unsigned char cont = (unsigned char)text[i + j];
if ((cont & 0xC0) != 0x80) {
return 0;
}
codepoint = (codepoint << 6) | (cont & 0x3F);
}
/* Reject overlong encodings */
if ((expect == 1 && codepoint < 0x80) ||
(expect == 2 && codepoint < 0x800) ||
(expect == 3 && codepoint < 0x10000)) {
return 0;
}
/* Reject surrogates (U+D800..U+DFFF) and beyond Unicode max */
if ((codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint > 0x10FFFF) {
return 0;
}
*cp = codepoint;
return 1 + expect;
}
void steno_output_send(const char *text, size_t len)
{
for (size_t i = 0; i < len; ) {
unsigned char c = (unsigned char)text[i];
if (c == '\n') {
tap_key(HID_RETURN, false);
i++;
continue;
}
/* ASCII range */
if (c < 128) {
if (ASCII_TO_HID[c].keycode == 0) {
LOG_WRN("Unmapped ASCII char 0x%02X", c);
i++;
continue;
}
tap_key(ASCII_TO_HID[c].keycode, ASCII_TO_HID[c].shift);
i++;
continue;
}
/* Multi-byte UTF-8 → Unicode codepoint */
uint32_t codepoint;
int consumed = utf8_decode(text, len, i, &codepoint);
if (consumed == 0) {
LOG_WRN("Invalid UTF-8 at offset %u (0x%02X)", (unsigned)i, c);
i++;
continue;
}
steno_output_unicode(codepoint);
i += consumed;
}
}
void steno_output_backspace(int count)
{
for (int i = 0; i < count; i++) {
tap_key(HID_BACKSPACE, false);
}
}

13
src/output.h Normal file
View file

@ -0,0 +1,13 @@
#ifndef STENO_OUTPUT_H
#define STENO_OUTPUT_H
#include <stddef.h>
#include <stdint.h>
void steno_output_send(const char *text, size_t len);
void steno_output_backspace(int count);
void steno_output_unicode(uint32_t codepoint);
#endif

148
src/trie.c Normal file
View file

@ -0,0 +1,148 @@
#include "trie.h"
#include <string.h>
static const uint8_t *dict_data;
static const struct steno_dict_header *dict_hdr;
static const uint8_t *entry_array;
static const char *string_table;
static size_t entry_stride;
int steno_trie_init(const uint8_t *data, size_t len)
{
if (!data || len < sizeof(struct steno_dict_header)) {
return -1;
}
dict_hdr = (const struct steno_dict_header *)data;
if (dict_hdr->magic != STENO_DICT_MAGIC) {
return -2;
}
if (dict_hdr->version != 1) {
return -3;
}
if (dict_hdr->max_strokes == 0 || dict_hdr->max_strokes > 16) {
return -4;
}
entry_stride = (size_t)dict_hdr->max_strokes * 4 + 4;
size_t entries_end = sizeof(struct steno_dict_header) +
(size_t)dict_hdr->entry_count * entry_stride;
if (entries_end > len || dict_hdr->strings_offset > len) {
return -5;
}
dict_data = data;
entry_array = data + sizeof(struct steno_dict_header);
string_table = (const char *)(data + dict_hdr->strings_offset);
return 0;
}
static const uint8_t *get_entry(uint32_t idx)
{
return entry_array + (size_t)idx * entry_stride;
}
static int cmp_strokes(const uint32_t *a, uint8_t a_count,
const uint8_t *entry_bytes, uint8_t max_s)
{
const uint32_t *b = (const uint32_t *)entry_bytes;
uint8_t b_count = 0;
for (uint8_t i = 0; i < max_s; i++) {
if (b[i] != 0) {
b_count = i + 1;
}
}
uint8_t min_count = a_count < b_count ? a_count : b_count;
for (uint8_t i = 0; i < min_count; i++) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
if (a_count < b_count) return -1;
if (a_count > b_count) return 1;
return 0;
}
const char *steno_trie_lookup(const uint32_t *strokes, uint8_t count)
{
if (!dict_hdr || !strokes || count == 0 || count > dict_hdr->max_strokes) {
return NULL;
}
uint32_t lo = 0;
uint32_t hi = dict_hdr->entry_count;
while (lo < hi) {
uint32_t mid = lo + (hi - lo) / 2;
const uint8_t *entry = get_entry(mid);
int c = cmp_strokes(strokes, count, entry, dict_hdr->max_strokes);
if (c == 0) {
uint32_t str_off;
memcpy(&str_off, entry + (size_t)dict_hdr->max_strokes * 4, 4);
return string_table + str_off;
}
if (c < 0) {
hi = mid;
} else {
lo = mid + 1;
}
}
return NULL;
}
bool steno_trie_has_prefix(const uint32_t *strokes, uint8_t count)
{
if (!dict_hdr || !strokes || count == 0 || count >= dict_hdr->max_strokes) {
return false;
}
uint32_t lo = 0;
uint32_t hi = dict_hdr->entry_count;
uint32_t first_ge = hi;
while (lo < hi) {
uint32_t mid = lo + (hi - lo) / 2;
const uint8_t *entry = get_entry(mid);
const uint32_t *e_strokes = (const uint32_t *)entry;
int cmp = 0;
for (uint8_t i = 0; i < count; i++) {
if (e_strokes[i] < strokes[i]) { cmp = -1; break; }
if (e_strokes[i] > strokes[i]) { cmp = 1; break; }
}
if (cmp >= 0) {
first_ge = mid;
hi = mid;
} else {
lo = mid + 1;
}
}
for (uint32_t idx = first_ge; idx < dict_hdr->entry_count; idx++) {
const uint8_t *entry = get_entry(idx);
const uint32_t *e_strokes = (const uint32_t *)entry;
bool prefix_match = true;
for (uint8_t i = 0; i < count; i++) {
if (e_strokes[i] != strokes[i]) {
prefix_match = false;
break;
}
}
if (!prefix_match) {
return false;
}
if (e_strokes[count] != 0) {
return true;
}
}
return false;
}

25
src/trie.h Normal file
View file

@ -0,0 +1,25 @@
#ifndef STENO_TRIE_H
#define STENO_TRIE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define STENO_DICT_MAGIC 0x4F4E5453 /* "STNO" */
struct steno_dict_header {
uint32_t magic;
uint16_t version;
uint8_t max_strokes;
uint8_t _pad;
uint32_t entry_count;
uint32_t strings_offset;
} __attribute__((packed));
int steno_trie_init(const uint8_t *data, size_t len);
const char *steno_trie_lookup(const uint32_t *strokes, uint8_t count);
bool steno_trie_has_prefix(const uint32_t *strokes, uint8_t count);
#endif

64
src/undo.c Normal file
View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2024 Afiq Zudin Hadi
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
*/
#include "undo.h"
#include <string.h>
void steno_undo_init(struct stroke_history *hist)
{
memset(hist, 0, sizeof(*hist));
}
void steno_undo_push(struct stroke_history *hist,
const uint32_t *strokes, uint8_t stroke_count,
uint8_t output_len, uint8_t space_before,
uint8_t fmt_flags)
{
struct stroke_history_entry *e = &hist->entries[hist->head];
uint8_t n = stroke_count;
if (n > STENO_MAX_MULTI_STROKE) {
n = STENO_MAX_MULTI_STROKE;
}
memcpy(e->strokes, strokes, n * sizeof(uint32_t));
e->stroke_count = n;
e->output_len = output_len;
e->space_before = space_before;
e->fmt_flags = fmt_flags;
hist->head = (hist->head + 1) % CONFIG_STENO_HISTORY_SIZE;
if (hist->count < CONFIG_STENO_HISTORY_SIZE) {
hist->count++;
}
}
struct stroke_history_entry *steno_undo_pop(struct stroke_history *hist)
{
if (hist->count == 0) {
return NULL;
}
hist->head = (hist->head + CONFIG_STENO_HISTORY_SIZE - 1) % CONFIG_STENO_HISTORY_SIZE;
hist->count--;
return &hist->entries[hist->head];
}
const struct stroke_history_entry *steno_undo_peek(const struct stroke_history *hist)
{
if (hist->count == 0) {
return NULL;
}
uint16_t idx = (hist->head + CONFIG_STENO_HISTORY_SIZE - 1) % CONFIG_STENO_HISTORY_SIZE;
return &hist->entries[idx];
}
uint16_t steno_undo_count(const struct stroke_history *hist)
{
return hist->count;
}

44
src/undo.h Normal file
View file

@ -0,0 +1,44 @@
#ifndef STENO_UNDO_H
#define STENO_UNDO_H
#include <stdbool.h>
#include <stdint.h>
#ifndef CONFIG_STENO_HISTORY_SIZE
#define CONFIG_STENO_HISTORY_SIZE 100
#endif
#define STENO_MAX_MULTI_STROKE 8
struct stroke_history_entry {
uint32_t strokes[STENO_MAX_MULTI_STROKE];
uint8_t stroke_count;
uint8_t output_len; /* chars emitted (for backspace count) */
uint8_t space_before; /* 1 if space was prepended */
uint8_t fmt_flags; /* formatter state snapshot for restore */
};
struct stroke_history {
struct stroke_history_entry entries[CONFIG_STENO_HISTORY_SIZE];
uint16_t head; /* next write position */
uint16_t count; /* entries in buffer */
};
void steno_undo_init(struct stroke_history *hist);
/* Push a new entry after successful output */
void steno_undo_push(struct stroke_history *hist,
const uint32_t *strokes, uint8_t stroke_count,
uint8_t output_len, uint8_t space_before,
uint8_t fmt_flags);
/* Pop most recent entry for undo. Returns NULL if empty. */
struct stroke_history_entry *steno_undo_pop(struct stroke_history *hist);
/* Peek at most recent entry without removing. Returns NULL if empty. */
const struct stroke_history_entry *steno_undo_peek(const struct stroke_history *hist);
/* Get current count */
uint16_t steno_undo_count(const struct stroke_history *hist);
#endif