upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-18 22:46:53 +0530
committerYour Name <you@example.com>2026-05-18 22:46:53 +0530
commit910fe47b2ed1284d93ff4d274f5c2341cb0d2d0b (patch)
tree2c0e48e153337b22dbe7f9cc531f5e92e91561fb
parentf2c4b15c72a2fa89caed5d8a11a4ea9832c48be6 (diff)
feat: add on-screen keyboard with layout/hit detection tests
-rw-r--r--main/keyboard.c162
-rw-r--r--main/keyboard.h47
-rw-r--r--tests/unit/Makefile5
-rw-r--r--tests/unit/test_keyboard.c168
4 files changed, 381 insertions, 1 deletions
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 @@
1#include "keyboard.h"
2#include <string.h>
3
4static const char *s_alpha_lower[] = {
5 "qwertyuiop",
6 "asdfghjkl",
7 "\001zxcvbnm\b",
8 "\002\003 \004"
9};
10
11static const char *s_alpha_upper[] = {
12 "QWERTYUIOP",
13 "ASDFGHJKL",
14 "\001ZXCVBNM\b",
15 "\002\003 \004"
16};
17
18static const char *s_numsym[] = {
19 "1234567890",
20 "-/:;()$&@\"",
21 "\001.,?!'\\b",
22 "\002\003 \004"
23};
24
25#define CTRL_SHIFT '\001'
26#define CTRL_LAYER '\002'
27#define CTRL_SPACE '\003'
28#define CTRL_DONE '\004'
29#define CTRL_BS '\b'
30
31void kb_state_init(kb_state_t *st) {
32 if (!st) return;
33 memset(st, 0, sizeof(*st));
34 st->layer = KB_ALPHA_LOWER;
35 st->reveal = false;
36}
37
38static const char **get_layer(kb_layer_t layer) {
39 switch (layer) {
40 case KB_ALPHA_UPPER: return s_alpha_upper;
41 case KB_NUMSYM: return s_numsym;
42 default: return s_alpha_lower;
43 }
44}
45
46static int key_is_ctrl(char c) {
47 return c == CTRL_SHIFT || c == CTRL_LAYER || c == CTRL_SPACE || c == CTRL_DONE || c == CTRL_BS;
48}
49
50int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out) {
51 if (row < 0 || row >= KB_ROW_COUNT) {
52 *keys_out = NULL;
53 return 0;
54 }
55 const char **layer_rows = get_layer(layer);
56 const char *row_str = layer_rows[row];
57 *keys_out = row_str;
58 return (int)strlen(row_str);
59}
60
61static int row_x_offset(int row) {
62 switch (row) {
63 case 0: return 5;
64 case 1: return 14;
65 case 2: return 23;
66 case 3: return 5;
67 default: return 0;
68 }
69}
70
71static int key_width_at(int row, int col, int total_keys) {
72 (void)col;
73 if (row == 3) {
74 if (col == 0) return 42;
75 if (col == total_keys - 1) return 50;
76 if (total_keys == 3 && col == 1) return 168;
77 if (total_keys == 4 && col == 1) return 168;
78 if (total_keys == 4 && col == 2) return 42;
79 }
80 return KB_KEY_W;
81}
82
83kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer) {
84 kb_result_t result = {KB_ACTION_NONE, 0};
85
86 if (ty < KB_START_Y || ty >= KB_START_Y + KB_ROW_COUNT * (KB_KEY_H + KB_KEY_GAP)) {
87 return result;
88 }
89
90 int row = (ty - KB_START_Y) / (KB_KEY_H + KB_KEY_GAP);
91 if (row < 0 || row >= KB_ROW_COUNT) return result;
92
93 const char *row_str;
94 int total_keys = kb_get_row_keys(row, layer, &row_str);
95 if (total_keys == 0) return result;
96
97 int x_off = row_x_offset(row);
98 int cx = x_off;
99
100 for (int col = 0; col < total_keys; col++) {
101 int kw = key_width_at(row, col, total_keys);
102 if (tx >= cx && tx < cx + kw) {
103 char c = row_str[col];
104 if (c == CTRL_SHIFT) {
105 result.action = KB_ACTION_SHIFT;
106 } else if (c == CTRL_LAYER) {
107 result.action = KB_ACTION_LAYER;
108 } else if (c == CTRL_SPACE) {
109 result.action = KB_ACTION_SPACE;
110 result.ch = ' ';
111 } else if (c == CTRL_DONE) {
112 result.action = KB_ACTION_DONE;
113 } else if (c == CTRL_BS) {
114 result.action = KB_ACTION_BACKSPACE;
115 } else {
116 result.action = KB_ACTION_CHAR;
117 result.ch = c;
118 }
119 return result;
120 }
121 cx += kw + KB_KEY_GAP;
122 }
123
124 return result;
125}
126
127void kb_apply(kb_state_t *st, kb_result_t result) {
128 if (!st || result.action == KB_ACTION_NONE) return;
129
130 switch (result.action) {
131 case KB_ACTION_CHAR:
132 if (st->cursor < KB_INPUT_MAX) {
133 st->input[st->cursor++] = result.ch;
134 st->input[st->cursor] = '\0';
135 }
136 break;
137 case KB_ACTION_BACKSPACE:
138 if (st->cursor > 0) {
139 st->cursor--;
140 st->input[st->cursor] = '\0';
141 }
142 break;
143 case KB_ACTION_SHIFT:
144 if (st->layer == KB_ALPHA_LOWER) st->layer = KB_ALPHA_UPPER;
145 else if (st->layer == KB_ALPHA_UPPER) st->layer = KB_ALPHA_LOWER;
146 break;
147 case KB_ACTION_LAYER:
148 if (st->layer == KB_NUMSYM) st->layer = KB_ALPHA_LOWER;
149 else st->layer = KB_NUMSYM;
150 break;
151 case KB_ACTION_SPACE:
152 if (st->cursor < KB_INPUT_MAX) {
153 st->input[st->cursor++] = ' ';
154 st->input[st->cursor] = '\0';
155 }
156 break;
157 case KB_ACTION_DONE:
158 break;
159 default:
160 break;
161 }
162}
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 @@
1#ifndef KEYBOARD_H
2#define KEYBOARD_H
3
4#include <stdint.h>
5#include <stdbool.h>
6
7#define KB_INPUT_MAX 64
8#define KB_KEY_W 28
9#define KB_KEY_H 36
10#define KB_KEY_GAP 2
11#define KB_ROW_COUNT 4
12#define KB_START_Y 310
13
14typedef enum {
15 KB_ALPHA_LOWER,
16 KB_ALPHA_UPPER,
17 KB_NUMSYM
18} kb_layer_t;
19
20typedef enum {
21 KB_ACTION_NONE = 0,
22 KB_ACTION_CHAR,
23 KB_ACTION_SHIFT,
24 KB_ACTION_BACKSPACE,
25 KB_ACTION_DONE,
26 KB_ACTION_LAYER,
27 KB_ACTION_SPACE
28} kb_action_t;
29
30typedef struct {
31 kb_action_t action;
32 char ch;
33} kb_result_t;
34
35typedef struct {
36 char input[KB_INPUT_MAX + 1];
37 int cursor;
38 bool reveal;
39 kb_layer_t layer;
40} kb_state_t;
41
42void kb_state_init(kb_state_t *st);
43int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out);
44kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer);
45void kb_apply(kb_state_t *st, kb_result_t result);
46
47#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
22 22
23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o 23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o
24 24
25TESTS := 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 25TESTS := 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
26 26
27.PHONY: all test clean $(TESTS) 27.PHONY: all test clean $(TESTS)
28 28
@@ -84,5 +84,8 @@ test_cvm_server: test_cvm_server.c
84test_touch: test_touch.c $(REPO_ROOT)/main/touch.c 84test_touch: test_touch.c $(REPO_ROOT)/main/touch.c
85 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/touch.c -o $@ $(LDFLAGS) 85 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/touch.c -o $@ $(LDFLAGS)
86 86
87test_keyboard: test_keyboard.c $(REPO_ROOT)/main/keyboard.c
88 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/keyboard.c -o $@ $(LDFLAGS)
89
87clean: 90clean:
88 rm -f $(TESTS) $(SECP256K1_OBJ) 91 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 @@
1#include "test_framework.h"
2#include "../../main/keyboard.h"
3#include <string.h>
4
5int main(void)
6{
7 printf("=== test_keyboard ===\n");
8
9 const char *keys;
10 int count;
11
12 count = kb_get_row_keys(0, KB_ALPHA_LOWER, &keys);
13 ASSERT_EQ_INT(10, count, "Row 0 alpha lower has 10 keys");
14 ASSERT_EQ_INT('q', keys[0], "Row 0 starts with 'q'");
15 ASSERT_EQ_INT('p', keys[9], "Row 0 ends with 'p'");
16
17 count = kb_get_row_keys(1, KB_ALPHA_LOWER, &keys);
18 ASSERT_EQ_INT(9, count, "Row 1 alpha lower has 9 keys");
19 ASSERT_EQ_INT('a', keys[0], "Row 1 starts with 'a'");
20
21 count = kb_get_row_keys(2, KB_ALPHA_LOWER, &keys);
22 ASSERT(count > 0, "Row 2 alpha lower has keys");
23 ASSERT_EQ_INT('\001', keys[0], "Row 2 starts with SHIFT control char");
24
25 count = kb_get_row_keys(0, KB_ALPHA_UPPER, &keys);
26 ASSERT_EQ_INT(10, count, "Row 0 alpha upper has 10 keys");
27 ASSERT_EQ_INT('Q', keys[0], "Row 0 upper starts with 'Q'");
28
29 count = kb_get_row_keys(0, KB_NUMSYM, &keys);
30 ASSERT_EQ_INT(10, count, "Row 0 numsym has 10 keys");
31 ASSERT_EQ_INT('1', keys[0], "Row 0 numsym starts with '1'");
32 ASSERT_EQ_INT('0', keys[9], "Row 0 numsym ends with '0'");
33
34 count = kb_get_row_keys(-1, KB_ALPHA_LOWER, &keys);
35 ASSERT_EQ_INT(0, count, "Invalid row -1 returns 0");
36
37 count = kb_get_row_keys(99, KB_ALPHA_LOWER, &keys);
38 ASSERT_EQ_INT(0, count, "Invalid row 99 returns 0");
39
40 {
41 kb_result_t r = kb_hit_test(160, 100, KB_ALPHA_LOWER);
42 ASSERT(r.action == KB_ACTION_NONE, "Touch above keyboard = NONE");
43
44 r = kb_hit_test(160, 500, KB_ALPHA_LOWER);
45 ASSERT(r.action == KB_ACTION_NONE, "Touch below keyboard = NONE");
46 }
47
48 {
49 int mid_x = 5 + KB_KEY_W / 2;
50 int mid_y = KB_START_Y + KB_KEY_H / 2;
51 kb_result_t r = kb_hit_test(mid_x, mid_y, KB_ALPHA_LOWER);
52 ASSERT(r.action == KB_ACTION_CHAR, "Row 0 first key is a char");
53 ASSERT_EQ_INT('q', r.ch, "Row 0 first key = 'q'");
54 }
55
56 {
57 int x = 5 + KB_KEY_W + KB_KEY_GAP + KB_KEY_W / 2;
58 int y = KB_START_Y + KB_KEY_H / 2;
59 kb_result_t r = kb_hit_test(x, y, KB_ALPHA_LOWER);
60 ASSERT(r.action == KB_ACTION_CHAR, "Row 0 second key is a char");
61 ASSERT_EQ_INT('w', r.ch, "Row 0 second key = 'w'");
62 }
63
64 {
65 int y_row1 = KB_START_Y + (KB_KEY_H + KB_KEY_GAP) + KB_KEY_H / 2;
66 int x_row1 = 14 + KB_KEY_W / 2;
67 kb_result_t r = kb_hit_test(x_row1, y_row1, KB_ALPHA_LOWER);
68 ASSERT(r.action == KB_ACTION_CHAR, "Row 1 first key is a char");
69 ASSERT_EQ_INT('a', r.ch, "Row 1 first key = 'a'");
70 }
71
72 {
73 int y_row2 = KB_START_Y + 2 * (KB_KEY_H + KB_KEY_GAP) + KB_KEY_H / 2;
74 int x_row2 = 23 + 42 / 2;
75 kb_result_t r = kb_hit_test(x_row2, y_row2, KB_ALPHA_LOWER);
76 ASSERT(r.action == KB_ACTION_SHIFT, "Row 2 first key = SHIFT");
77 }
78
79 {
80 kb_state_t st;
81 kb_state_init(&st);
82 ASSERT_EQ_INT(0, st.cursor, "Initial cursor = 0");
83 ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Initial layer = lower");
84 ASSERT_EQ_STR("", st.input, "Initial input is empty");
85
86 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'h'});
87 ASSERT_EQ_STR("h", st.input, "After typing 'h': input='h'");
88 ASSERT_EQ_INT(1, st.cursor, "After typing 'h': cursor=1");
89
90 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'i'});
91 ASSERT_EQ_STR("hi", st.input, "After typing 'i': input='hi'");
92
93 kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0});
94 ASSERT_EQ_STR("h", st.input, "After backspace: input='h'");
95 ASSERT_EQ_INT(1, st.cursor, "After backspace: cursor=1");
96
97 kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0});
98 ASSERT_EQ_STR("", st.input, "After second backspace: empty");
99 ASSERT_EQ_INT(0, st.cursor, "After second backspace: cursor=0");
100
101 kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0});
102 ASSERT_EQ_INT(0, st.cursor, "Backspace on empty stays at 0");
103 }
104
105 {
106 kb_state_t st;
107 kb_state_init(&st);
108
109 kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0});
110 ASSERT_EQ_INT(KB_ALPHA_UPPER, st.layer, "Shift: lower->upper");
111
112 kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0});
113 ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Shift: upper->lower");
114
115 kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0});
116 ASSERT_EQ_INT(KB_NUMSYM, st.layer, "Layer: lower->numsym");
117
118 kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0});
119 ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Layer: numsym->lower");
120 }
121
122 {
123 kb_state_t st;
124 kb_state_init(&st);
125
126 for (int i = 0; i < KB_INPUT_MAX; i++) {
127 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'a' + (i % 26)});
128 }
129 ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Filled to max");
130 ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "String length = max");
131
132 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'Z'});
133 ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Overflow blocked");
134 ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "Length unchanged after overflow");
135 }
136
137 {
138 kb_state_t st;
139 kb_state_init(&st);
140
141 kb_apply(&st, (kb_result_t){KB_ACTION_SPACE, ' '});
142 ASSERT_EQ_STR(" ", st.input, "Space adds space char");
143 ASSERT_EQ_INT(1, st.cursor, "Space increments cursor");
144 }
145
146 {
147 kb_state_t st;
148 kb_state_init(&st);
149 kb_result_t none = {KB_ACTION_NONE, 0};
150 kb_apply(&st, none);
151 ASSERT_EQ_STR("", st.input, "NONE action does nothing");
152
153 kb_apply(NULL, (kb_result_t){KB_ACTION_CHAR, 'x'});
154 }
155
156 {
157 kb_state_t st;
158 kb_state_init(&st);
159
160 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'P'});
161 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, '@'});
162 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'});
163 kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'});
164 ASSERT_EQ_STR("P@ss", st.input, "Password build: P@ss");
165 }
166
167 TEST_SUMMARY();
168}