diff --git a/lib/core/src/app.h b/lib/core/src/app.h index 9f2f64d..1a47dcb 100644 --- a/lib/core/src/app.h +++ b/lib/core/src/app.h @@ -1,5 +1,7 @@ #pragma once +#include + #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; } diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index 537b993..7656644 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -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_; } diff --git a/lib/input/src/key_mapper.cpp b/lib/input/src/key_mapper.cpp new file mode 100644 index 0000000..ecf5e0c --- /dev/null +++ b/lib/input/src/key_mapper.cpp @@ -0,0 +1,107 @@ +#include "key_mapper.h" + +#include + +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 KeyMapper::update(const RawKeys& keys) { + std::vector 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& 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(accent), keys)); + out.push_back(charEvent(static_cast(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(c), keys)); +} + +} // namespace roro diff --git a/lib/input/src/key_mapper.h b/lib/input/src/key_mapper.h new file mode 100644 index 0000000..08c9fe7 --- /dev/null +++ b/lib/input/src/key_mapper.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#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 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 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& out); + + RawKeys previous_; + char compose_ = 0; +}; + +} // namespace roro diff --git a/lib/ui/src/dialog_model.h b/lib/ui/src/dialog_model.h new file mode 100644 index 0000000..3147278 --- /dev/null +++ b/lib/ui/src/dialog_model.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#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 buttons) : buttons_(std::move(buttons)) {} + + // Consumes every key while open. + bool onKey(const KeyEvent& e) { + if (result_ != kPending) return true; + int last = static_cast(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& buttons() const { return buttons_; } + int selected() const { return selected_; } + int result() const { return result_; } + + private: + std::vector buttons_; + int selected_ = 0; + int result_ = kPending; +}; + +} // namespace roro diff --git a/lib/ui/src/line_editor.cpp b/lib/ui/src/line_editor.cpp new file mode 100644 index 0000000..2924527 --- /dev/null +++ b/lib/ui/src/line_editor.cpp @@ -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 diff --git a/lib/ui/src/line_editor.h b/lib/ui/src/line_editor.h new file mode 100644 index 0000000..58358e4 --- /dev/null +++ b/lib/ui/src/line_editor.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +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 diff --git a/lib/ui/src/list_model.cpp b/lib/ui/src/list_model.cpp new file mode 100644 index 0000000..c929bd8 --- /dev/null +++ b/lib/ui/src/list_model.cpp @@ -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 diff --git a/lib/ui/src/list_model.h b/lib/ui/src/list_model.h new file mode 100644 index 0000000..65edebf --- /dev/null +++ b/lib/ui/src/list_model.h @@ -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 diff --git a/lib/ui/src/text_wrap.cpp b/lib/ui/src/text_wrap.cpp new file mode 100644 index 0000000..a795079 --- /dev/null +++ b/lib/ui/src/text_wrap.cpp @@ -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& 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 wrapText(const std::string& text, int maxWidth, const MeasureText& measure) { + std::vector 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 diff --git a/lib/ui/src/text_wrap.h b/lib/ui/src/text_wrap.h new file mode 100644 index 0000000..f16ef10 --- /dev/null +++ b/lib/ui/src/text_wrap.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +using MeasureText = std::function; + +// 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 wrapText(const std::string& text, int maxWidth, const MeasureText& measure); + +} // namespace roro diff --git a/lib/ui/src/toast_queue.cpp b/lib/ui/src/toast_queue.cpp new file mode 100644 index 0000000..a54faec --- /dev/null +++ b/lib/ui/src/toast_queue.cpp @@ -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 diff --git a/lib/ui/src/toast_queue.h b/lib/ui/src/toast_queue.h new file mode 100644 index 0000000..ecef5ac --- /dev/null +++ b/lib/ui/src/toast_queue.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#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 queue_; + bool showing_ = false; + uint32_t shownAtMs_ = 0; + bool changed_ = false; +}; + +} // namespace roro diff --git a/lib/ui/src/utf8.h b/lib/ui/src/utf8.h new file mode 100644 index 0000000..9e7a752 --- /dev/null +++ b/lib/ui/src/utf8.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +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(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(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(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(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(s[pos]))) pos--; + return pos; +} + +} // namespace roro::utf8 diff --git a/roro9.sh b/roro9.sh new file mode 100644 index 0000000..a195dd1 --- /dev/null +++ b/roro9.sh @@ -0,0 +1 @@ +claude --resume a3bf56ca-1259-41f1-99f6-9acea510e771 diff --git a/src/apps/demo_app.cpp b/src/apps/demo_app.cpp new file mode 100644 index 0000000..585d86a --- /dev/null +++ b/src/apps/demo_app.cpp @@ -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(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(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 diff --git a/src/apps/demo_app.h b/src/apps/demo_app.h new file mode 100644 index 0000000..91b8899 --- /dev/null +++ b/src/apps/demo_app.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +#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 dialog_; +}; + +} // namespace roro diff --git a/src/apps/diagnostics_app.cpp b/src/apps/diagnostics_app.cpp new file mode 100644 index 0000000..985fd05 --- /dev/null +++ b/src/apps/diagnostics_app.cpp @@ -0,0 +1,84 @@ +#include "diagnostics_app.h" + +#include + +#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 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(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(settings_.getInt(Setting::Brightness)), + static_cast(settings_.getInt(Setting::DimTimeoutS)), + static_cast(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 diff --git a/src/apps/diagnostics_app.h b/src/apps/diagnostics_app.h new file mode 100644 index 0000000..40cd74b --- /dev/null +++ b/src/apps/diagnostics_app.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#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 formatDialog_; + uint32_t lastRefreshMs_ = 0; +}; + +} // namespace roro diff --git a/src/apps/launcher_app.cpp b/src/apps/launcher_app.cpp new file mode 100644 index 0000000..10af6da --- /dev/null +++ b/src/apps/launcher_app.cpp @@ -0,0 +1,27 @@ +#include "launcher_app.h" + +#include "ui/widgets.h" + +namespace roro { + +void LauncherApp::onEnter() { list_.setCount(static_cast(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 diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h new file mode 100644 index 0000000..e75ef4f --- /dev/null +++ b/src/apps/launcher_app.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 82d1d24..77b8391 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 +#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); } } diff --git a/src/ui/canvas.h b/src/ui/canvas.h new file mode 100644 index 0000000..df2d17c --- /dev/null +++ b/src/ui/canvas.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +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 diff --git a/src/ui/font_data.cpp b/src/ui/font_data.cpp new file mode 100644 index 0000000..c9d2ecb --- /dev/null +++ b/src/ui/font_data.cpp @@ -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\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 + +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 diff --git a/src/ui/notifier.cpp b/src/ui/notifier.cpp new file mode 100644 index 0000000..13b69aa --- /dev/null +++ b/src/ui/notifier.cpp @@ -0,0 +1,38 @@ +#include "notifier.h" + +#include + +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(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(nowMs - ledOffAtMs_) >= 0) { + rgbLedWrite(kLedPin, 0, 0, 0); + ledOn_ = false; + } +} + +} // namespace roro diff --git a/src/ui/notifier.h b/src/ui/notifier.h new file mode 100644 index 0000000..aee57d9 --- /dev/null +++ b/src/ui/notifier.h @@ -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 diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp new file mode 100644 index 0000000..e45fd42 --- /dev/null +++ b/src/ui/screen.cpp @@ -0,0 +1,24 @@ +#include "screen.h" + +#include + +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 diff --git a/src/ui/screen.h b/src/ui/screen.h new file mode 100644 index 0000000..be9010d --- /dev/null +++ b/src/ui/screen.h @@ -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 diff --git a/src/ui/theme.h b/src/ui/theme.h new file mode 100644 index 0000000..52a28da --- /dev/null +++ b/src/ui/theme.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +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 diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp new file mode 100644 index 0000000..c826765 --- /dev/null +++ b/src/ui/widgets.cpp @@ -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(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& 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& 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(lines.size()); row++) + c.drawString(lines[firstLine + row].c_str(), area.x + 4, area.y + row * kLineHeight + 1); + int total = static_cast(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(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 diff --git a/src/ui/widgets.h b/src/ui/widgets.h new file mode 100644 index 0000000..de94868 --- /dev/null +++ b/src/ui/widgets.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#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& label); + +// Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit. +void textLines(Canvas& c, const std::vector& 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 diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp index c13f6b7..d412381 100644 --- a/test/test_app_manager/test_app_manager.cpp +++ b/test/test_app_manager/test_app_manager.cpp @@ -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 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(); } diff --git a/test/test_dialog_model/test_dialog_model.cpp b/test/test_dialog_model/test_dialog_model.cpp new file mode 100644 index 0000000..97429b5 --- /dev/null +++ b/test/test_dialog_model/test_dialog_model.cpp @@ -0,0 +1,56 @@ +#include + +#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(); +} diff --git a/test/test_key_mapper/test_key_mapper.cpp b/test/test_key_mapper/test_key_mapper.cpp new file mode 100644 index 0000000..3b0fff4 --- /dev/null +++ b/test/test_key_mapper/test_key_mapper.cpp @@ -0,0 +1,195 @@ +#include + +#include + +#include "key_mapper.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static RawKeys chars(std::vector c) { + RawKeys r; + r.chars = c; + return r; +} + +static RawKeys withFn(std::vector c) { + RawKeys r = chars(c); + r.fn = true; + return r; +} + +static RawKeys optOnly() { + RawKeys r; + r.opt = true; + return r; +} + +static std::vector 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(Key::Char), static_cast(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(Key::Up), static_cast(press(m, withFn({';'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Down), static_cast(press(m, withFn({'.'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Left), static_cast(press(m, withFn({','}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Right), static_cast(press(m, withFn({'/'}))[0].key)); +} + +void test_backtick_is_back_and_fn_backtick_is_home() { + KeyMapper m; + TEST_ASSERT_EQUAL(static_cast(Key::Back), static_cast(press(m, chars({'`'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Home), static_cast(press(m, withFn({'`'}))[0].key)); +} + +void test_enter_delete_tab() { + KeyMapper m; + RawKeys r; + r.enter = true; + TEST_ASSERT_EQUAL(static_cast(Key::Select), static_cast(press(m, r)[0].key)); + release(m); + r = RawKeys{}; + r.del = true; + TEST_ASSERT_EQUAL(static_cast(Key::Delete), static_cast(press(m, r)[0].key)); + release(m); + r = RawKeys{}; + r.tab = true; + TEST_ASSERT_EQUAL(static_cast(Key::Tab), static_cast(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(Key::Back), static_cast(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(); +} diff --git a/test/test_line_editor/test_line_editor.cpp b/test/test_line_editor/test_line_editor.cpp new file mode 100644 index 0000000..a1cd1aa --- /dev/null +++ b/test/test_line_editor/test_line_editor.cpp @@ -0,0 +1,104 @@ +#include + +#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(); +} diff --git a/test/test_list_model/test_list_model.cpp b/test/test_list_model/test_list_model.cpp new file mode 100644 index 0000000..947cac1 --- /dev/null +++ b/test/test_list_model/test_list_model.cpp @@ -0,0 +1,94 @@ +#include + +#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(); +} diff --git a/test/test_text_wrap/test_text_wrap.cpp b/test/test_text_wrap/test_text_wrap.cpp new file mode 100644 index 0000000..5197617 --- /dev/null +++ b/test/test_text_wrap/test_text_wrap.cpp @@ -0,0 +1,65 @@ +#include + +#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(); +} diff --git a/test/test_toast_queue/test_toast_queue.cpp b/test/test_toast_queue/test_toast_queue.cpp new file mode 100644 index 0000000..b572750 --- /dev/null +++ b/test/test_toast_queue/test_toast_queue.cpp @@ -0,0 +1,77 @@ +#include + +#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(); +}