CDC Badge OS
Firmware for the CDC Badge v1.0 hardware security key
Loading...
Searching...
No Matches
ViewStack.cpp
Go to the documentation of this file.
1
13
14#include "cdc_ui/ViewStack.h"
15#include "cdc_core/EventBus.h"
16#include "cdc_hal/IDisplay.h"
17#include "cdc_log.h"
18
19static const char* TAG = "ViewStack";
20
21namespace cdc::ui {
22
30 return static_cast<uint8_t>(a) < static_cast<uint8_t>(b) ? a : b;
31}
32
43 if (!display) return;
44 constexpr uint16_t kBlack = 0x0000; // EPD_BLACK
45 display->setFont(nullptr); // built-in 6x8 glcdfont
46 display->setTextSize(1);
47 display->setTextColor(kBlack);
48}
49
53ViewStack& ViewStack::instance() {
54 static ViewStack instance;
55 instance.ensureMutex();
56 return instance;
57}
58
62void ViewStack::ensureMutex() {
63 if (!mutex_) {
64 mutex_ = xSemaphoreCreateRecursiveMutex();
65 }
66}
67
68namespace {
69
71class StackLock {
72public:
73 explicit StackLock(SemaphoreHandle_t m) : m_(m) {
74 if (m_) xSemaphoreTakeRecursive(m_, portMAX_DELAY);
75 }
76 ~StackLock() {
77 if (m_) xSemaphoreGiveRecursive(m_);
78 }
79 StackLock(const StackLock&) = delete;
80 StackLock& operator=(const StackLock&) = delete;
81private:
82 SemaphoreHandle_t m_;
83};
84
85} // anonymous namespace
86
87// ---------------------------------------------------------------------------
88// Unlocked helpers. Caller MUST hold mutex_.
89// ---------------------------------------------------------------------------
90
91void ViewStack::escalatePending_unlocked(hal::RefreshMode mode) {
92 pendingRefresh_ = strongerRefresh(pendingRefresh_, mode);
93}
94
95void ViewStack::push_unlocked(IView* view, void* context) {
96 if (!view) {
97 LOG_W(TAG, "Attempted to push null view");
98 return;
99 }
100 if (exclusiveOwner_ && exclusiveOwner_ != view) {
101 LOG_W(TAG, "push('%s') blocked: exclusive lock held by %p",
102 view->getName(), exclusiveOwner_);
103 return;
104 }
105 if (depth_ >= MAX_DEPTH) {
106 LOG_E(TAG, "ViewStack overflow (max %d)", MAX_DEPTH);
107 return;
108 }
109
110 if (depth_ > 0 && stack_[depth_ - 1]) {
111 stack_[depth_ - 1]->onPause(); // counterpart to onResume() on pop
112 }
113 stack_[depth_++] = view;
114 view->onEnter(context);
115 // Transitions render as partials by default; ghosting is bounded by the
116 // HAL escalation chain. The entering view may request a stronger mode.
117 escalatePending_unlocked(view->preferredEnterRefresh());
118
119 LOG_D(TAG, "Pushed view '%s' (depth=%d)", view->getName(), depth_);
120}
121
122void ViewStack::pop_unlocked() {
123 if (depth_ <= 1) {
124 LOG_W(TAG, "Cannot pop root view");
125 return;
126 }
127
128 IView* top = stack_[depth_ - 1];
129 if (exclusiveOwner_ && exclusiveOwner_ != top) {
130 LOG_W(TAG, "pop blocked: exclusive lock held by %p (top='%s')",
131 exclusiveOwner_, top ? top->getName() : "(null)");
132 return;
133 }
134
135 depth_--;
136 if (top) {
137 top->onExit();
138 LOG_D(TAG, "Popped view '%s' (depth=%d)", top->getName(), depth_);
139 }
140 stack_[depth_] = nullptr;
141
142 if (depth_ > 0 && stack_[depth_ - 1]) {
143 stack_[depth_ - 1]->onResume();
144 // The revealed view repaints over the popped one; honor its preference.
145 escalatePending_unlocked(stack_[depth_ - 1]->preferredEnterRefresh());
146 }
147}
148
149void ViewStack::hideModal_unlocked() {
150 if (modalDepth_ == 0) return;
151
152 IView* top = modals_[--modalDepth_];
153 modals_[modalDepth_] = nullptr;
154 LOG_D(TAG, "Hiding modal '%s' (depth=%d)", top->getName(), modalDepth_);
155 top->onExit();
156
157 // The dismissed modal must be erased and whatever it covered repainted:
158 // rebuild the framebuffer composite bottom-up. A partial flush covers the
159 // whole panel, so erasure is correct without a full refresh; ghost residue
160 // is bounded by the HAL escalation chain.
161 needsCompositeRepaint_ = true;
162 if (modalDepth_ > 0) {
163 modals_[modalDepth_ - 1]->markDirty();
164 } else {
165 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
166 if (view) {
167 view->onResume();
168 view->markDirty();
169 escalatePending_unlocked(view->preferredEnterRefresh());
170 }
171 }
172}
173
174void ViewStack::removeModal_unlocked(IView* modal) {
175 if (!modal) return;
176 int idx = -1;
177 for (uint8_t i = 0; i < modalDepth_; ++i) {
178 if (modals_[i] == modal) { idx = i; break; }
179 }
180 if (idx < 0) return;
181
182 modal->onExit();
183 LOG_D(TAG, "Removing modal '%s' (depth=%d)", modal->getName(), modalDepth_ - 1);
184 for (uint8_t i = static_cast<uint8_t>(idx); i + 1 < modalDepth_; ++i) {
185 modals_[i] = modals_[i + 1];
186 }
187 modalDepth_--;
188 modals_[modalDepth_] = nullptr;
189
190 needsCompositeRepaint_ = true;
191 if (modalDepth_ > 0) {
192 modals_[modalDepth_ - 1]->markDirty();
193 } else {
194 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
195 if (view) {
196 view->onResume();
197 view->markDirty();
198 escalatePending_unlocked(view->preferredEnterRefresh());
199 }
200 }
201}
202
203// ---------------------------------------------------------------------------
204// Public API. Each entry point acquires the mutex once.
205// ---------------------------------------------------------------------------
206
207void ViewStack::push(IView* view, void* context) {
208 StackLock lock(mutex_);
209 push_unlocked(view, context);
210}
211
213 StackLock lock(mutex_);
214 pop_unlocked();
215}
216
217void ViewStack::replace(IView* view, void* context) {
218 StackLock lock(mutex_);
219 if (!view) {
220 LOG_W(TAG, "Attempted to replace with null view");
221 return;
222 }
223 if (depth_ == 0) {
224 push_unlocked(view, context);
225 return;
226 }
227
228 IView* top = stack_[depth_ - 1];
229 if (exclusiveOwner_ && exclusiveOwner_ != view && exclusiveOwner_ != top) {
230 LOG_W(TAG, "replace('%s') blocked: exclusive lock held by %p",
231 view->getName(), exclusiveOwner_);
232 return;
233 }
234
235 if (top) {
236 top->onExit();
237 LOG_D(TAG, "Replaced view '%s'", top->getName());
238 }
239
240 stack_[depth_ - 1] = view;
241 view->onEnter(context);
242 escalatePending_unlocked(view->preferredEnterRefresh());
243
244 LOG_D(TAG, "Replaced with view '%s'", view->getName());
245}
246
248 StackLock lock(mutex_);
249 while (depth_ > 1) {
250 pop_unlocked();
251 }
252}
253
255 StackLock lock(mutex_);
256 while (depth_ > 1) {
257 IView* cur = stack_[depth_ - 1];
258 if (cur == anchor) break;
259 pop_unlocked();
260 }
261}
262
263void ViewStack::popToDepth(uint8_t targetDepth) {
264 StackLock lock(mutex_);
265 while (depth_ > targetDepth && depth_ > 1) {
266 pop_unlocked();
267 }
268}
269
271 StackLock lock(mutex_);
272 if (depth_ == 0) return nullptr;
273 return stack_[depth_ - 1];
274}
275
276IView* ViewStack::at(uint8_t idx) const {
277 StackLock lock(mutex_);
278 if (idx >= depth_) return nullptr;
279 return stack_[idx];
280}
281
284 static_cast<uint8_t>(key));
285 StackLock lock(mutex_);
287
288 if (modalDepth_ > 0) {
289 // Input stays on the top modal and never falls through to the view (or
290 // lower modals) behind it.
291 InputResult result = modals_[modalDepth_ - 1]->onKey(key);
292 if (result == InputResult::REQUEST_POP) {
293 hideModal_unlocked();
294 }
295 return;
296 }
297
298 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
299 if (view) {
300 InputResult result = view->onKey(key);
301 if (result == InputResult::REQUEST_POP) {
302 pop_unlocked();
303 }
304 }
305}
306
309 static_cast<uint8_t>(key));
310 StackLock lock(mutex_);
311
312 if (modalDepth_ > 0) {
313 InputResult result = modals_[modalDepth_ - 1]->onLongPress(key);
314 // 'N' is the universal back/cancel gesture: hide the top modal unless it
315 // consumed the press itself. Other keys hide only on REQUEST_POP.
316 if (result == InputResult::REQUEST_POP ||
317 (key == 'N' && result != InputResult::CONSUMED)) {
318 hideModal_unlocked();
319 }
320 return result;
321 }
322
323 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
324 if (!view) {
326 }
327 InputResult result = view->onLongPress(key);
328 // 'N' is the universal back/cancel gesture: pop unless the view consumed it
329 // (e.g. an input view that cancels itself and notifies its owner). Other
330 // keys pop only on an explicit REQUEST_POP.
331 if (result == InputResult::REQUEST_POP ||
332 (key == 'N' && result != InputResult::CONSUMED && depth_ > 1)) {
333 pop_unlocked();
334 }
335 return result;
336}
337
338void ViewStack::dispatchTick(uint32_t nowMs) {
339 StackLock lock(mutex_);
340 if (modalDepth_ > 0) {
341 modals_[modalDepth_ - 1]->onTick(nowMs);
342 }
343 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
344 if (view) {
345 view->onTick(nowMs);
346 }
347}
348
349void ViewStack::render(bool synchronous) {
350 StackLock lock(mutex_);
351 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
352 if (!view) {
353 return;
354 }
355
357
358 if (modalDepth_ > 0) {
359 // Modals own the screen and stack on top of the base view. Repaint the
360 // base first (when dirty or on a composite repaint, e.g. after a modal
361 // was dismissed) and then draw every modal bottom-to-top in the same
362 // pass, so the composite stays correct and nothing of a gone modal lingers.
363 bool baseDirty = view->needsRender();
364 bool anyModalDirty = false;
365 for (uint8_t i = 0; i < modalDepth_; ++i) {
366 if (modals_[i]->needsRender()) { anyModalDirty = true; break; }
367 }
368 if (!baseDirty && !anyModalDirty && !needsCompositeRepaint_) {
369 return;
370 }
371 if (baseDirty || needsCompositeRepaint_) {
373 view->render(false);
374 view->clearDirty();
375 }
376 for (uint8_t i = 0; i < modalDepth_; ++i) {
378 modals_[i]->render(true);
379 modals_[i]->clearDirty();
380 }
381 if (display) {
382 // The composite repaint fixes the framebuffer; the flush itself
383 // stays PARTIAL unless something escalated pendingRefresh_.
385 if (synchronous) display->flushSync(mode);
386 else display->flush(mode);
387 }
388 needsCompositeRepaint_ = false;
389 pendingRefresh_ = hal::RefreshMode::PARTIAL_LIGHT;
390 return;
391 }
392
393 if (!view->needsRender()) {
394 return;
395 }
397 view->render(false);
398 view->clearDirty();
399
401 pendingRefresh_,
403 if (display) {
404 if (synchronous) display->flushSync(mode);
405 else display->flush(mode);
406 }
407 pendingRefresh_ = hal::RefreshMode::PARTIAL_LIGHT;
408}
409
411 StackLock lock(mutex_);
412 for (uint8_t i = 0; i < modalDepth_; ++i) {
413 if (modals_[i]->needsRender()) return true;
414 }
415 IView* view = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
416 return view && view->needsRender();
417}
418
420 StackLock lock(mutex_);
421 if (!modal) return;
422 if (exclusiveOwner_) {
423 LOG_W(TAG, "showModal('%s') blocked: exclusive lock held by %p",
424 modal->getName(), exclusiveOwner_);
425 return;
426 }
427
428 // If this modal is already stacked, lift it back to the top rather than
429 // duplicating it (the shared toast/confirm singletons get re-shown in place).
430 for (uint8_t i = 0; i < modalDepth_; ++i) {
431 if (modals_[i] == modal) {
432 for (uint8_t j = i + 1; j < modalDepth_; ++j) modals_[j - 1] = modals_[j];
433 modalDepth_--;
434 break;
435 }
436 }
437
438 // Stack full: drop the oldest modal at the bottom to make room.
439 if (modalDepth_ >= MAX_MODAL_DEPTH) {
440 modals_[0]->onExit();
441 for (uint8_t i = 1; i < modalDepth_; ++i) modals_[i - 1] = modals_[i];
442 modalDepth_--;
443 }
444
445 // Stacking on top of an existing modal repaints the composite bottom-up so
446 // the layering in the framebuffer stays correct. The flush itself remains
447 // a partial; ghost hygiene comes from the HAL escalation chain.
448 if (modalDepth_ > 0) needsCompositeRepaint_ = true;
449
450 if (modalDepth_ > 0) {
451 modals_[modalDepth_ - 1]->onPause();
452 } else {
453 IView* base = (depth_ == 0) ? nullptr : stack_[depth_ - 1];
454 if (base) base->onPause(); // counterpart to onResume() in hideModal
455 }
456 modals_[modalDepth_++] = modal;
457 modal->onEnter(nullptr);
458 LOG_D(TAG, "Showing modal '%s' (depth=%d)", modal->getName(), modalDepth_);
459}
460
462 StackLock lock(mutex_);
463 hideModal_unlocked();
464}
465
467 StackLock lock(mutex_);
468 removeModal_unlocked(modal);
469}
470
472 StackLock lock(mutex_);
473 escalatePending_unlocked(mode);
474 // Make the request self-sufficient: mark the top-most visible surface
475 // dirty so the next render pass actually flushes.
476 if (modalDepth_ > 0) {
477 modals_[modalDepth_ - 1]->markDirty();
478 } else if (depth_ > 0 && stack_[depth_ - 1]) {
479 stack_[depth_ - 1]->markDirty();
480 }
481}
482
483void ViewStack::setInactivityTimeout(InactivityCallback callback, uint32_t timeoutMs) {
484 StackLock lock(mutex_);
485 inactivityCallback_ = callback;
486 inactivityTimeoutMs_ = timeoutMs;
487 lastActivityMs_ = 0;
488 LOG_D(TAG, "Inactivity timeout set: %lu ms", timeoutMs);
489}
490
492 StackLock lock(mutex_);
493 lastActivityMs_ = 0;
494}
495
496void ViewStack::checkInactivity(uint32_t nowMs) {
497 InactivityCallback cb = nullptr;
498 bool triggered = false;
499 uint32_t elapsedForLog = 0;
500
501 {
502 StackLock lock(mutex_);
503 if (inactivityTimeoutMs_ == 0 || !inactivityCallback_) {
504 return;
505 }
506 if (lastActivityMs_ == 0) {
507 lastActivityMs_ = nowMs;
508 return;
509 }
510 uint32_t elapsed = nowMs - lastActivityMs_;
511 if (elapsed >= inactivityTimeoutMs_) {
512 cb = inactivityCallback_;
513 triggered = true;
514 elapsedForLog = elapsed;
515 lastActivityMs_ = nowMs;
516 }
517 }
518
519 if (cb && triggered) {
520 LOG_I(TAG, "Inactivity timeout triggered after %lu ms", elapsedForLog);
521 cb();
522 }
523}
524
525bool ViewStack::acquireExclusive(const void* owner) {
526 StackLock lock(mutex_);
527 if (!owner) {
528 LOG_W(TAG, "acquireExclusive called with null owner");
529 return false;
530 }
531 if (exclusiveOwner_ && exclusiveOwner_ != owner) {
532 LOG_W(TAG, "Exclusive lock already held by %p, refusing %p", exclusiveOwner_, owner);
533 return false;
534 }
535 exclusiveOwner_ = owner;
536 LOG_D(TAG, "Exclusive lock acquired by %p", owner);
537 return true;
538}
539
540bool ViewStack::releaseExclusive(const void* owner) {
541 StackLock lock(mutex_);
542 if (!exclusiveOwner_) {
543 return false;
544 }
545 if (exclusiveOwner_ != owner) {
546 LOG_W(TAG, "releaseExclusive: owner mismatch (held=%p, caller=%p)",
547 exclusiveOwner_, owner);
548 return false;
549 }
550 LOG_D(TAG, "Exclusive lock released by %p", owner);
551 exclusiveOwner_ = nullptr;
552 return true;
553}
554
555} // namespace cdc::ui
static const char * TAG
CDC Log: logging over TinyUSB CDC and UART.
#define LOG_W(tag, fmt,...)
Definition cdc_log.h:146
#define LOG_D(tag, fmt,...)
Definition cdc_log.h:148
#define LOG_I(tag, fmt,...)
Definition cdc_log.h:147
#define LOG_E(tag, fmt,...)
Definition cdc_log.h:145
static EventBus & instance()
Returns singleton event-bus instance.
Definition EventBus.cpp:19
bool publish(const Event &event, bool fromISR=false)
Publishes an event to the queue.
Definition EventBus.cpp:88
virtual InputResult onLongPress(char key)
Definition IView.h:112
virtual hal::RefreshMode preferredEnterRefresh() const
Definition IView.h:96
virtual void clearDirty()=0
virtual const char * getName() const =0
virtual InputResult onKey(char key)=0
virtual void onTick(uint32_t nowMs)
Definition IView.h:120
virtual void onExit()=0
virtual void onEnter(void *context=nullptr)=0
virtual void onPause()
Definition IView.h:52
virtual void render(bool partial)=0
virtual bool needsRender() const =0
virtual bool prefersLightRefresh() const
Definition IView.h:86
void popToDepth(uint8_t targetDepth)
Pops views until the stack depth is at most targetDepth.
void setInactivityTimeout(InactivityCallback callback, uint32_t timeoutMs)
IView * current() const
void replace(IView *view, void *context=nullptr)
void dispatchKey(char key)
void dispatchTick(uint32_t nowMs)
void render(bool synchronous=false)
Render current view (and modal if present) and flush to display.
bool needsRender() const
static ViewStack & instance()
Returns singleton view-stack instance.
Definition ViewStack.cpp:53
void removeModal(IView *modal)
Remove a specific modal from any position in the modal stack.
void showModal(IView *modal)
bool releaseExclusive(const void *owner)
Releases exclusive ownership.
static constexpr uint8_t MAX_DEPTH
Definition ViewStack.h:20
void forceRefresh(hal::RefreshMode mode)
Escalates the refresh mode of the next render.
void(*)() InactivityCallback
Definition ViewStack.h:209
bool acquireExclusive(const void *owner)
Acquires exclusive ownership of the view stack.
InputResult dispatchLongPress(char key)
void checkInactivity(uint32_t nowMs)
void popToAnchor(IView *anchor)
Pops views until the specified anchor view is the current view.
void push(IView *view, void *context=nullptr)
IView * at(uint8_t depth) const
void resetInactivityTimer()
IDisplay * getDisplayInstance()
Returns lazily created singleton display instance.
Centralized key-code constants for cdc_views.
Definition IModule.h:8
static hal::RefreshMode strongerRefresh(hal::RefreshMode a, hal::RefreshMode b)
Returns the stronger of two refresh modes.
Definition ViewStack.cpp:29
Gdey029T94 * display
InputResult
Definition IView.h:11
static void resetTextState(hal::IDisplay *display)
Reset the shared GFX text state to the defaults before a view renders.
Definition ViewStack.cpp:42
static const char * TAG