diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index b0ad3fcb..157ebd9d 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -71,6 +71,7 @@ public: int val_x = display.valCol(); _visible = display.listVisible(item_h); if (_visible < 1) _visible = 1; + int reserve = scrollIndicatorReserve(display, ITEM_COUNT, _visible); display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); // reply counter, right-aligned in the header @@ -91,7 +92,7 @@ public: int i = _scroll + vi; int y = start_y + vi * item_h; bool sel = (i == _sel); - display.drawSelectionRow(0, y - 1, display.width(), item_h, sel); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h, sel); display.setCursor(2, y); display.print(labels[i]); display.setCursor(val_x, y); @@ -104,7 +105,7 @@ public: } else { ChannelDetails ch; if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, ch.name); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,ch.name); else display.print("?"); } @@ -113,10 +114,10 @@ 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, shown); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,shown); } else if (i == 3 || i == 5) { const char* rp = (i == 3) ? _prefs->bot_reply_dm : _prefs->bot_reply_ch; - display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)"); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,rp[0] ? rp : "(none)"); } else if (i == 6) { display.print(_prefs->bot_commands_enabled ? "ON" : "OFF"); } else { // i == 7 (quiet from) or i == 8 (quiet to) @@ -131,8 +132,7 @@ public: } display.setColor(DisplayDriver::LIGHT); } - display.drawScrollArrows(start_y, start_y + (_visible - 1) * item_h, - _scroll > 0, _scroll + _visible < ITEM_COUNT); + drawScrollIndicator(display, start_y, _visible * item_h, ITEM_COUNT, _visible, _scroll); return 2000; } diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 6c04128d..074305d1 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -588,13 +588,14 @@ public: display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, hint); } } else { + int reserve = scrollIndicatorReserve(display, _count, _visible); for (int i = 0; i < _visible && (_scroll + i) < _count; i++) { int idx = _scroll + i; bool sel = (idx == _sel); int y = start_y + i * item_h; const Entry& e = _entries[idx]; - display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); char filt[32]; display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); @@ -620,12 +621,11 @@ public: if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strncpy(right, "?GPS", sizeof(right)); } - display.setCursor(display.width() - display.getTextWidth(right) - 2, y); + display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y); display.print(right); } - display.drawScrollArrows(start_y, start_y + (_visible - 1) * item_h, - _scroll > 0, _scroll + _visible < _count); + drawScrollIndicator(display, start_y, _visible * item_h, _count, _visible, _scroll); } if (renderActivePopup(display)) return 50; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index bf8ccf34..a27ed655 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -834,9 +834,7 @@ public: int y = start_y + i * item_h; bool sel = (i == _mode_sel); display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(cw + 2, y); + display.setCursor(2, y); display.print(opts[i]); if (badges[i] > 0) { char badge[5]; @@ -858,18 +856,17 @@ public: return 5000; } + int reserve = scrollIndicatorReserve(display, _num_contacts, _visible); for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) { int list_idx = _contact_scroll + i; int mesh_idx = _sorted[list_idx]; bool sel = (list_idx == _contact_sel); int y = start_y + i * item_h; - display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { - display.setCursor(0, y); - display.print(sel ? ">" : " "); char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); @@ -879,15 +876,14 @@ public: snprintf(badge, sizeof(badge), "%d", (int)dm_unread); bw = display.getTextWidth(badge) + 2; } - display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw - 1, filtered); + display.drawTextEllipsized(2, y, display.width() - 2 - bw - 1 - reserve, filtered); if (dm_unread > 0) { - display.setCursor(display.width() - bw + 1, y); + display.setCursor(display.width() - bw + 1 - reserve, y); display.print(badge); } } } - display.drawScrollArrows(start_y, start_y + (_visible-1)*item_h, - _contact_scroll > 0, _contact_scroll + _visible < _num_contacts); + drawScrollIndicator(display, start_y, _visible * item_h, _num_contacts, _visible, _contact_scroll); // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); @@ -901,14 +897,13 @@ public: return 5000; } + int reserve = scrollIndicatorReserve(display, _num_channels, _visible); for (int i = 0; i < _visible && (_channel_scroll+i) < _num_channels; i++) { int list_idx = _channel_scroll + i; bool sel = (list_idx == _channel_sel); int y = start_y + i * item_h; - display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { uint8_t unread = _ch_unread[_channel_indices[list_idx]]; @@ -918,15 +913,14 @@ public: snprintf(badge, sizeof(badge), "%d", (int)unread); bw = display.getTextWidth(badge) + 2; } - display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4 - bw, ch.name); + display.drawTextEllipsized(2, y, display.width() - 4 - bw - reserve, ch.name); if (unread > 0) { - display.setCursor(display.width() - bw, y); + display.setCursor(display.width() - bw - reserve, y); display.print(badge); } } } - display.drawScrollArrows(start_y, start_y + (_visible-1)*item_h, - _channel_scroll > 0, _channel_scroll + _visible < _num_channels); + drawScrollIndicator(display, start_y, _visible * item_h, _num_channels, _visible, _channel_scroll); // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); @@ -974,6 +968,10 @@ public: bool portrait_expand = (display.height() > display.width()); const int MAX_VIS_BOXES = 8; int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0; + // Scrollbar gutter, from last frame's visible count (this frame's isn't + // known until the layout loop runs); keeps portrait wrap width and the box + // width consistent so wrapped text never spills under the scrollbar. + int reserve = scrollIndicatorReserve(display, dm_count, _hist_visible); { const int fixed_bh = 2 * lh + 1; int cur_y = hist_start_y; @@ -985,7 +983,7 @@ public: char trans[160]; display.translateUTF8ToBlocks(trans, skipReplyPrefix(_dm_hist[rp].text), sizeof(trans)); char wl[8][FS_CHARS_MAX]; - int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6, wl, 8); + int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6 - reserve, wl, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; } } @@ -995,7 +993,6 @@ public: } } _hist_visible = n_vis; - for (int i = 0; i < n_vis && (_dm_hist_scroll + i) < dm_count; i++) { int item = _dm_hist_scroll + i; bool sel = (item == _dm_hist_sel); @@ -1013,26 +1010,26 @@ public: if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), bh); + display.fillRect(0, y, display.width() - reserve, bh); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), bh); - display.fillRect(1, y + 1, display.width() - 2, lh); + display.drawRect(0, y, display.width() - reserve, bh); + display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); display.setColor(DisplayDriver::DARK); } - display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); if (e.outgoing) { // delivery marker after "Me" int gx = 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1); } - if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); } + if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); if (portrait_expand) { char trans[160]; display.translateUTF8ToBlocks(trans, skipReplyPrefix(e.text), sizeof(trans)); char wl[8][FS_CHARS_MAX]; - int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6, wl, 8); + int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6 - reserve, wl, 8); for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(wl[li]); } } else { display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(e.text)); @@ -1046,8 +1043,8 @@ public: { int arrow_y = (n_vis > 0) ? box_ys[n_vis - 1] + box_hs[n_vis - 1] - lh : hist_start_y; - display.drawScrollArrows(hist_start_y + 1, arrow_y, - _dm_hist_scroll > 0, _dm_hist_scroll + _hist_visible < dm_count); + drawScrollIndicator(display, hist_start_y + 1, arrow_y + lh - (hist_start_y + 1), + dm_count, _hist_visible, _dm_hist_scroll); } bool compose_sel = (_dm_hist_sel == -1); @@ -1113,6 +1110,8 @@ public: bool portrait_expand = (display.height() > display.width()); const int MAX_VIS_BOXES = 8; int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0; + // Scrollbar gutter, from last frame's visible count (see DM history above). + int reserve = scrollIndicatorReserve(display, ch_hist_count, _hist_visible); { const int fixed_bh = 2 * lh + 1; int cur_y = hist_start_y; @@ -1127,7 +1126,7 @@ public: char trans[160]; display.translateUTF8ToBlocks(trans, skipReplyPrefix(rbody), sizeof(trans)); char wl[8][FS_CHARS_MAX]; - int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6, wl, 8); + int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6 - reserve, wl, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; } } @@ -1166,15 +1165,15 @@ public: if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), bh); + display.fillRect(0, y, display.width() - reserve, bh); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), bh); - display.fillRect(1, y + 1, display.width() - 2, lh); + display.drawRect(0, y, display.width() - reserve, bh); + display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); display.setColor(DisplayDriver::DARK); } - display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); // Channels have no recipient ACK — only show ✓ once a repeater echo // confirms the send was relayed into the mesh; otherwise no marker // (absence is normal, not a failure). @@ -1182,13 +1181,13 @@ public: int gx = 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, ACK_OK); } - if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); } + if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); if (portrait_expand) { char trans[160]; display.translateUTF8ToBlocks(trans, skipReplyPrefix(msg_part), sizeof(trans)); char wl[8][FS_CHARS_MAX]; - int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6, wl, 8); + int nl = FullscreenMsgView::wrapLines(display, trans, display.width() - 6 - reserve, wl, 8); for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(wl[li]); } } else { display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(msg_part)); @@ -1203,8 +1202,8 @@ public: // scroll hints { int arrow_y = (n_vis > 0) ? box_ys[n_vis - 1] + box_hs[n_vis - 1] - lh : hist_start_y; - display.drawScrollArrows(hist_start_y + 1, arrow_y, - _hist_scroll > 0, _hist_scroll + _hist_visible < ch_hist_count); + drawScrollIndicator(display, hist_start_y + 1, arrow_y + lh - (hist_start_y + 1), + ch_hist_count, _hist_visible, _hist_scroll); } // small compose button (bottom-left, always bordered, inverted when selected) @@ -1249,27 +1248,25 @@ public: display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); int total_msg_items = 1 + _active_msg_count; + int reserve = scrollIndicatorReserve(display, total_msg_items, _visible); for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) { int idx = _msg_scroll + i; bool sel = (idx == _msg_sel); int y = start_y + i * item_h; - display.drawSelectionRow(0, y - 1, display.width(), item_h - 1, sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); if (idx == 0) { - display.setCursor(cw + 2, y); + display.setCursor(2, y); display.print("Custom message..."); } else { NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[idx - 1]; const char* tmpl = p ? p->custom_msgs[slot] : ""; - display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4, tmpl); + display.drawTextEllipsized(2, y, display.width() - 4 - reserve, tmpl); } } - display.drawScrollArrows(start_y, start_y + (_visible-1)*item_h, - _msg_scroll > 0, _msg_scroll + _visible < total_msg_items); + drawScrollIndicator(display, start_y, _visible * item_h, total_msg_items, _visible, _msg_scroll); } return 2000; } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index e9e1fb27..8abd7c24 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -74,6 +74,7 @@ class SettingsScreen : public UIScreen { int _selected; int _scroll; int _visible; // items fitting on screen; updated each render, used by handleInput + int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits) bool _dirty; uint8_t _collapsed = 0x7F; // bit N set = section N collapsed (Display=0..Messages=6) static const int MAX_VIS = 60; @@ -112,7 +113,7 @@ class SettingsScreen : public UIScreen { void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { const int gap = 2; - const int avail = display.width() - x; + const int avail = display.width() - x - _reserve; const int raw = (avail - (max_val - 1) * gap) / max_val; const int cap = display.getLineHeight() - 2; const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap; @@ -402,10 +403,8 @@ class SettingsScreen : public UIScreen { bool collapsed = (_collapsed >> si) & 1; bool sel = (item == _selected); display.setColor(DisplayDriver::LIGHT); - display.drawSelectionRow(0, y - 1, display.width(), display.lineStep(), sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(display.getCharWidth() + 2, y); + display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep(), sel); + display.setCursor(2, y); display.print(collapsed ? "+" : "-"); display.print(" "); display.print(sectionName(item)); @@ -413,11 +412,9 @@ class SettingsScreen : public UIScreen { } bool sel = (item == _selected); - display.drawSelectionRow(0, y - 1, display.width(), display.lineStep(), sel); + display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep(), sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(display.getCharWidth() + 2, y); + display.setCursor(2, y); #if FEAT_BRIGHTNESS_SETTING if (item == BRIGHTNESS) { @@ -471,7 +468,7 @@ class SettingsScreen : public UIScreen { display.print(pb); } display.print(homePageLabel(item)); - display.setCursor(display.width() - 6 * display.getCharWidth(), y); + display.setCursor(display.width() - 6 * display.getCharWidth() - _reserve, y); if (!homePageToggleable(item)) display.print("always"); else @@ -584,7 +581,7 @@ 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, tmpl); + display.drawTextEllipsized(xm, y, display.width() - xm - _reserve, tmpl); } } @@ -617,6 +614,7 @@ public: int item_h = display.lineStep(); int start_y = display.listStart(); _visible = display.listVisible(item_h); + _reserve = scrollIndicatorReserve(display, _vis_count, _visible); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); @@ -626,8 +624,7 @@ public: renderItem(display, _vis[_scroll + i], start_y + i * item_h); } - display.drawScrollArrows(start_y, start_y + (_visible - 1) * item_h, - _scroll > 0, _scroll + _visible < _vis_count); + drawScrollIndicator(display, start_y, _visible * item_h, _vis_count, _visible, _scroll); return 2000; } diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 271dcf71..2c9bc654 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -21,7 +21,6 @@ public: int item_h = display.lineStep(); int start_y = display.listStart(); - int cw = display.getCharWidth(); int vis = display.listVisible(item_h); if (vis < 1) vis = 1; @@ -29,18 +28,16 @@ public: if (_sel < _scroll) _scroll = _sel; if (_sel >= _scroll + vis) _scroll = _sel - vis + 1; + int reserve = scrollIndicatorReserve(display, ITEM_COUNT, vis); for (int i = 0; i < vis && (_scroll + i) < ITEM_COUNT; i++) { int idx = _scroll + i; int y = start_y + i * item_h; bool sel = (idx == _sel); - display.drawSelectionRow(0, y - 1, display.width(), item_h, sel); - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(cw + 2, y); + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h, sel); + display.setCursor(2, y); display.print(ITEMS[idx]); } - display.drawScrollArrows(start_y, start_y + (vis - 1) * item_h, - _scroll > 0, _scroll + vis < ITEM_COUNT); + drawScrollIndicator(display, start_y, vis * item_h, ITEM_COUNT, vis, _scroll); return 500; } diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 778fb549..5466b603 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -412,9 +412,7 @@ private: display.print(buf); } - display.drawScrollArrows(y0, y0 + (visible - 1) * step, - _summary_scroll > 0, - _summary_scroll + visible < SUMMARY_ITEM_COUNT); + drawScrollIndicator(display, y0, visible * step, SUMMARY_ITEM_COUNT, visible, _summary_scroll); } void renderList(DisplayDriver& display) { @@ -466,8 +464,7 @@ private: display.print(row); } - display.drawScrollArrows(top, top + (visible - 1) * step, - _list_scroll > 0, _list_scroll + visible < total); + drawScrollIndicator(display, top, visible * step, total, visible, _list_scroll); } // Shared map projection: geographic (1e-6 deg) → screen pixels. The scale is diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index f710029b..5f63390e 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -131,18 +131,16 @@ class WaypointsView { display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); const int top = display.listStart(); const int step = display.lineStep(); - const int cw = display.getCharWidth(); for (int i = 0; i < 4; i++) { int y = top + i * step; bool sel = (i == _add_sel); display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel); - display.setCursor(0, y); display.print(sel ? ">" : " "); char row[28]; if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--"); else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--"); else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)"); else snprintf(row, sizeof(row), "[Save]"); - display.setCursor(cw + 2, y); display.print(row); + display.setCursor(2, y); display.print(row); display.setColor(DisplayDriver::LIGHT); } } @@ -159,7 +157,6 @@ class WaypointsView { int total = n + 1; // final row = "+ Add by coords" const int top = display.listStart(); const int step = display.lineStep(); - const int cw = display.getCharWidth(); int vis = display.listVisible(); if (vis < 1) vis = 1; if (_sel < _scroll) _scroll = _sel; @@ -167,15 +164,15 @@ class WaypointsView { int32_t mylat, mylon; bool have = ownPos(mylat, mylon); + int reserve = scrollIndicatorReserve(display, total, vis); for (int i = 0; i < vis && (_scroll + i) < total; i++) { int row = _scroll + i; int y = top + i * step; bool sel = (row == _sel); - display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel); - display.setCursor(0, y); display.print(sel ? ">" : " "); + display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel); if (row == n) { // the synthetic "Add" row - display.setCursor(cw + 2, y); display.print("+ Add by coords"); + display.setCursor(2, y); display.print("+ Add by coords"); display.setColor(DisplayDriver::LIGHT); continue; } @@ -189,10 +186,11 @@ class WaypointsView { } char nm[24]; display.translateUTF8ToBlocks(nm, label, sizeof(nm)); - display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw, nm); - if (dist[0]) { display.setCursor(display.width() - bw + 1, y); display.print(dist); } + display.drawTextEllipsized(2, y, display.width() - 2 - bw - reserve, nm); + if (dist[0]) { display.setCursor(display.width() - bw + 1 - reserve, y); display.print(dist); } display.setColor(DisplayDriver::LIGHT); } + drawScrollIndicator(display, top, vis * step, total, vis, _scroll); } void renderWpNav(DisplayDriver& display) { diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 660c463b..7a62c0da 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -65,6 +65,37 @@ inline void miniIconDraw(DisplayDriver& d, int x, int top_y, const MiniIcon& ic) miniIconDraw(d, x, top_y, ic.rows, ic.w, ic.h); } +// Draw a mini-icon at an exact top-left (no vertical centring). Used where the +// caller controls placement, e.g. flush to the top/bottom of a scroll track. +inline void miniIconDrawTop(DisplayDriver& d, int x, int y, const MiniIcon& ic) { + const int s = miniIconScale(d); + for (int r = 0; r < ic.h; r++) + for (int c = 0; c < ic.w; c++) + if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s); +} + +// Like miniIconDrawTop, but first lays down a 1px DARK halo that hugs the icon's +// shape (each pixel dilated by 1px), then the icon in LIGHT. Invisible on a dark +// row; on the LIGHT selection bar it outlines just the glyph — no boxed-in +// rectangle around it. Restores ink to LIGHT. clip_top bounds the halo so it +// can't bleed above a given y (e.g. onto a header separator just above the icon). +inline void miniIconDrawHalo(DisplayDriver& d, int x, int y, const MiniIcon& ic, + int clip_top = -100000) { + const int s = miniIconScale(d); + d.setColor(DisplayDriver::DARK); + for (int r = 0; r < ic.h; r++) + for (int c = 0; c < ic.w; c++) + if (ic.rows[r] & (1 << c)) { + int hy = y + r * s - 1, hh = s + 2; + if (hy < clip_top) { hh -= clip_top - hy; hy = clip_top; } + if (hh > 0) d.fillRect(x + c * s - 1, hy, s + 2, hh); + } + d.setColor(DisplayDriver::LIGHT); + for (int r = 0; r < ic.h; r++) + for (int c = 0; c < ic.w; c++) + if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s); +} + // Horizontal row of `count` square dots (scaled, vertically centred). Used by // the "awaiting ACK" marker, where the dot count = number of send attempts. inline void miniIconDotRow(DisplayDriver& d, int x, int top_y, int count) { @@ -117,6 +148,79 @@ MINI_ICON(ICON_SPACE_R, 8, packRow(".......#"), packRow("########")); +// Scroll-indicator caps — small up/down triangles (authored on the 1× grid). +MINI_ICON(ICON_SCROLL_UP, 5, // ▲ + packRow("..#.."), + packRow(".###."), + packRow("#####")); +MINI_ICON(ICON_SCROLL_DOWN, 5, // ▼ + packRow("#####"), + packRow(".###."), + packRow("..#..")); + +// Width of the right-edge column drawScrollIndicator occupies, or 0 when the +// list fits and no indicator is drawn. Subtract from a row's content width so +// text never runs under the scrollbar. Mirrors the column math below (5*scale +// triangle + 1px gap). +inline int scrollIndicatorReserve(DisplayDriver& d, int total, int visible) { + return (total > visible) ? 5 * miniIconScale(d) + 1 : 0; +} + +// Right-edge scroll indicator: a proportional track + thumb topped/tailed with +// up/down triangle mini-icon caps. Drop-in replacement for +// DisplayDriver::drawScrollArrows that also shows how much of the list is on +// screen and where you are within it. Everything lives in a ~5px column at the +// right edge and scales with the font (mini-icon scale). +// top_y : y of the first visible row (top of the viewport) +// track_h : pixel height of the viewport (e.g. visible_rows * row_pitch) +// total : total number of items +// visible : items shown at once +// first : index of the first visible item (scroll offset) +inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h, + int total, int visible, int first) { + if (total <= visible || track_h <= 0) return; // whole list fits — no indicator + if (first < 0) first = 0; + if (first > total - visible) first = total - visible; + + const int s = miniIconScale(d); + const int col = 5 * s; // triangle / column width + const int th = 3 * s; // triangle height + const int x = d.width() - col; // indicator column origin + + // Caps are static end-markers, always drawn flush at the very top / bottom of + // the track; the thumb travels in the fixed band between them. (They mark the + // track ends, not "more above/below", so they never vanish at the extremes.) + const int cap = th + 2; // triangle height + 2px gap + const int band_t = top_y + cap; + const int band_b = top_y + track_h - cap; + int band = band_b - band_t; + if (band < s) band = s; + + const int bar_w = 3 * s; // odd width → centres exactly in the 5*s column + const int bar_x = x + (col - bar_w) / 2; + + int thumb_h = (int)((long)band * visible / total); + if (thumb_h < th) thumb_h = th; + if (thumb_h > band) thumb_h = band; + const int span = total - visible; // > 0 here (total > visible) + const int thumb_y = band_t + (int)((long)(band - thumb_h) * first / span); + + // Each marker is drawn with a 1px DARK halo: invisible on a normal (dark) row, + // but on the LIGHT selection bar it carves out contrast so the LIGHT marker + // stays visible. Avoids having to know which row is currently selected. + // Thumb: a rectangle, so a plain box halo matches its shape. + d.setColor(DisplayDriver::DARK); + d.fillRect(bar_x - 1, thumb_y - 1, bar_w + 2, thumb_h + 2); + d.setColor(DisplayDriver::LIGHT); + d.fillRect(bar_x, thumb_y, bar_w, thumb_h); + + // Arrows: shape-hugging halo so they aren't boxed in on the selection bar. + // The top arrow clips its halo at top_y so it can't bite into the header + // separator sitting one pixel above the list area. + miniIconDrawHalo(d, x, top_y, ICON_SCROLL_UP, top_y); + miniIconDrawHalo(d, x, top_y + track_h - th, ICON_SCROLL_DOWN); +} + // ── Big ASCII-art icons (skeleton, not yet used) ───────────────────────────── // Same authoring idea as the mini-icons but for full page glyphs up to 32 px // wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…)