Public Access
Fn+h on any screen, text fields included, and ? outside Text Entry, open a panel over the content area: the screen's own keys, then the ones that work everywhere. Every App declares its keys for the state it is in (pages, viewers, dialogs and text fields answer for themselves); the App manager opens the panel and takes every key while it is open. About 30 hint lines are gone, from every App. What stays on a screen is state. The first-start Setup keeps its hints and teaches the key; a device set up before gets one Toast, once. The guide and the FAQ open with it. `key help` over the consoles. 476 host tests (8 new). Checked on the device with key help and screenshots: the Launcher, all nine Apps and several of their states. 8.5 KB of flash and 40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
215 lines
8.5 KiB
C++
215 lines
8.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;
|
|
}
|
|
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
|
|
case StatusInfo::Debug::On: right("DBG", kMuted); break;
|
|
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
|
|
case StatusInfo::Debug::Off: break;
|
|
}
|
|
if (info.tracking) right("REC", kWarning);
|
|
if (info.capturing) right("CAP", kWarning);
|
|
switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet
|
|
case StatusInfo::Radio::Listening: right("L", kMuted); break;
|
|
case StatusInfo::Radio::Packet: right("L", kAccent); break;
|
|
case StatusInfo::Radio::Sweep: right("SW", kMuted); break; // the Sniffer is paused
|
|
case StatusInfo::Radio::None: break;
|
|
}
|
|
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();
|
|
}
|
|
|
|
void help(Canvas& c, const HelpModel& model) {
|
|
const auto& area = kContent;
|
|
c.fillRect(area.x, area.y, area.w, area.h, kBackground);
|
|
c.setTextDatum(top_left);
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(kAccent);
|
|
c.drawString(("Keys: " + model.title()).c_str(), 4, area.y + 1);
|
|
c.drawFastHLine(4, area.y + 13, area.w - 8, kMuted);
|
|
|
|
const int top = area.y + 16, keysX = 4, actionX = 70;
|
|
const auto& rows = model.rows();
|
|
int count = static_cast<int>(rows.size());
|
|
for (int row = 0; row < model.visibleRows(); row++) {
|
|
int i = model.top() + row;
|
|
if (i >= count) break;
|
|
int y = top + row * kLineHeight;
|
|
if (!rows[i].action) { // a heading
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(kMuted);
|
|
c.drawString(rows[i].keys, keysX, y);
|
|
continue;
|
|
}
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(kAccent);
|
|
c.drawString(rows[i].keys, keysX, y);
|
|
c.setTextColor(kText);
|
|
c.drawString(rows[i].action, actionX, y);
|
|
}
|
|
if (count > model.visibleRows()) {
|
|
int h = model.visibleRows() * kLineHeight;
|
|
int barH = h * model.visibleRows() / count;
|
|
int barY = top + (h - barH) * model.top() / (count - model.visibleRows());
|
|
c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted);
|
|
}
|
|
}
|
|
|
|
} // namespace roro::widgets
|