From 1bb28296c2623dabab5e586cec6e8dea62e71272 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:17:55 +0200 Subject: [PATCH] feat(ui): marquee-scroll selected long names/labels instead of static ellipsis Selecting a row whose ellipsized text overflows now animates a "swing" marquee: holds at the start, scrolls to reveal the full tail, holds there, then scrolls back and repeats. Unselected/non-overflowing text is unchanged (still a static "..."). E-ink gets slower, coarser steps (fewer, cheaper partial refreshes) than OLED; unchanged frames are already skipped by the display's CRC diff, so idle holds are free. Wired into every screen with a selectable row: home favourites, DM/ channel lists and message bodies, Settings, popup menus, Bot, Admin, Nearby, Waypoints, Locator, Live Share, and the alarm screen. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/ui-new/AdminScreen.h | 7 +- examples/companion_radio/ui-new/BotScreen.h | 21 ++- .../companion_radio/ui-new/ChannelsView.h | 12 +- .../companion_radio/ui-new/ClockToolsScreen.h | 9 +- .../companion_radio/ui-new/LiveShareScreen.h | 8 +- .../companion_radio/ui-new/LocatorScreen.h | 19 +- .../companion_radio/ui-new/MessagesScreen.h | 24 ++- .../companion_radio/ui-new/NearbyScreen.h | 10 +- examples/companion_radio/ui-new/PopupMenu.h | 4 +- .../companion_radio/ui-new/SettingsScreen.h | 26 ++- examples/companion_radio/ui-new/UITask.cpp | 12 +- .../companion_radio/ui-new/WaypointsView.h | 13 +- src/helpers/ui/DisplayDriver.h | 164 +++++++++++++++--- 13 files changed, 250 insertions(+), 79 deletions(-) diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 4bdbe204..746b7189 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -514,6 +514,7 @@ public: } tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab); int n = ROWS_PER_TAB[_tab]; + int mq_delay = 0; drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) { drawRowSelection(display, y, sel, reserve); const AdminField& f = fieldAt(_tab, i); @@ -523,7 +524,8 @@ public: // the value being edited -- only rows without an inline value get the // full row width. int label_max = show_val ? display.valCol() - 4 : display.width() - 4 - reserve; - display.drawTextEllipsized(2, y, label_max, f.label); + int r = display.drawTextEllipsized(2, y, label_max, f.label, sel); + if (sel && r > 0) mq_delay = r; if (show_val) { if (f.kind == FK_RADIO_FREQ) { // valCol() reserves exactly the 8-char width this editor draws (4 @@ -540,7 +542,8 @@ public: } }); if (_confirm.active) { _confirm.render(display); return 50; } - return _value_editing ? 50 : 2000; + if (_value_editing) return 50; + return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; } // REPLY diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 4084080c..e07fe763 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -149,6 +149,7 @@ public: } int n = ROWS_PER_TAB[_tab]; + int mq_delay = 0; drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = tabRow(_tab, i); drawRowSelection(display, y, sel, reserve); @@ -173,9 +174,10 @@ public: ChannelDetails ch; if (_num_channels == 0) display.print("(none)"); - else if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name); - else + else if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) { + int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name, sel); + if (sel && mqr > 0) mq_delay = mqr; + } else display.print("?"); break; } @@ -184,9 +186,10 @@ public: display.print("(none)"); } else { ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN); - if (c && c->name[0]) - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name); - else + if (c && c->name[0]) { + int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name, sel); + if (sel && mqr > 0) mq_delay = mqr; + } else display.print("?"); } break; @@ -200,7 +203,8 @@ public: const char* shown = !tr[0] ? "(none)" : (tr[0] == '*' && !tr[1]) ? "(any msg)" // wildcard / away mode : tr; - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown); + int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown, sel); + if (sel && mqr > 0) mq_delay = mqr; break; } case REPLY_DM: @@ -209,7 +213,8 @@ public: const char* rp = (r.kind == REPLY_DM) ? _prefs->bot_reply_dm : (r.kind == REPLY_CH) ? _prefs->bot_reply_ch : _prefs->bot_reply_room; - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)"); + int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)", sel); + if (sel && mqr > 0) mq_delay = mqr; break; } case COMMANDS_DM: diff --git a/examples/companion_radio/ui-new/ChannelsView.h b/examples/companion_radio/ui-new/ChannelsView.h index 23314a9f..67dad1d6 100644 --- a/examples/companion_radio/ui-new/ChannelsView.h +++ b/examples/companion_radio/ui-new/ChannelsView.h @@ -188,6 +188,7 @@ public: if (_mode == ADD_HASHTAG) { display.drawCenteredHeader("ADD CHANNEL"); + int mq_delay = 0; for (int i = 0; i < 2; i++) { int y = top + i * step; bool sel = (i == _sel); @@ -195,13 +196,15 @@ public: char row[40]; if (i == 0) snprintf(row, sizeof(row), "Topic: %s", _topic[0] ? _topic : "(none)"); else snprintf(row, sizeof(row), "[Save]"); - display.drawTextEllipsized(2, y, display.width() - 4, row); + int r = display.drawTextEllipsized(2, y, display.width() - 4, row, sel); + if (sel && r > 0) mq_delay = r; display.setColor(DisplayDriver::LIGHT); } - return 1000; + return (mq_delay > 0 && mq_delay < 1000) ? mq_delay : 1000; } display.drawCenteredHeader(_mode == ADD ? "ADD CHANNEL" : "EDIT CHANNEL"); + int mq_delay = 0; for (int i = 0; i < 3; i++) { int y = top + i * step; bool sel = (i == _sel); @@ -213,10 +216,11 @@ public: else snprintf(row, sizeof(row), "[Save]"); // Ellipsize rather than print() directly -- a long name/secret must not // wrap onto the next row's line (print() wraps by default). - display.drawTextEllipsized(2, y, display.width() - 4, row); + int r = display.drawTextEllipsized(2, y, display.width() - 4, row, sel); + if (sel && r > 0) mq_delay = r; display.setColor(DisplayDriver::LIGHT); } - return 1000; + return (mq_delay > 0 && mq_delay < 1000) ? mq_delay : 1000; } bool handleInput(char c) { diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h index 61012709..1f2eb1a4 100644 --- a/examples/companion_radio/ui-new/ClockToolsScreen.h +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -166,19 +166,22 @@ class ClockToolsScreen : public UIScreen { const char* rows[3] = { "Time", "Repeat", "Armed" }; if (_sel > 2) _sel = 2; const int valx = d.width() / 2 + 6; + int mq_delay = 0; drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { drawRowSelection(d, y, sel, reserve); d.setCursor(4, y); d.print(rows[i]); if (i == 0) { drawAlarmTime(d, y, valx, sel && _alarm_editing); } else if (i == 1) { - d.drawTextEllipsized(valx, y, d.width() - valx - reserve, - NodePrefs::alarmRepeatLabel(NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask))); + int mqr = d.drawTextEllipsized(valx, y, d.width() - valx - reserve, + NodePrefs::alarmRepeatLabel(NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask)), sel); + if (sel && mqr > 0) mq_delay = mqr; } else { d.drawTextEllipsized(valx, y, d.width() - valx - reserve, _prefs->alarm_on ? "ON" : "OFF"); } }); - return _alarm_editing ? 50 : 60000; + if (_alarm_editing) return 50; + return (mq_delay > 0 && mq_delay < 60000) ? mq_delay : 60000; } int renderTimer(DisplayDriver& d) { diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 726b3757..e69e499c 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -90,6 +90,7 @@ public: display.drawCenteredHeader("LIVE SHARE"); const int valx = display.width() / 2 + 6; + int mq_delay = 0; drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); drawRowSelection(display, y, sel, reserve); @@ -97,9 +98,12 @@ public: display.print(r.label); char val[24]; valueLabel(r.kind, val, sizeof(val)); - if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val); + if (val[0]) { + int mqr = display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val, sel); + if (sel && mqr > 0) mq_delay = mqr; + } }); - return 500; + return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500; } void moveSel(int dir) { _sel = (_sel + dir + ROW_COUNT) % ROW_COUNT; } diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index 0cf7fba0..85d94559 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -108,12 +108,13 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - if (_picking) { renderPicker(display); return 400; } + if (_picking) { int mq = renderPicker(display); return (mq > 0 && mq < 400) ? mq : 400; } display.drawCenteredHeader("LOCATOR"); const int rc = visibleRows(); if (_sel >= rc) _sel = rc - 1; // beeper row may have just been hidden const int valx = display.width() / 2 + 6; + int mq_delay = 0; drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); drawRowSelection(display, y, sel, reserve); @@ -121,9 +122,12 @@ public: display.print(r.label); char val[24]; valueLabel(r.kind, val, sizeof(val)); - if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val); + if (val[0]) { + int mqr = display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val, sel); + if (sel && mqr > 0) mq_delay = mqr; + } }); - return 500; + return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500; } void moveSel(int dir) { int rc = visibleRows(); _sel = (_sel + dir + rc) % rc; } @@ -261,9 +265,12 @@ public: _picking = true; } - void renderPicker(DisplayDriver& display) { + // Returns 0, or the ms until the selected row should next redraw to keep a + // marquee animation going (see DisplayDriver::drawTextEllipsized). + int renderPicker(DisplayDriver& display) { display.drawCenteredHeader("PICK TARGET"); uint32_t now = rtc_clock.getCurrentTime(); + int mq_delay = 0; drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) { drawRowSelection(display, y, sel, reserve); const Target& t = _targets[i]; @@ -279,8 +286,10 @@ public: } else { snprintf(row, sizeof(row), "@%s", t.name); // favourite, no position known yet } - display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row); + int mqr = display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row, sel); + if (sel && mqr > 0) mq_delay = mqr; }); + return mq_delay; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 90794762..c25b7cb6 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -955,6 +955,7 @@ public: } int render(DisplayDriver& display) override { + int mq_delay = 0; // >0 while a selected row's text is marquee-scrolling display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); @@ -1007,7 +1008,8 @@ public: display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); int bw = dm_unread > 0 ? display.unreadBadgeWidth(dm_unread) + 2 : 0; - display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, filtered); + int r = display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, filtered, sel); + if (sel && r > 0) mq_delay = r; if (dm_unread > 0) display.drawUnreadBadge(display.width() - reserve, y, dm_unread, sel); } @@ -1040,7 +1042,8 @@ public: if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { uint8_t unread = _history.chUnread(_channel_indices[list_idx]); int bw = unread > 0 ? display.unreadBadgeWidth(unread) + 2 : 0; - display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name); + int r = display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name, sel); + if (sel && r > 0) mq_delay = r; if (unread > 0) display.drawUnreadBadge(display.width() - reserve, y, unread, sel); } @@ -1179,6 +1182,9 @@ public: BubbleBox box = computeBubbleBox(full_avail, e.outgoing, header_w, body_w); drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); + // Only the body marquees, not the sender too: both share the single + // marquee slot on DisplayDriver, and if two texts in the same row both + // qualified they'd keep resetting each other's animation every frame. display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender); if (e.outgoing) { // delivery marker after "Me" int gx = box.x + 3 + display.getTextWidth(sender) + 3; @@ -1189,7 +1195,8 @@ public: if (portrait_expand) { for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } } else { - display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body); + int r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body, sel); + if (sel && r_body > 0) mq_delay = r_body; } } @@ -1213,7 +1220,7 @@ public: drawComposeButton(display, cby, lh, _dm_hist_sel == -1); if (_ctx_menu.active) _ctx_menu.render(display); - return dm_count > 0 ? 500 : 2000; + { int ret = dm_count > 0 ? 500 : 2000; return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { @@ -1354,6 +1361,7 @@ public: BubbleBox box = computeBubbleBox(full_avail, outgoing, header_w, body_w); drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel); + // Only the body marquees, not the sender — see the DM history block above. display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender); if (show_ack) { int gx = box.x + 3 + display.getTextWidth(sender) + 3; @@ -1364,7 +1372,8 @@ public: if (portrait_expand) { for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); } } else { - display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body); + int r_body = display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body, sel); + if (sel && r_body > 0) mq_delay = r_body; } } @@ -1421,11 +1430,12 @@ public: NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[idx - 1]; const char* tmpl = p ? p->custom_msgs[slot] : ""; - display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl); + int r = display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl, sel); + if (sel && r > 0) mq_delay = r; } }); } - return 2000; + return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 0ca4b402..3192da83 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -771,6 +771,7 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); + int mq_delay = 0; // >0 while the selected row's name is marquee-scrolling // Periodic refresh of the selected entry while in detail or navigate view, // preserving the selection across the list rebuild. Navigate refreshes @@ -876,7 +877,8 @@ public: if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]" snprintf(filt, sizeof(filt), "[%s]", typeName(e.type)); } - display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt); + int mqr = display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt, sel); + if (sel && mqr > 0) mq_delay = mqr; display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char right[10]; @@ -894,8 +896,10 @@ public: } if (renderActivePopup(display)) return 50; - if (_source == SRC_SCAN) return _scanning ? 200 : 2000; - return _count == 0 ? 3000 : 2000; + int ret; + if (_source == SRC_SCAN) ret = _scanning ? 200 : 2000; + else ret = _count == 0 ? 3000 : 2000; + return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index d0e482db..67333a09 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -114,7 +114,9 @@ struct PopupMenu { } else { display.setColor(DisplayDriver::LIGHT); } - display.drawTextEllipsized(bx + pad, py, text_w, _items[idx]); + // Return value not needed here: this popup already redraws every 50ms + // (below), faster than any marquee step, so the animation is already smooth. + display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel); display.setColor(DisplayDriver::LIGHT); } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 02c18683..a089ae8f 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -440,8 +440,11 @@ class SettingsScreen : public UIScreen { return item - MSG_SLOT_0; } - void renderItem(DisplayDriver& display, int item, int y, bool sel) { + // Returns 0, or the ms until the selected row's value should next redraw + // to keep a marquee animation going (see DisplayDriver::drawTextEllipsized). + int renderItem(DisplayDriver& display, int item, int y, bool sel) { NodePrefs* p = _task->getNodePrefs(); + int mq_delay = 0; drawRowSelection(display, y, sel, _reserve); @@ -514,7 +517,8 @@ class SettingsScreen : public UIScreen { display.print("Preset"); const char* name = p ? _picker.currentName(p, radioTarget(p)) : "Custom"; int xc = valCol(display); - display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name); + int r = display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name, sel); + if (sel && r > 0) mq_delay = r; } else if (item == CUSTOM_FREQ) { display.print("Freq"); int xc = valCol(display); @@ -559,8 +563,9 @@ class SettingsScreen : public UIScreen { } else if (item == SCOPE_NAME) { display.print("Scope"); int vx = valCol(display); - display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, - (p && p->default_scope_name[0]) ? p->default_scope_name : "(none)"); + int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, + (p && p->default_scope_name[0]) ? p->default_scope_name : "(none)", sel); + if (sel && r > 0) mq_delay = r; #if AUTO_OFF_MILLIS > 0 } else if (item == AUTO_OFF) { display.print("AutoOff"); @@ -596,7 +601,8 @@ class SettingsScreen : public UIScreen { } else if (item == DEVICE_NAME) { display.print("Name"); int vx = valCol(display); - display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, the_mesh.getNodeName()); + int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, the_mesh.getNodeName(), sel); + if (sel && r > 0) mq_delay = r; } else if (item == REBOOT) { display.print("Reboot"); // action row: Enter reboots this device } else if (item == KEYBOARD_TYPE) { @@ -677,8 +683,10 @@ class SettingsScreen : public UIScreen { display.print(label); const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; int xm = 8 + display.getCharWidth() * 4; - display.drawTextEllipsized(xm, y, display.width() - xm - _reserve, tmpl); + int r = display.drawTextEllipsized(xm, y, display.width() - xm - _reserve, tmpl, sel); + if (sel && r > 0) mq_delay = r; } + return mq_delay; } // Keyboard state for editing message slots @@ -722,6 +730,7 @@ public: display.drawCenteredHeader("SETTINGS"); + int mq_delay = 0; _acc.render(display, // Section header: "[+/-] Name" [&](int sec, int y, bool sel, int reserve, bool collapsed) { @@ -736,12 +745,13 @@ public: // Item row [&](int sec, int item, int y, bool sel, int reserve) { _reserve = reserve; - renderItem(display, _sec_items[sec][item], y, sel); + int r = renderItem(display, _sec_items[sec][item], y, sel); + if (r > 0) mq_delay = r; }); if (_picker.menu.active) _picker.menu.render(display); - return 2000; + return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 71e98c2c..c1587ed9 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -718,6 +718,7 @@ public: int render(DisplayDriver& display) override { char tmp[80]; + int mq_delay = 0; // >0 while a selected row's name is marquee-scrolling display.setTextSize(1); const int lh = display.getLineHeight(); // line height at sz1 const int step = display.lineStep(); // lh + 2 @@ -1198,7 +1199,8 @@ public: int name_y = cy + (cell_h - line_h) / 2; int name_max_w = cell_w - 4 - bw; if (name_max_w < 6) name_max_w = 6; - display.drawTextEllipsized(cx + 2, name_y, name_max_w, name); + int r = display.drawTextEllipsized(cx + 2, name_y, name_max_w, name, sel); + if (sel && r > 0) mq_delay = r; if (unread > 0) display.drawUnreadBadge(cx + cell_w - 2, name_y, unread, sel); } else { @@ -1244,13 +1246,15 @@ public: (auto_adv || _task->trail().isActive() || repeating || loc_sharing); if (Features::IS_EINK) { // slow display: poll every 30 s; inbound msgs force immediate refresh via notify() - return Features::HOME_REFRESH_MS; + return (mq_delay > 0 && mq_delay < Features::HOME_REFRESH_MS) ? mq_delay : Features::HOME_REFRESH_MS; } if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - return need_blink ? 1000 : (show_sec ? 1000 : 60000); + int ret = need_blink ? 1000 : (show_sec ? 1000 : 60000); + return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } - return need_blink ? 1000 : 5000; + int ret = need_blink ? 1000 : 5000; + return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 4408a006..dd501e1a 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -190,7 +190,10 @@ class WaypointsView { display.setCursor(2, top + 2 * step); display.print("Cancel to abort"); } - void renderWpList(DisplayDriver& display) { + // Returns 0, or the ms until the selected row's name should next redraw to + // keep a marquee animation going (see DisplayDriver::drawTextEllipsized). + int renderWpList(DisplayDriver& display) { + int mq_delay = 0; display.setColor(DisplayDriver::LIGHT); char title[24]; snprintf(title, sizeof(title), "WAYPOINTS %d/%d", @@ -219,10 +222,12 @@ class WaypointsView { } char nm[24]; display.translateUTF8ToBlocks(nm, label, sizeof(nm)); - display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, nm); + int mqr = display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, nm, sel); + if (sel && mqr > 0) mq_delay = mqr; if (dist[0]) { display.setCursor(display.width() - bw + 1 - reserve, y); display.print(dist); } display.setColor(DisplayDriver::LIGHT); }); + return mq_delay; } void renderWpNav(DisplayDriver& display) { @@ -361,9 +366,9 @@ public: if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; } if (_mode == TRACKBACK) { renderTrackBack(display); return 1000; } if (_mode == NAV) { renderWpNav(display); return 1000; } - renderWpList(display); // LIST + int mq_delay = renderWpList(display); // LIST if (_ctx.active) _ctx.render(display); - return 1000; + return (mq_delay > 0 && mq_delay < 1000) ? mq_delay : 1000; } // Returns true if the input was consumed (always, while active()). diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 4f801528..8ad6ca20 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -2,12 +2,24 @@ #include #include +#include class DisplayDriver { int _w, _h; protected: bool _vw_dirty = true; bool _vw_result = false; + + // ---- Marquee state for the single currently-selected overflowing label ---- + // Only one row/label can be "selected" at a time across the whole UI, so one + // slot of state (rather than per-caller) is enough, and keeps every call + // site down to passing a bool. + char _marquee_text[64] = {0}; + int _marquee_max_w = -1; + uint16_t _marquee_skip_cp = 0; + uint16_t _marquee_max_skip_cp = 0; + uint8_t _marquee_phase = 0; // 0=hold@start 1=scroll fwd 2=hold@end 3=scroll back + unsigned long _marquee_next_at = 0; DisplayDriver(int w, int h) { _w = w; _h = h; } void setDimensions(int w, int h) { _w = w; _h = h; } public: @@ -320,8 +332,24 @@ public: } - // draw text with ellipsis if it exceeds max_width - virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) { + // Marquee timing — e-ink gets slower, coarser steps (fewer, cheaper partial + // refreshes; a fine pixel/char scroll would be both slow and prone to + // ghosting there). Unchanged frames are skipped by the display's own CRC + // diff before any real panel push happens, so the hold phases are free. + virtual unsigned long marqueeStepMs() { return isEink() ? 900 : 220; } + virtual unsigned long marqueeHoldMs() { return isEink() ? 2500 : 1500; } + virtual uint8_t marqueeStepChars() { return isEink() ? 3 : 1; } + + // draw text with ellipsis if it exceeds max_width. Pass selected=true for + // the row currently highlighted/focused by the user: instead of a static + // ellipsis, an overflowing label then animates a "swing" marquee — holds at + // the start, scrolls to reveal the full tail, holds there, scrolls back to + // the start, and repeats for as long as the caller keeps passing + // selected=true for this same text. + // Returns 0 if nothing is animating (the caller's normal redraw cadence is + // fine), or the number of ms until the next animation step is due — screens + // clamp their render() return value to this so the marquee stays smooth. + virtual int drawTextEllipsized(int x, int y, int max_width, const char* str, bool selected = false) { char temp_str[256]; // reasonable buffer size translateUTF8ToBlocks(temp_str, str, sizeof(temp_str)); @@ -333,40 +361,120 @@ public: // measures the same, so the width/ellipsis maths below is unaffected. for (char* q = temp_str; *q; q++) if (*q == '\n' || *q == '\r') *q = ' '; - if (getTextWidth(temp_str) <= max_width) { + int full_width = getTextWidth(temp_str); + if (full_width <= max_width) { setCursor(x, y); print(temp_str); - return; + return 0; } - - // for variable-width fonts (GxEPD), add space after ellipsis - // for fixed-width fonts (OLED), keep tight spacing to save precious characters - const char* ellipsis; - // use a simple heuristic: if 'i' and 'l' have different widths, it's variable-width - if (_vw_dirty) { - _vw_result = (getTextWidth("i") != getTextWidth("l")); - _vw_dirty = false; + + if (!selected) { + // for variable-width fonts (GxEPD), add space after ellipsis + // for fixed-width fonts (OLED), keep tight spacing to save precious characters + const char* ellipsis; + // use a simple heuristic: if 'i' and 'l' have different widths, it's variable-width + if (_vw_dirty) { + _vw_result = (getTextWidth("i") != getTextWidth("l")); + _vw_dirty = false; + } + if (_vw_result) { + ellipsis = "... "; // variable-width fonts: add space + } else { + ellipsis = "..."; // fixed-width fonts: no space + } + + int ellipsis_width = getTextWidth(ellipsis); + int str_len = strlen(temp_str); + + while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { + temp_str[--str_len] = 0; + } + // Strip orphaned UTF-8 leading byte left by byte-at-a-time trimming above. + while (str_len > 0 && ((uint8_t)temp_str[str_len - 1] & 0xC0) == 0xC0) { + temp_str[--str_len] = 0; + } + strcat(temp_str, ellipsis); + + setCursor(x, y); + print(temp_str); + return 0; } - if (_vw_result) { - ellipsis = "... "; // variable-width fonts: add space - } else { - ellipsis = "..."; // fixed-width fonts: no space + + // ---- marquee (selected + overflowing) ---- + unsigned long now = millis(); + bool is_new = (strcmp(temp_str, _marquee_text) != 0) || (max_width != _marquee_max_w); + if (is_new) { + strncpy(_marquee_text, temp_str, sizeof(_marquee_text) - 1); + _marquee_text[sizeof(_marquee_text) - 1] = 0; + _marquee_max_w = max_width; + _marquee_skip_cp = 0; + _marquee_phase = 0; // hold at start + _marquee_next_at = now + marqueeHoldMs(); + + // Find the codepoint skip at which the remaining suffix's own width + // already fits max_width — i.e. the fully-scrolled end position. + const uint8_t* p = (const uint8_t*)temp_str; + int removed_w = 0; + uint16_t cp_count = 0; + while (*p) { + uint32_t cp = decodeCodepoint(p); + removed_w += getCodepointWidth(cp); + cp_count++; + if (full_width - removed_w <= max_width) break; + } + _marquee_max_skip_cp = cp_count; } - - int ellipsis_width = getTextWidth(ellipsis); - int str_len = strlen(temp_str); - - while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { - temp_str[--str_len] = 0; + + // Advance the state machine at most once per elapsed step/hold interval. + if ((int32_t)(now - _marquee_next_at) >= 0) { + uint8_t step = marqueeStepChars(); + switch (_marquee_phase) { + case 0: // was holding at start -> begin scrolling forward + case 1: // scrolling forward + _marquee_skip_cp += step; + _marquee_phase = 1; + if (_marquee_skip_cp >= _marquee_max_skip_cp) { + _marquee_skip_cp = _marquee_max_skip_cp; + _marquee_phase = 2; + _marquee_next_at = now + marqueeHoldMs(); + } else { + _marquee_next_at = now + marqueeStepMs(); + } + break; + case 2: // was holding at end -> begin scrolling back + case 3: // scrolling back + _marquee_phase = 3; + if (_marquee_skip_cp <= step) { + _marquee_skip_cp = 0; + _marquee_phase = 0; + _marquee_next_at = now + marqueeHoldMs(); + } else { + _marquee_skip_cp -= step; + _marquee_next_at = now + marqueeStepMs(); + } + break; + } } - // Strip orphaned UTF-8 leading byte left by byte-at-a-time trimming above. - while (str_len > 0 && ((uint8_t)temp_str[str_len - 1] & 0xC0) == 0xC0) { - temp_str[--str_len] = 0; + + // Render the window starting _marquee_skip_cp codepoints in, trimmed from + // the tail (same technique as the static ellipsis path, minus the "..." + // suffix) until it fits max_width. + const uint8_t* p = (const uint8_t*)temp_str; + for (uint16_t i = 0; i < _marquee_skip_cp && *p; i++) decodeCodepoint(p); + char window[256]; + strncpy(window, (const char*)p, sizeof(window) - 1); + window[sizeof(window) - 1] = 0; + int wlen = strlen(window); + while (wlen > 0 && getTextWidth(window) > max_width) { + window[--wlen] = 0; + } + while (wlen > 0 && ((uint8_t)window[wlen - 1] & 0xC0) == 0xC0) { + window[--wlen] = 0; } - strcat(temp_str, ellipsis); - setCursor(x, y); - print(temp_str); + print(window); + + return (int)(_marquee_next_at > now ? (_marquee_next_at - now) : 1); } virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default