diff --git a/DEVICES.md b/DEVICES.md index 48205038..7135fe30 100644 --- a/DEVICES.md +++ b/DEVICES.md @@ -16,7 +16,7 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases | LilyGo T-Lora Pager | ESP32-S3, LR1121 or SX1262 | 2.33" IPS LCD, 480x222 (no touch), QWERTY + rotary encoder | Web flasher (pick the build matching your radio) | Beta (new in beta_45) | Fully supported; keyboard-first navigation, microSD, map tile packs | | Heltec V4-R8 + Expansion Kit V2 | ESP32-S3 (8 MB octal PSRAM), SX1262 | 2.4" touch (CHSC6x) | Web flasher | Beta (new in beta_45) | As the V4 plus microSD and the Expansion Kit sensors; buzzer supported | | LilyGo T-Display P4 | ESP32-P4 + ESP32-C6, SX1262 | AMOLED or TFT-LCD, touch | Web flasher (AMOLED) or the .bin for the LCD SKU | Beta (new in beta_45) | Two screen SKUs; fuel-gauge battery reporting, full TX power | -| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported | +| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display + front D-pad | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported; the front D-pad + SELECT navigate the UI | ## Feature notes per board diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 9c08415b..19aa0db4 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1917,6 +1917,18 @@ static bool tbFingerTouchOnTabBarBlocked(uint16_t y) { static bool s_kbd_nav = true; #endif +#if defined(ATTAKY_MESH_SERIES) +// Attaky Mesh Deck: touch is the primary input, and the front D-pad + SELECT are +// a second one — so this takes the same CAP_KEYPAD_NAV "secondary indev" path as +// the M9 block below, fed by attakyNavPump() draining the @0x59 expander queue. +// The three symbols are the ones that path needs (see the M9's note); nav is +// always on because the buttons are soldered to the board — there is nothing to +// toggle off, and with no press the group just sits unfocused and invisible. +static bool s_kbd_nav = true; +static bool s_tb_nav = false; // no trackball — read by the shared nav-rebuild gate, never set +static lv_indev_drv_t s_nav_keypad_drv; +#endif + #if defined(HAS_THINKNODE_M9) // Keyboard-only device (no touch, no trackball): nav is always on, same as Tanmatsu above. // Unlike Tanmatsu (which registers its KEYPAD indev as the PRIMARY one, driven by navPump() @@ -36910,11 +36922,14 @@ static bool popupRegistryBlocksSwipe(); // True if any popup/modal is currently up (rows flagged PF_COUNT). static bool anyPopupOpen() { return popupRegistryAny(); } -#if CAP_KEYBOARD +#if CAP_KEYBOARD || CAP_KEYPAD_NAV // Close the topmost popup/modal (registry order = front-to-back priority), // like tapping its X / close button. Returns true if one was dismissed. +// Widened past CAP_KEYBOARD for the Attaky: navGoToMainTab() (inside the +// CAP_KEYPAD_NAV block) calls this, and that board navigates with a D-pad but +// has no physical keyboard, so the old gate left the declaration undefined. static bool hwKeyDismissTopPopup() { return popupRegistryDismissTop(); } -#endif // HAS_TDECK_KEYBOARD || HAS_TANMATSU (hwKeyDismissTopPopup) +#endif // CAP_KEYBOARD || CAP_KEYPAD_NAV (hwKeyDismissTopPopup) #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) // Keys that act as "close the popup" when no text field is focused. @@ -38965,6 +38980,89 @@ if (g_lv.task && g_lv.task->isManualLock()) { } #endif +#if defined(ATTAKY_MESH_SERIES) +// ---- Attaky front D-pad -> focus navigation --------------------------------- +// The five nav buttons on the @0x59 expander (P00 down, P01 left, P02 select, +// P03 right, P04 up) drive the SAME focus group the Tanmatsu arrows and the +// T-Deck trackball use, so they reach every screen with no per-screen handling. +// Touch stays this board's primary input and the D-pad is a second one — which +// is why the focus ring starts hidden (s_nav_show is false until a press) and +// nothing here is toggleable: the buttons are soldered on, so "off" would just +// make them dead. +// +// This board compiles no handleHwKey() (its keyboard module types straight into +// the focused textarea), so the nav routing the T-Deck and M9 do from there +// lives here instead. Deliberately narrower than the M9's: that board has no +// touch, so it also needs d-pad paths for Back, Home, the chat-bubble action +// menu and map panning — all of which are still a tap away here. +static void attakyNavPump() { + // The field currently bound to the keyboard, and whether the D-pad should be + // editing it: focus has to be really ON it (a tap sets s_nav_ta_editing), so + // arrows moving through a page don't get swallowed as caret moves. + lv_obj_t* const ta_focused = g_lv.keyboard ? lv_keyboard_get_textarea(g_lv.keyboard) : nullptr; + lv_obj_t* const ta = (ta_focused && s_nav_ta_editing) ? ta_focused : nullptr; + + for (int i = 0; i < 8; ++i) { + const int ev = attakyNavKeyRead(); + if (ev == ATTAKY_NAV_NONE) break; + + switch (ev) { + case ATTAKY_NAV_UP: + case ATTAKY_NAV_DOWN: { + const bool up = (ev == ATTAKY_NAV_UP); + // The @-mention picker owns up/down while it shows: its cells carry + // NAV_SKIP_FLAG (they're tap targets on touch boards), so the focus + // group cannot reach them at all. + if (mentionNavActive()) { mentionNavMove(up ? -1 : +1); break; } + // An open dropdown LIST owns them too — navMoveDir would move focus + // off the dropdown, which closes it. The FIFO'd LV_KEY_UP/DOWN reach + // the dropdown's own handler, which moves the highlighted option. + if (navOpenDropdown()) { navPushTap(up ? LV_KEY_UP : LV_KEY_DOWN); break; } + lv_obj_t* const was = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; + navMoveDir(up ? NAV_UP : NAV_DOWN); + // Focus had nowhere to go: page-scroll instead. A display-only Lua app + // scrolls its own body; everything else falls to navScrollFocused, so + // pages with few or no focusable rows still move under the D-pad. + if (s_nav_group && lv_group_get_focused(s_nav_group) == was) { + if (!(luaAppIsOpen() && luaAppScroll(up))) navScrollFocused(up); + } + break; + } + case ATTAKY_NAV_LEFT: + case ATTAKY_NAV_RIGHT: { + const bool left = (ev == ATTAKY_NAV_LEFT); + if (mentionNavActive() || navOpenDropdown()) break; // vertical-only: swallow + if (ta) { + // Caret already at the edge: fall through to a focus move, so the + // pad always eventually LEAVES the field instead of no-oping. + const uint32_t p = lv_textarea_get_cursor_pos(ta); + if (left) lv_textarea_cursor_left(ta); else lv_textarea_cursor_right(ta); + if (lv_textarea_get_cursor_pos(ta) == p) navMoveDir(left ? NAV_LEFT : NAV_RIGHT); + } + else if (navOnTabBar()) navSwitchTab(left ? -1 : +1); + else navMoveDir(left ? NAV_LEFT : NAV_RIGHT); + break; + } + case ATTAKY_NAV_SELECT: + if (mentionNavActive()) { mentionNavConfirm(); break; } + if (navOnTabBar()) { navSwitchTab(+1); break; } + // First SELECT on a focused field starts typing (the caret appears and + // left/right walk it); the next one activates whatever is focused. + if (ta_focused && !ta && s_nav_group && lv_group_get_focused(s_nav_group) == ta_focused) { + s_nav_ta_editing = true; + break; + } + navMarkEntered(s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr); + navPushTap(LV_KEY_ENTER); + break; + default: break; + } + s_nav_show = true; + if (g_lv.task) g_lv.task->noteUserInput(); + } +} +#endif + static void refreshLiveDiag(unsigned long now) { if (!s_live_diag_label) return; if (now < s_live_diag_next_ms) return; @@ -52004,6 +52102,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(ESP32) #if defined(HAS_TANMATSU) || defined(HAS_THINKNODE_M9) s_kbd_nav = true; // keyboard-only device: nav is always on (no touch to fall back to) +#elif defined(ATTAKY_MESH_SERIES) + // Soldered-on D-pad, and no settings row toggles it (the "Keyboard navigation" + // switch is CAP_TRACKBALL-only) — reading the pref here would let a value left + // over from another board leave the buttons dead with no way to turn them back on. + s_kbd_nav = true; #else s_kbd_nav = touchPrefsGetKbdNav(); #endif @@ -54980,6 +55083,22 @@ void UITask::loop() { if (_screen_off) wakeScreen(); else sleepScreen(); } + // Front D-pad (P00..P04) -> focus navigation. Two things have to happen before + // the drain: with the screen dark a press only WAKES (and is swallowed, so a + // wake never also activates whatever the ring happened to be on — the pager's + // encoder-wake bug), and in remote mode the panel is a placeholder the browser + // drives. Otherwise sync the focus group to the current tree first: Enter's + // tree mutations only land inside lv_timer_handler at the END of a tick, so a + // drain against a stale mirror navigates the screen behind a just-opened modal + // (the same fix the M9's branch above carries). + if (_screen_off || s_remote_mode) { + const bool any = (attakyNavKeyRead() != ATTAKY_NAV_NONE); + attakyNavKeysDiscard(); + if (any && _screen_off) wakeScreen(); + } else { + navMaybeRebuild(); + attakyNavPump(); + } #endif #if defined(HAS_ATTAKY_MESH_KEYBOARD) { diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index b005ffce..960d8c34 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -163,13 +163,16 @@ // Focus-group D-pad navigation (no pointer): Tanmatsu keypad, T-Deck trackball, // the pager (no touch at all — the rotary encoder is its only nav input, so // like Tanmatsu this is always-on, not an optional toggle like the T-Deck's), -// or the ThinkNode M9's d-pad. The underlying machinery (navFifo, navMoveDir, -// the focus group, the secondary KEYPAD indev) is generic — only the *pump* -// that feeds it differs per board: Tanmatsu's navPump() reads bsp-input events; -// T-Deck's WASDZ-letter nav and the M9's raw d-pad bytes are both fed straight -// from handleHwKey() instead (see UITask.cpp's `#elif defined(HAS_M9_KEYBOARD)` -// block, parallel to the T-Deck's `#if CAP_TRACKBALL` block). -#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) +// the ThinkNode M9's d-pad, or the Attaky's front D-pad + SELECT. The underlying +// machinery (navFifo, navMoveDir, the focus group, the secondary KEYPAD indev) +// is generic — only the *pump* that feeds it differs per board: Tanmatsu's +// navPump() reads bsp-input events; T-Deck's WASDZ-letter nav and the M9's raw +// d-pad bytes are both fed straight from handleHwKey() instead (see UITask.cpp's +// `#elif defined(HAS_M9_KEYBOARD)` block, parallel to the T-Deck's `#if +// CAP_TRACKBALL` block); the Attaky drains its expander queue in attakyNavPump(). +// NOTE: the Attaky is the first board here with CAP_KEYBOARD == 0, so anything +// this flag pulls in must not assume a physical keyboard is also compiled. +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(ATTAKY_MESH_SERIES) #define CAP_KEYPAD_NAV 1 #else #define CAP_KEYPAD_NAV 0 diff --git a/variants/attaky_mesh_series/AttakyMeshSeriesKeys.cpp b/variants/attaky_mesh_series/AttakyMeshSeriesKeys.cpp index 6c6b8129..aa7e5e04 100644 --- a/variants/attaky_mesh_series/AttakyMeshSeriesKeys.cpp +++ b/variants/attaky_mesh_series/AttakyMeshSeriesKeys.cpp @@ -20,15 +20,50 @@ static const uint8_t POWER_BIT = (1u << 7); // P07 = POWER_BTN static const uint8_t DEBOUNCE_POLLS = 2; static const uint32_t POLL_INTERVAL_MS = 30; +// Auto-repeat for the four directions (never SELECT — one press must stay one +// activation). Without it, walking a long contact list means one press per row. +static const uint32_t REPEAT_DELAY_MS = 450; +static const uint32_t REPEAT_RATE_MS = 130; + +// P0 bit -> nav event. 0 = not a nav button (L1/R2 are read but unbound, POWER +// has its own edge flag below). +static const uint8_t BIT_NAV[8] = { + ATTAKY_NAV_DOWN, // P00 + ATTAKY_NAV_LEFT, // P01 + ATTAKY_NAV_SELECT, // P02 + ATTAKY_NAV_RIGHT, // P03 + ATTAKY_NAV_UP, // P04 + ATTAKY_NAV_NONE, // P05 BTN_L1 — unbound + ATTAKY_NAV_NONE, // P06 BTN_R2 — unbound + ATTAKY_NAV_NONE, // P07 POWER_BTN — attakyPowerKeyPressed() +}; + static bool s_inited = false; static bool s_present = false; static uint32_t s_next_ms = 0; -static bool s_power_down = false; // debounced state -static uint8_t s_pending_n = 0; // consecutive polls agreeing with s_pending_down -static bool s_pending_down = false; +// Debounced whole-port state, active-low (a 0 bit = that button is down). +static uint8_t s_stable = 0xFF; +static uint8_t s_pending = 0xFF; // candidate awaiting DEBOUNCE_POLLS agreement +static uint8_t s_pending_n = 0; static volatile bool s_power_event = false; +// Nav press queue. Small ring: the poll runs at ~30 ms and the UI drains every +// tick, so it only has to absorb a burst, never buffer. +static const uint8_t NAV_Q_LEN = 8; +static uint8_t s_navq[NAV_Q_LEN]; +static uint8_t s_navq_head = 0, s_navq_tail = 0; + +static int8_t s_repeat_bit = -1; // direction currently held, or -1 +static uint32_t s_repeat_ms = 0; // when it next repeats + +static void navPush(uint8_t ev) { + const uint8_t next = (uint8_t)((s_navq_head + 1) % NAV_Q_LEN); + if (next == s_navq_tail) return; // full — drop the newest rather than stall + s_navq[s_navq_head] = ev; + s_navq_head = next; +} + static bool awWrite(uint8_t reg, uint8_t val) { Wire.beginTransmission(KEYS_ADDR); Wire.write(reg); @@ -75,14 +110,37 @@ void attakyKeysPoll() { } if (!ok) return; // bus busy or read failed — hold the previous state - const bool down = (p0 & POWER_BIT) == 0; // active low + // Every button reading pressed at once is a bad read, not a hand: acting on + // it would inject a burst of nav keys AND a panel toggle from one glitch. + // Resync the debouncer instead of treating the recovery as presses. + if (p0 == 0x00) { s_pending = s_stable; s_pending_n = 0; return; } - if (down != s_pending_down) { s_pending_down = down; s_pending_n = 1; return; } + if (p0 != s_pending) { s_pending = p0; s_pending_n = 1; return; } if (s_pending_n < DEBOUNCE_POLLS) { s_pending_n++; return; } - if (down != s_power_down) { - s_power_down = down; - if (down) s_power_event = true; // fire on the press edge + const uint8_t changed = (uint8_t)(s_stable ^ p0); + if (changed) { + const uint8_t went_down = (uint8_t)(changed & ~p0); // active low: 1 -> 0 + s_stable = p0; + if (went_down & POWER_BIT) s_power_event = true; // fire on the press edge + for (uint8_t b = 0; b < 8; b++) { + if (!(went_down & (1u << b)) || BIT_NAV[b] == ATTAKY_NAV_NONE) continue; + navPush(BIT_NAV[b]); + // Only the directions repeat, and only the most recent one: rolling a + // thumb across the pad should follow the finger, not fire two axes. + if (BIT_NAV[b] != ATTAKY_NAV_SELECT) { + s_repeat_bit = (int8_t)b; + s_repeat_ms = now + REPEAT_DELAY_MS; + } + } + // The repeating direction was released (or another button's edge arrived + // while it was already up) — stop repeating. + if (s_repeat_bit >= 0 && (s_stable & (1u << (uint8_t)s_repeat_bit))) s_repeat_bit = -1; + } + + if (s_repeat_bit >= 0 && (int32_t)(now - s_repeat_ms) >= 0) { + navPush(BIT_NAV[(uint8_t)s_repeat_bit]); + s_repeat_ms = now + REPEAT_RATE_MS; } } @@ -92,12 +150,26 @@ bool attakyPowerKeyPressed() { return true; } +int attakyNavKeyRead() { + if (s_navq_tail == s_navq_head) return ATTAKY_NAV_NONE; + const uint8_t ev = s_navq[s_navq_tail]; + s_navq_tail = (uint8_t)((s_navq_tail + 1) % NAV_Q_LEN); + return (int)ev; +} + +void attakyNavKeysDiscard() { + s_navq_tail = s_navq_head; + s_repeat_bit = -1; +} + bool attakyKeysPresent() { return s_present; } #else void attakyKeysPoll() {} bool attakyPowerKeyPressed() { return false; } +int attakyNavKeyRead() { return ATTAKY_NAV_NONE; } +void attakyNavKeysDiscard() {} bool attakyKeysPresent() { return false; } #endif diff --git a/variants/attaky_mesh_series/AttakyMeshSeriesKeys.h b/variants/attaky_mesh_series/AttakyMeshSeriesKeys.h index c30f2282..361f2f9b 100644 --- a/variants/attaky_mesh_series/AttakyMeshSeriesKeys.h +++ b/variants/attaky_mesh_series/AttakyMeshSeriesKeys.h @@ -3,9 +3,24 @@ #include -// Front buttons on the board's AW9523 @0x59 (P00-P07, active-low). Only -// POWER_BTN (P07) is used: short-press toggles the panel. A long press is the -// board's hardware power-cut, outside firmware control. +// Front buttons on the board's AW9523 @0x59 (P00-P07, active-low), per Attaky's +// hardware catalog: +// P00 DOWN P01 LEFT P02 SELECT P03 RIGHT P04 UP +// P05 BTN_L1 (top-left shoulder) P06 BTN_R2 (top-right shoulder) +// P07 POWER_BTN +// POWER_BTN short-press toggles the panel (a long press is the board's hardware +// power-cut, outside firmware control). The D-pad + SELECT feed the UI's focus +// navigation. L1/R2 are read but deliberately unbound — nothing claims them yet. + +/** D-pad events drained by attakyNavKeyRead(). */ +enum AttakyNavKey { + ATTAKY_NAV_NONE = 0, + ATTAKY_NAV_UP, + ATTAKY_NAV_DOWN, + ATTAKY_NAV_LEFT, + ATTAKY_NAV_RIGHT, + ATTAKY_NAV_SELECT, +}; /** Poll the expander. Call once per UI tick; rate-limits its own I2C traffic. */ void attakyKeysPoll(); @@ -13,5 +28,11 @@ void attakyKeysPoll(); /** True once per POWER_BTN press edge (consumes the event). */ bool attakyPowerKeyPressed(); +/** Next queued D-pad event, or ATTAKY_NAV_NONE when the queue is empty. */ +int attakyNavKeyRead(); + +/** Drop every queued D-pad event (screen-off wake, remote mode). */ +void attakyNavKeysDiscard(); + /** Whether the @0x59 expander answered at init (false = keys unavailable). */ bool attakyKeysPresent();