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:
@@ -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