Files
twislaandClaude Opus 5.5 7fe8b3d22a
CI / build (pull_request) Successful in 7m22s
Site / build (pull_request) Successful in 14s
Help: Fn+h lists the keys of the screen you're on, and no screen names its keys any more (#69)
Fn+h on any screen, text fields included, and ? outside Text Entry, open a
panel over the content area: the screen's own keys, then the ones that work
everywhere. Every App declares its keys for the state it is in (pages,
viewers, dialogs and text fields answer for themselves); the App manager
opens the panel and takes every key while it is open.

About 30 hint lines are gone, from every App. What stays on a screen is
state. The first-start Setup keeps its hints and teaches the key; a device
set up before gets one Toast, once. The guide and the FAQ open with it.
`key help` over the consoles.

476 host tests (8 new). Checked on the device with key help and screenshots:
the Launcher, all nine Apps and several of their states. 8.5 KB of flash and
40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:29:51 +02:00

285 lines
8.8 KiB
C++

#include <unity.h>
#include <vector>
#include "key_mapper.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static RawKeys chars(std::vector<char> c) {
RawKeys r;
r.chars = c;
return r;
}
static RawKeys withFn(std::vector<char> c) {
RawKeys r = chars(c);
r.fn = true;
return r;
}
static RawKeys optOnly() {
RawKeys r;
r.opt = true;
return r;
}
static std::vector<KeyEvent> press(KeyMapper& m, const RawKeys& r) { return m.update(r); }
static void release(KeyMapper& m) { m.update(RawKeys{}); }
void test_plain_character() {
KeyMapper m;
auto ev = press(m, chars({'a'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('a', ev[0].ch);
}
void test_held_key_is_not_repeated_on_later_changes() {
KeyMapper m;
press(m, chars({'a'}));
auto ev = press(m, chars({'a', 'b'})); // rolling: b pressed while a still held
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL('b', ev[0].ch);
}
void test_same_key_again_after_release() {
KeyMapper m;
press(m, chars({'a'}));
release(m);
TEST_ASSERT_EQUAL(1, press(m, chars({'a'})).size());
}
void test_fn_combos_are_arrows() {
KeyMapper m;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, withFn({'.'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, withFn({','}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, withFn({'/'}))[0].key));
}
void test_backtick_is_back_and_fn_backtick_is_home() {
KeyMapper m;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(press(m, chars({'`'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Home), static_cast<int>(press(m, withFn({'`'}))[0].key));
}
void test_enter_delete_tab() {
KeyMapper m;
RawKeys r;
r.enter = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Select), static_cast<int>(press(m, r)[0].key));
release(m);
r = RawKeys{};
r.del = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Delete), static_cast<int>(press(m, r)[0].key));
release(m);
r = RawKeys{};
r.tab = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(press(m, r)[0].key));
}
void test_ctrl_and_alt_flags_are_carried() {
KeyMapper m;
RawKeys r = chars({'c'});
r.ctrl = true;
auto ev = press(m, r);
TEST_ASSERT_TRUE(ev[0].ctrl);
TEST_ASSERT_EQUAL('c', ev[0].ch);
}
void test_compose_acute() {
KeyMapper m;
TEST_ASSERT_EQUAL(0, press(m, optOnly()).size());
release(m);
TEST_ASSERT_EQUAL(0, press(m, chars({'\''})).size());
release(m);
auto ev = press(m, chars({'e'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(0xE9, ev[0].ch); // é
}
void test_compose_while_holding_opt() {
KeyMapper m;
RawKeys r = chars({'`'});
r.opt = true;
TEST_ASSERT_EQUAL(0, press(m, r).size()); // grave accent, not Back
release(m);
auto ev = press(m, chars({'a'}));
TEST_ASSERT_EQUAL(0xE0, ev[0].ch); // à
}
void test_compose_table() {
struct Case {
char accent, base;
uint32_t expected;
} cases[] = {{'^', 'e', 0xEA}, {'"', 'e', 0xEB}, {',', 'c', 0xE7}, {',', 'C', 0xC7},
{'~', 'n', 0xF1}, {'\'', 'E', 0xC9}, {'`', 'u', 0xF9}, {'"', 'i', 0xEF}};
for (auto& c : cases) {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, chars({c.accent}));
release(m);
auto ev = press(m, chars({c.base}));
TEST_ASSERT_EQUAL_HEX32(c.expected, ev[0].ch);
}
}
void test_unknown_combination_types_both_characters() {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, chars({'\''}));
release(m);
auto ev = press(m, chars({'z'}));
TEST_ASSERT_EQUAL(2, ev.size());
TEST_ASSERT_EQUAL('\'', ev[0].ch);
TEST_ASSERT_EQUAL('z', ev[1].ch);
}
void test_opt_followed_by_a_non_accent_types_it_normally() {
KeyMapper m;
press(m, optOnly());
release(m);
auto ev = press(m, chars({'x'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL('x', ev[0].ch);
}
void test_pressing_opt_twice_cancels() {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, optOnly());
release(m);
auto ev = press(m, chars({'`'}));
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
}
void test_compose_state_is_visible_for_the_ui() {
KeyMapper m;
TEST_ASSERT_EQUAL(0, m.pendingCompose());
press(m, optOnly());
TEST_ASSERT_EQUAL('*', m.pendingCompose()); // armed, no accent yet
release(m);
press(m, chars({'^'}));
TEST_ASSERT_EQUAL('^', m.pendingCompose());
}
void test_arrow_keys_work_without_fn_when_not_typing() {
KeyMapper m;
m.setTextEntry(false);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, chars({';'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, chars({'.'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, chars({','}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, chars({'/'}))[0].key));
}
void test_arrow_keys_type_characters_during_text_entry() {
KeyMapper m;
m.setTextEntry(true);
auto ev = press(m, chars({';'}));
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL(';', ev[0].ch);
}
void test_fn_arrows_work_in_both_modes() {
KeyMapper m;
m.setTextEntry(false);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
release(m);
m.setTextEntry(true);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
}
void test_other_characters_still_type_when_not_typing() {
KeyMapper m;
m.setTextEntry(false);
auto ev = press(m, chars({'w'}));
TEST_ASSERT_EQUAL('w', ev[0].ch);
}
void test_shifted_arrow_keys_type_their_symbols_when_not_typing() {
// Shift+; is ':' — the driver reports the shifted character, which isn't an arrow.
KeyMapper m;
m.setTextEntry(false);
RawKeys r = chars({':'});
r.shift = true;
TEST_ASSERT_EQUAL(':', press(m, r)[0].ch);
}
// Issue #69, Q196: Fn+h everywhere, ? when it wouldn't be typed.
void test_fn_h_is_help_in_both_modes() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, withFn({'h'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
}
}
void test_plain_h_still_types() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, chars({'h'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('h', ev[0].ch);
}
}
void test_question_mark_is_help_only_when_not_typing() {
KeyMapper m;
m.setTextEntry(false);
auto ev = press(m, chars({'?'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
release(m);
m.setTextEntry(true);
ev = press(m, chars({'?'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('?', ev[0].ch);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plain_character);
RUN_TEST(test_held_key_is_not_repeated_on_later_changes);
RUN_TEST(test_same_key_again_after_release);
RUN_TEST(test_fn_combos_are_arrows);
RUN_TEST(test_backtick_is_back_and_fn_backtick_is_home);
RUN_TEST(test_enter_delete_tab);
RUN_TEST(test_ctrl_and_alt_flags_are_carried);
RUN_TEST(test_compose_acute);
RUN_TEST(test_compose_while_holding_opt);
RUN_TEST(test_compose_table);
RUN_TEST(test_unknown_combination_types_both_characters);
RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally);
RUN_TEST(test_pressing_opt_twice_cancels);
RUN_TEST(test_compose_state_is_visible_for_the_ui);
RUN_TEST(test_arrow_keys_work_without_fn_when_not_typing);
RUN_TEST(test_arrow_keys_type_characters_during_text_entry);
RUN_TEST(test_fn_arrows_work_in_both_modes);
RUN_TEST(test_other_characters_still_type_when_not_typing);
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
RUN_TEST(test_fn_h_is_help_in_both_modes);
RUN_TEST(test_plain_h_still_types);
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
return UNITY_END();
}