Arrow keys work without Fn outside Text Entry

; . , / are arrows on their own unless the foreground App is editing
text (App::textEntryActive); Fn + those keys are arrows everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 13:59:49 +02:00
co-authored by Claude Opus 5.5
parent 51698a8011
commit 5fd351c45e
10 changed files with 82 additions and 6 deletions
+4
View File
@@ -66,6 +66,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**: **Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
**Text Entry**:
When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own.
_Avoid_: edit mode, insert mode
**Compose Key**: **Compose Key**:
The `opt` key used as a dead key. Pressing it and then a base letter types an accented character (e.g. `opt` `'` `e` → é). The `opt` key used as a dead key. Pressing it and then a base letter types an accented character (e.g. `opt` `'` `e` → é).
_Avoid_: modifier, alt _Avoid_: modifier, alt
+3
View File
@@ -22,6 +22,9 @@ class App {
return false; return false;
} }
// True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
virtual bool textEntryActive() const { return false; }
// Called every main-loop pass while in the foreground (e.g. to refresh live values). // Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; } virtual void update(uint32_t nowMs) { (void)nowMs; }
+10 -3
View File
@@ -87,14 +87,21 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return; return;
} }
if (keys.fn) { if (keys.fn || !textEntry_) {
switch (c) { switch (c) {
case ';': out.push_back(KeyEvent::of(Key::Up)); return; case ';': out.push_back(KeyEvent::of(Key::Up)); return;
case '.': out.push_back(KeyEvent::of(Key::Down)); return; case '.': out.push_back(KeyEvent::of(Key::Down)); return;
case ',': out.push_back(KeyEvent::of(Key::Left)); return; case ',': out.push_back(KeyEvent::of(Key::Left)); return;
case '/': out.push_back(KeyEvent::of(Key::Right)); return; case '/': out.push_back(KeyEvent::of(Key::Right)); return;
case '`': out.push_back(KeyEvent::of(Key::Home)); return; case '`':
default: return; // other Fn combos are unassigned if (keys.fn) {
out.push_back(KeyEvent::of(Key::Home));
return;
}
break;
default:
if (keys.fn) return; // other Fn combos are unassigned
break;
} }
} }
if (c == '`') { if (c == '`') {
+8 -2
View File
@@ -22,12 +22,17 @@ struct RawKeys {
}; };
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events; // Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events;
// Fn + ; . , / are arrows; ` is Back and Fn + ` is Home; the Compose Key (opt) followed by an // Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and
// accent and a letter types the accented letter (opt ' e -> é). // Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented
// letter (opt ' e -> é).
class KeyMapper { class KeyMapper {
public: public:
std::vector<KeyEvent> update(const RawKeys& keys); std::vector<KeyEvent> update(const RawKeys& keys);
// Whether the foreground App is editing text (default). Outside text entry, the arrow keys
// don't need Fn.
void setTextEntry(bool active) { textEntry_ = active; }
// 0 when idle, '*' when opt was pressed, or the accent character waiting for its letter. // 0 when idle, '*' when opt was pressed, or the accent character waiting for its letter.
char pendingCompose() const { return compose_; } char pendingCompose() const { return compose_; }
@@ -36,6 +41,7 @@ class KeyMapper {
RawKeys previous_; RawKeys previous_;
char compose_ = 0; char compose_ = 0;
bool textEntry_ = true;
}; };
} // namespace roro } // namespace roro
+1 -1
View File
@@ -11,7 +11,7 @@ const char* const kText =
"Bienvenue sur roro9stack ! Ce texte teste le retour \xC3\xA0 la ligne automatique et les " "Bienvenue sur roro9stack ! Ce texte teste le retour \xC3\xA0 la ligne automatique et les "
"accents : \xC3\xA9t\xC3\xA9, \xC3\xA0 bient\xC3\xB4t, gar\xC3\xA7on, No\xC3\xABl, " "accents : \xC3\xA9t\xC3\xA9, \xC3\xA0 bient\xC3\xB4t, gar\xC3\xA7on, No\xC3\xABl, "
"ma\xC3\xAEtre, o\xC3\xB9.\n\n" "ma\xC3\xAEtre, o\xC3\xB9.\n\n"
"Use Fn+; and Fn+. to scroll, ` to go back. Fn+` always returns to the Launcher.\n\n" "Use ; and . to scroll, ` to go back. Fn+` always returns to the Launcher.\n\n"
"Unbreakablewordthatislongerthanawholelineofthescreen to test hard breaks."; "Unbreakablewordthatislongerthanawholelineofthescreen to test hard breaks.";
} // namespace } // namespace
+1
View File
@@ -19,6 +19,7 @@ class DemoApp : public App {
explicit DemoApp(EventBus& bus) : bus_(bus) {} explicit DemoApp(EventBus& bus) : bus_(bus) {}
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
private: private:
+1
View File
@@ -34,6 +34,7 @@ class SettingsApp : public App {
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Text; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
private: private:
+3
View File
@@ -13,6 +13,9 @@ class SetupApp : public App {
public: public:
SetupApp(Settings& settings, AppManager& apps); SetupApp(Settings& settings, AppManager& apps);
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override {
return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName;
}
void draw(Canvas& c) override; void draw(Canvas& c) override;
private: private:
+1
View File
@@ -143,6 +143,7 @@ void loop() {
M5Cardputer.update(); M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) { if (M5Cardputer.Keyboard.isChange()) {
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
if (!swallow) if (!swallow)
for (auto& e : events) apps->handleKey(e); for (auto& e : events) apps->handleKey(e);
+50
View File
@@ -175,6 +175,51 @@ void test_compose_state_is_visible_for_the_ui() {
TEST_ASSERT_EQUAL('^', m.pendingCompose()); 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);
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_plain_character); RUN_TEST(test_plain_character);
@@ -191,5 +236,10 @@ int main() {
RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally); RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally);
RUN_TEST(test_pressing_opt_twice_cancels); RUN_TEST(test_pressing_opt_twice_cancels);
RUN_TEST(test_compose_state_is_visible_for_the_ui); 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);
return UNITY_END(); return UNITY_END();
} }