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
+5
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
#include "key_event.h"
namespace roro {
@@ -20,6 +22,9 @@ class App {
return false;
}
// Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; }
virtual void draw(Canvas& canvas) = 0;
void requestRedraw() { redraw_ = true; }
+1
View File
@@ -27,6 +27,7 @@ class AppManager {
void home();
void handleKey(const KeyEvent& event);
void update(uint32_t nowMs) { foreground_->update(nowMs); }
App& foreground() const { return *foreground_; }
+107
View File
@@ -0,0 +1,107 @@
#include "key_mapper.h"
#include <algorithm>
namespace roro {
namespace {
constexpr char kArmed = '*';
bool isAccent(char c) { return c == '\'' || c == '`' || c == '^' || c == '"' || c == ',' || c == '~'; }
// Latin-1 code point for accent + base letter, or 0.
uint32_t composeLatin1(char accent, char base) {
struct Entry {
char accent;
const char* bases;
const uint32_t* codes;
};
static const uint32_t acute[] = {0xE1, 0xE9, 0xED, 0xF3, 0xFA, 0xFD, 0xC1, 0xC9, 0xCD, 0xD3, 0xDA, 0xDD};
static const uint32_t grave[] = {0xE0, 0xE8, 0xEC, 0xF2, 0xF9, 0xC0, 0xC8, 0xCC, 0xD2, 0xD9};
static const uint32_t circ[] = {0xE2, 0xEA, 0xEE, 0xF4, 0xFB, 0xC2, 0xCA, 0xCE, 0xD4, 0xDB};
static const uint32_t diaer[] = {0xE4, 0xEB, 0xEF, 0xF6, 0xFC, 0xFF, 0xC4, 0xCB, 0xCF, 0xD6, 0xDC};
static const uint32_t cedil[] = {0xE7, 0xC7};
static const uint32_t tilde[] = {0xE3, 0xF1, 0xF5, 0xC3, 0xD1, 0xD5};
static const Entry table[] = {
{'\'', "aeiouyAEIOUY", acute}, {'`', "aeiouAEIOU", grave}, {'^', "aeiouAEIOU", circ},
{'"', "aeiouyAEIOU", diaer}, {',', "cC", cedil}, {'~', "anoANO", tilde},
};
for (auto& e : table) {
if (e.accent != accent) continue;
for (int i = 0; e.bases[i]; i++)
if (e.bases[i] == base) return e.codes[i];
}
return 0;
}
KeyEvent charEvent(uint32_t cp, const RawKeys& keys) {
KeyEvent e = KeyEvent::character(cp);
e.shift = keys.shift;
e.ctrl = keys.ctrl;
e.alt = keys.alt;
return e;
}
} // namespace
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
std::vector<KeyEvent> out;
if (keys.opt && !previous_.opt && keys.chars.empty()) {
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
}
if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select));
if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete));
if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab));
for (char c : keys.chars) {
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
if (!wasHeld) onChar(c, keys, out);
}
previous_ = keys;
return out;
}
void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out) {
// Holding opt while typing the accent is the same as pressing opt first.
if (keys.opt && !compose_) compose_ = kArmed;
if (compose_ == kArmed) {
if (isAccent(c)) {
compose_ = c;
return;
}
compose_ = 0; // not an accent: type it normally below
} else if (compose_) {
char accent = compose_;
compose_ = 0;
uint32_t composed = composeLatin1(accent, c);
if (composed) {
out.push_back(charEvent(composed, keys));
} else {
out.push_back(charEvent(static_cast<unsigned char>(accent), keys));
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
}
return;
}
if (keys.fn) {
switch (c) {
case ';': out.push_back(KeyEvent::of(Key::Up)); return;
case '.': out.push_back(KeyEvent::of(Key::Down)); return;
case ',': out.push_back(KeyEvent::of(Key::Left)); return;
case '/': out.push_back(KeyEvent::of(Key::Right)); return;
case '`': out.push_back(KeyEvent::of(Key::Home)); return;
default: return; // other Fn combos are unassigned
}
}
if (c == '`') {
out.push_back(KeyEvent::of(Key::Back));
return;
}
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
}
} // namespace roro
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <vector>
#include "key_event.h"
namespace roro {
// The physical keyboard state as reported by the keyboard driver: every character key currently
// held (already shifted) plus the modifier and special keys.
struct RawKeys {
std::vector<char> chars;
bool fn = false;
bool opt = false;
bool ctrl = false;
bool alt = false;
bool shift = false;
bool enter = false;
bool del = false;
bool tab = false;
};
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events;
// Fn + ; . , / are arrows; ` is Back and Fn + ` is Home; the Compose Key (opt) followed by an
// accent and a letter types the accented letter (opt ' e -> é).
class KeyMapper {
public:
std::vector<KeyEvent> update(const RawKeys& keys);
// 0 when idle, '*' when opt was pressed, or the accent character waiting for its letter.
char pendingCompose() const { return compose_; }
private:
void onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out);
RawKeys previous_;
char compose_ = 0;
};
} // namespace roro
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <string>
#include <vector>
#include "key_event.h"
namespace roro {
// A modal choice between buttons. The first button should be the safe one (e.g. "Cancel"):
// it's highlighted initially, and Back always cancels.
class DialogModel {
public:
static constexpr int kPending = -1;
static constexpr int kCancelled = -2;
explicit DialogModel(std::vector<std::string> buttons) : buttons_(std::move(buttons)) {}
// Consumes every key while open.
bool onKey(const KeyEvent& e) {
if (result_ != kPending) return true;
int last = static_cast<int>(buttons_.size()) - 1;
switch (e.key) {
case Key::Left: if (selected_ > 0) selected_--; break;
case Key::Right: if (selected_ < last) selected_++; break;
case Key::Tab: selected_ = selected_ == last ? 0 : selected_ + 1; break;
case Key::Select: result_ = selected_; break;
case Key::Back: result_ = kCancelled; break;
default: break;
}
return true;
}
const std::vector<std::string>& buttons() const { return buttons_; }
int selected() const { return selected_; }
int result() const { return result_; }
private:
std::vector<std::string> buttons_;
int selected_ = 0;
int result_ = kPending;
};
} // namespace roro
+30
View File
@@ -0,0 +1,30 @@
#include "line_editor.h"
#include "utf8.h"
namespace roro {
void LineEditor::setText(const std::string& text) {
text_ = text.substr(0, utf8::clampToBoundary(text, max_));
cursor_ = text_.size();
}
bool LineEditor::insert(uint32_t codePoint) {
std::string encoded = utf8::encode(codePoint);
if (text_.size() + encoded.size() > max_) return false;
text_.insert(cursor_, encoded);
cursor_ += encoded.size();
return true;
}
void LineEditor::backspace() {
if (cursor_ == 0) return;
size_t start = utf8::previous(text_, cursor_);
text_.erase(start, cursor_ - start);
cursor_ = start;
}
void LineEditor::left() { cursor_ = utf8::previous(text_, cursor_); }
void LineEditor::right() { cursor_ = utf8::next(text_, cursor_); }
} // namespace roro
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro {
// Single-line UTF-8 text editing. The cursor is a byte index always on a character boundary,
// and the text never exceeds maxBytes (protocol fields are limited in bytes, not characters).
class LineEditor {
public:
explicit LineEditor(size_t maxBytes) : max_(maxBytes) {}
void setText(const std::string& text); // truncates, cursor to end
const std::string& text() const { return text_; }
size_t cursor() const { return cursor_; }
size_t maxBytes() const { return max_; }
bool insert(uint32_t codePoint); // false if it would not fit
void backspace();
void left();
void right();
void home() { cursor_ = 0; }
void end() { cursor_ = text_.size(); }
private:
size_t max_;
std::string text_;
size_t cursor_ = 0;
};
} // namespace roro
+41
View File
@@ -0,0 +1,41 @@
#include "list_model.h"
namespace roro {
void ListModel::setCount(int count) {
count_ = count < 0 ? 0 : count;
if (count_ == 0) {
selected_ = -1;
first_ = 0;
return;
}
select(selected_ < 0 ? 0 : selected_);
}
void ListModel::select(int index) {
if (count_ == 0) return;
selected_ = index < 0 ? 0 : index >= count_ ? count_ - 1 : index;
scrollToSelection();
}
void ListModel::up() {
if (count_ == 0) return;
select(selected_ == 0 ? count_ - 1 : selected_ - 1);
}
void ListModel::down() {
if (count_ == 0) return;
select(selected_ == count_ - 1 ? 0 : selected_ + 1);
}
void ListModel::pageUp() { select(selected_ - rows_); }
void ListModel::pageDown() { select(selected_ + rows_); }
void ListModel::scrollToSelection() {
if (selected_ < first_) first_ = selected_;
if (selected_ >= first_ + rows_) first_ = selected_ - rows_ + 1;
int maxFirst = count_ > rows_ ? count_ - rows_ : 0;
if (first_ > maxFirst) first_ = maxFirst;
}
} // namespace roro
+32
View File
@@ -0,0 +1,32 @@
#pragma once
namespace roro {
// Selection and scrolling for a vertical list showing `visibleRows` rows at a time.
// Up/Down wrap around; page moves don't.
class ListModel {
public:
explicit ListModel(int visibleRows) : rows_(visibleRows) {}
void setCount(int count);
int count() const { return count_; }
int selected() const { return selected_; } // -1 when empty
int firstVisible() const { return first_; }
int visibleRows() const { return rows_; }
void select(int index);
void up();
void down();
void pageUp();
void pageDown();
private:
void scrollToSelection();
int rows_;
int count_ = 0;
int selected_ = -1;
int first_ = 0;
};
} // namespace roro
+60
View File
@@ -0,0 +1,60 @@
#include "text_wrap.h"
#include "utf8.h"
namespace roro {
namespace {
void wrapParagraph(const std::string& para, int maxWidth, const MeasureText& measure,
std::vector<std::string>& out) {
std::string line;
size_t pos = 0;
while (pos < para.size()) {
size_t space = para.find(' ', pos);
std::string word = para.substr(pos, space == std::string::npos ? std::string::npos : space - pos);
pos = space == std::string::npos ? para.size() : space + 1;
std::string candidate = line.empty() ? word : line + " " + word;
if (measure(candidate) <= maxWidth) {
line = candidate;
continue;
}
if (!line.empty()) {
out.push_back(line);
line.clear();
}
// The word alone may still be too wide: cut it character by character.
while (measure(word) > maxWidth) {
size_t cut = 0;
while (true) {
size_t nextCut = utf8::next(word, cut);
if (measure(word.substr(0, nextCut)) > maxWidth) break;
cut = nextCut;
}
if (cut == 0) cut = utf8::next(word, 0); // a single character wider than the line
out.push_back(word.substr(0, cut));
word = word.substr(cut);
}
line = word;
}
out.push_back(line);
}
} // namespace
std::vector<std::string> wrapText(const std::string& text, int maxWidth, const MeasureText& measure) {
std::vector<std::string> lines;
if (text.empty()) return lines;
size_t start = 0;
while (true) {
size_t nl = text.find('\n', start);
wrapParagraph(text.substr(start, nl == std::string::npos ? std::string::npos : nl - start), maxWidth,
measure, lines);
if (nl == std::string::npos) break;
start = nl + 1;
}
return lines;
}
} // namespace roro
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
namespace roro {
using MeasureText = std::function<int(const std::string&)>;
// Splits UTF-8 text into lines no wider than maxWidth: breaks at spaces when possible, inside
// words only when a word alone is too wide, never inside a character. '\n' forces a break.
std::vector<std::string> wrapText(const std::string& text, int maxWidth, const MeasureText& measure);
} // namespace roro
+40
View File
@@ -0,0 +1,40 @@
#include "toast_queue.h"
namespace roro {
void ToastQueue::push(const std::string& text, NotificationLevel level, uint32_t nowMs) {
current(nowMs); // retire an expired Toast first, so the new one gets its full time
if (queue_.size() == capacity_) {
// Keep the visible one; drop the oldest waiting.
queue_.erase(queue_.begin() + (showing_ && queue_.size() > 1 ? 1 : 0));
if (!showing_) changed_ = true;
}
queue_.push_back({text, level});
if (!showing_) {
showing_ = true;
shownAtMs_ = nowMs;
changed_ = true;
}
}
const Toast* ToastQueue::current(uint32_t nowMs) {
if (!showing_) return nullptr;
if (nowMs - shownAtMs_ >= durationMs_) dismiss(shownAtMs_ + durationMs_);
return showing_ ? &queue_.front() : nullptr;
}
void ToastQueue::dismiss(uint32_t nowMs) {
if (!showing_) return;
queue_.pop_front();
changed_ = true;
showing_ = !queue_.empty();
shownAtMs_ = nowMs;
}
bool ToastQueue::takeChanged() {
bool c = changed_;
changed_ = false;
return c;
}
} // namespace roro
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <cstdint>
#include <deque>
#include <string>
#include "event.h"
namespace roro {
struct Toast {
std::string text;
NotificationLevel level;
};
// Notifications waiting to be shown as Toasts, one at a time, each for the same duration.
class ToastQueue {
public:
explicit ToastQueue(uint32_t durationMs, size_t capacity = 4) : durationMs_(durationMs), capacity_(capacity) {}
void push(const std::string& text, NotificationLevel level, uint32_t nowMs);
// The Toast to show now, or nullptr. Advances the queue as Toasts expire.
const Toast* current(uint32_t nowMs);
void dismiss(uint32_t nowMs);
// True once after the visible Toast changed (appeared, switched or disappeared).
bool takeChanged();
private:
uint32_t durationMs_;
size_t capacity_;
std::deque<Toast> queue_;
bool showing_ = false;
uint32_t shownAtMs_ = 0;
bool changed_ = false;
};
} // namespace roro
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::utf8 {
inline bool isContinuation(unsigned char c) { return (c & 0xC0) == 0x80; }
inline std::string encode(uint32_t cp) {
std::string out;
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
return out;
}
// Byte index of the character start before / after `pos`.
inline size_t previous(const std::string& s, size_t pos) {
if (pos == 0) return 0;
do pos--;
while (pos > 0 && isContinuation(static_cast<unsigned char>(s[pos])));
return pos;
}
inline size_t next(const std::string& s, size_t pos) {
if (pos >= s.size()) return s.size();
do pos++;
while (pos < s.size() && isContinuation(static_cast<unsigned char>(s[pos])));
return pos;
}
// Longest prefix of at most `maxBytes` that doesn't cut a character.
inline size_t clampToBoundary(const std::string& s, size_t maxBytes) {
if (s.size() <= maxBytes) return s.size();
size_t pos = maxBytes;
while (pos > 0 && isContinuation(static_cast<unsigned char>(s[pos]))) pos--;
return pos;
}
} // namespace roro::utf8