Files
MeshCore-Solo/examples/companion_radio/ui-new/TabBar.h
T
JakubandClaude Opus 5.5 608231ed6b feat(l1): L2 splash, hovering Home icons, soft selections, two-line DM bubbles
- Splash: the L2 wordmark and lettering, shared through ui-core/Lettering.h,
  rise in with loading dots under the version line (static frame on e-ink).
- Home: the header shows the time once the clock is set; icon pages carry
  only their title. One 32 px icon family (3 px strokes) bobs 2 px over a
  dithered blob shadow that shrinks as it rises; the unread badge rides on
  the Messages bubble; Bluetooth off is the icon at half density.
- Soft corners on every selection, the page pill, tabs and popups;
  dotted scrollbar track with a rounded thumb; the tab bar lays out whole
  tabs and shows edge arrows only in free space.
- DM bubbles drop the sender row and wrap to a second line, with the
  delivery mark and age closing it.
- Keyboards name what is typed (channel, contact, Password, Scope...)
  and show characters left near the limit; PIN entry has its own preview.
- Loading dots for waits (login, scan, GPS) and signal bars in place of
  raw RSSI in Nearby.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:59:44 +02:00

94 lines
4.2 KiB
C++

#pragma once
// Shared circular tab-bar header, extracted after NearbyScreen and BotScreen
// had each independently grown byte-identical rendering code for the same
// "active tab centred as a pill, neighbours fan out either side and wrap
// around" layout (see NearbyScreen::drawFilterTabs / BotScreen::drawTabBar,
// pre-extraction). A third screen (AdminScreen) needing the same thing was
// the rule-of-three trigger to finally share it.
//
// Tab-switching itself (LEFT/RIGHT cycling the active index) is NOT part of
// this helper -- each screen's cycle has different side effects (refresh a
// list, reset a row selection, ...), so that one-liner stays inline per screen.
namespace tabbar {
// One tab: short label; the active one is an inverted pill (same look as a
// selected row) so it reads as "you are here" in the tab strip.
inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bool active) {
int lh = display.getLineHeight();
if (active) {
display.setColor(DisplayDriver::LIGHT);
display.fillSoftRect(x, 0, w, lh + 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.drawTextCentered(x + w / 2, 0, label);
display.setColor(DisplayDriver::LIGHT);
}
// Small solid triangle pointing left (dir < 0) or right, 3 px wide and 5
// tall, centred on row y_mid: "more tabs this way".
inline void drawEdgeArrow(DisplayDriver& display, int x, int y_mid, int dir) {
display.setColor(DisplayDriver::LIGHT);
for (int c = 0; c < 3; c++) {
int h = dir < 0 ? c * 2 + 1 : 5 - c * 2;
display.fillRect(x + c, y_mid - h / 2, 1, h);
}
}
// Header as a circular tab bar: the active tab sits centred as a soft pill and
// the neighbours that fit whole fan out either side. When some tabs don't fit,
// a small arrow at each edge says there are more (the bar wraps around, so
// both ways lead to them) instead of squeezing in cut-off "Sn..." labels.
// `right_reserve` keeps any trailing decoration the caller draws afterwards
// (a context-menu hint, a counter) clear. Also draws the header separator.
inline void draw(DisplayDriver& display, const char* const* labels, int count, int active, int right_reserve = 0) {
const int pad = 3, gap = 2;
const int aw = display.getTextWidth(labels[active]) + pad * 2;
const int ax = display.width() / 2 - aw / 2;
const int rx_limit = display.width() - right_reserve;
// Lays the neighbours out (and draws them when `paint`), keeping `edge` px
// free at both ends; returns how many tabs are on screen and the free space
// left at each end.
struct Fit { int shown, left_free, right_free; };
auto layout = [&](int edge, bool paint) {
int lx = ax - gap, rx = ax + aw + gap, shown = 1;
bool lfit = true, rfit = true;
for (int k = 1; k <= count / 2 && (lfit || rfit); k++) {
int li = (active - k + count) % count;
int ri = (active + k) % count;
if (lfit) {
int w = display.getTextWidth(labels[li]) + pad * 2;
if (lx - w >= edge) { if (paint) drawPill(display, lx - w, w, labels[li], false); lx -= w + gap; shown++; }
else lfit = false;
}
// Skip the right side when it lands on the same tab as the left (the
// single opposite tab on an even count) so it isn't drawn twice.
if (rfit && ri != li) {
int w = display.getTextWidth(labels[ri]) + pad * 2;
if (rx + w <= rx_limit - edge) { if (paint) drawPill(display, rx, w, labels[ri], false); rx += w + gap; shown++; }
else rfit = false;
}
}
return Fit{ shown, lx + gap, rx_limit - rx + gap };
};
drawPill(display, ax, aw, labels[active], true);
Fit f = layout(0, false);
if (f.shown < count && f.left_free < 4 && f.right_free < 4)
f = layout(5, true); // no room for an arrow anywhere: give up a tab for them
else
layout(0, true);
if (f.shown < count) { // more tabs off screen: an arrow wherever there's room
int y_mid = display.getLineHeight() / 2;
if (f.left_free >= 4) drawEdgeArrow(display, 0, y_mid, -1);
if (f.right_free >= 4) drawEdgeArrow(display, rx_limit - 3, y_mid, +1);
}
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
}
} // namespace tabbar