From 8c70acc3ef2adb4a206222b1d3adf39053bc8afb Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Tue, 30 Jun 2026 22:10:18 +0200 Subject: [PATCH] fix(eink): queue input so a burst of taps survives a blocking refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The joystick directions and Back were never begin()'d, so on the e-ink build they never claimed a GPIOTE channel — edges landing during a blocking panel refresh were lost and the IRQ-capture work didn't reach them. begin() them. Even with edges captured, rapid taps of one direction replayed into a single check() and collapsed into a double/triple-click event the navigation handler ignores, and the loop only ever dispatched one key per render. So: - MomentaryButton: for multiclick=false buttons emit one CLICK per completed release (one per check() call) instead of collapsing — each tap stays a discrete key. multiclick=true buttons (double/triple) are unchanged. - UITask: add a key FIFO; drain each direction fully into it, apply the whole queued burst, then redraw once. N taps captured during a refresh become N navigation steps at the cost of a single refresh. Also fixes losing a key when two buttons fire in the same loop iteration. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/ui-new/UITask.cpp | 100 +++++++++++++-------- examples/companion_radio/ui-new/UITask.h | 11 +++ src/helpers/ui/MomentaryButton.cpp | 17 +++- 3 files changed, 91 insertions(+), 37 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0cec3c5d..ab46426d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1395,6 +1395,21 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(PIN_USER_BTN) user_btn.begin(); #endif +#if UI_HAS_JOYSTICK + // The directional joystick + Back share the same MomentaryButton machinery as + // user_btn but were never begin()'d — they only worked because the pins + // default to INPUT and the board has external pulls. That left them on the + // polling path: with BUTTON_USE_INTERRUPTS (e-ink) they'd silently never + // attach a GPIOTE channel, so edges landing during a blocking panel refresh + // were lost. begin() sets pinMode and claims an IRQ slot for each. + joystick_left.begin(); + joystick_right.begin(); + back_btn.begin(); +#if UI_HAS_JOYSTICK_UPDOWN + joystick_up.begin(); + joystick_down.begin(); +#endif +#endif #if defined(PIN_USER_BTN_ANA) analog_btn.begin(); #endif @@ -1877,8 +1892,22 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m } } +void UITask::enqueueKey(char c) { + if (c == 0) return; + uint8_t next = (_kq_head + 1) % KEY_QUEUE_SIZE; + if (next == _kq_tail) return; // full: drop newest rather than clobber unprocessed keys + _key_queue[_kq_head] = c; + _kq_head = next; +} + +bool UITask::dequeueKey(char& c) { + if (_kq_tail == _kq_head) return false; + c = _key_queue[_kq_tail]; + _kq_tail = (_kq_tail + 1) % KEY_QUEUE_SIZE; + return true; +} + void UITask::loop() { - char c = 0; // Background delivery: resend pending on-device DMs whose ACK timed out, and // finalise the ✗ marker — runs regardless of which screen is active. ((QuickMsgScreen*)quick_msg)->tickDmResends(); @@ -1910,32 +1939,27 @@ void UITask::loop() { } // eat the Enter — don't pass to curr } else { - c = checkDisplayOn(KEY_ENTER); + enqueueKey(checkDisplayOn(KEY_ENTER)); } } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code + enqueueKey(handleLongPress(KEY_ENTER)); // REVISIT: could be mapped to different key code } + // Drain each direction fully: a burst of taps captured during a blocking + // refresh replays as several CLICKs, queued here and applied before one + // redraw (see enqueueKey / the dispatch at the end of loop()). #if UI_HAS_JOYSTICK_UPDOWN - ev = joystick_up.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot)); - } - ev = joystick_down.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot)); - } + while (joystick_up.check() == BUTTON_EVENT_CLICK) + enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot))); + while (joystick_down.check() == BUTTON_EVENT_CLICK) + enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot))); #endif - ev = joystick_left.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot)); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot)); + while ((ev = joystick_left.check()) != BUTTON_EVENT_NONE) { + if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot))); + else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot))); break; } } - ev = joystick_right.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot)); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot)); + while ((ev = joystick_right.check()) != BUTTON_EVENT_NONE) { + if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot))); + else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot))); break; } } if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { _lock_seq_used = false; // safety reset if Back release event was missed @@ -1947,34 +1971,34 @@ void UITask::loop() { _lock_seq_count = 0; _lock_seq_used = false; } else { - c = checkDisplayOn(KEY_CANCEL); + enqueueKey(checkDisplayOn(KEY_CANCEL)); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - if (!_locked) c = handleTripleClick(KEY_SELECT); + if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_NEXT); + enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); + enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { - c = handleDoubleClick(KEY_PREV); + enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + enqueueKey(handleTripleClick(KEY_SELECT)); } #endif #if defined(PIN_USER_BTN_ANA) if (millis() - _analogue_pin_read_millis > 10) { int ev = analog_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_NEXT); + enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); + enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { - c = handleDoubleClick(KEY_PREV); + enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + enqueueKey(handleTripleClick(KEY_SELECT)); } _analogue_pin_read_millis = millis(); } @@ -1991,17 +2015,21 @@ void UITask::loop() { } #endif - if (c != 0) { + if (_kq_head != _kq_tail) { if (!_locked && curr) { - curr->handleInput(c); + // Apply the whole queued burst, then redraw once — N taps captured during + // a blocking refresh become N navigation steps at the cost of one refresh. + char k; + while (dequeueKey(k)) curr->handleInput(k); { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer // Note timing no longer depends on render cadence (TIMER1 IRQ advances // notes directly — see buzzer.cpp), so a redraw right after a keypress // can't clip a note; no need to hold it back while buzzer.isPlaying(). _next_refresh = 100; // trigger refresh immediately - } else if (_locked) { - // Locked: eat all keys — wake window is set only when display first turns on - _next_refresh = 0; + } else { + _kq_head = _kq_tail = 0; // locked or no screen: eat all queued keys + // Locked: wake window is set only when display first turns on + if (_locked) _next_refresh = 0; } } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 07517965..b2b7f131 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -149,6 +149,17 @@ class UITask : public AbstractUITask { char handleDoubleClick(char c); char handleTripleClick(char c); + // Key FIFO: a burst of taps captured during a blocking refresh (e-ink) is + // drained from the buttons into this queue, then all keys are applied before + // a single redraw — so rapid navigation steps neither get lost nor cost one + // slow refresh each. Also fixes losing a key when two buttons fire in the + // same loop iteration. + static const uint8_t KEY_QUEUE_SIZE = 16; + char _key_queue[KEY_QUEUE_SIZE]; + uint8_t _kq_head = 0, _kq_tail = 0; + void enqueueKey(char c); + bool dequeueKey(char& c); + void setCurrScreen(UIScreen* c); public: diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index ae27ee09..db61c2dd 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -202,7 +202,22 @@ int MomentaryButton::check(bool repeat_click) { } } - if (_pending_click && (millis() - _last_click_time) >= _multi_click_window) { + if (_multi_click_window == 0) { + // No multi-click semantics: every completed press is an independent CLICK. + // When a burst of taps is captured during a blocking refresh (e-ink), all + // their edges replay into _click_count in a single check(); emit them one + // CLICK per call (decrementing) instead of collapsing into a lone + // double/triple event the caller would ignore — so each tap survives as a + // discrete key. Waits for release (down_at == 0) so a held button doesn't + // fire mid-press. + if (_pending_click && down_at == 0) { + event = BUTTON_EVENT_CLICK; + if (--_click_count == 0) { + _last_click_time = 0; + _pending_click = false; + } + } + } else if (_pending_click && (millis() - _last_click_time) >= _multi_click_window) { if (down_at > 0) { // still pressed - wait for button release before processing clicks return event;