CDC Badge OS
Firmware for the CDC Badge v1.0 hardware security key
Loading...
Searching...
No Matches
CanvasView.cpp
Go to the documentation of this file.
1
6
8#include "cdc_views/Fonts.h"
13#include "cdc_hal/IDisplay.h"
14#include "cdc_hal/IKeypad.h"
15#include "cdc_ui/ViewStack.h"
16#include "cdc_log.h"
17#include "esp_timer.h"
18#include <goodisplay/gdey029T94.h>
19#include <algorithm>
20#include <cstring>
21#include <utility>
22
23namespace cdc::ui {
24
25namespace {
26
27constexpr int TITLE_Y = 5;
28constexpr int HEADER_HEIGHT_DEFAULT = 30;
29constexpr uint16_t BLOB_NONE = 0xFFFF; // sentinel: bitmap not stored
30
31const char* t9_chars(char key) {
32 switch (key) {
33 case '0': return " 0";
34 case '1': return ".?!,;:'\"()-_@#$%&*+=/\\<>[]{}|^~`1";
35 case '2': return "abc2";
36 case '3': return "def3";
37 case '4': return "ghi4";
38 case '5': return "jkl5";
39 case '6': return "mno6";
40 case '7': return "pqrs7";
41 case '8': return "tuv8";
42 case '9': return "wxyz9";
43 default: return nullptr;
44 }
45}
46
47uint32_t nowMs() {
48 return static_cast<uint32_t>(esp_timer_get_time() / 1000);
49}
50
52constexpr uint8_t ANIM_DONE_CAP =
54
55} // namespace
56
57Gdey029T94* CanvasView::gfx() const {
59 if (!display) return nullptr;
60 return static_cast<Gdey029T94*>(display->getNativeHandle());
61}
62
63int CanvasView::bodyBottom() const {
65 if (!display) return 128;
66 // Reserve the footer strip only when a footer hint is actually shown;
67 // a footerless canvas (a full-screen badge) gets the whole panel height.
68 const bool hasFooter = footer_ || customFooter_;
69 return display->getHeight() - (hasFooter ? cdc::ui::layout::FOOTER_HEIGHT : 0);
70}
71
72void CanvasView::init(const char* title) {
73 title_ = title;
74 allocArenas();
75 widgetCount_ = 0;
76 focused_ = 0;
77 textSize_ = 1;
78 textInverted_ = false;
79 keyCb_ = nullptr;
80 widgetCb_ = nullptr;
81 longPressCb_ = nullptr;
82 footer_ = nullptr;
83 headerHeight_ = (title && title[0] != '\0') ? HEADER_HEIGHT_DEFAULT : 0;
84 needsFullRefresh_ = true;
85 keyRepeatInitialMs_ = 0;
86 keyRepeatPeriodMs_ = 0;
87 headerDrawnOnce_ = false;
88 customFooter_ = nullptr;
89 cmdCount_ = 0;
90 textArenaUsed_ = 0;
91 blobUsed_ = 0;
92 elemCount_ = 0;
93 curElem_ = 0;
94 memset(elems_, 0, sizeof(elems_));
95 overflowLogged_ = false;
96 fontId_ = 0;
97 shade_ = 255;
98 inkWhite_ = false;
99 animator_.reset();
100 if (sprites_.hasArena()) sprites_.setArena(spriteArena_.get(), SPRITE_ARENA);
101 animCb_ = nullptr;
102 animPolicy_ = ANIM_REFRESH_AUTO;
103 animFrameIntervalMs_ = 1000 / ANIM_FPS_DEFAULT;
104 lastAnimStepMs_ = 0;
105 idleCleanupAtMs_ = 0;
106 pausedAtMs_ = 0;
107 animCommitCount_ = 0;
108 animActive_ = false;
109 dirty_ = true;
110}
111
112void CanvasView::setFooter(const char* hint) {
113 footer_ = hint;
114 customFooter_ = hint;
115}
116
117void CanvasView::setKeyRepeat(uint16_t initial_ms, uint16_t repeat_ms) {
118 keyRepeatInitialMs_ = initial_ms;
119 keyRepeatPeriodMs_ = repeat_ms;
120 applyKeypadConfig();
121}
122
123void CanvasView::getBodySize(uint16_t* w, uint16_t* h) const {
125 if (!display) {
126 if (w) *w = 0;
127 if (h) *h = 0;
128 return;
129 }
130 if (w) *w = display->getWidth();
131 if (h) *h = static_cast<uint16_t>(bodyBottom() - bodyTop());
132}
133
134void CanvasView::clearBody(bool keep_sprites) {
135 cdc::core::RecursiveMutexGuard guard(editMutex_);
136 cmdCount_ = 0;
137 textArenaUsed_ = 0;
138 blobUsed_ = 0;
139 elemCount_ = 0;
140 curElem_ = 0;
141 memset(elems_, 0, sizeof(elems_));
142 // Elements are gone, so their animations go with them (silently: no
143 // completion events during teardown).
144 animator_.reset();
145 if (keep_sprites) {
146 // Assets survive for the next screen build; stop playback so an
147 // orphaned loop (e.g. a marquee's backing sprite) cannot keep the
148 // animation clock and the panel busy with nothing to show.
149 sprites_.stopAll();
150 } else if (sprites_.hasArena()) {
151 sprites_.setArena(spriteArena_.get(), SPRITE_ARENA);
152 }
153 animActive_ = false;
154 idleCleanupAtMs_ = 0;
155}
156
157void CanvasView::pushCmd(DrawCmd& c) {
158 c.elemId = curElem_;
159 cmds_[cmdCount_++] = c;
160}
161
162void CanvasView::allocArenas() {
163 if (cmds_ && textArena_ && widgets_ && blobArena_) {
164 memset(cmds_.get(), 0, sizeof(DrawCmd) * MAX_CMDS);
165 memset(widgets_.get(), 0, sizeof(Widget) * MAX_WIDGETS);
166 return;
167 }
172 if (!cmds_ || !textArena_ || !widgets_ || !blobArena_) {
173 LOG_E("CanvasView", "PSRAM arena allocation failed");
174 cmds_.reset();
175 textArena_.reset();
176 widgets_.reset();
177 blobArena_.reset();
178 return;
179 }
180 memset(cmds_.get(), 0, sizeof(DrawCmd) * MAX_CMDS);
181 memset(widgets_.get(), 0, sizeof(Widget) * MAX_WIDGETS);
182}
183
184uint16_t CanvasView::internBlob(const uint8_t* data, uint16_t len) {
185 if (!blobArena_ || !data || len == 0) return BLOB_NONE;
186 uint16_t off = blobUsed_;
187 if (off + len > BLOB_ARENA) {
188 if (!overflowLogged_) {
189 LOG_W("CanvasView", "draw blob arena full, dropping bitmap");
190 overflowLogged_ = true;
191 }
192 return BLOB_NONE;
193 }
194 memcpy(&blobArena_[off], data, len);
195 blobUsed_ = static_cast<uint16_t>(off + len);
196 return off;
197}
198
199uint16_t CanvasView::internText(const char* text, uint16_t* outLen) {
200 if (!textArena_) {
201 if (outLen) *outLen = 0;
202 return 0;
203 }
204 size_t len = text ? strlen(text) : 0;
205 uint16_t off = textArenaUsed_;
206 if (off + len + 1 > TEXT_ARENA) {
207 // Truncate to the remaining arena (leave one byte for the NUL).
208 len = (off + 1 < TEXT_ARENA) ? (TEXT_ARENA - off - 1) : 0;
209 if (!overflowLogged_) {
210 LOG_W("CanvasView", "draw text arena full, truncating");
211 overflowLogged_ = true;
212 }
213 }
214 if (len) memcpy(&textArena_[off], text, len);
215 textArena_[off + len] = '\0';
216 textArenaUsed_ = static_cast<uint16_t>(off + len + 1);
217 *outLen = static_cast<uint16_t>(len);
218 return off;
219}
220
221void CanvasView::drawText(int16_t x, int16_t y, const char* text) {
222 cdc::core::RecursiveMutexGuard guard(editMutex_);
223 if (!text || !cmds_ || cmdCount_ >= MAX_CMDS) {
224 if (text && !overflowLogged_) {
225 LOG_W("CanvasView", "display list full, dropping draw");
226 overflowLogged_ = true;
227 }
228 return;
229 }
230 DrawCmd c{};
231 c.type = CmdType::Text;
232 c.x = x;
233 c.y = y;
234 c.fontId = fontId_;
235 c.textSize = textSize_;
236 c.inverted = textInverted_;
237 c.strOff = internText(text, &c.strLen);
238 pushCmd(c);
239}
240
241void CanvasView::drawTextAligned(int16_t x, int16_t y, int16_t w,
242 const char* text, uint8_t align) {
243 cdc::core::RecursiveMutexGuard guard(editMutex_);
244 if (!text || !cmds_ || cmdCount_ >= MAX_CMDS) {
245 if (text && !overflowLogged_) {
246 LOG_W("CanvasView", "display list full, dropping draw");
247 overflowLogged_ = true;
248 }
249 return;
250 }
251 DrawCmd c{};
252 c.type = CmdType::TextAligned;
253 c.x = x;
254 c.y = y;
255 c.w = w;
256 c.align = align;
257 c.fontId = fontId_;
258 c.textSize = textSize_;
259 c.inverted = textInverted_;
260 c.strOff = internText(text, &c.strLen);
261 pushCmd(c);
262}
263
264void CanvasView::drawRect(int16_t x, int16_t y, int16_t w, int16_t h, bool filled) {
265 cdc::core::RecursiveMutexGuard guard(editMutex_);
266 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
267 DrawCmd c{};
268 c.type = CmdType::Rect;
269 c.x = x;
270 c.y = y;
271 c.w = w;
272 c.h = h;
273 c.filled = filled;
274 c.shade = shade_;
275 c.inverted = inkWhite_;
276 pushCmd(c);
277}
278
279void CanvasView::drawPixel(int16_t x, int16_t y) {
280 cdc::core::RecursiveMutexGuard guard(editMutex_);
281 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
282 DrawCmd c{};
283 c.type = CmdType::Pixel;
284 c.x = x;
285 c.y = y;
286 c.inverted = inkWhite_;
287 pushCmd(c);
288}
289
290void CanvasView::drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1) {
291 cdc::core::RecursiveMutexGuard guard(editMutex_);
292 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
293 DrawCmd c{};
294 c.type = CmdType::Line;
295 c.x = x0;
296 c.y = y0;
297 c.w = x1;
298 c.h = y1;
299 c.inverted = inkWhite_;
300 pushCmd(c);
301}
302
303void CanvasView::drawCircle(int16_t x, int16_t y, int16_t r, bool filled) {
304 cdc::core::RecursiveMutexGuard guard(editMutex_);
305 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
306 DrawCmd c{};
307 c.type = CmdType::Circle;
308 c.x = x;
309 c.y = y;
310 c.w = r;
311 c.filled = filled;
312 c.shade = shade_;
313 c.inverted = inkWhite_;
314 pushCmd(c);
315}
316
317void CanvasView::drawTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1,
318 int16_t x2, int16_t y2, bool filled) {
319 cdc::core::RecursiveMutexGuard guard(editMutex_);
320 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
321 DrawCmd c{};
322 c.type = CmdType::Triangle;
323 c.x = x0;
324 c.y = y0;
325 c.w = x1;
326 c.h = y1;
327 c.x2 = x2;
328 c.y2 = y2;
329 c.filled = filled;
330 c.shade = shade_;
331 c.inverted = inkWhite_;
332 pushCmd(c);
333}
334
335void CanvasView::drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h,
336 int16_t r, bool filled) {
337 cdc::core::RecursiveMutexGuard guard(editMutex_);
338 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
339 DrawCmd c{};
340 c.type = CmdType::RoundRect;
341 c.x = x;
342 c.y = y;
343 c.w = w;
344 c.h = h;
345 c.x2 = r;
346 c.filled = filled;
347 c.inverted = inkWhite_;
348 pushCmd(c);
349}
350
351void CanvasView::drawBitmap(int16_t x, int16_t y, int16_t w, int16_t h,
352 const uint8_t* data, uint32_t len) {
353 cdc::core::RecursiveMutexGuard guard(editMutex_);
354 if (!cmds_ || cmdCount_ >= MAX_CMDS || !data || w <= 0 || h <= 0) return;
355 uint32_t need = static_cast<uint32_t>((w + 7) / 8) * static_cast<uint32_t>(h);
356 if (len < need || need == 0 || need > BLOB_ARENA) {
357 if (!overflowLogged_) {
358 LOG_W("CanvasView", "bitmap too large or short, dropping");
359 overflowLogged_ = true;
360 }
361 return;
362 }
363 uint16_t off = internBlob(data, static_cast<uint16_t>(need));
364 if (off == BLOB_NONE) return;
365 DrawCmd c{};
366 c.type = CmdType::Bitmap;
367 c.x = x;
368 c.y = y;
369 c.w = w;
370 c.h = h;
371 c.strOff = off;
372 c.strLen = static_cast<uint16_t>(need);
373 pushCmd(c);
374}
375
376void CanvasView::drawHLine(int16_t x, int16_t y, int16_t w) {
377 cdc::core::RecursiveMutexGuard guard(editMutex_);
378 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
379 DrawCmd c{};
380 c.type = CmdType::HLine;
381 c.x = x;
382 c.y = y;
383 c.w = w;
384 c.inverted = inkWhite_;
385 pushCmd(c);
386}
387
388void CanvasView::drawVLine(int16_t x, int16_t y, int16_t h) {
389 cdc::core::RecursiveMutexGuard guard(editMutex_);
390 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
391 DrawCmd c{};
392 c.type = CmdType::VLine;
393 c.x = x;
394 c.y = y;
395 c.h = h;
396 c.inverted = inkWhite_;
397 pushCmd(c);
398}
399
400void CanvasView::paintText(int16_t x, int16_t y, int16_t w, const char* text,
401 uint8_t align, uint8_t fontId, uint8_t textSize,
402 bool inverted) {
403 auto* g = gfx();
404 if (!g || !text) return;
405 const GFXfont* font = getGfxFont(fontId);
406 g->setFont(font);
407 g->setTextSize(textSize);
408 g->setTextColor(inverted ? EPD_WHITE : EPD_BLACK);
409 g->setTextWrap(false);
410
411 int16_t draw_x = x;
412 if (align == 1 || align == 2) {
413 int16_t bx, by;
414 uint16_t bw, bh;
415 render::measureText(g, text, font, 0, 0, &bx, &by, &bw, &bh);
416 if (align == 1) {
417 draw_x = x + (w - static_cast<int16_t>(bw)) / 2;
418 } else {
419 draw_x = x + w - static_cast<int16_t>(bw);
420 }
421 }
422 g->setCursor(draw_x, y + bodyTop());
423 render::drawText(g, text, font);
424}
425
426CanvasView::Elem* CanvasView::findElem(uint32_t id) {
427 if (id == 0) return nullptr;
428 for (uint8_t i = 0; i < elemCount_; ++i) {
429 if (elems_[i].id == id) return &elems_[i];
430 }
431 return nullptr;
432}
433
434const CanvasView::Elem* CanvasView::findElem(uint32_t id) const {
435 return const_cast<CanvasView*>(this)->findElem(id);
436}
437
438bool CanvasView::beginElem(uint32_t id) {
439 cdc::core::RecursiveMutexGuard guard(editMutex_);
440 if (id == 0) return false;
441 if (!findElem(id)) {
442 if (elemCount_ >= MAX_ELEMS) {
443 if (!overflowLogged_) {
444 LOG_W("CanvasView", "element table full, dropping element");
445 overflowLogged_ = true;
446 }
447 return false;
448 }
449 elems_[elemCount_] = Elem{};
450 elems_[elemCount_].id = id;
451 ++elemCount_;
452 }
453 curElem_ = id;
454 return true;
455}
456
458 cdc::core::RecursiveMutexGuard guard(editMutex_);
459 curElem_ = 0;
460}
461
462bool CanvasView::elemSetOffset(uint32_t id, int16_t ox, int16_t oy) {
463 cdc::core::RecursiveMutexGuard guard(editMutex_);
464 Elem* e = findElem(id);
465 if (!e) return false;
466 e->ox = ox;
467 e->oy = oy;
468 return true;
469}
470
471bool CanvasView::elemMove(uint32_t id, int16_t dx, int16_t dy) {
472 cdc::core::RecursiveMutexGuard guard(editMutex_);
473 Elem* e = findElem(id);
474 if (!e) return false;
475 e->ox = static_cast<int16_t>(e->ox + dx);
476 e->oy = static_cast<int16_t>(e->oy + dy);
477 return true;
478}
479
480bool CanvasView::elemShow(uint32_t id, bool visible) {
481 cdc::core::RecursiveMutexGuard guard(editMutex_);
482 Elem* e = findElem(id);
483 if (!e) return false;
484 e->hidden = !visible;
485 return true;
486}
487
488// Drop all draw commands tagged with `id` and re-pack the arenas.
489bool CanvasView::dropElemCmds(uint32_t id) {
490 uint16_t out = 0;
491 for (uint16_t i = 0; i < cmdCount_; ++i) {
492 if (cmds_[i].elemId != id) {
493 if (out != i) cmds_[out] = cmds_[i];
494 ++out;
495 }
496 }
497 bool dropped = out != cmdCount_;
498 cmdCount_ = out;
499 if (dropped) compactArenas();
500 return dropped;
501}
502
503bool CanvasView::elemRemove(uint32_t id) {
504 cdc::core::RecursiveMutexGuard guard(editMutex_);
505 Elem* e = findElem(id);
506 if (!e) return false;
507
508 dropElemCmds(id);
509 animator_.cancelForElem(id);
510
511 uint8_t idx = static_cast<uint8_t>(e - elems_);
512 for (uint8_t i = idx + 1; i < elemCount_; ++i) {
513 elems_[i - 1] = elems_[i];
514 }
515 --elemCount_;
516 elems_[elemCount_] = Elem{};
517 if (curElem_ == id) curElem_ = 0;
518 return true;
519}
520
521bool CanvasView::elemClear(uint32_t id) {
522 cdc::core::RecursiveMutexGuard guard(editMutex_);
523 if (!findElem(id)) return false;
524 dropElemCmds(id);
525 return true;
526}
527
528bool CanvasView::elemSetZ(uint32_t id, int8_t z) {
529 cdc::core::RecursiveMutexGuard guard(editMutex_);
530 Elem* e = findElem(id);
531 if (!e) return false;
532 e->z = z;
533 return true;
534}
535
536bool CanvasView::elemGetOffset(uint32_t id, int16_t* ox, int16_t* oy) const {
537 cdc::core::RecursiveMutexGuard guard(editMutex_);
538 const Elem* e = findElem(id);
539 if (!e) return false;
540 if (ox) *ox = e->ox;
541 if (oy) *oy = e->oy;
542 return true;
543}
544
545bool CanvasView::elemGetBounds(uint32_t id, int16_t* x, int16_t* y,
546 uint16_t* w, uint16_t* h) {
547 cdc::core::RecursiveMutexGuard guard(editMutex_);
548 Elem* e = findElem(id);
549 if (!e || !cmds_) return false;
550
551 int32_t minX = INT32_MAX, minY = INT32_MAX;
552 int32_t maxX = INT32_MIN, maxY = INT32_MIN;
553 bool any = false;
554 for (uint16_t i = 0; i < cmdCount_; ++i) {
555 if (cmds_[i].elemId != id) continue;
556 int16_t cx, cy;
557 uint16_t cw, ch;
558 if (!cmdBounds(cmds_[i], &cx, &cy, &cw, &ch)) continue;
559 any = true;
560 minX = std::min<int32_t>(minX, cx);
561 minY = std::min<int32_t>(minY, cy);
562 maxX = std::max<int32_t>(maxX, cx + cw);
563 maxY = std::max<int32_t>(maxY, cy + ch);
564 }
565 if (!any) return false;
566 if (x) *x = static_cast<int16_t>(minX + e->ox);
567 if (y) *y = static_cast<int16_t>(minY + e->oy);
568 if (w) *w = static_cast<uint16_t>(maxX - minX);
569 if (h) *h = static_cast<uint16_t>(maxY - minY);
570 return true;
571}
572
573// Recorded-coordinate bounding box of one command (element offset NOT applied).
574bool CanvasView::cmdBounds(const DrawCmd& c, int16_t* x, int16_t* y,
575 uint16_t* w, uint16_t* h) {
576 int32_t x0 = c.x, y0 = c.y, x1 = c.x, y1 = c.y;
577 switch (c.type) {
578 case CmdType::Text:
579 case CmdType::TextAligned: {
580 auto* g = gfx();
581 if (!g) return false;
582 const GFXfont* font = getGfxFont(c.fontId);
583 g->setTextSize(c.textSize);
584 int16_t bx, by;
585 uint16_t bw, bh;
586 render::measureText(g, &textArena_[c.strOff], font, 0, 0,
587 &bx, &by, &bw, &bh);
588 int16_t draw_x = c.x;
589 if (c.align == 1) {
590 draw_x = static_cast<int16_t>(c.x + (c.w - static_cast<int16_t>(bw)) / 2);
591 } else if (c.align == 2) {
592 draw_x = static_cast<int16_t>(c.x + c.w - static_cast<int16_t>(bw));
593 }
594 // measureText yields the box relative to the baseline cursor.
595 x0 = draw_x + bx;
596 y0 = c.y + by;
597 x1 = x0 + bw;
598 y1 = y0 + bh;
599 break;
600 }
601 case CmdType::Rect:
602 case CmdType::RoundRect:
603 x1 = c.x + c.w;
604 y1 = c.y + c.h;
605 break;
606 case CmdType::HLine:
607 x1 = c.x + c.w;
608 y1 = c.y + 1;
609 break;
610 case CmdType::VLine:
611 x1 = c.x + 1;
612 y1 = c.y + c.h;
613 break;
614 case CmdType::Pixel:
615 x1 = c.x + 1;
616 y1 = c.y + 1;
617 break;
618 case CmdType::Line:
619 x0 = std::min(c.x, c.w);
620 y0 = std::min(c.y, c.h);
621 x1 = std::max(c.x, c.w) + 1;
622 y1 = std::max(c.y, c.h) + 1;
623 break;
624 case CmdType::Circle:
625 x0 = c.x - c.w;
626 y0 = c.y - c.w;
627 x1 = c.x + c.w + 1;
628 y1 = c.y + c.w + 1;
629 break;
630 case CmdType::Triangle:
631 x0 = std::min(c.x, std::min(c.w, c.x2));
632 y0 = std::min(c.y, std::min(c.h, c.y2));
633 x1 = std::max(c.x, std::max(c.w, c.x2)) + 1;
634 y1 = std::max(c.y, std::max(c.h, c.y2)) + 1;
635 break;
636 case CmdType::Bitmap:
637 x1 = c.x + c.w;
638 y1 = c.y + c.h;
639 break;
640 case CmdType::Sprite: {
641 anim::SpriteStore::FrameView fv;
642 if (!sprites_.frameView(c.strOff, &fv)) return false;
643 uint16_t bw = fv.scrollWindow != 0 ? fv.scrollWindow
644 : ((fv.flags & anim::SPRITE_FLAG_ROT_90) ? fv.h : fv.w);
645 uint16_t bh = (fv.scrollWindow == 0 && (fv.flags & anim::SPRITE_FLAG_ROT_90))
646 ? fv.w : fv.h;
647 x1 = c.x + bw * fv.scale;
648 y1 = c.y + bh * fv.scale;
649 break;
650 }
651 }
652 *x = static_cast<int16_t>(x0);
653 *y = static_cast<int16_t>(y0);
654 *w = static_cast<uint16_t>(x1 - x0);
655 *h = static_cast<uint16_t>(y1 - y0);
656 return true;
657}
658
659// Re-pack both arenas after commands were dropped so removing and re-adding
660// elements does not exhaust the append-only allocators. Commands keep their
661// recording order, so arena spans stay ascending and moves only go left.
662void CanvasView::compactArenas() {
663 uint16_t textCursor = 0;
664 uint16_t blobCursor = 0;
665 for (uint16_t i = 0; i < cmdCount_; ++i) {
666 DrawCmd& c = cmds_[i];
667 if (c.type == CmdType::Text || c.type == CmdType::TextAligned) {
668 uint16_t span = static_cast<uint16_t>(c.strLen + 1); // incl. NUL
669 if (c.strOff != textCursor) {
670 memmove(&textArena_[textCursor], &textArena_[c.strOff], span);
671 c.strOff = textCursor;
672 }
673 textCursor = static_cast<uint16_t>(textCursor + span);
674 } else if (c.type == CmdType::Bitmap && c.strOff != BLOB_NONE) {
675 if (c.strOff != blobCursor) {
676 memmove(&blobArena_[blobCursor], &blobArena_[c.strOff], c.strLen);
677 c.strOff = blobCursor;
678 }
679 blobCursor = static_cast<uint16_t>(blobCursor + c.strLen);
680 }
681 }
682 textArenaUsed_ = textCursor;
683 blobUsed_ = blobCursor;
684}
685
686// Shift a command copy by the owning element's offset. Size-like fields stay
687// untouched; Line and Triangle store further points in w/h/x2/y2, so those
688// shift along.
689void CanvasView::applyElemOffset(DrawCmd& c, int16_t ox, int16_t oy) const {
690 c.x = static_cast<int16_t>(c.x + ox);
691 c.y = static_cast<int16_t>(c.y + oy);
692 switch (c.type) {
693 case CmdType::Line:
694 c.w = static_cast<int16_t>(c.w + ox);
695 c.h = static_cast<int16_t>(c.h + oy);
696 break;
697 case CmdType::Triangle:
698 c.w = static_cast<int16_t>(c.w + ox);
699 c.h = static_cast<int16_t>(c.h + oy);
700 c.x2 = static_cast<int16_t>(c.x2 + ox);
701 c.y2 = static_cast<int16_t>(c.y2 + oy);
702 break;
703 default:
704 break;
705 }
706}
707
708// Replay the display list in z layers: distinct element z values ascending,
709// untagged commands and z==0 elements interleave in recording order within
710// layer 0. With <= MAX_ELEMS layers the repeated sweep stays trivial.
711void CanvasView::replayDisplayList() {
712 cdc::core::RecursiveMutexGuard guard(editMutex_);
713 if (!gfx() || !cmds_) return;
714
715 int16_t layers[MAX_ELEMS + 1];
716 uint8_t layerCount = 0;
717 layers[layerCount++] = 0;
718 for (uint8_t i = 0; i < elemCount_; ++i) {
719 int16_t z = elems_[i].z;
720 bool seen = false;
721 for (uint8_t j = 0; j < layerCount; ++j) {
722 if (layers[j] == z) {
723 seen = true;
724 break;
725 }
726 }
727 if (!seen) layers[layerCount++] = z;
728 }
729 // Insertion sort ascending; layerCount <= 17.
730 for (uint8_t i = 1; i < layerCount; ++i) {
731 int16_t v = layers[i];
732 int8_t j = static_cast<int8_t>(i - 1);
733 while (j >= 0 && layers[j] > v) {
734 layers[j + 1] = layers[j];
735 --j;
736 }
737 layers[j + 1] = v;
738 }
739
740 for (uint8_t l = 0; l < layerCount; ++l) {
741 for (uint16_t i = 0; i < cmdCount_; ++i) {
742 DrawCmd c = cmds_[i];
743 int16_t z = 0;
744 if (c.elemId != 0) {
745 Elem* e = findElem(c.elemId);
746 if (e) {
747 if (e->hidden) continue;
748 z = e->z;
749 if (e->ox != 0 || e->oy != 0) applyElemOffset(c, e->ox, e->oy);
750 }
751 }
752 if (z != layers[l]) continue;
753 replayCmd(c);
754 }
755 }
756}
757
758void CanvasView::replayCmd(const DrawCmd& c) {
759 auto* g = gfx();
760 if (!g) return;
761 // Shapes recorded with white ink (setInkWhite) erase instead of paint.
762 const uint16_t ink = c.inverted ? EPD_WHITE : EPD_BLACK;
763 {
764 switch (c.type) {
765 case CmdType::Text:
766 paintText(c.x, c.y, 0, &textArena_[c.strOff], 0,
767 c.fontId, c.textSize, c.inverted);
768 break;
769 case CmdType::TextAligned:
770 paintText(c.x, c.y, c.w, &textArena_[c.strOff], c.align,
771 c.fontId, c.textSize, c.inverted);
772 break;
773 case CmdType::Rect: {
774 int16_t yy = c.y + bodyTop();
775 if (c.filled) {
776 if (c.shade >= 255) g->fillRect(c.x, yy, c.w, c.h, ink);
777 else if (c.shade > 0) render::fillRectDither(g, c.x, yy, c.w, c.h, c.shade, ink);
778 } else {
779 g->drawRect(c.x, yy, c.w, c.h, ink);
780 }
781 break;
782 }
783 case CmdType::HLine:
784 g->drawFastHLine(c.x, c.y + bodyTop(), c.w, ink);
785 break;
786 case CmdType::VLine:
787 g->drawFastVLine(c.x, c.y + bodyTop(), c.h, ink);
788 break;
789 case CmdType::Pixel:
790 g->drawPixel(c.x, c.y + bodyTop(), ink);
791 break;
792 case CmdType::Line:
793 g->drawLine(c.x, c.y + bodyTop(), c.w, c.h + bodyTop(), ink);
794 break;
795 case CmdType::Circle:
796 if (c.filled) {
797 if (c.shade >= 255) g->fillCircle(c.x, c.y + bodyTop(), c.w, ink);
798 else if (c.shade > 0) render::fillCircleDither(g, c.x, c.y + bodyTop(), c.w, c.shade, ink);
799 } else {
800 g->drawCircle(c.x, c.y + bodyTop(), c.w, ink);
801 }
802 break;
803 case CmdType::Triangle:
804 if (c.filled) {
805 if (c.shade >= 255)
806 g->fillTriangle(c.x, c.y + bodyTop(), c.w, c.h + bodyTop(),
807 c.x2, c.y2 + bodyTop(), ink);
808 else if (c.shade > 0)
809 render::fillTriangleDither(g, c.x, c.y + bodyTop(), c.w, c.h + bodyTop(),
810 c.x2, c.y2 + bodyTop(), c.shade, ink);
811 } else {
812 g->drawTriangle(c.x, c.y + bodyTop(), c.w, c.h + bodyTop(),
813 c.x2, c.y2 + bodyTop(), ink);
814 }
815 break;
816 case CmdType::RoundRect:
817 if (c.filled) {
818 g->fillRoundRect(c.x, c.y + bodyTop(), c.w, c.h, c.x2, ink);
819 } else {
820 g->drawRoundRect(c.x, c.y + bodyTop(), c.w, c.h, c.x2, ink);
821 }
822 break;
823 case CmdType::Bitmap:
824 g->drawBitmap(c.x, c.y + bodyTop(), &blobArena_[c.strOff],
825 c.w, c.h, ink);
826 break;
827 case CmdType::Sprite: {
828 anim::SpriteStore::FrameView fv;
829 if (!sprites_.frameView(c.strOff, &fv)) break; // destroyed
830 render::BlitOpts opts;
831 opts.mask = fv.mask;
832 opts.opaque = (fv.flags & anim::SPRITE_FLAG_OPAQUE) != 0;
833 opts.flipH = (fv.flags & anim::SPRITE_FLAG_FLIP_H) != 0;
834 opts.flipV = (fv.flags & anim::SPRITE_FLAG_FLIP_V) != 0;
835 opts.rot90 = (fv.flags & anim::SPRITE_FLAG_ROT_90) != 0;
836 opts.scale = fv.scale;
837 if (fv.scrollWindow != 0) {
838 opts.srcX = fv.scrollX;
839 opts.srcW = fv.scrollWindow;
840 opts.srcSpan = static_cast<uint16_t>(fv.w + fv.scrollGap);
841 }
843 g, c.x, static_cast<int16_t>(c.y + bodyTop()), fv.data,
844 static_cast<int16_t>(fv.w), static_cast<int16_t>(fv.h),
845 opts, EPD_BLACK, EPD_WHITE);
846 break;
847 }
848 }
849 }
850}
851
852void CanvasView::commit(bool full_refresh) {
853 if (full_refresh) {
854 needsFullRefresh_ = true;
855 }
856 markDirty();
857}
858
859// --- Sprites -----------------------------------------------------------------
860
861bool CanvasView::ensureSpriteArena() {
862 if (sprites_.hasArena()) return true;
864 if (!spriteArena_) {
865 LOG_E("CanvasView", "sprite arena allocation failed");
866 return false;
867 }
868 sprites_.setArena(spriteArena_.get(), SPRITE_ARENA);
869 return true;
870}
871
872int32_t CanvasView::spriteCreate(uint16_t w, uint16_t h, uint16_t frame_count,
873 const uint8_t* data, uint32_t len) {
874 cdc::core::RecursiveMutexGuard guard(editMutex_);
875 if (!ensureSpriteArena()) return -1;
876 return sprites_.create(w, h, frame_count, data, len);
877}
878
879bool CanvasView::spriteSetMask(uint32_t handle, const uint8_t* mask, uint32_t len) {
880 cdc::core::RecursiveMutexGuard guard(editMutex_);
881 return sprites_.setMask(handle, mask, len);
882}
883
884bool CanvasView::spriteSetFlags(uint32_t handle, uint8_t flags) {
885 cdc::core::RecursiveMutexGuard guard(editMutex_);
886 return sprites_.setFlags(handle, flags);
887}
888
889bool CanvasView::spriteSetScale(uint32_t handle, uint8_t scale) {
890 cdc::core::RecursiveMutexGuard guard(editMutex_);
891 return sprites_.setScale(handle, scale);
892}
893
894// Render the text once into a MonoSurface, keep it as a single-frame sprite
895// and let the store's scroll mode slide a window through it. The gap equals
896// the window, so the text fully leaves before re-entering.
897int32_t CanvasView::marquee(int16_t x, int16_t y, int16_t window_w,
898 const char* text, uint16_t step_px, uint16_t frame_ms) {
899 cdc::core::RecursiveMutexGuard guard(editMutex_);
900 auto* g = gfx();
901 if (!g || !text || window_w <= 0 || step_px == 0) return 0;
902 if (!cmds_ || cmdCount_ >= MAX_CMDS) return -1;
903 if (!ensureSpriteArena()) return -1;
904
905 const GFXfont* font = getGfxFont(fontId_);
906 g->setTextSize(textSize_);
907 int16_t bx, by;
908 uint16_t bw, bh;
909 render::measureText(g, text, font, 0, 0, &bx, &by, &bw, &bh);
910 if (bw == 0 || bh == 0) return 0;
911 // Cap the strip so one line can never exhaust the arena.
912 if (bw > 1024) bw = 1024;
913
914 MonoSurface strip(bw, static_cast<uint16_t>(bh + 2));
915 if (!strip.ok()) return -1;
916 strip.setFont(font);
917 strip.setTextSize(textSize_);
918 strip.setTextColor(1);
919 strip.setTextWrap(false);
920 strip.setCursor(static_cast<int16_t>(-bx), static_cast<int16_t>(-by));
921 render::drawText(&strip, text, font);
922
923 int32_t handle = sprites_.create(bw, static_cast<uint16_t>(bh + 2), 1,
924 strip.buffer(), strip.byteSize());
925 if (handle <= 0) return handle;
926 sprites_.scroll(static_cast<uint32_t>(handle), static_cast<uint16_t>(window_w),
927 step_px, static_cast<uint16_t>(window_w), frame_ms, nowMs());
928
929 DrawCmd c{};
930 c.type = CmdType::Sprite;
931 c.x = x;
932 c.y = y;
933 c.strOff = static_cast<uint16_t>(handle);
934 pushCmd(c);
935
936 animActive_ = true;
937 idleCleanupAtMs_ = 0;
938 markDirty();
939 return handle;
940}
941
942bool CanvasView::spriteSetFrame(uint32_t handle, uint16_t frame) {
943 cdc::core::RecursiveMutexGuard guard(editMutex_);
944 return sprites_.setFrame(handle, frame);
945}
946
947int32_t CanvasView::spriteGetFrame(uint32_t handle) const {
948 cdc::core::RecursiveMutexGuard guard(editMutex_);
949 return sprites_.frame(handle);
950}
951
952bool CanvasView::spriteSetFrameDurations(uint32_t handle, const uint16_t* ms,
953 uint16_t count) {
954 cdc::core::RecursiveMutexGuard guard(editMutex_);
955 return sprites_.setFrameDurations(handle, ms, count);
956}
957
958bool CanvasView::spritePlay(uint32_t handle, uint8_t mode, uint16_t frame_ms,
959 uint16_t repeat, uint32_t done_action_id) {
960 cdc::core::RecursiveMutexGuard guard(editMutex_);
961 if (!sprites_.play(handle, mode, frame_ms, repeat, done_action_id, nowMs())) {
962 return false;
963 }
964 animActive_ = true;
965 idleCleanupAtMs_ = 0;
966 markDirty();
967 return true;
968}
969
970bool CanvasView::spriteStop(uint32_t handle) {
971 cdc::core::RecursiveMutexGuard guard(editMutex_);
972 return sprites_.stop(handle);
973}
974
975bool CanvasView::spriteDestroy(uint32_t handle) {
976 cdc::core::RecursiveMutexGuard guard(editMutex_);
977 return sprites_.destroy(handle);
978}
979
980bool CanvasView::spriteFrameView(uint32_t handle,
981 anim::SpriteStore::FrameView* out) const {
982 cdc::core::RecursiveMutexGuard guard(editMutex_);
983 return sprites_.frameView(handle, out);
984}
985
986void CanvasView::drawSprite(int16_t x, int16_t y, uint32_t handle) {
987 cdc::core::RecursiveMutexGuard guard(editMutex_);
988 if (!cmds_ || cmdCount_ >= MAX_CMDS) return;
990 if (!sprites_.frameView(handle, &fv)) return;
991 DrawCmd c{};
992 c.type = CmdType::Sprite;
993 c.x = x;
994 c.y = y;
995 c.strOff = static_cast<uint16_t>(handle);
996 pushCmd(c);
997}
998
999// --- Tweens ------------------------------------------------------------------
1000
1002 cdc::core::RecursiveMutexGuard guard(editMutex_);
1003 if (!findElem(cfg.elemId)) return 0;
1004 uint32_t handle = animator_.start(cfg, nowMs());
1005 if (handle != 0) {
1006 animActive_ = true;
1007 idleCleanupAtMs_ = 0;
1008 markDirty();
1009 }
1010 return handle;
1011}
1012
1013uint32_t CanvasView::animBlink(uint32_t elem_id, uint16_t period_ms,
1014 uint16_t count, uint32_t done_action_id) {
1015 cdc::core::RecursiveMutexGuard guard(editMutex_);
1016 if (!findElem(elem_id)) return 0;
1017 uint32_t handle = animator_.blink(elem_id, period_ms, count,
1018 done_action_id, nowMs());
1019 if (handle != 0) {
1020 animActive_ = true;
1021 idleCleanupAtMs_ = 0;
1022 }
1023 return handle;
1024}
1025
1026bool CanvasView::animCancel(uint32_t handle) {
1027 cdc::core::RecursiveMutexGuard guard(editMutex_);
1028 return animator_.cancel(handle);
1029}
1030
1031bool CanvasView::animPause(uint32_t handle, bool paused) {
1032 cdc::core::RecursiveMutexGuard guard(editMutex_);
1033 return animator_.pause(handle, paused, nowMs());
1034}
1035
1036int8_t CanvasView::animState(uint32_t handle) const {
1037 cdc::core::RecursiveMutexGuard guard(editMutex_);
1038 return animator_.state(handle);
1039}
1040
1042 cdc::core::RecursiveMutexGuard guard(editMutex_);
1043 uint8_t n = animator_.activeCount();
1044 // Playing sprites count as active animations for the caller.
1045 return sprites_.anyPlaying() ? static_cast<uint8_t>(n + 1) : n;
1046}
1047
1048bool CanvasView::setAnimPolicy(uint8_t policy, uint8_t max_fps) {
1049 if (policy > ANIM_REFRESH_LIGHT || max_fps > ANIM_FPS_MAX) return false;
1050 cdc::core::RecursiveMutexGuard guard(editMutex_);
1051 animPolicy_ = policy;
1052 uint8_t fps = max_fps == 0 ? ANIM_FPS_DEFAULT : max_fps;
1053 animFrameIntervalMs_ = static_cast<uint16_t>(1000 / fps);
1054 return true;
1055}
1056
1057// --- anim::AnimTarget (invoked under editMutex_) -------------------------------
1058
1059bool CanvasView::getElemOffset(uint32_t elemId, int16_t* ox, int16_t* oy) {
1060 Elem* e = findElem(elemId);
1061 if (!e) return false;
1062 *ox = e->ox;
1063 *oy = e->oy;
1064 return true;
1065}
1066
1067bool CanvasView::setElemOffset(uint32_t elemId, int16_t ox, int16_t oy) {
1068 Elem* e = findElem(elemId);
1069 if (!e) return false;
1070 e->ox = ox;
1071 e->oy = oy;
1072 return true;
1073}
1074
1075bool CanvasView::setElemVisible(uint32_t elemId, bool visible) {
1076 Elem* e = findElem(elemId);
1077 if (!e) return false;
1078 e->hidden = !visible;
1079 return true;
1080}
1081
1082CanvasView::Widget* CanvasView::findWidget(uint32_t id) {
1083 if (id == 0) return nullptr;
1084 for (uint8_t i = 0; i < widgetCount_; ++i) {
1085 if (widgets_[i].id == id) return &widgets_[i];
1086 }
1087 return nullptr;
1088}
1089
1090const CanvasView::Widget* CanvasView::findWidget(uint32_t id) const {
1091 if (id == 0) return nullptr;
1092 for (uint8_t i = 0; i < widgetCount_; ++i) {
1093 if (widgets_[i].id == id) return &widgets_[i];
1094 }
1095 return nullptr;
1096}
1097
1098CanvasView::Widget* CanvasView::focusedWidget() {
1099 return findWidget(focused_);
1100}
1101
1102bool CanvasView::addSlider(uint32_t id, int32_t min, int32_t max,
1103 int32_t initial, int32_t step) {
1104 if (id == 0 || !widgets_ || widgetCount_ >= MAX_WIDGETS || findWidget(id)) {
1105 return false;
1106 }
1107 Widget& w = widgets_[widgetCount_++];
1108 w = Widget{};
1109 w.id = id;
1110 w.type = WidgetType::Slider;
1111 w.min = min;
1112 w.max = max;
1113 w.step = step > 0 ? step : 1;
1114 w.value = std::clamp(initial, min, max);
1115 return true;
1116}
1117
1118bool CanvasView::addText(uint32_t id, uint16_t max_len, const char* initial) {
1119 if (id == 0 || !widgets_ || widgetCount_ >= MAX_WIDGETS || findWidget(id)) {
1120 return false;
1121 }
1122 Widget& w = widgets_[widgetCount_++];
1123 w = Widget{};
1124 w.id = id;
1125 w.type = WidgetType::Text;
1126 w.max_len = max_len < MAX_TEXT_LEN ? max_len : (MAX_TEXT_LEN - 1);
1127 if (initial) {
1128 size_t n = std::min(strlen(initial), static_cast<size_t>(w.max_len));
1129 memcpy(w.text, initial, n);
1130 w.text[n] = '\0';
1131 w.text_len = static_cast<uint16_t>(n);
1132 }
1133 return true;
1134}
1135
1136bool CanvasView::addButton(uint32_t id) {
1137 if (id == 0 || !widgets_ || widgetCount_ >= MAX_WIDGETS || findWidget(id)) {
1138 return false;
1139 }
1140 Widget& w = widgets_[widgetCount_++];
1141 w = Widget{};
1142 w.id = id;
1143 w.type = WidgetType::Button;
1144 return true;
1145}
1146
1147bool CanvasView::removeWidget(uint32_t id) {
1148 for (uint8_t i = 0; i < widgetCount_; ++i) {
1149 if (widgets_[i].id == id) {
1150 for (uint8_t j = i + 1; j < widgetCount_; ++j) {
1151 widgets_[j - 1] = widgets_[j];
1152 }
1153 --widgetCount_;
1154 widgets_[widgetCount_] = Widget{};
1155 if (focused_ == id) focused_ = 0;
1156 return true;
1157 }
1158 }
1159 return false;
1160}
1161
1162bool CanvasView::setValue(uint32_t id, int32_t value) {
1163 Widget* w = findWidget(id);
1164 if (!w || w->type != WidgetType::Slider) return false;
1165 w->value = std::clamp(value, w->min, w->max);
1166 return true;
1167}
1168
1169bool CanvasView::getValue(uint32_t id, int32_t* out) const {
1170 const Widget* w = findWidget(id);
1171 if (!w || !out) return false;
1172 *out = w->value;
1173 return true;
1174}
1175
1176bool CanvasView::setText(uint32_t id, const char* text) {
1177 Widget* w = findWidget(id);
1178 if (!w || w->type != WidgetType::Text) return false;
1179 size_t n = std::min(text ? strlen(text) : size_t{0},
1180 static_cast<size_t>(w->max_len));
1181 if (text) memcpy(w->text, text, n);
1182 w->text[n] = '\0';
1183 w->text_len = static_cast<uint16_t>(n);
1184 w->t9_last_key = 0;
1185 w->t9_press_count = 0;
1186 return true;
1187}
1188
1189int CanvasView::getText(uint32_t id, char* out, size_t cap) const {
1190 const Widget* w = findWidget(id);
1191 if (!w || !out || cap == 0) return -1;
1192 size_t n = std::min(static_cast<size_t>(w->text_len), cap - 1);
1193 memcpy(out, w->text, n);
1194 out[n] = '\0';
1195 return static_cast<int>(n);
1196}
1197
1198bool CanvasView::setFocus(uint32_t id) {
1199 if (id == 0) {
1200 focused_ = 0;
1201 return true;
1202 }
1203 Widget* w = findWidget(id);
1204 if (!w) return false;
1205 focused_ = id;
1206 return true;
1207}
1208
1209void CanvasView::t9_commit_pending(Widget& w) {
1210 w.t9_last_key = 0;
1211 w.t9_press_count = 0;
1212}
1213
1214void CanvasView::t9_apply_key(Widget& w, char key, uint32_t now) {
1215 const char* table = t9_chars(key);
1216 if (!table) return;
1217 size_t table_len = strlen(table);
1218 if (table_len == 0) return;
1219
1220 bool same_key = (w.t9_last_key == key)
1221 && ((now - w.t9_last_time) < T9_SETTLE_MS)
1222 && w.text_len > 0;
1223
1224 if (same_key) {
1225 w.t9_press_count = (w.t9_press_count + 1) % static_cast<uint8_t>(table_len);
1226 w.text[w.text_len - 1] = table[w.t9_press_count];
1227 } else {
1228 if (w.text_len >= w.max_len) {
1229 return;
1230 }
1231 w.text[w.text_len++] = table[0];
1232 w.text[w.text_len] = '\0';
1233 w.t9_press_count = 0;
1234 }
1235 w.t9_last_key = key;
1236 w.t9_last_time = now;
1237}
1238
1239InputResult CanvasView::dispatchKeyToWidget(Widget& w, char key) {
1240 uint32_t now = nowMs();
1241 switch (w.type) {
1242 case WidgetType::Slider: {
1243 if (key == '4' || key == KEY_UP) {
1244 int32_t nv = std::clamp<int32_t>(w.value - w.step, w.min, w.max);
1245 if (nv != w.value) {
1246 w.value = nv;
1247 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1248 }
1249 return InputResult::CONSUMED;
1250 }
1251 if (key == '6' || key == KEY_DOWN) {
1252 int32_t nv = std::clamp<int32_t>(w.value + w.step, w.min, w.max);
1253 if (nv != w.value) {
1254 w.value = nv;
1255 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1256 }
1257 return InputResult::CONSUMED;
1258 }
1259 if (key == KEY_YES) {
1260 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Committed);
1261 return InputResult::CONSUMED;
1262 }
1263 if (key == KEY_NO) {
1264 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Cancelled);
1265 return InputResult::CONSUMED;
1266 }
1267 return InputResult::IGNORED;
1268 }
1269 case WidgetType::Text: {
1270 if (key >= '0' && key <= '9') {
1271 t9_apply_key(w, key, now);
1272 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1273 return InputResult::CONSUMED;
1274 }
1275 if (key == '*') {
1276 t9_commit_pending(w);
1277 if (w.text_len > 0) {
1278 --w.text_len;
1279 w.text[w.text_len] = '\0';
1280 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1281 }
1282 return InputResult::CONSUMED;
1283 }
1284 if (key == '#') {
1285 t9_commit_pending(w);
1286 if (w.text_len < w.max_len) {
1287 w.text[w.text_len++] = ' ';
1288 w.text[w.text_len] = '\0';
1289 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1290 }
1291 return InputResult::CONSUMED;
1292 }
1293 if (key == KEY_YES) {
1294 t9_commit_pending(w);
1295 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Committed);
1296 return InputResult::CONSUMED;
1297 }
1298 if (key == KEY_NO) {
1299 if (w.text_len > 0 && w.t9_last_key != 0
1300 && (now - w.t9_last_time) < T9_SETTLE_MS) {
1301 t9_commit_pending(w);
1302 return InputResult::CONSUMED;
1303 }
1304 if (w.text_len > 0) {
1305 --w.text_len;
1306 w.text[w.text_len] = '\0';
1307 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Changed);
1308 return InputResult::CONSUMED;
1309 }
1310 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Cancelled);
1311 return InputResult::CONSUMED;
1312 }
1313 return InputResult::IGNORED;
1314 }
1315 case WidgetType::Button: {
1316 if (key == KEY_YES) {
1317 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Committed);
1318 return InputResult::CONSUMED;
1319 }
1320 if (key == KEY_NO) {
1321 if (widgetCb_) widgetCb_(w.id, WidgetEvent::Cancelled);
1322 return InputResult::CONSUMED;
1323 }
1324 return InputResult::IGNORED;
1325 }
1326 default:
1327 return InputResult::IGNORED;
1328 }
1329}
1330
1332 Widget* focused = focusedWidget();
1333 if (focused) {
1334 InputResult r = dispatchKeyToWidget(*focused, key);
1335 if (r == InputResult::CONSUMED) {
1336 return InputResult::CONSUMED;
1337 }
1338 }
1339
1340 if (keyCb_) {
1341 keyCb_(key, focused_);
1342 return InputResult::CONSUMED;
1343 }
1344
1345 if (key == KEY_NO) {
1347 }
1348 return InputResult::IGNORED;
1349}
1350
1351// Push this canvas's keypad modes (deferred short-press while a long-press
1352// handler is registered, and key-repeat) to the global keypad. Applied while
1353// the canvas is the active view; onExit restores the defaults for other views.
1354void CanvasView::applyKeypadConfig() {
1355 if (auto* kp = hal::getKeypadInstance()) {
1356 kp->setDeferShortPress(hal::IKeypad::DEFER_SRC_VIEW, longPressCb_ != nullptr);
1357 kp->setKeyRepeat(keyRepeatInitialMs_, keyRepeatPeriodMs_);
1358 }
1359}
1360
1362 longPressCb_ = cb;
1363 applyKeypadConfig();
1364}
1365
1367 if (longPressCb_) {
1368 longPressCb_(key);
1369 return InputResult::CONSUMED;
1370 }
1371 return InputResult::IGNORED;
1372}
1373
1374void CanvasView::onEnter(void* context) {
1375 ViewBase::onEnter(context);
1376 applyKeypadConfig();
1377}
1378
1381 applyKeypadConfig();
1382 // Time passed under a covering view/modal must not fast-forward
1383 // animations: shift every time base by the pause duration.
1384 if (pausedAtMs_ != 0) {
1385 uint32_t delta = nowMs() - pausedAtMs_;
1386 pausedAtMs_ = 0;
1387 cdc::core::RecursiveMutexGuard guard(editMutex_);
1388 animator_.shiftTime(delta);
1389 sprites_.shiftTime(delta);
1390 if (idleCleanupAtMs_ != 0) idleCleanupAtMs_ += delta;
1391 }
1392}
1393
1395 pausedAtMs_ = nowMs();
1397}
1398
1400 if (auto* kp = hal::getKeypadInstance()) {
1401 kp->setDeferShortPress(hal::IKeypad::DEFER_SRC_VIEW, false);
1402 kp->setKeyRepeat(0, 0);
1403 }
1404 // Stop the engines without firing completion events into a view that is
1405 // going away.
1406 {
1407 cdc::core::RecursiveMutexGuard guard(editMutex_);
1408 animator_.reset();
1409 if (sprites_.hasArena()) sprites_.setArena(spriteArena_.get(), SPRITE_ARENA);
1410 animActive_ = false;
1411 idleCleanupAtMs_ = 0;
1412 }
1414}
1415
1416// Animation clock: runs on the UI task inside ViewStack::dispatchTick, i.e.
1417// in the same loop iteration as a subsequent render. Advancing at the FPS cap
1418// (time-based, so slow refreshes drop frames instead of slowing motion) and
1419// completion dispatch happens outside the edit mutex.
1420void CanvasView::onTick(uint32_t nowMs) {
1421 if (pausedAtMs_ != 0) return; // covered by a modal; frozen
1422
1423 anim::Completion done[ANIM_DONE_CAP];
1424 uint8_t doneCount = 0;
1425 bool cleanup = false;
1426
1427 {
1428 cdc::core::RecursiveMutexGuard guard(editMutex_);
1429 bool wasActive = animator_.activeCount() > 0 || sprites_.anyPlaying();
1430 if (wasActive
1431 && static_cast<uint32_t>(nowMs - lastAnimStepMs_) >= animFrameIntervalMs_) {
1432 lastAnimStepMs_ = nowMs;
1433 bool changed = false;
1434 uint8_t n = 0;
1435 changed |= animator_.advance(nowMs, *this, done,
1436 ANIM_DONE_CAP, &n);
1437 doneCount = n;
1438 n = 0;
1439 changed |= sprites_.advance(nowMs, done + doneCount,
1440 static_cast<uint8_t>(ANIM_DONE_CAP - doneCount),
1441 &n);
1442 doneCount = static_cast<uint8_t>(doneCount + n);
1443 if (changed) {
1444 markDirty();
1445 if (animPolicy_ == ANIM_REFRESH_AUTO
1446 && ++animCommitCount_ >= ANIM_HYGIENE_COMMITS) {
1447 // Endless animations never reach the idle cleanup; restore
1448 // the panel's DC balance with a rare single flash.
1449 animCommitCount_ = 0;
1450 cleanup = true;
1451 }
1452 }
1453 }
1454 bool nowActive = animator_.activeCount() > 0 || sprites_.anyPlaying();
1455 if (wasActive && !nowActive && animPolicy_ == ANIM_REFRESH_AUTO) {
1456 idleCleanupAtMs_ = nowMs + ANIM_CLEANUP_IDLE_MS;
1457 }
1458 if (nowActive) idleCleanupAtMs_ = 0;
1459 animActive_ = nowActive;
1460 }
1461
1462 if (idleCleanupAtMs_ != 0
1463 && static_cast<int32_t>(nowMs - idleCleanupAtMs_) >= 0) {
1464 idleCleanupAtMs_ = 0;
1465 cleanup = true;
1466 }
1467
1468 // Outside the lock: plugin dispatch may re-enter canvas host calls.
1469 for (uint8_t i = 0; i < doneCount; ++i) {
1470 if (animCb_) animCb_(done[i].doneActionId, done[i].handle, done[i].refId);
1471 }
1472 if (cleanup) {
1474 }
1475}
1476
1477void CanvasView::render(bool partial) {
1479 if (!display) return;
1480 auto* g = gfx();
1481 if (!g) return;
1482
1483 const uint16_t width = display->getWidth();
1484 const uint16_t height = display->getHeight();
1485
1486 if (!partial || needsFullRefresh_) {
1487 g->fillScreen(EPD_WHITE);
1488 needsFullRefresh_ = false;
1489 headerDrawnOnce_ = false;
1490 }
1491
1492 if (title_ && title_[0] != '\0' && !headerDrawnOnce_) {
1493 g->setTextColor(EPD_BLACK);
1494 g->setTextSize(1);
1495 g->setTextWrap(false);
1496 render::drawHeaderLeft(g, title_, 4, TITLE_Y, width);
1497 headerDrawnOnce_ = true;
1498 }
1499
1500 replayDisplayList();
1501 // The replay leaves the plugin's font/size/color set, but the footer below
1502 // sets its own style and any view pushed on top is reset centrally in
1503 // ViewStack::render, so no local restore is needed here.
1504
1505 const char* hint = customFooter_ ? customFooter_ : footer_;
1506 render::drawFooterBar(g, width, height, nullptr, hint, hint != nullptr);
1507
1508 dirty_ = false;
1509}
1510
1511} // namespace cdc::ui
Offscreen 1-bpp Adafruit_GFX render target in PSRAM.
static const char * t9_chars[]
T9 digit-to-character mapping table.
uint8_t flags
CDC Log: logging over TinyUSB CDC and UART.
#define LOG_W(tag, fmt,...)
Definition cdc_log.h:146
#define LOG_E(tag, fmt,...)
Definition cdc_log.h:145
Scoped guard for a FreeRTOS recursive mutex.
Definition Raii.h:245
static constexpr uint32_t DEFER_SRC_VIEW
active view (e.g. canvas long-press)
Definition IKeypad.h:90
bool elemShow(uint32_t id, bool visible)
Show or hide the element (hidden elements are skipped on replay).
void render(bool partial) override
uint32_t animStart(const anim::TweenConfig &cfg)
void getBodySize(uint16_t *w, uint16_t *h) const
bool removeWidget(uint32_t id)
void endElem()
Stop tagging draw calls with an element id.
bool spriteSetScale(uint32_t handle, uint8_t scale)
Integer upscale 1..4 applied whenever the sprite is drawn.
static constexpr uint8_t ANIM_FPS_DEFAULT
Definition CanvasView.h:62
bool elemGetBounds(uint32_t id, int16_t *x, int16_t *y, uint16_t *w, uint16_t *h)
InputResult onLongPress(char key) override
int getText(uint32_t id, char *out, size_t cap) const
uint32_t animBlink(uint32_t elem_id, uint16_t period_ms, uint16_t count, uint32_t done_action_id)
@ ANIM_REFRESH_LIGHT
PARTIAL_LIGHT always, no automatic cleanup.
Definition CanvasView.h:58
@ ANIM_REFRESH_AUTO
PARTIAL_LIGHT while running + FAST cleanup.
Definition CanvasView.h:57
void drawText(int16_t x, int16_t y, const char *text)
void(*)(char key) LongPressCallback
Definition CanvasView.h:71
void drawHLine(int16_t x, int16_t y, int16_t w)
void drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1)
static constexpr uint8_t ANIM_FPS_MAX
Definition CanvasView.h:63
void clearBody(bool keep_sprites=false)
bool beginElem(uint32_t id)
void drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, bool filled)
void init(const char *title)
InputResult onKey(char key) override
bool elemClear(uint32_t id)
void commit(bool full_refresh)
bool spriteFrameView(uint32_t handle, anim::SpriteStore::FrameView *out) const
bool spriteDestroy(uint32_t handle)
void onResume() override
static constexpr uint16_t MAX_CMDS
Definition CanvasView.h:48
static constexpr uint8_t ANIM_HYGIENE_COMMITS
Endless animations get one hygiene FAST every this many anim commits.
Definition CanvasView.h:67
void onExit() override
bool spriteSetFlags(uint32_t handle, uint8_t flags)
void setLongPressCallback(LongPressCallback cb)
void onEnter(void *context) override
bool animPause(uint32_t handle, bool paused)
bool spriteStop(uint32_t handle)
static constexpr uint16_t ANIM_CLEANUP_IDLE_MS
Idle time after the last animation before the AUTO ghost-cleanup FAST.
Definition CanvasView.h:65
uint8_t animActiveCount() const
Live tweens plus playing sprites.
static constexpr uint16_t BLOB_ARENA
Pixel-data arena for recorded bitmaps. 8 KB holds one full-body mono bitmap.
Definition CanvasView.h:51
void drawTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, int16_t y2, bool filled)
bool spriteSetMask(uint32_t handle, const uint8_t *mask, uint32_t len)
static constexpr uint8_t MAX_WIDGETS
Definition CanvasView.h:41
bool setFocus(uint32_t id)
bool spritePlay(uint32_t handle, uint8_t mode, uint16_t frame_ms, uint16_t repeat, uint32_t done_action_id)
bool spriteSetFrameDurations(uint32_t handle, const uint16_t *ms, uint16_t count)
int8_t animState(uint32_t handle) const
void drawBitmap(int16_t x, int16_t y, int16_t w, int16_t h, const uint8_t *data, uint32_t len)
bool animCancel(uint32_t handle)
Handle 0 cancels all (no events).
bool setValue(uint32_t id, int32_t value)
bool addText(uint32_t id, uint16_t max_len, const char *initial)
bool elemSetZ(uint32_t id, int8_t z)
static constexpr uint32_t SPRITE_ARENA
Pixel arena for sprite frame sheets (lazy PSRAM allocation).
Definition CanvasView.h:53
bool getValue(uint32_t id, int32_t *out) const
bool addButton(uint32_t id)
bool setText(uint32_t id, const char *text)
static constexpr uint16_t T9_SETTLE_MS
Definition CanvasView.h:44
bool elemRemove(uint32_t id)
bool elemMove(uint32_t id, int16_t dx, int16_t dy)
Shift the element's replay offset by a delta.
int32_t spriteGetFrame(uint32_t handle) const
static constexpr uint16_t MAX_TEXT_LEN
Definition CanvasView.h:43
void setKeyRepeat(uint16_t initial_ms, uint16_t repeat_ms)
int32_t marquee(int16_t x, int16_t y, int16_t window_w, const char *text, uint16_t step_px, uint16_t frame_ms)
bool spriteSetFrame(uint32_t handle, uint16_t frame)
void drawCircle(int16_t x, int16_t y, int16_t r, bool filled)
bool elemSetOffset(uint32_t id, int16_t ox, int16_t oy)
Set the element's replay offset relative to its recorded coordinates.
bool addSlider(uint32_t id, int32_t min, int32_t max, int32_t initial, int32_t step)
bool setAnimPolicy(uint8_t policy, uint8_t max_fps)
Configure refresh policy and step-rate cap (0 = default 4 fps).
void drawPixel(int16_t x, int16_t y)
void setFooter(const char *hint)
static constexpr uint16_t TEXT_ARENA
Definition CanvasView.h:49
void drawTextAligned(int16_t x, int16_t y, int16_t w, const char *text, uint8_t align)
void onPause() override
void drawRect(int16_t x, int16_t y, int16_t w, int16_t h, bool filled)
bool elemGetOffset(uint32_t id, int16_t *ox, int16_t *oy) const
Read the element's current replay offset.
void drawSprite(int16_t x, int16_t y, uint32_t handle)
Record a draw-by-reference of the sprite's current frame at (x, y).
int32_t spriteCreate(uint16_t w, uint16_t h, uint16_t frame_count, const uint8_t *data, uint32_t len)
void onTick(uint32_t nowMs) override
static constexpr uint8_t MAX_ELEMS
Definition CanvasView.h:42
void drawVLine(int16_t x, int16_t y, int16_t h)
const uint8_t * buffer() const
Packed pixel rows, strideBytes() * height() bytes.
Definition MonoSurface.h:42
size_t byteSize() const
Total packed byte size.
Definition MonoSurface.h:48
bool ok() const
True when the buffer allocation succeeded.
Definition MonoSurface.h:34
void onResume() override
Definition IView.h:180
void markDirty() override
Definition IView.h:197
void onPause() override
Definition IView.h:185
const char * title_
Definition IView.h:213
void onExit() override
Definition IView.h:178
void onEnter(void *context) override
Definition IView.h:173
const char * customFooter_
Definition IView.h:214
static ViewStack & instance()
Returns singleton view-stack instance.
Definition ViewStack.cpp:53
void forceRefresh(hal::RefreshMode mode)
Escalates the refresh mode of the next render.
static constexpr uint8_t MAX_TWEENS
Definition CanvasAnim.h:119
void setArena(uint8_t *buf, uint32_t cap)
Inject the pixel arena. Must happen before create(); resets the store.
static constexpr uint8_t MAX_SPRITES
Definition CanvasAnim.h:214
PsramUniquePtr< T > psramAlloc(std::size_t count) noexcept
Allocate count elements of T in PSRAM (8-bit capable region).
Definition Raii.h:51
IDisplay * getDisplayInstance()
Returns lazily created singleton display instance.
IKeypad * getKeypadInstance()
Returns the singleton keypad service instance.
@ SPRITE_FLAG_ROT_90
Rotate 90 deg clockwise (before flips).
Definition CanvasAnim.h:62
@ SPRITE_FLAG_OPAQUE
Unset data bits paint white.
Definition CanvasAnim.h:59
constexpr int FOOTER_HEIGHT
Footer bar height in pixels (used by drawFooterBar).
void drawText(Adafruit_GFX *gfx, const char *text, const GFXfont *font)
Draws CP437-encoded text correctly for the given font: the built-in glcdfont (font == nullptr) is CP4...
void drawFooterBar(Gdey029T94 *gfx, uint16_t width, uint16_t height, const char *prefix, const char *hint, bool force=false)
Draws footer bar with optional prefix and hint text.
void drawBitmapMasked(Adafruit_GFX *gfx, int16_t x, int16_t y, const uint8_t *data, int16_t w, int16_t h, const BlitOpts &opts, uint16_t fg, uint16_t bg)
Blit a packed 1-bpp bitmap with optional mask plane, flipping, 90-degree rotation,...
void fillTriangleDither(Adafruit_GFX *gfx, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, int16_t y2, uint8_t shade, uint16_t color)
Dithered filled triangle; see fillRectDither.
void fillCircleDither(Adafruit_GFX *gfx, int16_t cx, int16_t cy, int16_t r, uint8_t shade, uint16_t color)
Dithered filled circle; see fillRectDither.
void measureText(Adafruit_GFX *gfx, const char *text, const GFXfont *font, int16_t x0, int16_t y0, int16_t *x1, int16_t *y1, uint16_t *w, uint16_t *h)
Measures CP437 text exactly as drawText would render it with font, so width-based layout (centering,...
void drawHeaderLeft(Gdey029T94 *gfx, const char *title, int x, int y, uint16_t width, int underlineOffset=18)
void fillRectDither(Adafruit_GFX *gfx, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t shade, uint16_t color)
Fills a rectangle with an ordered-dither fake-grey pattern.
Centralized key-code constants for cdc_views.
Definition IModule.h:8
const GFXfont * getGfxFont(FontId id)
Resolves a FontId to its underlying GFX font pointer.
Definition Fonts.cpp:26
static constexpr char KEY_DOWN
Move selection down (numeric '8').
Definition KeyCodes.h:35
Gdey029T94 * display
InputResult
Definition IView.h:11
static constexpr char KEY_NO
Cancel / Back / Backspace.
Definition KeyCodes.h:44
static constexpr int TITLE_Y
Layout constants mirror the ones used by T9InputView.
static constexpr char KEY_UP
Move selection up (numeric '2').
Definition KeyCodes.h:32
static constexpr char KEY_YES
Confirm / OK / Save.
Definition KeyCodes.h:41
Replay accessors: geometry, flags and pixel pointers of a sprite.
Definition CanvasAnim.h:267
Tween start configuration (mirrors host_anim_t plus the blink shorthand).
Definition CanvasAnim.h:94
uint8_t scale
Integer upscale 1..4.
const uint8_t * mask
Mask plane (same layout) or null.
uint16_t srcX
Horizontal source window start.
bool opaque
Without a mask: unset bits paint bg.