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
+1
View File
@@ -0,0 +1 @@
claude --resume a3bf56ca-1259-41f1-99f6-9acea510e771
+110
View File
@@ -0,0 +1,110 @@
#include "demo_app.h"
#include "ui/widgets.h"
namespace roro {
namespace {
const char* const kMenu[] = {"Text view", "Line editor", "Dialog", "Toast: info", "Toast: warning",
"Toast: burst of 5"};
const char* const kText =
"Bienvenue sur roro9stack ! Ce texte teste le retour \xC3\xA0 la ligne automatique et les "
"accents : \xC3\xA9t\xC3\xA9, \xC3\xA0 bient\xC3\xB4t, gar\xC3\xA7on, No\xC3\xABl, "
"ma\xC3\xAEtre, o\xC3\xB9.\n\n"
"Use Fn+; and Fn+. to scroll, ` to go back. Fn+` always returns to the Launcher.\n\n"
"Unbreakablewordthatislongerthanawholelineofthescreen to test hard breaks.";
} // namespace
void DemoApp::onEnter() {
page_ = Page::Menu;
dialog_.reset();
menu_.setCount(sizeof(kMenu) / sizeof(kMenu[0]));
}
void DemoApp::notify(const char* text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(level)));
}
bool DemoApp::onKey(const KeyEvent& e) {
requestRedraw();
if (dialog_) {
dialog_->onKey(e);
int r = dialog_->result();
if (r != DialogModel::kPending) {
notify(r == DialogModel::kCancelled ? "Dialog cancelled" : ("Chose " + dialog_->buttons()[r]).c_str(),
NotificationLevel::Info);
dialog_.reset();
}
return true;
}
switch (page_) {
case Page::Menu: return onMenuKey(e);
case Page::Text:
if (e.key == Key::Up && textScroll_ > 0) textScroll_--;
else if (e.key == Key::Down && textScroll_ < textLineCount_ - 1) textScroll_++;
else if (e.key == Key::Back) page_ = Page::Menu;
return true;
case Page::Editor:
switch (e.key) {
case Key::Char: editor_.insert(e.ch); break;
case Key::Delete: editor_.backspace(); break;
case Key::Left: editor_.left(); break;
case Key::Right: editor_.right(); break;
case Key::Up: editor_.home(); break;
case Key::Down: editor_.end(); break;
case Key::Select:
if (!editor_.text().empty()) notify(editor_.text().c_str(), NotificationLevel::Message);
break;
case Key::Back: page_ = Page::Menu; break;
default: break;
}
return true;
}
return false;
}
bool DemoApp::onMenuKey(const KeyEvent& e) {
switch (e.key) {
case Key::Up: menu_.up(); return true;
case Key::Down: menu_.down(); return true;
case Key::Select: break;
default: return false; // Back leaves the App
}
switch (menu_.selected()) {
case 0: page_ = Page::Text; textScroll_ = 0; break;
case 1: page_ = Page::Editor; break;
case 2: dialog_.reset(new DialogModel({"Cancel", "Maybe", "OK"})); break;
case 3: notify("Hello from the demo", NotificationLevel::Info); break;
case 4: notify("Something needs attention", NotificationLevel::Warning); break;
case 5:
for (int i = 1; i <= 5; i++) notify(("Burst " + std::to_string(i)).c_str(), NotificationLevel::Info);
break;
}
return true;
}
void DemoApp::draw(Canvas& c) {
const auto& area = theme::kContent;
switch (page_) {
case Page::Menu:
widgets::list(c, menu_, area, [](int i) { return std::string(kMenu[i]); });
break;
case Page::Text: {
auto lines = wrapText(kText, area.w - 10, widgets::bodyMeasure(c));
textLineCount_ = static_cast<int>(lines.size());
widgets::textLines(c, lines, textScroll_, area);
break;
}
case Page::Editor:
c.setTextColor(theme::kMuted);
c.drawString("Type (opt ' e = \xC3\xA9). Enter: toast", 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(theme::kMuted);
c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44);
break;
}
if (dialog_)
widgets::dialog(c, "Dialog", "Left/Right or Tab to choose, Enter to confirm, ` to cancel.", *dialog_);
}
} // namespace roro
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "dialog_model.h"
#include "event_bus.h"
#include "line_editor.h"
#include "list_model.h"
#include "ui/theme.h"
namespace roro {
// Exercises every widget: text view, line editor, dialog and Toasts.
class DemoApp : public App {
public:
explicit DemoApp(EventBus& bus) : bus_(bus) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override;
private:
enum class Page { Menu, Text, Editor };
bool onMenuKey(const KeyEvent& e);
void notify(const char* text, NotificationLevel level);
EventBus& bus_;
Page page_ = Page::Menu;
ListModel menu_{theme::kContent.h / theme::kLineHeight};
int textScroll_ = 0;
int textLineCount_ = 0;
LineEditor editor_{39};
std::unique_ptr<DialogModel> dialog_;
};
} // namespace roro
+84
View File
@@ -0,0 +1,84 @@
#include "diagnostics_app.h"
#include <Arduino.h>
#include "ui/fonts.h"
#include "ui/widgets.h"
#include "version.h"
namespace roro {
bool DiagnosticsApp::onKey(const KeyEvent& e) {
requestRedraw();
if (formatDialog_) {
formatDialog_->onKey(e);
if (formatDialog_->result() == 1) storage_.requestFormat();
if (formatDialog_->result() != DialogModel::kPending) formatDialog_.reset();
return true;
}
if (e.key != Key::Char) return false;
int brightness = settings_.getInt(Setting::Brightness);
switch (e.ch) {
case '+':
case '=': settings_.setInt(Setting::Brightness, brightness + 10); return true;
case '-': settings_.setInt(Setting::Brightness, brightness - 10); return true;
case 'd':
settings_.setInt(Setting::DimTimeoutS, 5);
settings_.setInt(Setting::OffTimeoutS, 10);
return true;
case 'r': // restore default timeouts
settings_.setInt(Setting::OffTimeoutS, 60);
settings_.setInt(Setting::DimTimeoutS, 30);
return true;
case 'f':
if (storage_.state().present) formatDialog_.reset(new DialogModel({"Cancel", "Erase"}));
return true;
}
return false;
}
void DiagnosticsApp::update(uint32_t nowMs) {
if (nowMs - lastRefreshMs_ >= 1000) {
lastRefreshMs_ = nowMs;
requestRedraw();
}
}
void DiagnosticsApp::draw(Canvas& c) {
const auto& area = theme::kContent;
const auto& b = battery_.estimator();
const auto s = storage_.state();
std::vector<std::string> lines;
char buf[64];
snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString());
lines.push_back(buf);
snprintf(buf, sizeof(buf), "Battery %d%% %d mV (raw %d)", b.percent(), b.millivolts(), battery_.lastRawMillivolts());
lines.push_back(buf);
if (storage_.formatting())
lines.push_back("SD: formatting...");
else if (s.present) {
snprintf(buf, sizeof(buf), "SD %d%% of %u MB logs:%s capt:%s", s.usedPercent,
static_cast<unsigned>(s.totalBytes >> 20), s.logsAllowed ? "y" : "n", s.capturesAllowed ? "y" : "n");
lines.push_back(buf);
} else
lines.push_back("SD: no card");
snprintf(buf, sizeof(buf), "Uptime %lus clock %s", millis() / 1000, clock_.displayTime().c_str());
lines.push_back(buf);
snprintf(buf, sizeof(buf), "Heap %u KB free, min %u KB", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024);
lines.push_back(buf);
snprintf(buf, sizeof(buf), "Bright %d%% dim %ds off %ds", static_cast<int>(settings_.getInt(Setting::Brightness)),
static_cast<int>(settings_.getInt(Setting::DimTimeoutS)),
static_cast<int>(settings_.getInt(Setting::OffTimeoutS)));
lines.push_back(buf);
widgets::textLines(c, lines, 0, area);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("+/- bright d: 5s/10s r: 30s/60s f: erase SD", 4, area.y + area.h - 10);
if (formatDialog_)
widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.",
*formatDialog_);
}
} // namespace roro
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <memory>
#include "app.h"
#include "dialog_model.h"
#include "services/battery_service.h"
#include "services/clock_service.h"
#include "services/power_service.h"
#include "services/storage_service.h"
#include "settings.h"
namespace roro {
// Live hardware values for bring-up; becomes the About screen in step 7.
class DiagnosticsApp : public App {
public:
DiagnosticsApp(Settings& settings, BatteryService& battery, StorageService& storage, ClockService& clock)
: settings_(settings), battery_(battery), storage_(storage), clock_(clock) {}
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
Settings& settings_;
BatteryService& battery_;
StorageService& storage_;
ClockService& clock_;
std::unique_ptr<DialogModel> formatDialog_;
uint32_t lastRefreshMs_ = 0;
};
} // namespace roro
+27
View File
@@ -0,0 +1,27 @@
#include "launcher_app.h"
#include "ui/widgets.h"
namespace roro {
void LauncherApp::onEnter() { list_.setCount(static_cast<int>(manager_->visibleApps().size())); }
bool LauncherApp::onKey(const KeyEvent& e) {
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Select:
if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id);
return true;
default: return false;
}
requestRedraw();
return true;
}
void LauncherApp::draw(Canvas& c) {
auto apps = manager_->visibleApps();
widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); });
}
} // namespace roro
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "app.h"
#include "app_manager.h"
#include "list_model.h"
#include "ui/theme.h"
namespace roro {
// Home screen: lists the visible Apps; Select opens one.
class LauncherApp : public App {
public:
void setManager(AppManager& manager) { manager_ = &manager; }
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override;
private:
AppManager* manager_ = nullptr;
ListModel list_{theme::kContent.h / theme::kLineHeight};
};
} // namespace roro
+78 -92
View File
@@ -1,7 +1,12 @@
// roro9stack — temporary diagnostics screen for M0 hardware checks (replaced by the Launcher in step 7).
// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
#include <M5Cardputer.h>
#include "app_manager.h"
#include "apps/demo_app.h"
#include "apps/diagnostics_app.h"
#include "apps/launcher_app.h"
#include "event_bus.h"
#include "key_mapper.h"
#include "platform/nvs_store.h"
#include "service_manager.h"
#include "services/battery_service.h"
@@ -9,6 +14,8 @@
#include "services/power_service.h"
#include "services/storage_service.h"
#include "settings.h"
#include "ui/notifier.h"
#include "ui/screen.h"
#include "version.h"
using namespace roro;
@@ -17,52 +24,45 @@ static EventBus bus;
static NvsStore nvs;
static Settings settings(nvs, bus);
static ServiceManager services;
static KeyMapper keyMapper;
static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery;
static StorageService* storage;
static PowerService* power;
static ClockService* clockService;
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
static String lastKey = "-";
static String typed; // last characters typed, for the FORMAT confirmation
static String lastEvent = "-";
static bool dirty = true;
static void draw() {
auto& d = M5Cardputer.Display;
d.fillScreen(TFT_BLACK);
d.setTextSize(1);
d.setTextColor(TFT_GREEN);
d.drawString(String(kProductName) + " " + versionString(), 4, 2);
d.setTextColor(TFT_WHITE);
const auto& b = battery->estimator();
const auto s = storage->state();
int y = 16;
auto line = [&](const String& text) {
d.drawString(text, 4, y);
y += 11;
};
line("Battery: " + String(b.percent()) + "% " + String(b.millivolts()) + " mV (raw " +
String(battery->lastRawMillivolts()) + ")");
if (s.present)
line("SD: " + String(s.usedPercent) + "% of " + String((uint32_t)(s.totalBytes >> 20)) +
" MB logs:" + (s.logsAllowed ? "y" : "n") + " capt:" + (s.capturesAllowed ? "y" : "n"));
else
line("SD: no card");
line("Clock: " + String(clockService->displayTime().c_str()) + " uptime " + String(millis() / 1000) + " s");
line("Heap: " + String(ESP.getFreeHeap() / 1024) + " KB free, min " + String(ESP.getMinFreeHeap() / 1024) + " KB");
line("Bright: " + String(settings.getInt(Setting::Brightness)) + "% dim " +
String(settings.getInt(Setting::DimTimeoutS)) + "s off " + String(settings.getInt(Setting::OffTimeoutS)) + "s");
line("Key: " + lastKey);
line("Event: " + lastEvent);
d.setTextColor(TFT_DARKGREY);
line(storage->formatting() ? "Formatting SD card..." : "+/- bright, d: dim test, FORMAT+enter: wipe SD");
static RawKeys readKeys() {
auto state = M5Cardputer.Keyboard.keysState();
RawKeys raw;
raw.chars.assign(state.word.begin(), state.word.end());
raw.fn = state.fn;
raw.opt = state.opt;
raw.ctrl = state.ctrl;
raw.alt = state.alt;
raw.shift = state.shift;
raw.enter = state.enter;
raw.del = state.del;
raw.tab = state.tab;
return raw;
}
static void logEvent(const String& text) {
lastEvent = text;
dirty = true;
Serial.println("event: " + text);
static StatusInfo currentStatus() {
StatusInfo s;
s.title = kProductName;
for (auto* info : apps->visibleApps())
if (info->app == &apps->foreground()) s.title = info->title;
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
s.clock = clockService->displayTime();
auto storageState = storage->state();
s.sdPresent = storageState.present;
s.sdLevel = storageState.level;
s.compose = keyMapper.pendingCompose();
return s;
}
void setup() {
@@ -78,69 +78,55 @@ void setup() {
storage = new StorageService(bus);
power = new PowerService(settings);
clockService = new ClockService(settings, bus);
notifier = new Notifier(bus, settings);
services.add(*power);
services.add(*clockService);
services.add(*battery);
services.add(*storage);
bus.subscribe(EventType::BatteryChanged,
[](const Event& e) { logEvent("battery " + String(e.a) + "% " + String(e.b) + "mV"); });
bus.subscribe(EventType::StorageThreshold,
[](const Event& e) { logEvent("storage level " + String(e.b) + " (" + String(e.a) + "%)"); });
bus.subscribe(EventType::Notification, [](const Event& e) { logEvent("notify: " + String(e.text)); });
bus.subscribe(EventType::SettingChanged, [](const Event& e) { logEvent("setting " + String(e.text)); });
apps = new AppManager(launcher);
launcher.setManager(*apps);
apps->registerApp({"demo", "Widget demo", false, new DemoApp(bus)});
apps->registerApp({"diagnostics", "Diagnostics", false,
new DiagnosticsApp(settings, *battery, *storage, *clockService)});
bus.subscribe(EventType::Notification, [](const Event& e) { Serial.printf("notification: %s\n", e.text); });
if (!screen.begin()) Serial.println("frame buffer allocation failed");
services.startAll(millis());
}
static void handleKeys() {
if (!M5Cardputer.Keyboard.isChange() || !M5Cardputer.Keyboard.isPressed()) return;
if (power->onKey(millis())) return; // only woke the screen
auto state = M5Cardputer.Keyboard.keysState();
String word;
for (auto c : state.word) word += c;
lastKey = word + (state.fn ? " +fn" : "") + (state.opt ? " +opt" : "") + (state.ctrl ? " +ctrl" : "") +
(state.enter ? " enter" : "") + (state.del ? " del" : "");
dirty = true;
if (state.enter) {
if (typed.endsWith("FORMAT") && storage->requestFormat()) logEvent("format requested");
typed = "";
} else {
typed += word;
if (typed.length() > 16) typed.remove(0, typed.length() - 16);
}
int brightness = settings.getInt(Setting::Brightness);
if (word == "=" || word == "+") settings.setInt(Setting::Brightness, brightness + 10);
if (word == "-") settings.setInt(Setting::Brightness, brightness - 10);
if (word == "d") {
// Short timeouts to test dimming quickly; persisted, so they survive a reboot.
settings.setInt(Setting::DimTimeoutS, 5);
settings.setInt(Setting::OffTimeoutS, 10);
}
apps->begin();
Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap());
}
void loop() {
static uint32_t lastDraw = 0, lastLog = 0;
M5Cardputer.update();
handleKeys();
services.tick(millis());
bus.dispatch();
static StatusInfo lastStatus;
static uint32_t lastLog = 0;
uint32_t now = millis();
if (millis() - lastLog > 5000) {
lastLog = millis();
const auto s = storage->state();
Serial.printf("status: batt %d%% %dmV raw %d | sd %s %d%% total %lluMB | heap %u min %u | screen %d\n",
battery->estimator().percent(), battery->estimator().millivolts(),
battery->lastRawMillivolts(), s.present ? "yes" : "no", s.usedPercent,
(unsigned long long)(s.totalBytes >> 20), ESP.getFreeHeap(), ESP.getMinFreeHeap(),
(int)power->screen());
M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) {
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
if (!swallow)
for (auto& e : events) apps->handleKey(e);
}
if (power->screen() != ScreenState::Off && (dirty || millis() - lastDraw > 1000)) {
lastDraw = millis();
dirty = false;
draw();
services.tick(now);
bus.dispatch();
notifier->update(now);
apps->update(now);
if (now - lastLog > 10000) {
lastLog = now;
Serial.printf("status: heap %u min %u\n", ESP.getFreeHeap(), ESP.getMinFreeHeap());
}
if (power->screen() == ScreenState::Off) return;
StatusInfo status = currentStatus();
const Toast* toast = notifier->toasts().current(now);
bool toastChanged = notifier->toasts().takeChanged();
if (apps->takeRedraw() || toastChanged || status != lastStatus) {
lastStatus = status;
screen.render(*apps, status, toast);
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <M5GFX.h>
namespace roro {
// The off-screen frame Apps draw into (full M5GFX drawing API). It's pushed to the display in one
// transfer, so the screen never shows a half-drawn frame.
class Canvas : public M5Canvas {
public:
using M5Canvas::M5Canvas;
};
} // namespace roro
+224
View File
@@ -0,0 +1,224 @@
// Vendored from u8g2 (https://github.com/olikraus/u8g2, csrc/u8g2_fonts.c).
// X11 misc-fixed fonts, public domain. Latin-1 coverage (code points 32-255).
#include "fonts.h"
namespace roro::fontdata {
/*
Fontname: -Misc-Fixed-Medium-R-SemiCondensed--13-120-75-75-C-60-ISO10646-1
Copyright: Public domain font. Share and enjoy.
Glyphs: 191/4121
BBX Build Mode: 0
*/
const uint8_t _6x13_tf[2161] =
"\277\0\3\2\3\4\3\5\4\5\15\0\376\11\376\12\377\1_\2\304\10T \5\0n\7!\7IC"
"\307!\11\42\7\233ZGb\11#\16=FO)I\6\245\62(\245$\1$\17MBW\66(Q"
"\222mI\224\14Z\4%\15MBO\244t\314\212I\213\224\0&\16EBO\226DIV\211\242)"
"R\2'\6\31[\307\0(\13\333>W\22%Q\267(\13)\14\333>G\26eQ\227(\211\0*"
"\12-RW\245\262\64E\0+\12-JW\30\15R\30\1,\7\233>\217R\2-\6\15R\307 "
".\10\233>O\262$\0/\13MBg-\314\212Y\30\2\60\13MBW\226\324\274%\265\10\61\13"
"MBW&%aO\203\0\62\13MB\317\222ia\326q\20\63\14MB\307 f\265\65\324\222\5"
"\64\17MB_\230II\224\224\222A\13\23\0\65\14MB\307\261b\12C-Y\0\66\15MB\317"
"\222\211\341\220d\266d\1\67\14MB\307 fa\26fa\15\70\15MB\317\222\331\222%\263%\13"
"\0\71\15MB\317\222\331\222!\14\265d\1:\13\303>O\262\344H\262$\0;\12\303>O\262\344"
"\210R\2<\10MBg\326\265\3=\11%J\307\240c\203\0>\11MBG\332\255#\0\77\14M"
"B\317\222iaV\314\241\10@\15MB\317\222\231\226DI,\351\20A\14MBW\226\324l\303\220"
"\331\2B\20MB\307\20U\242$J\246J\224D\203\2C\13MB\317\222\211\275%\13\0D\22M"
"B\307\20U\242$J\242$J\242$\32\24\0E\13MB\307\261\70$aq\20F\13MB\307\261"
"\70$a#\0G\14MB\317\222\211M\233\226,\0H\13MBG\346\66\14\231[\0I\10\313B"
"\307\22\365eJ\13MB\327\26\366\22E\22\0K\15MBGfJJZ\22U\62-L\11MB"
"G\330\217\203\0M\15MBG\246-K\242$\232[\0N\15MBG\66MJ\242$\322M\13O"
"\12MB\317\222\371-Y\0P\14MB\307\220d\266A\11\33\1Q\12U>\317\222\371\245\262\6R"
"\15MB\307\220d\266A)U\62-S\14MB\317\222\211\351\32j\311\2T\11MB\307 \205\375"
"\4U\11MBG\346o\311\2V\15MBG\346\226\224\222(\311\302\10W\15MBG\346%Q\22"
"%Q\272\0X\16MBG\246%\245$\253DIM\13Y\14MBG\246%\245$\13;\1Z\14"
"MB\307 faV\314\302A[\11\333>\307\20\365\247\1\134\13MBG\230\206i\61\15\3]\11"
"\333>\307\324\237\206\0^\10\35ZW\226\324\2_\6\15>\307 `\6\22cG\24a\13\65B\317"
"\232\14\232\244(\1b\14MBGX\34\222\314mP\0c\12\65B\317\222\211\265d\1d\13MB"
"g\313\240\271%C\0e\14\65B\317\222\15C\230%\13\0f\14MB\227T\11\263!\12\333\0g"
"\14E:\317\222\331\222!\324\222\5h\12MBG\330b\322\334\2i\10\303BO(u\31j\13T"
":_\254\265IR\242\0k\15MBG\330\224\224\264$\252d\1l\10\313B\207\324/\3m\15\65"
"B\207\322\242$J\242$Z\0n\11\65BGb\322\334\2o\12\65B\317\222\271%\13\0p\14E"
":\307\220d\266A\11\213\0q\12E:\317\240\331\222!lr\11\65BGb\22\33\1s\14\65B"
"\317\222%j\222%\13\0t\14EBO\230\15Q\330\24)\0u\11\65BG\346IQ\2v\13\65"
"BGfKJI\26\1w\13\65BGfI\224D\351\2x\13\65BG\226\324\302,\251\5y\14"
"E:G\346\244(\241\226,\0z\11\65B\307\240u\34\4{\13]>\237\24\66\251a\253\0|\6"
"IC\307\3}\13]>\207\32\266Ja\223\6~\11\35ZO\244$R\2\240\5\0n\7\241\10I"
"CG\62\14\1\242\16EFW\266T\224(\211\222\312\26\1\243\15MB\227T\11\263-,)\12\0"
"\244\14\65FG\226LI\224DK\26\245\17MBG\246%\245$\31\244h\220\302\10\246\10IC\307"
"\220\14\1\247\16\324B\217\22\211J$%\242\224(\0\250\7\223bG\242\4\251\17MF\317\222)\311"
"!Z\226DK\26\0\252\14=J\317\232\14Z\62\244\203\0\253\13\65FW\322S\222%Y\22\254\7"
"\35J\307 \26\255\6\213R\307\0\256\16MF\317\222\15\311!\31\224[\262\0\257\6\15b\307 \260"
"\11\244V\217\22I\211\2\261\13=FW\30\15R\230\17\2\262\10+VOS\222\14\263\7+VO"
"\267\5\264\6\22c\217\2\265\12E:G\346iP\304\20\266\22MB\317\60$C\62$C\222(Q"
"\22%Q\22\267\6\12S\207\0\270\6\22;\217\2\271\10+VO\42U\6\272\12=J\317\222\331\222"
"y\20\273\14\65FG\222%Y\22%=\1\274\14UBO&\326\346LJ\242\61\275\14UBO&"
"\326\326,\11\263l\276\17UBO\226di\224dQ&%\321\30\277\14MBW\16\205Y\61\323\222"
"\5\300\16UBO\232CYR\323\206!\323\2\301\15UB_\35\312\222\232\66\14\231\26\302\15UB"
"\227T\317\222\232\66\14\231\26\303\16UBW\322\216dIM\33\206L\13\304\17UBO)\311\221,"
"\251i\303\220i\1\305\17UBW\226da\226\324\264a\310\264\0\306\21MBO\262DI\224D\211"
"\62%Q\22%\3\307\13]:\317\222\211\275%[\15\310\15UBO\232\17c\70$a\70\10\311\14"
"UB_}\30\303!\11\303A\312\15UB\227T\35\306pH\302p\20\313\16UBO)\211\207\61"
"\34\222\60\34\4\314\12\323BG\26.Q\227\1\315\12\323BW\22.Q\227\1\316\13\324B\217\22\205"
"S\326i\1\317\12\323BG\242dK\324e\320\21MB\307\20U\242$\32\222R\22%\321\240\0\321"
"\15UBW\322\234i\223\222H\233\26\322\14UBO\232#K\346-Y\0\323\13UB_\35Y\62"
"o\311\2\324\13UB\227T^\62o\311\2\325\13UBW\322\276d\336\222\5\326\14UBO)\311"
"\227\314[\262\0\327\12-FG\226\324*\265\0\330\20]>g\262D\227DI\224d\232\222%\4\331"
"\13UBO\232g\276%\13\0\332\12UB_=\363-Y\0\333\13UB\227T\315|K\26\0\334"
"\13UBO)\211\63\337\222\5\335\13UB_=\323\222Z\330\4\336\15MBG\70$\231mP\302"
"\42\0\337\16MB\217T\211\222R\22U\62\213\2\340\15MBO\232#k\62h\222\242\4\341\14M"
"B_\35Y\223A\223\24%\342\14MB\227T^\223A\223\24%\343\15MBW\322\276&\203&)"
"J\0\344\15MBO)\311\327d\320$E\11\345\15UB\227T\322\327d\320$E\11\346\13\65B"
"\317\226$K)\351\2\347\13E:\317\222\211\265d\253\1\350\16MBO\232#K\66\14a\226,\0"
"\351\15MB_\35Y\262a\10\263d\1\352\15MB\227T^\262a\10\263d\1\353\16MBO)"
"\311\227l\30\302,Y\0\354\11\313BG\26J]\6\355\11\313BW\22J]\6\356\12\314B\217\22"
"\205Z\247\5\357\12\313BG\242dR\227\1\360\15UBO-S\243%sK\26\0\361\13MBW"
"\322\234\230\64\267\0\362\14MBO\232#K\346\226,\0\363\13MB_\35Y\62\267d\1\364\13M"
"B\227T^\62\267d\1\365\13MBW\322\276dn\311\2\366\14MBO)\311\227\314-Y\0\367"
"\12=FW\230\17z\30\1\370\16E>g\262DK\242$S\262\204\0\371\12MBO\232g\236\24"
"%\372\12MB_=\363\244(\1\373\13MB\227T\315<)J\0\374\13MBO)\211\63O\212"
"\22\375\15]:_=sR\224PK\26\0\376\15U:GX\61i\332\244(a\10\377\16]:O"
")\211\63'E\11\265d\1\0\0\0\4\377\377\0";
/*
Fontname: -Misc-Fixed-Bold-R-SemiCondensed--13-120-75-75-C-60-ISO10646-1
Copyright: Public domain font. Share and enjoy.
Glyphs: 191/1282
BBX Build Mode: 0
*/
const uint8_t _6x13B_tf[2247] =
"\277\0\3\3\3\4\3\5\4\6\15\0\376\11\376\12\377\1e\2\300\10\252 \5\0n\7!\7JC"
"\307\223\0\42\10\35Z\207\204E\0#\14>F\17\211b\241XH\24\0$\13>F\227\214Bc\241"
"\211\0%\17NB\217\344\20!\312\24)\207\210\4\0&\14>B\217\214D\223\35\42\242\2'\6\32"
"[\207\1(\14\334>\227D$\21\351&\222\11)\15\334>\207L$\23\351\42\222\210\0*\13.J"
"\207HB\261PD\2+\12.J\227Pd\22\212\0,\10\234>\317D\11\0-\6\16R\207\1."
"\10\234>\217\204\42\1/\14NB\247\232P\246(\23\12\1\60\14NB\227\214\42\342IB\23\1\61"
"\12NB\227lD\324\223\1\62\13NB\17ED\22\312t\64\63\15NB\207Q\246F\25\222$\24"
"\0\64\16NB\237P\66\42E\264\330\204\22\0\65\16NB\307!(\254L\204B\222\204\2\66\16N"
"B\327H&\24V&$\222\204\2\67\15NB\207Q&\224\11eB\65\0\70\16NB\17E\304$"
"\241\210\230$\24\0\71\15NB\17E\304\62)\252\211F\0:\14\304B\217\204\42\207I(\22\0;"
"\13\304>\217\204\42\207M\224\0<\10NB\247LW\35=\7&J\207\35j>\11NB\207T\67"
"\35\1\77\16NB\17ED\22\312\24\345\60\21\0@\14NB\17E\304r\70X\244\5A\14NB"
"\227\214\42b:\214\230\4B\15NBGI\242\27\222D\227\13\0C\13NB\17ED\324\223\204\2"
"D\13NBGI\242\377\313\5\0E\13NB\307!\250X\21*\32F\13NB\307!\250X\21j"
"\4G\15NB\17ED\324R\42I(\0H\13NB\207\210\323a\304I\0I\11\314B\7E\244"
"/\4J\13NB\27Q\217$\11\5\0K\16NB\207\210EB\232\221$J$\1L\10NB\207"
"P\77\32M\13NB\207\210tx\10q\22N\15NB\207\250R\71\34$M$\1O\13NB\17"
"E\304O\22\12\0P\13NBGE\304t\21j\4Q\13V>\17E\304\247C\204*R\15NB"
"GE\304t!I\224H\2S\16NB\17ED\224R\205$\11\5\0T\11NB\207I\250\237\0"
"U\12NB\207\210\77I(\0V\14NB\207\210\223\204\304&\24\1W\12NB\207\210\323\341\223\0"
"X\16NB\207\210$!\321d$\212\210$Y\14NB\207\210$!\321\204:\1Z\14NB\207Q"
"&\224)\312\204\6[\10\334>\207I\177\42\134\14NB\207P*\224*J\205\2]\10\334>\7I"
"\177\62^\10\36Z\227\214\42\22_\6\16>\207\1`\7\223b\207H\0a\12\66B\17Ur\242L"
"\24b\16NB\207P\213dB\42U(\22\0c\13\66B\17EDT\222P\0d\13NB\247\26"
"\11\245\304\62Qe\13\66B\17Et\30J)\0f\14NB\327H\242(+\11\265\1g\15F:"
"\317E\323HJ\21I(\0h\13NB\207P\213dB\342$i\12\314B\217H:\322\205\0j\14"
"]:\237\372L\33\205\42\231\0k\15NB\207P\213\204\64#I\224\4l\10\314B\307H\277\20m"
"\11\66BG\345\360P\22n\12\66B\207D\62!q\22o\12\66B\17E\304IB\1p\15F:"
"\207D\62!U(\212\212\0q\13F:\217\204R\242L\24\65r\12\66B\207D\62!j\4s\15"
"\66B\17E$\231JD\22\12\0t\14FB\217PV\22j\21M\0u\11\66B\207\210\227\211\2"
"v\13\66B\207\210IB\242\211\0w\14\66B\207\210t\70XB\21\0x\14\66B\207HB\23\312"
"(\42\1y\14F:\207\210\313D\221$\241\0z\10\66B\207MG\3{\15]>\327D\246(\31"
"\212d\212\3|\7JC\307\3\1}\16]>\307P\246$\234\10e*#\0~\10\35Z\217\344\24"
"\1\240\5\0n\7\241\7RC\7\351p\242\20FF_\214\42\211LB\222\220$\205\30\2\243\15N"
"B\327H\242(+\11\225,\0\244\14\66F\207\350\20\11e\71\204\4\245\16NB\207\210$!QL"
"\42\223P\4\246\10RC\307!x\10\247\17VB\17ED\245\210H\22*IB\1\250\7\26^\207"
"\210$\251\20NB\17E\64\221PJ\224\211d$\241\0\252\12>JG\365 ;\204\15\253\21>F"
"\227$\42\211HB\221\230$&\211I\2\254\10&J\307\241\232\0\255\6\214R\7\1\256\20NB\17"
"E\64\251D&\25\225\10IB\1\257\7\25^\307a\0\260\12&V\17ED\222P\0\261\15NB"
"\227Pt(\11\345\220C\1\262\12\64R\307$\42\222(\21\263\12\64R\307$\242\66\251\0\264\7\223"
"b\17\5\0\265\12F:\207\210\247\203\64\12\266\20NB\317\341\20i\211LB\222\220$$\11\267\6"
"\22S\7\1\270\7\223:\17\5\0\271\11\64R\217d\244\205\0\272\11=N\317\304f\31\27\273\22>"
"FGD\26\221Ed\221PD\22\221DD\0\274\15VB\217l\250\351\66\222\204\210\2\275\15VB"
"\217l\250H\231\10e\212\4\276\21VB\307,\42\223\212\42\22\212l$\11\21\5\277\17VB\227P"
"\16\23\312\24E$\11\5\0\300\16VB\217T\16\223QD\244\303\210$\301\16VB\237:LF\21"
"\221\16#\222\0\302\17VB\327H\42\207\310(\42\322aD\22\303\17VB\227$E\16\222QD\244"
"\303\210$\304\17VB\207\210$\207\310(\42\322aD\22\305\17VB\227,\24\242\311(\42\322aD"
"\22\306\21NB\217E\24\21ED\221\11\211\24\21E\10\307\14^:\17ED\324\223\204\246\6\310\16"
"VB\217T\16\71\4\205\25\241\320\0\311\15VB\237:\344\20\24V\204B\3\312\16VB\327H\42"
">\4\205\25\241\320\0\313\16VB\207\210$>\4\205\25\241\320\0\314\12\324B\207LJ\21\351B\315"
"\12\324B\227DJ\21\351B\316\13UB\317D\231$\323\211\0\317\14VB\207\210$\247\11u\243\0"
"\320\14NBGI\242KE_.\0\321\16VB\227$E.\252T\16\223&\1\322\15VB\217T"
"\16\242\210x\222P\0\323\14VB\237:\210\42\342IB\1\324\15VB\327H\42\247\210x\222P\0"
"\325\16VB\227$E\16\241\210x\222P\0\326\15VB\207\210$\247\210x\222P\0\327\13.F\207"
"HB\223QD\2\330\16N>o\204\42i\252T$\224(\0\331\14VB\217T\16\21\361IB\1"
"\332\14VB\237:D\304'\11\5\0\333\14VB\327H\42\26\361IB\1\334\13VB\217\316\42>"
"I(\0\335\16VB\237:D$\11\205hBM\0\336\14NB\207\260\42b\272\10\25\1\337\15N"
">\17ED\272\210\230.Q\0\340\15NB\217T\16\242JN\224\211\2\341\14NB\237:\210*\71"
"Q&\12\342\15NB\327H\42\247JN\224\211\2\343\16NB\227$E\16\241JN\224\211\2\344\13"
"NB\217\356T\311\211\62Q\345\17VB\327,\22\233C\250\222\23e\242\0\346\17\66B\17M\22\241"
"DD\21ID\22\1\347\14F:\17EDT\222\320\324\0\350\16NB\217T\16\242\210\16C)\5"
"\0\351\15NB\237:\210\42:\14\245\24\0\352\16NB\327H\42\247\210\16C)\5\0\353\14NB"
"\217\356\24\321a(\245\0\354\12\314B\207L:\322\205\0\355\12\314B\227D:\322\205\0\356\12MB"
"\317Dy\246\23\1\357\12MB\207\204\42\236\351D\360\20VB\207D\64\42\205D\24\21'\11\5\0"
"\361\15NB\227$E.\221LH\234\4\362\15NB\217T\16\242\210\70I(\0\363\14NB\237:"
"\210\42\342$\241\0\364\15NB\327H\42\247\210\70I(\0\365\16NB\227$E\16\241\210\70I("
"\0\366\15NB\207\210$\247\210\70I(\0\367\14>F\227P\16\261C\204\42\0\370\16N>o\204"
"\42\242\224*$\11%\12\371\13NB\217T\16\21\361\62Q\372\13NB\237:D\304\313D\1\373\13"
"NB\327H\42\26\361\62Q\374\13NB\207\210$\26\361\62Q\375\16^:\237:D\304e\242H\222"
"P\0\376\16V:\207PE\62!U(\212\212\0\377\17^:\207\210$\26q\231(\222$\24\0\0"
"\0\0\4\377\377\0";
/*
Fontname: -Misc-Fixed-Medium-R-Normal--8-80-75-75-C-50-ISO10646-1
Copyright: Public domain font. Share and enjoy.
Glyphs: 191/1426
BBX Build Mode: 0
*/
const uint8_t _5x8_tf[1715] =
"\277\0\2\2\3\4\3\4\4\5\10\0\377\6\377\6\0\1\32\2\61\6\226 \5\0~\3!\7\61c"
"\63R\0\42\7\233n\223\254\0#\15=bW\246\64T\65T\231\22\0$\12=b\233W\275S\332"
"\21%\10\253f\23Sg\0&\12<b\27S\263j\246\0'\5\31o\63(\7\262b\247\232\1)"
"\10\262b\23S\245\0*\12,b\23\223\32I\305\0+\12-b\233Q\34\62\243\10,\7\233^\247"
"J\0-\6\14j\63\2.\7\233^\227V\2/\10\64b_\266\63\0\60\10\263bW\271*\0\61"
"\7\263b\227dk\62\12\64b\247bN*\217\0\63\12\64b\63b\324H&\5\64\12\64b\33U"
"\65bN\0\65\12\64b\63\364F\62)\0\66\12\64b\247\362\212\62)\0\67\12\64b\63r\314\61"
"G\0\70\12\64b\247bRQ&\5\71\12\64b\247\242L;)\0:\7\252b\63\342\10;\10\263"
"^g#U\2<\7\263b\233\312\134=\10\34f\63\62\32\1>\10\263b\223\313T\2\77\11\263b"
"\327L\31&\0@\14E^+\243\134I%YC\5A\11\64b\247\242\34S\6B\12\64b\263\342"
"HQ\216\4C\11\64b\247\242.\223\2D\11\64b\263\242s$\0E\11\64b\63\364\312y\4F"
"\11\64b\63\364\312\65\0G\12\64b\247\242N\63)\0H\11\64b\23\345\230f\0I\7\263b\263"
"bkJ\11\64b\67sUF\0K\11\64b\23U\222\251\63L\10\64b\223\273G\0M\11\64b"
"\23\307\21\315\0N\11\64b\23\327Xg\0O\11\64b\247\242\63)\0P\12\64b\263\242\34)g"
"\0Q\11<^\247\242\134n\24R\12\64b\263\242\34)\312\0S\12\64b\247b\312\250L\12T\10"
"\263b\263b\27\0U\10\64b\23=\223\2V\11\64b\23\235I*\0W\11\64b\23\315q\304\0"
"X\12\64b\23e\222*\312\0Y\13\65b\223u\252\63\312(\2Z\11\64b\63rl\217\0[\7"
"\263b\63bs\134\12\64b\223\63\312(\243\34]\7\263b\63\233#^\6\223r\327\0_\6\14^"
"\63\2`\6\222r\23\3a\10$b\67\242L\3b\12\64b\223\363\212r$\0c\7\243b\67\263"
"\0d\11\64b_\215(\323\0e\10$b\247\322\310\12f\11\64b[\225\63G\0g\11,^\247"
"b\332I\1h\11\64b\223\363\212f\0i\10\263b\227\221\254\6j\11\273^\233a\251*\0k\11"
"\64b\223\313\221\242\14l\7\263b#\273\6m\11%b\243Z*\251\2n\7$b\263\242\31o\10"
"$b\247\242L\12p\11,^\263\342H\71\3q\10,^\67b\332\5r\10$b\223\222\235\1s"
"\7\243b\67\362\2t\12\64b\227\343\314)&\0u\7$b\23\315\64v\7\243b\223\254\12w\11"
"%b\223UR]\0x\10$b\23\223T\61y\12,^\23e\32\61)\0z\10$b\63b\71"
"\2{\13<b\253\62J\32\305\214\4|\5\61cs}\14<b\243Q\314He\224$\0~\7\24"
"r\227T\2\240\5\0~\3\241\7\61c\223F\0\242\11\64^\33Gj\316\4\243\12\64b[\215\230"
"\223J\0\244\12-b\223\323Lq\345\0\245\13\65b\223S\65d\34\62\2\246\6\71c\263\6\247\12"
"<b\67\362\212i\217\4\250\6\213v\223\2\251\12\65b\267\252\71U\265\0\252\7\253j\267\222\36\253"
"\10\34f\227TL\1\254\6\233b\63\13\255\5\213j\63\256\11\65b\367\241\226Z\0\257\5\213v\63"
"\260\6\233n\327\5\261\10\253b\227VF\3\262\7\253j\327Li\263\7\253j\243/\0\264\6\222r"
"\247\0\265\11,^\23\315\221\62\0\266\14\65b\67F\32)\251\230b\12\267\5\11k\23\270\6\222^"
"\247\0\271\7\253j\227d\65\272\7\253j\327\215\6\273\10\34f\223bR\11\274\12<b\223[Q\215"
"\230\0\275\12<b\223\253\244r\214\3\276\14<b\223Q\314HU#&\0\277\11\263b\227a\212\251"
"\2\300\12<b\227QTqL\31\301\11<b[\253\70\246\14\302\12<b\247bRqL\31\303\12"
"<b\227TTqL\31\304\12<b\23\63TqL\31\305\12<b\247bRqL\31\306\11\64b"
"\67Rk\250J\307\12<^\247\242.\223\214\0\310\12<b\227Q\32z\345\21\311\11<b[\16\275"
"\362\10\312\12<b\247\342\330+\217\0\313\12<b\23\63\32z\345\21\314\11\273b\223\323\212\325\0\315"
"\11\273b\233\322\212\325\0\316\11\273bW\215\24\253\1\317\11\273b\223\362\212\325\0\320\13\65b\67\343"
"He\212i\1\321\12<b\227T\271\324\224\1\322\12<b\227QT\321L\12\323\11<b[\253h"
"&\5\324\12<b\247bR\321L\12\325\12<b\227TT\321L\12\326\12<b\23\63T\321L\12"
"\327\6\233b\223:\330\11\64b\67\322\221\216\4\331\11<b\227Q\351L\12\332\10<b\333t&\5"
"\333\11<b\247\242gR\0\334\12<b\23\63\212\316\244\0\335\13=b_\346Tg\224Q\4\336\12"
"\64b\223W\224#e\0\337\11\64b\247\242\352T\11\340\12<b\227Q\306#\312\64\341\11<b["
"S#\312\64\342\12<b[e\70\242L\3\343\12<b\227T\306#\312\64\344\11\64bW\303\21e"
"\32\345\12<b\247b\222#\312\64\346\11%b\63\242\62G\0\347\10\253^\67\263J\0\350\13<b"
"\227Q\306*\215\254\0\351\12<b[S*\215\254\0\352\13<b\247b\206*\215\254\0\353\12\64b"
"WC\225FV\0\354\11\273b\223\63\222\325\0\355\11\273b\233\62\222\325\0\356\10\273b\327\226\325\0"
"\357\10\263b\223\262\254\6\360\14<b\223b\225Q\32\61)\0\361\12<b\227T\206+\232\1\362\13"
"<b\227Q\306*\312\244\0\363\12<b[S*\312\244\0\364\13<b\247b\206*\312\244\0\365\13"
"<b\227T\306*\312\244\0\366\12\64b\23\63TQ&\5\367\10\253b\227\321F\11\370\11$b\67"
"\322H#\1\371\12<b\227Q\206\321L\3\372\11<b[\343h\246\1\373\12<b\247bF\321L"
"\3\374\11\64b\23\63\212f\32\375\13D^[\343(\323\210I\1\376\12<^\223\363\212#\345\14\377"
"\14<^\23\63\212\62\215\230\24\0\0\0\0\4\377\377\0";
} // namespace roro::fontdata
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <M5GFX.h>
namespace roro {
namespace fontdata {
extern const uint8_t _6x13_tf[];
extern const uint8_t _6x13B_tf[];
extern const uint8_t _5x8_tf[];
} // namespace fontdata
// Latin-1 bitmap fonts (accented letters included).
namespace fonts {
inline const lgfx::U8g2font body(fontdata::_6x13_tf); // 6x13
inline const lgfx::U8g2font bold(fontdata::_6x13B_tf); // 6x13 bold
inline const lgfx::U8g2font small(fontdata::_5x8_tf); // 5x8, Status Bar
} // namespace fonts
} // namespace roro
+38
View File
@@ -0,0 +1,38 @@
#include "notifier.h"
#include <M5Cardputer.h>
namespace roro {
namespace {
constexpr int kLedPin = 21; // WS2812, powered with the backlight rail (G38)
}
Notifier::Notifier(EventBus& bus, const Settings& settings) : settings_(settings) {
bus.subscribe(EventType::Notification, [this](const Event& e) { present(e); });
}
void Notifier::present(const Event& e) {
auto level = static_cast<NotificationLevel>(e.a);
uint32_t now = millis();
toasts_.push(e.text, level, now);
if (!settings_.getBool(Setting::Sound)) return;
M5Cardputer.Speaker.tone(level == NotificationLevel::Warning ? 880 : 1760, 60);
switch (level) {
case NotificationLevel::Warning: rgbLedWrite(kLedPin, 40, 16, 0); break;
case NotificationLevel::Message: rgbLedWrite(kLedPin, 0, 40, 0); break;
default: rgbLedWrite(kLedPin, 0, 16, 40); break;
}
ledOn_ = true;
ledOffAtMs_ = now + 150;
}
void Notifier::update(uint32_t nowMs) {
if (ledOn_ && static_cast<int32_t>(nowMs - ledOffAtMs_) >= 0) {
rgbLedWrite(kLedPin, 0, 0, 0);
ledOn_ = false;
}
}
} // namespace roro
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "event_bus.h"
#include "settings.h"
#include "toast_queue.h"
namespace roro {
// Presents Notifications: a Toast, plus a short beep and LED flash (when Sound is on).
class Notifier {
public:
Notifier(EventBus& bus, const Settings& settings);
void update(uint32_t nowMs); // turns the LED off after the flash
ToastQueue& toasts() { return toasts_; }
private:
void present(const Event& e);
const Settings& settings_;
ToastQueue toasts_{3000};
uint32_t ledOffAtMs_ = 0;
bool ledOn_ = false;
};
} // namespace roro
+24
View File
@@ -0,0 +1,24 @@
#include "screen.h"
#include <M5Cardputer.h>
namespace roro {
bool Screen::begin() {
canvas_.setColorDepth(16);
canvas_.setPsram(false);
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
}
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
canvas_.fillSprite(theme::kBackground);
const auto& area = theme::kContent;
canvas_.setClipRect(area.x, area.y, area.w, area.h);
apps.foreground().draw(canvas_);
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast);
canvas_.pushSprite(&M5Cardputer.Display, 0, 0);
}
} // namespace roro
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "app_manager.h"
#include "canvas.h"
#include "widgets.h"
namespace roro {
// Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display.
class Screen {
public:
bool begin(); // allocates the frame buffer (~64 KB)
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
Canvas& canvas() { return canvas_; }
private:
Canvas canvas_;
};
} // namespace roro
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <cstdint>
namespace roro::theme {
// RGB565
constexpr uint16_t kBackground = 0x0000;
constexpr uint16_t kText = 0xFFFF;
constexpr uint16_t kMuted = 0x8410; // grey
constexpr uint16_t kAccent = 0x2CBF; // teal-blue
constexpr uint16_t kSelection = 0x1A6F; // darker accent behind selected rows
constexpr uint16_t kBar = 0x2104; // Status Bar background
constexpr uint16_t kWarning = 0xFC60; // orange
constexpr uint16_t kMessage = 0x3666; // green
constexpr int kWidth = 240;
constexpr int kHeight = 135;
constexpr int kStatusBarHeight = 11;
constexpr int kLineHeight = 13; // body font
struct Rect {
int x, y, w, h;
};
// Where Apps draw: everything below the Status Bar.
constexpr Rect kContent{0, kStatusBarHeight + 1, kWidth, kHeight - kStatusBarHeight - 1};
} // namespace roro::theme
+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
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#include "canvas.h"
#include "dialog_model.h"
#include "line_editor.h"
#include "list_model.h"
#include "text_wrap.h"
#include "theme.h"
#include "toast_queue.h"
namespace roro {
struct StatusInfo {
std::string title;
int batteryPercent = -1; // -1: unknown
std::string clock = "--:--";
bool sdPresent = false;
int sdLevel = 0; // StorageMonitor level
char compose = 0; // pending Compose Key state
bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose;
}
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
};
namespace widgets {
// Width in pixels of text in the body font.
MeasureText bodyMeasure(Canvas& canvas);
void statusBar(Canvas& c, const StatusInfo& info);
// A selectable list; `label(i)` gives row i's text.
void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function<std::string(int)>& label);
// Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit.
void textLines(Canvas& c, const std::vector<std::string>& lines, int firstLine, theme::Rect area);
// One line of editable text with a cursor; scrolls horizontally to keep the cursor visible.
void lineEditor(Canvas& c, const LineEditor& editor, theme::Rect area);
void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model);
void toast(Canvas& c, const Toast& t);
} // namespace widgets
} // namespace roro
@@ -18,7 +18,9 @@ struct FakeApp : App {
keys.push_back(e);
return handles;
}
void update(uint32_t nowMs) override { lastUpdate = nowMs; }
void draw(Canvas&) override {}
uint32_t lastUpdate = 0;
std::vector<KeyEvent> keys;
bool handles = false;
@@ -145,6 +147,14 @@ void test_app_can_request_its_own_redraw() {
TEST_ASSERT_FALSE(f.manager.takeRedraw());
}
void test_update_reaches_only_the_foreground_app() {
Fixture f;
f.manager.open("notes");
f.manager.update(42);
TEST_ASSERT_EQUAL(42, f.notes.lastUpdate);
TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_launcher_is_in_foreground_after_begin);
@@ -159,5 +169,6 @@ int main() {
RUN_TEST(test_visible_apps_exclude_hidden_ones_and_keep_registration_order);
RUN_TEST(test_switching_apps_requests_a_redraw);
RUN_TEST(test_app_can_request_its_own_redraw);
RUN_TEST(test_update_reaches_only_the_foreground_app);
return UNITY_END();
}
@@ -0,0 +1,56 @@
#include <unity.h>
#include "dialog_model.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_starts_on_the_safe_first_button_and_undecided() {
DialogModel d({"Cancel", "Format"});
TEST_ASSERT_EQUAL(0, d.selected());
TEST_ASSERT_EQUAL(DialogModel::kPending, d.result());
}
void test_left_right_and_tab_move_between_buttons() {
DialogModel d({"Cancel", "Format"});
d.onKey(KeyEvent::of(Key::Right));
TEST_ASSERT_EQUAL(1, d.selected());
d.onKey(KeyEvent::of(Key::Right));
TEST_ASSERT_EQUAL(1, d.selected());
d.onKey(KeyEvent::of(Key::Tab));
TEST_ASSERT_EQUAL(0, d.selected());
d.onKey(KeyEvent::of(Key::Left));
TEST_ASSERT_EQUAL(0, d.selected());
}
void test_select_confirms_the_highlighted_button() {
DialogModel d({"Cancel", "Format"});
d.onKey(KeyEvent::of(Key::Right));
d.onKey(KeyEvent::of(Key::Select));
TEST_ASSERT_EQUAL(1, d.result());
}
void test_back_cancels() {
DialogModel d({"Cancel", "Format"});
d.onKey(KeyEvent::of(Key::Right));
d.onKey(KeyEvent::of(Key::Back));
TEST_ASSERT_EQUAL(DialogModel::kCancelled, d.result());
}
void test_dialog_consumes_every_key_while_open() {
DialogModel d({"OK"});
TEST_ASSERT_TRUE(d.onKey(KeyEvent::character('x')));
TEST_ASSERT_TRUE(d.onKey(KeyEvent::of(Key::Back)));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_starts_on_the_safe_first_button_and_undecided);
RUN_TEST(test_left_right_and_tab_move_between_buttons);
RUN_TEST(test_select_confirms_the_highlighted_button);
RUN_TEST(test_back_cancels);
RUN_TEST(test_dialog_consumes_every_key_while_open);
return UNITY_END();
}
+195
View File
@@ -0,0 +1,195 @@
#include <unity.h>
#include <vector>
#include "key_mapper.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static RawKeys chars(std::vector<char> c) {
RawKeys r;
r.chars = c;
return r;
}
static RawKeys withFn(std::vector<char> c) {
RawKeys r = chars(c);
r.fn = true;
return r;
}
static RawKeys optOnly() {
RawKeys r;
r.opt = true;
return r;
}
static std::vector<KeyEvent> press(KeyMapper& m, const RawKeys& r) { return m.update(r); }
static void release(KeyMapper& m) { m.update(RawKeys{}); }
void test_plain_character() {
KeyMapper m;
auto ev = press(m, chars({'a'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('a', ev[0].ch);
}
void test_held_key_is_not_repeated_on_later_changes() {
KeyMapper m;
press(m, chars({'a'}));
auto ev = press(m, chars({'a', 'b'})); // rolling: b pressed while a still held
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL('b', ev[0].ch);
}
void test_same_key_again_after_release() {
KeyMapper m;
press(m, chars({'a'}));
release(m);
TEST_ASSERT_EQUAL(1, press(m, chars({'a'})).size());
}
void test_fn_combos_are_arrows() {
KeyMapper m;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, withFn({'.'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, withFn({','}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, withFn({'/'}))[0].key));
}
void test_backtick_is_back_and_fn_backtick_is_home() {
KeyMapper m;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(press(m, chars({'`'}))[0].key));
release(m);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Home), static_cast<int>(press(m, withFn({'`'}))[0].key));
}
void test_enter_delete_tab() {
KeyMapper m;
RawKeys r;
r.enter = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Select), static_cast<int>(press(m, r)[0].key));
release(m);
r = RawKeys{};
r.del = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Delete), static_cast<int>(press(m, r)[0].key));
release(m);
r = RawKeys{};
r.tab = true;
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(press(m, r)[0].key));
}
void test_ctrl_and_alt_flags_are_carried() {
KeyMapper m;
RawKeys r = chars({'c'});
r.ctrl = true;
auto ev = press(m, r);
TEST_ASSERT_TRUE(ev[0].ctrl);
TEST_ASSERT_EQUAL('c', ev[0].ch);
}
void test_compose_acute() {
KeyMapper m;
TEST_ASSERT_EQUAL(0, press(m, optOnly()).size());
release(m);
TEST_ASSERT_EQUAL(0, press(m, chars({'\''})).size());
release(m);
auto ev = press(m, chars({'e'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(0xE9, ev[0].ch); // é
}
void test_compose_while_holding_opt() {
KeyMapper m;
RawKeys r = chars({'`'});
r.opt = true;
TEST_ASSERT_EQUAL(0, press(m, r).size()); // grave accent, not Back
release(m);
auto ev = press(m, chars({'a'}));
TEST_ASSERT_EQUAL(0xE0, ev[0].ch); // à
}
void test_compose_table() {
struct Case {
char accent, base;
uint32_t expected;
} cases[] = {{'^', 'e', 0xEA}, {'"', 'e', 0xEB}, {',', 'c', 0xE7}, {',', 'C', 0xC7},
{'~', 'n', 0xF1}, {'\'', 'E', 0xC9}, {'`', 'u', 0xF9}, {'"', 'i', 0xEF}};
for (auto& c : cases) {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, chars({c.accent}));
release(m);
auto ev = press(m, chars({c.base}));
TEST_ASSERT_EQUAL_HEX32(c.expected, ev[0].ch);
}
}
void test_unknown_combination_types_both_characters() {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, chars({'\''}));
release(m);
auto ev = press(m, chars({'z'}));
TEST_ASSERT_EQUAL(2, ev.size());
TEST_ASSERT_EQUAL('\'', ev[0].ch);
TEST_ASSERT_EQUAL('z', ev[1].ch);
}
void test_opt_followed_by_a_non_accent_types_it_normally() {
KeyMapper m;
press(m, optOnly());
release(m);
auto ev = press(m, chars({'x'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL('x', ev[0].ch);
}
void test_pressing_opt_twice_cancels() {
KeyMapper m;
press(m, optOnly());
release(m);
press(m, optOnly());
release(m);
auto ev = press(m, chars({'`'}));
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
}
void test_compose_state_is_visible_for_the_ui() {
KeyMapper m;
TEST_ASSERT_EQUAL(0, m.pendingCompose());
press(m, optOnly());
TEST_ASSERT_EQUAL('*', m.pendingCompose()); // armed, no accent yet
release(m);
press(m, chars({'^'}));
TEST_ASSERT_EQUAL('^', m.pendingCompose());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plain_character);
RUN_TEST(test_held_key_is_not_repeated_on_later_changes);
RUN_TEST(test_same_key_again_after_release);
RUN_TEST(test_fn_combos_are_arrows);
RUN_TEST(test_backtick_is_back_and_fn_backtick_is_home);
RUN_TEST(test_enter_delete_tab);
RUN_TEST(test_ctrl_and_alt_flags_are_carried);
RUN_TEST(test_compose_acute);
RUN_TEST(test_compose_while_holding_opt);
RUN_TEST(test_compose_table);
RUN_TEST(test_unknown_combination_types_both_characters);
RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally);
RUN_TEST(test_pressing_opt_twice_cancels);
RUN_TEST(test_compose_state_is_visible_for_the_ui);
return UNITY_END();
}
+104
View File
@@ -0,0 +1,104 @@
#include <unity.h>
#include "line_editor.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_typing_appends_at_cursor() {
LineEditor e(32);
e.insert('h');
e.insert('i');
TEST_ASSERT_EQUAL_STRING("hi", e.text().c_str());
TEST_ASSERT_EQUAL(2, e.cursor());
}
void test_accented_characters_are_stored_as_utf8() {
LineEditor e(32);
e.insert(0xE9); // é
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", e.text().c_str());
TEST_ASSERT_EQUAL(2, e.cursor());
}
void test_cursor_moves_over_whole_characters() {
LineEditor e(32);
e.setText("a\xC3\xA9z"); // aéz
TEST_ASSERT_EQUAL(4, e.cursor());
e.left();
TEST_ASSERT_EQUAL(3, e.cursor());
e.left();
TEST_ASSERT_EQUAL(1, e.cursor()); // skipped both bytes of é
e.right();
TEST_ASSERT_EQUAL(3, e.cursor());
}
void test_cursor_stops_at_both_ends() {
LineEditor e(32);
e.setText("ab");
e.right();
TEST_ASSERT_EQUAL(2, e.cursor());
e.home();
e.left();
TEST_ASSERT_EQUAL(0, e.cursor());
e.end();
TEST_ASSERT_EQUAL(2, e.cursor());
}
void test_insert_in_the_middle() {
LineEditor e(32);
e.setText("ac");
e.left();
e.insert('b');
TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str());
TEST_ASSERT_EQUAL(2, e.cursor());
}
void test_backspace_removes_the_whole_character_before_the_cursor() {
LineEditor e(32);
e.setText("a\xC3\xA9");
e.backspace();
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
e.home();
e.backspace();
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
}
void test_insert_rejected_when_it_would_exceed_max_bytes() {
LineEditor e(3);
e.setText("ab");
TEST_ASSERT_FALSE(e.insert(0xE9)); // 2 bytes, only 1 left
TEST_ASSERT_TRUE(e.insert('c'));
TEST_ASSERT_FALSE(e.insert('d'));
TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str());
}
void test_set_text_truncates_on_a_character_boundary() {
LineEditor e(2);
e.setText("a\xC3\xA9");
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
}
void test_three_and_four_byte_characters() {
LineEditor e(32);
e.insert(0x20AC); // €
e.insert(0x1F600); // 😀
TEST_ASSERT_EQUAL(7, e.text().size());
e.backspace();
TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", e.text().c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_typing_appends_at_cursor);
RUN_TEST(test_accented_characters_are_stored_as_utf8);
RUN_TEST(test_cursor_moves_over_whole_characters);
RUN_TEST(test_cursor_stops_at_both_ends);
RUN_TEST(test_insert_in_the_middle);
RUN_TEST(test_backspace_removes_the_whole_character_before_the_cursor);
RUN_TEST(test_insert_rejected_when_it_would_exceed_max_bytes);
RUN_TEST(test_set_text_truncates_on_a_character_boundary);
RUN_TEST(test_three_and_four_byte_characters);
return UNITY_END();
}
+94
View File
@@ -0,0 +1,94 @@
#include <unity.h>
#include "list_model.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_empty_list_has_no_selection() {
ListModel l(5);
TEST_ASSERT_EQUAL(-1, l.selected());
l.down();
TEST_ASSERT_EQUAL(-1, l.selected());
}
void test_selection_starts_at_first_item() {
ListModel l(5);
l.setCount(3);
TEST_ASSERT_EQUAL(0, l.selected());
}
void test_down_and_up_move_the_selection() {
ListModel l(5);
l.setCount(3);
l.down();
TEST_ASSERT_EQUAL(1, l.selected());
l.up();
TEST_ASSERT_EQUAL(0, l.selected());
}
void test_selection_wraps_around_at_both_ends() {
ListModel l(5);
l.setCount(3);
l.up();
TEST_ASSERT_EQUAL(2, l.selected());
l.down();
TEST_ASSERT_EQUAL(0, l.selected());
}
void test_scrolls_to_keep_the_selection_visible() {
ListModel l(3);
l.setCount(10);
for (int i = 0; i < 4; i++) l.down(); // selected 4
TEST_ASSERT_EQUAL(4, l.selected());
TEST_ASSERT_EQUAL(2, l.firstVisible()); // rows 2,3,4
for (int i = 0; i < 3; i++) l.up(); // selected 1
TEST_ASSERT_EQUAL(1, l.firstVisible());
}
void test_wrapping_to_the_end_scrolls_to_the_last_page() {
ListModel l(3);
l.setCount(10);
l.up();
TEST_ASSERT_EQUAL(9, l.selected());
TEST_ASSERT_EQUAL(7, l.firstVisible());
}
void test_shrinking_the_list_keeps_selection_in_range() {
ListModel l(3);
l.setCount(10);
l.up(); // 9
l.setCount(4);
TEST_ASSERT_EQUAL(3, l.selected());
TEST_ASSERT_EQUAL(1, l.firstVisible());
l.setCount(0);
TEST_ASSERT_EQUAL(-1, l.selected());
}
void test_page_moves_by_visible_rows_without_wrapping() {
ListModel l(3);
l.setCount(10);
l.pageDown();
TEST_ASSERT_EQUAL(3, l.selected());
l.pageDown();
l.pageDown();
l.pageDown();
TEST_ASSERT_EQUAL(9, l.selected());
l.pageUp();
TEST_ASSERT_EQUAL(6, l.selected());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_empty_list_has_no_selection);
RUN_TEST(test_selection_starts_at_first_item);
RUN_TEST(test_down_and_up_move_the_selection);
RUN_TEST(test_selection_wraps_around_at_both_ends);
RUN_TEST(test_scrolls_to_keep_the_selection_visible);
RUN_TEST(test_wrapping_to_the_end_scrolls_to_the_last_page);
RUN_TEST(test_shrinking_the_list_keeps_selection_in_range);
RUN_TEST(test_page_moves_by_visible_rows_without_wrapping);
return UNITY_END();
}
+65
View File
@@ -0,0 +1,65 @@
#include <unity.h>
#include "text_wrap.h"
using namespace roro;
void setUp() {}
void tearDown() {}
// Every character (code point) is 6 px wide.
static int measure(const std::string& s) {
int n = 0;
for (unsigned char c : s)
if ((c & 0xC0) != 0x80) n++;
return n * 6;
}
void test_short_text_is_one_line() {
auto lines = wrapText("hello", 60, measure);
TEST_ASSERT_EQUAL(1, lines.size());
TEST_ASSERT_EQUAL_STRING("hello", lines[0].c_str());
}
void test_wraps_at_word_boundaries() {
auto lines = wrapText("hello big world", 60, measure); // 10 chars per line
TEST_ASSERT_EQUAL(2, lines.size());
TEST_ASSERT_EQUAL_STRING("hello big", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("world", lines[1].c_str());
}
void test_breaks_words_longer_than_a_line() {
auto lines = wrapText("abcdefghijklmnop", 60, measure);
TEST_ASSERT_EQUAL(2, lines.size());
TEST_ASSERT_EQUAL_STRING("abcdefghij", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("klmnop", lines[1].c_str());
}
void test_never_splits_a_multibyte_character() {
// 12 x 'é' (2 bytes each) at 10 chars per line
std::string s;
for (int i = 0; i < 12; i++) s += "\xC3\xA9";
auto lines = wrapText(s, 60, measure);
TEST_ASSERT_EQUAL(2, lines.size());
TEST_ASSERT_EQUAL(20, lines[0].size());
TEST_ASSERT_EQUAL(4, lines[1].size());
}
void test_explicit_newlines_start_new_lines_and_keep_empty_ones() {
auto lines = wrapText("a\n\nb", 60, measure);
TEST_ASSERT_EQUAL(3, lines.size());
TEST_ASSERT_EQUAL_STRING("", lines[1].c_str());
}
void test_empty_text_is_no_lines() { TEST_ASSERT_EQUAL(0, wrapText("", 60, measure).size()); }
int main() {
UNITY_BEGIN();
RUN_TEST(test_short_text_is_one_line);
RUN_TEST(test_wraps_at_word_boundaries);
RUN_TEST(test_breaks_words_longer_than_a_line);
RUN_TEST(test_never_splits_a_multibyte_character);
RUN_TEST(test_explicit_newlines_start_new_lines_and_keep_empty_ones);
RUN_TEST(test_empty_text_is_no_lines);
return UNITY_END();
}
@@ -0,0 +1,77 @@
#include <unity.h>
#include "toast_queue.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_nothing_shown_when_empty() {
ToastQueue q(3000);
TEST_ASSERT_NULL(q.current(0));
}
void test_toast_is_shown_for_its_duration() {
ToastQueue q(3000);
q.push("hello", NotificationLevel::Info, 1000);
TEST_ASSERT_NOT_NULL(q.current(1000));
TEST_ASSERT_EQUAL_STRING("hello", q.current(3999)->text.c_str());
TEST_ASSERT_NULL(q.current(4000));
}
void test_queued_toasts_follow_each_other() {
ToastQueue q(3000);
q.push("one", NotificationLevel::Info, 0);
q.push("two", NotificationLevel::Info, 100);
TEST_ASSERT_EQUAL_STRING("one", q.current(2999)->text.c_str());
TEST_ASSERT_EQUAL_STRING("two", q.current(3000)->text.c_str());
TEST_ASSERT_EQUAL_STRING("two", q.current(5999)->text.c_str());
TEST_ASSERT_NULL(q.current(6000));
}
void test_a_toast_queued_later_gets_its_full_duration() {
ToastQueue q(3000);
q.push("one", NotificationLevel::Info, 0);
TEST_ASSERT_NULL(q.current(10000));
q.push("two", NotificationLevel::Info, 20000);
TEST_ASSERT_NOT_NULL(q.current(22999));
}
void test_flood_keeps_the_visible_toast_and_drops_the_oldest_waiting() {
ToastQueue q(3000, 2);
q.push("a", NotificationLevel::Info, 0);
q.push("b", NotificationLevel::Info, 0);
q.push("c", NotificationLevel::Info, 0);
TEST_ASSERT_EQUAL_STRING("a", q.current(0)->text.c_str());
TEST_ASSERT_EQUAL_STRING("c", q.current(3000)->text.c_str());
}
void test_dismiss_shows_the_next_one_immediately() {
ToastQueue q(3000);
q.push("one", NotificationLevel::Info, 0);
q.push("two", NotificationLevel::Info, 0);
q.dismiss(500);
TEST_ASSERT_EQUAL_STRING("two", q.current(500)->text.c_str());
}
void test_reports_when_a_new_toast_appears() {
ToastQueue q(3000);
q.push("one", NotificationLevel::Info, 0);
TEST_ASSERT_TRUE(q.takeChanged());
TEST_ASSERT_FALSE(q.takeChanged());
q.current(3000); // expires
TEST_ASSERT_TRUE(q.takeChanged());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_nothing_shown_when_empty);
RUN_TEST(test_toast_is_shown_for_its_duration);
RUN_TEST(test_queued_toasts_follow_each_other);
RUN_TEST(test_a_toast_queued_later_gets_its_full_duration);
RUN_TEST(test_flood_keeps_the_visible_toast_and_drops_the_oldest_waiting);
RUN_TEST(test_dismiss_shows_the_next_one_immediately);
RUN_TEST(test_reports_when_a_new_toast_appears);
return UNITY_END();
}