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
+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