Add widget kit, input layer with Compose Key, Launcher and demo apps

Steps 5 and 6 of M0.
- lib/ui (host-tested): ListModel, LineEditor (UTF-8, byte-limited),
  wrapText, ToastQueue, DialogModel
- lib/input (host-tested): KeyMapper turns keyboard state into logical
  keys (Fn arrows, ` Back, Fn+` Home) and composes accents with opt
- src/ui: off-screen 16-bit frame pushed in one transfer (fixes the
  flicker), Status Bar, list, text, line editor, dialog, Toast widgets,
  Notifier (Toast + beep + LED), vendored public-domain Latin-1 fonts
- Apps: minimal Launcher, Widget demo, Diagnostics (SD erase now
  behind a confirmation dialog)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 10:35:48 +02:00
co-authored by Claude Opus 5.5
parent 36ebd23417
commit 879cb0d0b6
39 changed files with 2119 additions and 92 deletions
+136
View File
@@ -0,0 +1,136 @@
#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.batteryPercent >= 0)
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted);
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) {
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);
}
// 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