Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
13 kB · 275 lines
C
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276#pragma once#include <I18n.h>
#include <algorithm>#include <atomic>#include <functional>#include <string>#include <vector>
#include "GfxRenderer.h"#include "MappedInputManager.h"#include "components/UITheme.h"#include "components/UiAppHelpers.h"
// Modal option picker drawn over the current screen (no clear) via// fui::optionDialog. Touch hit-testing is the SDK's InteractionBuffer: each// render registers the option buttons (plus a chrome guard rect) on the render// task, and handleInput routes touch snapshots against that table on the loop// task, gated by the uiReady handshake (same pattern as UiListActivity).// render() builds into InteractionBuffer's non-published generation// (beginPublishCycle()) and publishes it only once every hit() call for the// frame is done (publish()), so handleInput()'s routePublished()/// publishedData() reads on the loop task always see a complete table, never// one render is mid-rebuilding. uiReady closes when show() replaces the// popup's data, then stays open across ordinary repaints after the first// publication so a release cannot be dropped during a highlight repaint.class OptionPopup { public: void show(StrId titleId, const StrId* optionIds, int optionCount, int currentIndex, std::function<void(int)> onSelect) { title = I18N.get(titleId); headline.clear(); ownedStrings.resize(optionCount); for (int i = 0; i < optionCount; i++) { ownedStrings[i] = I18N.get(optionIds[i]); } activate(currentIndex, std::move(onSelect)); }
void show(const char* titleStr, const char* const* options, int optionCount, int currentIndex, std::function<void(int)> onSelect) { title = titleStr; headline.clear(); ownedStrings.resize(optionCount); for (int i = 0; i < optionCount; i++) { ownedStrings[i] = options[i]; } activate(currentIndex, std::move(onSelect)); }
// As above, plus a subject line inside the dialog (a book or event title). // It wraps to several lines under the caption; the dialog grows to fit. void show(const char* titleStr, const char* headlineStr, const char* const* options, int optionCount, int currentIndex, std::function<void(int)> onSelect) { show(titleStr, options, optionCount, currentIndex, std::move(onSelect)); headline = headlineStr ? headlineStr : ""; }
void show(StrId titleId, const std::vector<std::string>& options, int currentIndex, std::function<void(int)> onSelect) { title = I18N.get(titleId); headline.clear(); ownedStrings = options; activate(currentIndex, std::move(onSelect)); }
bool handleInput(MappedInputManager& input, const std::function<void()>& requestUpdate) { if (!active) return false;
// Match the render cap: only the first MAX_OPTIONS rows exist on screen, // so button wrap-around must not select an invisible option. const int total = static_cast<int>(ownedStrings.size()); const int count = total > MAX_OPTIONS ? MAX_OPTIONS : total; const freeink::ui::InputSnapshot snap = touchSnapshotFrom(input); if (snap.touchPressed || snap.touchReleased || snap.touchHeld) { // Interactions are registered on the render task; only route once the // first render after show() has populated the table (uiReady handshake). if (uiReady) { const freeink::ui::ActionEvent event = interactions.routePublished(snap); if (event && event.action == ACTION_OPTION) { // Tap released on an option: select it, fire, dismiss. selectedIndex = event.value; active = false; if (onSelectCallback) onSelectCallback(selectedIndex); requestUpdate(); return true; } if (event && event.action == ACTION_CHROME) { // Taps on the dialog chrome (title, padding) keep the popup open. return true; } if (snap.touchReleased && snap.touchX >= 0) { // Tap released outside the dialog: dismiss without firing. Swipe-end // releases arrive with -1,-1 coords and fall through (no dismiss). active = false; requestUpdate(); return true; } if (snap.touchPressed) { // Touch-down on an option moves the highlight (route() latched the // hit as the active interaction; read it back, no re-hit-testing). const int16_t idx = interactions.activeIndex(); if (idx >= 0) { const freeink::ui::Interaction& hit = interactions.publishedData()[idx]; if (hit.action == ACTION_OPTION && selectedIndex != hit.value) { selectedIndex = hit.value; requestUpdate(); } } } } return true; }
if (input.wasPressed(MappedInputManager::Button::NavPrevious)) { selectedIndex = (selectedIndex - 1 + count) % count; requestUpdate(); return true; } else if (input.wasPressed(MappedInputManager::Button::NavNext)) { selectedIndex = (selectedIndex + 1) % count; requestUpdate(); return true; } else if (input.wasReleased(MappedInputManager::Button::Confirm)) { active = false; if (onSelectCallback) onSelectCallback(selectedIndex); requestUpdate(); return true; } else if (input.wasReleased(MappedInputManager::Button::Back)) { active = false; requestUpdate(); return true; } return true; }
bool processRender(GfxRenderer& renderer, const MappedInputManager& input) const { if (!active) return false; const auto popupLabels = input.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, popupLabels.btn1, popupLabels.btn2, popupLabels.btn3, popupLabels.btn4); render(renderer); renderer.displayBuffer(); return true; }
void render(const GfxRenderer& renderer) const { if (!active) return; namespace fui = freeink::ui;
// Per-render target: a GfxRendererTarget is a renderer reference plus // three font ids, so rebuilding it here is trivially cheap and always // tracks the live orientation and uiScale fonts; a target held across // show() would stale-bind both after a rotation or scale change. fui::GfxRendererTarget target = makeUiTarget(renderer); const fui::ThemeTokens& theme = refreshSharedUiThemeTokens(target); // Frame stores a const DeviceContext&; keep it in a local that outlives // the frame (a deviceContext() temporary would dangle). const fui::DeviceContext device = target.deviceContext(); // Routing happens on the loop task against the member buffer; the frame // itself never dispatches, so it gets an empty snapshot. const fui::InputSnapshot noInput{};
// Builds into the generation handleInput()'s routePublished()/ // publishedData() aren't currently reading, so the loop task never sees // this table mid-rebuild — see publish() below and // InteractionBuffer::beginPublishCycle(). interactions.beginPublishCycle(); fui::Frame<INTERACTION_CAPACITY> frame(target, device, noInput, interactions);
const auto& metrics = UITheme::getInstance().getMetrics(); const int totalOptions = static_cast<int>(ownedStrings.size()); const uint8_t count = static_cast<uint8_t>(totalOptions > MAX_OPTIONS ? MAX_OPTIONS : totalOptions);
fui::DialogOption options[MAX_OPTIONS]; for (uint8_t i = 0; i < count; ++i) { options[i].label = ownedStrings[i].c_str(); options[i].action = ACTION_OPTION; options[i].value = static_cast<int16_t>(i); options[i].state = (i == selectedIndex) ? fui::StateFocused : fui::StateNormal; }
fui::OptionDialogProps props; props.title = title.c_str(); props.headline = headline.empty() ? nullptr : headline.c_str(); props.options = options; props.optionCount = count; props.verticalOptions = true; // Touch only: physical buttons stay on the legacy wrap/confirm path above, // so the buffer never competes with it for focus/confirm dispatch. props.inputMask = fui::InputTouch; props.titleText.font = fui::GfxRendererTarget::FONT_BODY; props.titleText.bold = true; props.titleText.align = fui::TextAlign::Center; // Captions like "Remove from Recent Books?" overflow the narrow portrait // dialog in one line; let them wrap and the panel grow. props.titleText.maxLines = 2; props.headlineText.font = fui::GfxRendererTarget::FONT_BODY; props.headlineText.align = fui::TextAlign::Center; props.headlineText.maxLines = 3; props.buttonText.font = fui::GfxRendererTarget::FONT_BODY; const int16_t innerPadding = static_cast<int16_t>(metrics.optionPopupInnerPadding); props.padding = fui::Insets{innerPadding, innerPadding, innerPadding, innerPadding}; props.gap = static_cast<int16_t>(metrics.optionPopupItemSpacing); // Rounded invert-fill themes use a black pill, not the default gray focus cursor. if (theme.listSelectionStyle == fui::SelectionStyle::InvertFill && theme.listRowRadius > 0) { props.buttonStyles = fui::defaultButtonStyles(); props.buttonStyles.focused = props.buttonStyles.selected; fui::setStyleRadius(props.buttonStyles, theme.listRowRadius); } // defaultPopupStyles() has no border, so opt in using the per-theme frame metrics. props.styles = fui::defaultPopupStyles(); props.styles.normal.border = fui::Paint::solid(fui::Color::Black); props.styles.normal.borderWidth = static_cast<uint8_t>(metrics.popupFrameThickness); props.styles.normal.radius = static_cast<uint8_t>(metrics.popupCornerRadius); props.styles.selected = props.styles.normal; props.styles.focused = props.styles.normal; props.styles.active = props.styles.normal; props.styles.disabled = props.styles.normal; props.buttonHeight = fui::clampI16(target.lineHeight(fui::GfxRendererTarget::FONT_BODY) + metrics.optionPopupSelectionVPadding * 2);
// Fixed fraction of the screen, clamped by the theme's side margins; the // old max-text-width sizing is gone, long labels wrap inside the buttons. const fui::Rect screen = device.screen(); const int16_t width = fui::clampI16(std::min<int>(screen.width * 3 / 4, screen.width - metrics.optionPopupDialogSideMargin * 2)); const int16_t height = fui::clampI16(fui::optionDialogHeight(target, props, width), 0, screen.height); const fui::Rect dialogRect = fui::centeredRect(screen, fui::Size{width, height});
// Chrome guard first, options after: route() scans newest-first, so the // option buttons win inside the dialog and the guard absorbs the rest. frame.hit(dialogRect, ACTION_CHROME, 0, fui::InputTouch); fui::optionDialog(frame, dialogRect, props); // Atomically make this generation the one handleInput() reads, now that // every hit() call for this frame is done. interactions.publish(); uiReady = true; }
bool isActive() const { return active; }
// Close without firing the callback (the surface under the popup is going // away, e.g. its host screen closes from outside the popup's own input). void dismiss() { active = false; onSelectCallback = nullptr; }
private: // The dialog has no scrolling, so options past MAX_OPTIONS would render off // screen anyway; a fixed cap keeps the DialogOption array on the stack and // the interaction table small. +1 slot for the chrome guard rect. static constexpr int MAX_OPTIONS = 16; static constexpr size_t INTERACTION_CAPACITY = MAX_OPTIONS + 1; static constexpr freeink::ui::ActionId ACTION_OPTION = 1; static constexpr freeink::ui::ActionId ACTION_CHROME = 2;
void activate(int currentIndex, std::function<void(int)> onSelect) { const int count = std::min<int>(ownedStrings.size(), MAX_OPTIONS); selectedIndex = currentIndex >= 0 && currentIndex < count ? currentIndex : 0; onSelectCallback = std::move(onSelect); uiReady = false; active = count > 0; }
bool active = false; std::string title; std::string headline; std::vector<std::string> ownedStrings; int selectedIndex = 0; std::function<void(int)> onSelectCallback; // Written by the render task (frame registration), routed by the loop task; // uiReady closes the rebuild window exactly like UiListActivity::uiReady. mutable freeink::ui::InteractionBuffer<INTERACTION_CAPACITY> interactions; mutable std::atomic<bool> uiReady{false};};