From 910fe47b2ed1284d93ff4d274f5c2341cb0d2d0b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 22:46:53 +0530 Subject: feat: add on-screen keyboard with layout/hit detection tests --- main/keyboard.c | 162 +++++++++++++++++++++++++++++++++++++++++++ main/keyboard.h | 47 +++++++++++++ tests/unit/Makefile | 5 +- tests/unit/test_keyboard.c | 168 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 main/keyboard.c create mode 100644 main/keyboard.h create mode 100644 tests/unit/test_keyboard.c diff --git a/main/keyboard.c b/main/keyboard.c new file mode 100644 index 0000000..741bd08 --- /dev/null +++ b/main/keyboard.c @@ -0,0 +1,162 @@ +#include "keyboard.h" +#include + +static const char *s_alpha_lower[] = { + "qwertyuiop", + "asdfghjkl", + "\001zxcvbnm\b", + "\002\003 \004" +}; + +static const char *s_alpha_upper[] = { + "QWERTYUIOP", + "ASDFGHJKL", + "\001ZXCVBNM\b", + "\002\003 \004" +}; + +static const char *s_numsym[] = { + "1234567890", + "-/:;()$&@\"", + "\001.,?!'\\b", + "\002\003 \004" +}; + +#define CTRL_SHIFT '\001' +#define CTRL_LAYER '\002' +#define CTRL_SPACE '\003' +#define CTRL_DONE '\004' +#define CTRL_BS '\b' + +void kb_state_init(kb_state_t *st) { + if (!st) return; + memset(st, 0, sizeof(*st)); + st->layer = KB_ALPHA_LOWER; + st->reveal = false; +} + +static const char **get_layer(kb_layer_t layer) { + switch (layer) { + case KB_ALPHA_UPPER: return s_alpha_upper; + case KB_NUMSYM: return s_numsym; + default: return s_alpha_lower; + } +} + +static int key_is_ctrl(char c) { + return c == CTRL_SHIFT || c == CTRL_LAYER || c == CTRL_SPACE || c == CTRL_DONE || c == CTRL_BS; +} + +int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out) { + if (row < 0 || row >= KB_ROW_COUNT) { + *keys_out = NULL; + return 0; + } + const char **layer_rows = get_layer(layer); + const char *row_str = layer_rows[row]; + *keys_out = row_str; + return (int)strlen(row_str); +} + +static int row_x_offset(int row) { + switch (row) { + case 0: return 5; + case 1: return 14; + case 2: return 23; + case 3: return 5; + default: return 0; + } +} + +static int key_width_at(int row, int col, int total_keys) { + (void)col; + if (row == 3) { + if (col == 0) return 42; + if (col == total_keys - 1) return 50; + if (total_keys == 3 && col == 1) return 168; + if (total_keys == 4 && col == 1) return 168; + if (total_keys == 4 && col == 2) return 42; + } + return KB_KEY_W; +} + +kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer) { + kb_result_t result = {KB_ACTION_NONE, 0}; + + if (ty < KB_START_Y || ty >= KB_START_Y + KB_ROW_COUNT * (KB_KEY_H + KB_KEY_GAP)) { + return result; + } + + int row = (ty - KB_START_Y) / (KB_KEY_H + KB_KEY_GAP); + if (row < 0 || row >= KB_ROW_COUNT) return result; + + const char *row_str; + int total_keys = kb_get_row_keys(row, layer, &row_str); + if (total_keys == 0) return result; + + int x_off = row_x_offset(row); + int cx = x_off; + + for (int col = 0; col < total_keys; col++) { + int kw = key_width_at(row, col, total_keys); + if (tx >= cx && tx < cx + kw) { + char c = row_str[col]; + if (c == CTRL_SHIFT) { + result.action = KB_ACTION_SHIFT; + } else if (c == CTRL_LAYER) { + result.action = KB_ACTION_LAYER; + } else if (c == CTRL_SPACE) { + result.action = KB_ACTION_SPACE; + result.ch = ' '; + } else if (c == CTRL_DONE) { + result.action = KB_ACTION_DONE; + } else if (c == CTRL_BS) { + result.action = KB_ACTION_BACKSPACE; + } else { + result.action = KB_ACTION_CHAR; + result.ch = c; + } + return result; + } + cx += kw + KB_KEY_GAP; + } + + return result; +} + +void kb_apply(kb_state_t *st, kb_result_t result) { + if (!st || result.action == KB_ACTION_NONE) return; + + switch (result.action) { + case KB_ACTION_CHAR: + if (st->cursor < KB_INPUT_MAX) { + st->input[st->cursor++] = result.ch; + st->input[st->cursor] = '\0'; + } + break; + case KB_ACTION_BACKSPACE: + if (st->cursor > 0) { + st->cursor--; + st->input[st->cursor] = '\0'; + } + break; + case KB_ACTION_SHIFT: + if (st->layer == KB_ALPHA_LOWER) st->layer = KB_ALPHA_UPPER; + else if (st->layer == KB_ALPHA_UPPER) st->layer = KB_ALPHA_LOWER; + break; + case KB_ACTION_LAYER: + if (st->layer == KB_NUMSYM) st->layer = KB_ALPHA_LOWER; + else st->layer = KB_NUMSYM; + break; + case KB_ACTION_SPACE: + if (st->cursor < KB_INPUT_MAX) { + st->input[st->cursor++] = ' '; + st->input[st->cursor] = '\0'; + } + break; + case KB_ACTION_DONE: + break; + default: + break; + } +} diff --git a/main/keyboard.h b/main/keyboard.h new file mode 100644 index 0000000..d7b3400 --- /dev/null +++ b/main/keyboard.h @@ -0,0 +1,47 @@ +#ifndef KEYBOARD_H +#define KEYBOARD_H + +#include +#include + +#define KB_INPUT_MAX 64 +#define KB_KEY_W 28 +#define KB_KEY_H 36 +#define KB_KEY_GAP 2 +#define KB_ROW_COUNT 4 +#define KB_START_Y 310 + +typedef enum { + KB_ALPHA_LOWER, + KB_ALPHA_UPPER, + KB_NUMSYM +} kb_layer_t; + +typedef enum { + KB_ACTION_NONE = 0, + KB_ACTION_CHAR, + KB_ACTION_SHIFT, + KB_ACTION_BACKSPACE, + KB_ACTION_DONE, + KB_ACTION_LAYER, + KB_ACTION_SPACE +} kb_action_t; + +typedef struct { + kb_action_t action; + char ch; +} kb_result_t; + +typedef struct { + char input[KB_INPUT_MAX + 1]; + int cursor; + bool reveal; + kb_layer_t layer; +} kb_state_t; + +void kb_state_init(kb_state_t *st); +int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out); +kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer); +void kb_apply(kb_state_t *st, kb_result_t result); + +#endif diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 5bc5eb1..b978891 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -22,7 +22,7 @@ LDFLAGS := -lmbedcrypto -lcjson -lm SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o -TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_touch +TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_touch test_keyboard .PHONY: all test clean $(TESTS) @@ -84,5 +84,8 @@ test_cvm_server: test_cvm_server.c test_touch: test_touch.c $(REPO_ROOT)/main/touch.c $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/touch.c -o $@ $(LDFLAGS) +test_keyboard: test_keyboard.c $(REPO_ROOT)/main/keyboard.c + $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/keyboard.c -o $@ $(LDFLAGS) + clean: rm -f $(TESTS) $(SECP256K1_OBJ) diff --git a/tests/unit/test_keyboard.c b/tests/unit/test_keyboard.c new file mode 100644 index 0000000..4cb923d --- /dev/null +++ b/tests/unit/test_keyboard.c @@ -0,0 +1,168 @@ +#include "test_framework.h" +#include "../../main/keyboard.h" +#include + +int main(void) +{ + printf("=== test_keyboard ===\n"); + + const char *keys; + int count; + + count = kb_get_row_keys(0, KB_ALPHA_LOWER, &keys); + ASSERT_EQ_INT(10, count, "Row 0 alpha lower has 10 keys"); + ASSERT_EQ_INT('q', keys[0], "Row 0 starts with 'q'"); + ASSERT_EQ_INT('p', keys[9], "Row 0 ends with 'p'"); + + count = kb_get_row_keys(1, KB_ALPHA_LOWER, &keys); + ASSERT_EQ_INT(9, count, "Row 1 alpha lower has 9 keys"); + ASSERT_EQ_INT('a', keys[0], "Row 1 starts with 'a'"); + + count = kb_get_row_keys(2, KB_ALPHA_LOWER, &keys); + ASSERT(count > 0, "Row 2 alpha lower has keys"); + ASSERT_EQ_INT('\001', keys[0], "Row 2 starts with SHIFT control char"); + + count = kb_get_row_keys(0, KB_ALPHA_UPPER, &keys); + ASSERT_EQ_INT(10, count, "Row 0 alpha upper has 10 keys"); + ASSERT_EQ_INT('Q', keys[0], "Row 0 upper starts with 'Q'"); + + count = kb_get_row_keys(0, KB_NUMSYM, &keys); + ASSERT_EQ_INT(10, count, "Row 0 numsym has 10 keys"); + ASSERT_EQ_INT('1', keys[0], "Row 0 numsym starts with '1'"); + ASSERT_EQ_INT('0', keys[9], "Row 0 numsym ends with '0'"); + + count = kb_get_row_keys(-1, KB_ALPHA_LOWER, &keys); + ASSERT_EQ_INT(0, count, "Invalid row -1 returns 0"); + + count = kb_get_row_keys(99, KB_ALPHA_LOWER, &keys); + ASSERT_EQ_INT(0, count, "Invalid row 99 returns 0"); + + { + kb_result_t r = kb_hit_test(160, 100, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_NONE, "Touch above keyboard = NONE"); + + r = kb_hit_test(160, 500, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_NONE, "Touch below keyboard = NONE"); + } + + { + int mid_x = 5 + KB_KEY_W / 2; + int mid_y = KB_START_Y + KB_KEY_H / 2; + kb_result_t r = kb_hit_test(mid_x, mid_y, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_CHAR, "Row 0 first key is a char"); + ASSERT_EQ_INT('q', r.ch, "Row 0 first key = 'q'"); + } + + { + int x = 5 + KB_KEY_W + KB_KEY_GAP + KB_KEY_W / 2; + int y = KB_START_Y + KB_KEY_H / 2; + kb_result_t r = kb_hit_test(x, y, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_CHAR, "Row 0 second key is a char"); + ASSERT_EQ_INT('w', r.ch, "Row 0 second key = 'w'"); + } + + { + int y_row1 = KB_START_Y + (KB_KEY_H + KB_KEY_GAP) + KB_KEY_H / 2; + int x_row1 = 14 + KB_KEY_W / 2; + kb_result_t r = kb_hit_test(x_row1, y_row1, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_CHAR, "Row 1 first key is a char"); + ASSERT_EQ_INT('a', r.ch, "Row 1 first key = 'a'"); + } + + { + int y_row2 = KB_START_Y + 2 * (KB_KEY_H + KB_KEY_GAP) + KB_KEY_H / 2; + int x_row2 = 23 + 42 / 2; + kb_result_t r = kb_hit_test(x_row2, y_row2, KB_ALPHA_LOWER); + ASSERT(r.action == KB_ACTION_SHIFT, "Row 2 first key = SHIFT"); + } + + { + kb_state_t st; + kb_state_init(&st); + ASSERT_EQ_INT(0, st.cursor, "Initial cursor = 0"); + ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Initial layer = lower"); + ASSERT_EQ_STR("", st.input, "Initial input is empty"); + + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'h'}); + ASSERT_EQ_STR("h", st.input, "After typing 'h': input='h'"); + ASSERT_EQ_INT(1, st.cursor, "After typing 'h': cursor=1"); + + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'i'}); + ASSERT_EQ_STR("hi", st.input, "After typing 'i': input='hi'"); + + kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); + ASSERT_EQ_STR("h", st.input, "After backspace: input='h'"); + ASSERT_EQ_INT(1, st.cursor, "After backspace: cursor=1"); + + kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); + ASSERT_EQ_STR("", st.input, "After second backspace: empty"); + ASSERT_EQ_INT(0, st.cursor, "After second backspace: cursor=0"); + + kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); + ASSERT_EQ_INT(0, st.cursor, "Backspace on empty stays at 0"); + } + + { + kb_state_t st; + kb_state_init(&st); + + kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); + ASSERT_EQ_INT(KB_ALPHA_UPPER, st.layer, "Shift: lower->upper"); + + kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); + ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Shift: upper->lower"); + + kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); + ASSERT_EQ_INT(KB_NUMSYM, st.layer, "Layer: lower->numsym"); + + kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); + ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Layer: numsym->lower"); + } + + { + kb_state_t st; + kb_state_init(&st); + + for (int i = 0; i < KB_INPUT_MAX; i++) { + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'a' + (i % 26)}); + } + ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Filled to max"); + ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "String length = max"); + + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'Z'}); + ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Overflow blocked"); + ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "Length unchanged after overflow"); + } + + { + kb_state_t st; + kb_state_init(&st); + + kb_apply(&st, (kb_result_t){KB_ACTION_SPACE, ' '}); + ASSERT_EQ_STR(" ", st.input, "Space adds space char"); + ASSERT_EQ_INT(1, st.cursor, "Space increments cursor"); + } + + { + kb_state_t st; + kb_state_init(&st); + kb_result_t none = {KB_ACTION_NONE, 0}; + kb_apply(&st, none); + ASSERT_EQ_STR("", st.input, "NONE action does nothing"); + + kb_apply(NULL, (kb_result_t){KB_ACTION_CHAR, 'x'}); + } + + { + kb_state_t st; + kb_state_init(&st); + + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'P'}); + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, '@'}); + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); + kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); + ASSERT_EQ_STR("P@ss", st.input, "Password build: P@ss"); + } + + TEST_SUMMARY(); +} -- cgit v1.2.3