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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-30 17:17:55 +02:00
co-authored by Claude Sonnet 5
parent 12cf81b13f
commit 1bb28296c2
13 changed files with 250 additions and 79 deletions
@@ -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
+13 -8
View File
@@ -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:
@@ -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) {
@@ -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) {
@@ -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; }
@@ -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 {
@@ -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 {
@@ -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 {
+3 -1
View File
@@ -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);
}
@@ -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 {
+8 -4
View File
@@ -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 {
@@ -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()).
+112 -4
View File
@@ -2,12 +2,24 @@
#include <stdint.h>
#include <string.h>
#include <Arduino.h>
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,12 +361,14 @@ 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;
}
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;
@@ -367,6 +397,84 @@ public:
setCursor(x, y);
print(temp_str);
return 0;
}
// ---- 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;
}
// 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;
}
}
// 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;
}
setCursor(x, y);
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