Public Access
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:
@@ -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; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user