Files
roro9stack/src/ui/widgets.cpp
T
twislaandClaude Opus 5.5 d7952612dd M2 step 6: Tracks, recorded to GPX in the background
`r` in the GNSS App (or `gnss track start|stop`) records a Track to
/gnss/tracks/YYYYMMDD-HHMMSS.gpx: a point every 5 s once moved 5 m
(lib/gnss/track, 7 tests: haversine, the rule, GPX text, the file name).
It keeps recording with the App closed, shows REC in the Status Bar, and
announces start and stop with a Toast. It needs a card and the time;
switching GNSS off stops it. Tracks are written like Captures: past 90 %
card usage, until full. Storage Clean-up gets a GNSS tracks category.

A Track cut short by a reset or power loss has no GPX footer; the GNSS
Service closes such files at the next boot.

Verified on the device: a recorded Track and one interrupted by a reset
both parse as GPX 1.1 on the PC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-04 22:46:57 +02:00

167 lines
6.5 KiB
C++

#include "widgets.h"
#include "fonts.h"
namespace roro::widgets {
using namespace theme;
MeasureText bodyMeasure(Canvas& canvas) {
return [&canvas](const std::string& s) {
canvas.setFont(&fonts::body);
return static_cast<int>(canvas.textWidth(s.c_str()));
};
}
void statusBar(Canvas& c, const StatusInfo& info) {
c.fillRect(0, 0, kWidth, kStatusBarHeight, kBar);
c.setFont(&fonts::small);
c.setTextDatum(top_left);
c.setTextColor(kText);
c.drawString(info.title.c_str(), 3, 2);
// Right side, laid out right to left.
int x = kWidth - 3;
auto right = [&](const std::string& text, uint16_t color) {
c.setTextColor(color);
c.setTextDatum(top_right);
c.drawString(text.c_str(), x, 2);
x -= c.textWidth(text.c_str()) + 6;
};
right(info.clock, kText);
if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage);
if (info.batteryPercent >= 0)
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted);
switch (info.wifi) {
case StatusInfo::Wifi::Connected: {
// Three signal bars, then the label.
for (int i = 0; i < 3; i++) {
int h = 2 + 2 * i;
c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kMuted);
}
x -= 14;
right("W", kText);
break;
}
case StatusInfo::Wifi::Searching: right("W?", kMuted); break;
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
case StatusInfo::Wifi::None: break;
}
if (info.tracking) right("REC", kWarning);
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix
case StatusInfo::Gnss::Searching: right("G", kMuted); break;
case StatusInfo::Gnss::TwoD: right("G", kText); break;
case StatusInfo::Gnss::ThreeD: right("G" + std::to_string(info.gnssSatellites), kText); break;
case StatusInfo::Gnss::None: break;
}
if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent);
c.setTextDatum(top_left);
}
void list(Canvas& c, const ListModel& model, Rect area, const std::function<std::string(int)>& label,
const std::function<std::string(int)>& value) {
c.setFont(&fonts::body);
c.setTextDatum(top_left);
int rows = model.visibleRows();
for (int row = 0; row < rows; row++) {
int i = model.firstVisible() + row;
if (i >= model.count()) break;
int y = area.y + row * kLineHeight;
bool selected = i == model.selected();
if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection);
c.setTextColor(kText);
c.drawString(label(i).c_str(), area.x + 4, y + 1);
if (value) {
c.setTextColor(selected ? kText : kMuted);
c.setTextDatum(top_right);
c.drawString(value(i).c_str(), area.x + area.w - 8, y + 1);
c.setTextDatum(top_left);
}
}
// Scrollbar
if (model.count() > rows) {
int barH = area.h * rows / model.count();
int barY = area.y + (area.h - barH) * model.firstVisible() / (model.count() - rows);
c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted);
}
}
void textLines(Canvas& c, const std::vector<std::string>& lines, int firstLine, Rect area) {
c.setFont(&fonts::body);
c.setTextDatum(top_left);
c.setTextColor(kText);
int rows = area.h / kLineHeight;
for (int row = 0; row < rows && firstLine + row < static_cast<int>(lines.size()); row++)
c.drawString(lines[firstLine + row].c_str(), area.x + 4, area.y + row * kLineHeight + 1);
int total = static_cast<int>(lines.size());
if (total > rows) {
int barH = area.h * rows / total;
int barY = area.y + (area.h - barH) * firstLine / (total - rows);
c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted);
}
}
void lineEditor(Canvas& c, const LineEditor& editor, Rect area) {
c.setFont(&fonts::body);
c.setTextDatum(top_left);
c.drawRect(area.x, area.y, area.w, kLineHeight + 4, kAccent);
const std::string& text = editor.text();
int cursorX = c.textWidth(text.substr(0, editor.cursor()).c_str());
int visible = area.w - 8;
int scroll = cursorX > visible ? cursorX - visible : 0;
c.setClipRect(area.x + 2, area.y + 1, area.w - 4, kLineHeight + 2);
c.setTextColor(kText);
c.drawString(text.c_str(), area.x + 3 - scroll, area.y + 3);
c.fillRect(area.x + 3 + cursorX - scroll, area.y + 2, 1, kLineHeight, kAccent);
c.clearClipRect();
}
void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model) {
Rect box{16, 26, kWidth - 32, 92};
c.fillRect(box.x, box.y, box.w, box.h, kBackground);
c.drawRect(box.x, box.y, box.w, box.h, kAccent);
c.setTextDatum(top_left);
c.setFont(&fonts::bold);
c.setTextColor(kText);
c.drawString(title.c_str(), box.x + 6, box.y + 4);
auto lines = wrapText(message, box.w - 12, bodyMeasure(c));
textLines(c, lines, 0, {box.x + 2, box.y + 20, box.w - 4, 3 * kLineHeight});
// Buttons, right-aligned.
c.setFont(&fonts::body);
int x = box.x + box.w - 6;
const auto& buttons = model.buttons();
for (int i = static_cast<int>(buttons.size()) - 1; i >= 0; i--) {
int w = c.textWidth(buttons[i].c_str()) + 12;
x -= w;
bool selected = i == model.selected();
if (selected) c.fillRect(x, box.y + box.h - 19, w, 15, kAccent);
else c.drawRect(x, box.y + box.h - 19, w, 15, kMuted);
c.setTextColor(selected ? kBackground : kText);
c.drawString(buttons[i].c_str(), x + 6, box.y + box.h - 17);
x -= 6;
}
}
void toast(Canvas& c, const Toast& t) {
uint16_t color = t.level == NotificationLevel::Warning ? kWarning
: t.level == NotificationLevel::Message ? kMessage
: kAccent;
c.setFont(&fonts::body);
int w = c.textWidth(t.text.c_str()) + 16;
if (w > kWidth - 8) w = kWidth - 8;
int x = (kWidth - w) / 2, y = kHeight - 22;
c.fillRect(x, y, w, 18, color);
c.setTextColor(kBackground);
c.setTextDatum(top_left);
c.setClipRect(x + 4, y, w - 8, 18);
c.drawString(t.text.c_str(), x + 8, y + 3);
c.clearClipRect();
}
} // namespace roro::widgets