Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
17 kB · 324 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325#include "ReaderToolbarUi.h"
#include <FreeInkUIIcon.h>#include <GfxRenderer.h>#include <I18n.h>
#include <algorithm>#include <cstdio>
#include "CrossPointSettings.h"#include "MappedInputManager.h"#include "components/UITheme.h"#include "components/icons/readerToolbarIcons.h"
namespace fui = freeink::ui;
namespace {constexpr fui::ActionId ACTION_DISMISS = 1; // tap anywhere on the page above the sheetconstexpr fui::ActionId ACTION_TOOL = 2; // value = 0 Contents, 1 Text, 2 Moreconstexpr fui::ActionId ACTION_PREV = 3; // scrub row: previous chapterconstexpr fui::ActionId ACTION_NEXT = 4; // scrub row: next chapterconstexpr fui::ActionId ACTION_SCRUB = 5; // progress track: dragPermille along the bookconstexpr fui::ActionId ACTION_ROW = 6; // panel list row, value = row index
// Scrub row: two small round-cornered chapter buttons flanking a thin progress// track with a round knob -- the reading page's chrome is light, so the// controls stay slim rather than control-center sized.constexpr int16_t kScrubButton = 36; // chapter step buttons (square)constexpr int16_t kScrubKnob = 16; // round knob on the 2px progress trackconstexpr int16_t kScrubGap = 12; // air between the buttons and the track// Tool row: a 24px glyph centred in each slot, the active slot in an outline// pill. The whole slot is the tap target; the row height sets its size.constexpr int16_t kToolRowH = 80;constexpr int16_t kToolPillInset = 10;constexpr int kToolCount = 3;// Bottom sheet height for the panels. ListNav fits whole rows in the remaining// list area; any spare pixels stay between the list and the switcher.constexpr int kPanelHeightPercent = 62;// Cap the sheet may grow to when rounding the list area up to a whole row.constexpr int kPanelHeightMaxPercent = 72;// Landscape has less vertical room; leave a narrow page strip for tap-to-dismiss.constexpr int kLandscapePanelHeightPercent = 88;} // namespace
ReaderToolbarUi::ReaderToolbarUi(GfxRenderer& renderer) : UiAppHost(renderer) {}
void ReaderToolbarUi::begin() { resetUi(); pending_ = Routed{}; nav_.reset(); for (fui::ActionId id = ACTION_DISMISS; id <= ACTION_ROW; ++id) { app.on(id, &ReaderToolbarUi::onAction, this); } app.setScreen(&ReaderToolbarUi::screenFn, this);}
void ReaderToolbarUi::render() { renderUi(); // Wrapped rows can fit fewer than the fixed-height estimate; the nav then // advances the viewport after layout and asks for a rebuild. Converges (top // only moves forward toward the selection); the bound is a backstop. for (int pass = 0; pass < 3 && nav_.consumeRebuildNeeded(); ++pass) renderUi();}
ReaderToolbarUi::Routed ReaderToolbarUi::route(const MappedInputManager& input) { pending_ = Routed{}; // routeHeld: the scrub track is a drag target, so held frames must reach it. const auto touch = routeTouch(input, false, /*routeHeld=*/true); pending_.routed = touch.routed; pending_.x = touch.snap.touchX; pending_.y = touch.snap.touchY; // Only the release commits a scrub: every held frame dispatches too // (dragPermille set), and re-paginating a chapter per frame would be seconds // of work per swipe. if (pending_.event == Event::Scrub && !touch.snap.touchReleased) pending_.event = Event::None; return pending_;}
void ReaderToolbarUi::onAction(const fui::ActionEvent& event, void* user) { auto* self = static_cast<ReaderToolbarUi*>(user); Routed& out = self->pending_; out.value = event.value; out.permille = event.dragPermille; if (event.action >= ACTION_DISMISS && event.action <= ACTION_ROW) out.event = static_cast<Event>(event.action); if (out.event == Event::Scrub && event.dragPermille < 0) out.event = Event::None; // A handled action repaints through the reader's own fast path, not through // app.invalidate(): the page underneath is the reader's to draw. self->app.clearTapFlash();}
void ReaderToolbarUi::screenFn(UiScreen& screen, void* user) { auto* self = static_cast<ReaderToolbarUi*>(user); if (self->model_.panel) { self->buildPanel(screen); } else { self->buildToolbar(screen); }}
// The Contents / Text / More row: three equal slots, an icon centred in each,// the active one inside an outline pill (the theme's control radius). Each// slot is registered as one tap target, so the row stays light (no filled// tiles, no labels -- the glyphs carry the meaning).void ReaderToolbarUi::buildToolRow(UiScreen& screen, const fui::LayoutAnchor anchor, const int16_t sideInset) { const auto& tokens = screen.theme(); const fui::BitmapRef icons[kToolCount] = {fui::bitmapFromIcon(icon_reader_contents_24), fui::bitmapFromIcon(icon_reader_text_24), fui::bitmapFromIcon(icon_reader_more_24)}; // sideInset absorbs the difference between the two hosts' content bands // (the toolbar's is spaceLg-inset, the panel's is full width): the slots // must land on the same x either way, or the icons jump when a tap swaps // the toolbar for a panel. const fui::Rect row = screen.take(anchor, kToolRowH).inset(fui::Insets{0, sideInset, 0, sideInset}); const int16_t slotW = static_cast<int16_t>(row.width / kToolCount); // Theme radius as-is (the frontlight panel pattern); the fill clamps to // the shape's own height so round themes cannot overshoot. const uint8_t pillRadius = tokens.controlRadius; for (int i = 0; i < kToolCount; ++i) { const fui::Rect slot{static_cast<int16_t>(row.x + slotW * i), row.y, slotW, row.height}; if (i == model_.activeTool) { screen.target().stroke(slot.inset(fui::Insets{4, kToolPillInset, 4, kToolPillInset}), fui::Paint::solid(fui::Color::Black), 2, pillRadius); } const fui::Rect iconRect{static_cast<int16_t>(slot.x + (slot.width - 24) / 2), static_cast<int16_t>(slot.y + (slot.height - 24) / 2), 24, 24}; screen.target().bitmap(iconRect, icons[i], fui::BitmapMode::Center); screen.frame().hit(slot, ACTION_TOOL, static_cast<int16_t>(i), fui::InputTouch); }}
void ReaderToolbarUi::buildToolbar(UiScreen& screen) { const auto& tokens = screen.theme();
// Sheet height from its content: scrub row, meta line, tool row, and the // air between them (a full spaceLg under the scrub row, so the progress // track and its chapter buttons don't crowd the meta line). const int16_t metaH = screen.target().lineHeight(tokens.smallText.font); const int16_t contentH = static_cast<int16_t>(tokens.spaceMd + kScrubButton + tokens.spaceLg + metaH + tokens.spaceSm + kToolRowH + tokens.spaceSm); fui::SheetProps sheetProps; sheetProps.anchor = fui::SheetEdge::Bottom; sheetProps.dismissAction = ACTION_DISMISS; // Grabber air matches the frontlight panel's card language (spaceLg around // the grabber, spaceMd more toward the free edge) so the two sheets read as // the same family. sheetProps.grabberMargin = tokens.spaceLg; sheetProps.grabberInset = static_cast<int16_t>(tokens.spaceLg + tokens.spaceMd); const int16_t grabberBand = static_cast<int16_t>(sheetProps.grabberMargin + sheetProps.grabberHeight + sheetProps.grabberInset); screen.sheet(sheetProps, static_cast<int16_t>(contentH + grabberBand)); screen.insetContent(fui::Insets{0, tokens.spaceLg, 0, tokens.spaceLg}); screen.spacer(tokens.spaceMd);
// Scrub row: < [progress track + knob: tap/drag to jump] > { const fui::Rect band = screen.takeTop(kScrubButton, tokens.spaceLg); stepProps_.label = nullptr; stepProps_.icon = fui::bitmapFromIcon(icon_reader_back_24); stepProps_.action = ACTION_PREV; stepProps_.inputMask = fui::InputTouch; stepProps_.styles.explicitlySet = true; stepProps_.styles.normal.background = fui::Paint::solid(fui::Color::White); stepProps_.styles.normal.foreground = fui::Paint::solid(fui::Color::Black); stepProps_.styles.normal.border = fui::Paint::solid(fui::Color::Black); stepProps_.styles.normal.borderWidth = 1; stepProps_.styles.normal.radius = tokens.controlRadius; stepProps_.styles.selected = stepProps_.styles.normal; stepProps_.styles.focused = stepProps_.styles.normal; stepProps_.styles.disabled = stepProps_.styles.normal; stepProps_.styles.active = stepProps_.styles.normal; stepProps_.styles.active.background = fui::Paint::solid(fui::Color::Black); stepProps_.styles.active.foreground = fui::Paint::solid(fui::Color::White); screen.button(stepProps_, fui::Rect{band.x, band.y, kScrubButton, kScrubButton}); stepProps_.icon = fui::bitmapFromIcon(icon_reader_next_24); stepProps_.action = ACTION_NEXT; screen.button(stepProps_, fui::Rect{static_cast<int16_t>(band.right() - kScrubButton), band.y, kScrubButton, kScrubButton});
// Progress: a 2px track with a solid round knob at the book position (the // fill edge IS the handle, so nothing else is drawn), the scrub hit rect // spanning the whole band height so a finger a little off the line still // scrubs. Tap-to-jump / drag arrive as dragPermille along this rect. const int16_t trackX = static_cast<int16_t>(band.x + kScrubButton + kScrubGap); const int16_t trackW = static_cast<int16_t>(band.width - 2 * (kScrubButton + kScrubGap)); const int16_t trackY = static_cast<int16_t>(band.y + band.height / 2); const fui::Paint ink = fui::Paint::solid(fui::Color::Black); screen.target().fill(fui::Rect{trackX, static_cast<int16_t>(trackY - 1), trackW, 2}, ink); const int permille = std::clamp(model_.progressPermille, 0, 1000); const int16_t knobX = static_cast<int16_t>(trackX + (static_cast<int32_t>(trackW - 1) * permille) / 1000); screen.target().fill(fui::Rect{static_cast<int16_t>(knobX - kScrubKnob / 2), static_cast<int16_t>(trackY - kScrubKnob / 2), kScrubKnob, kScrubKnob}, ink, static_cast<uint8_t>(kScrubKnob / 2)); screen.frame().hit(fui::Rect{trackX, band.y, trackW, band.height}, ACTION_SCRUB, 0, fui::InputTouch | fui::InputDrag); }
// Meta line: chapter title (left), chapter page / book percent (right). { const fui::Rect line = screen.takeTop(metaH, tokens.spaceSm); fui::TextStyle titleStyle = tokens.smallText; titleStyle.bold = true; fui::TextStyle infoStyle = tokens.smallText; infoStyle.align = fui::TextAlign::Right; const int16_t infoW = model_.pageInfo ? screen.target().measureText(infoStyle.font, model_.pageInfo, infoStyle).width : 0; const fui::Rect titleRect{line.x, line.y, static_cast<int16_t>(line.width - infoW - tokens.spaceMd), line.height}; if (model_.chapterTitle) screen.target().text(titleRect, model_.chapterTitle, titleStyle); if (model_.pageInfo) screen.target().text(line, model_.pageInfo, infoStyle); }
buildToolRow(screen, fui::LayoutAnchor::Top, 0); // content band already spaceLg-inset}
void ReaderToolbarUi::buildPanel(UiScreen& screen) { const auto& tokens = screen.theme(); const fui::Rect safe = screen.frame().safeRect();
fui::SheetProps sheetProps; sheetProps.anchor = fui::SheetEdge::Bottom; sheetProps.dismissAction = ACTION_DISMISS; // tap the page above the sheet = back to the toolbar // Same grabber air as the toolbar sheet / frontlight panel. sheetProps.grabberMargin = tokens.spaceLg; sheetProps.grabberInset = static_cast<int16_t>(tokens.spaceLg + tokens.spaceMd);
// Size the sheet to an exact number of list rows instead of a fixed screen // share: a percentage leaves up to a row's height of dead space above the // switcher, and a short list (Text, More) leaves empty row slots. Chrome = // everything in the sheet that is not the list; the row count starts from // the target share, grows one row when that still fits the cap, and shrinks // to the item count when the list is shorter than the space. const int16_t titleH = screen.target().lineHeight(tokens.titleText.font); const int16_t rowH = model_.denseRows ? static_cast<int16_t>(UITheme::getInstance().getMetrics().listRowHeight) : tokens.rowHeight; const int16_t rowGap = model_.denseRows ? tokens.listRowGap : std::max(tokens.listRowGap, tokens.listTouchRowGap); const int16_t rowStride = static_cast<int16_t>(rowH + rowGap); const int16_t grabberBand = static_cast<int16_t>(sheetProps.grabberMargin + sheetProps.grabberHeight + sheetProps.grabberInset); const int16_t chrome = static_cast<int16_t>(grabberBand + titleH + tokens.spaceMd + tokens.spaceSm + kToolRowH + tokens.spaceSm); const bool landscape = safe.width > safe.height; const int16_t target = static_cast<int16_t>((safe.height * (landscape ? kLandscapePanelHeightPercent : kPanelHeightPercent)) / 100); const int16_t cap = static_cast<int16_t>((safe.height * (landscape ? kLandscapePanelHeightPercent : kPanelHeightMaxPercent)) / 100); int sheetRows = (target - chrome + rowGap) / rowStride; if (static_cast<int16_t>(chrome + (sheetRows + 1) * rowStride - rowGap) <= cap) ++sheetRows; if (model_.itemCount > 0 && sheetRows > model_.itemCount) sheetRows = model_.itemCount; if (sheetRows < 1) sheetRows = 1; screen.sheet(sheetProps, static_cast<int16_t>(chrome + sheetRows * rowStride - rowGap)); // No blanket side inset: Screen::list() draws in the content band, and the // scroll track must reach the sheet's edge like a full-screen list's does. // The title insets itself; the rows inset via rowInset below.
// Title line: panel name left, page position right when the list spans pages. { fui::TextStyle titleStyle = tokens.titleText; titleStyle.bold = true; const fui::Rect line = screen.takeTop(titleH, tokens.spaceMd).inset(fui::Insets{0, tokens.spaceLg, 0, tokens.spaceLg}); screen.target().text(line, model_.panelTitle, titleStyle); // Filled in below once the viewport is known; reserve the rect now. pageIndicatorRect_ = line; }
// Switcher row along the sheet's bottom edge; the list takes what is left. screen.spacer(tokens.spaceSm, fui::LayoutAnchor::Bottom); buildToolRow(screen, fui::LayoutAnchor::Bottom, tokens.spaceLg); // full-width band screen.spacer(tokens.spaceSm, fui::LayoutAnchor::Bottom);
listProps_.count = static_cast<uint16_t>(std::max(0, model_.itemCount)); listProps_.action = ACTION_ROW; listProps_.inputMask = fui::InputTouch; // physical buttons stay with the reader listProps_.rowHeight = rowH; listProps_.rowGap = rowGap; // The label column starts flush with the panel title (no list-side padding // on top of the sheet's own inset). Body-size text: small reads condensed // and the taller row doubles as the tap target. listProps_.labelText = tokens.bodyText; listProps_.sidePadding = 0; // The list band spans the sheet's full width -- the scroll track hugs the // panel edge (theme bezel inset included) exactly like a full-screen list. // rowInset pulls the rows back to the title's spaceLg alignment. listProps_.rowInset = tokens.spaceLg; const fui::Rect listRect = screen.body(); const int count = std::max(0, model_.itemCount); // The nav owns selection + viewport (same fui::ListNav idiom as the list // menu screens). A shown cursor re-follows into view on every build; a // hidden one (-1, touch) leaves the viewport where scrolling put it. nav_.selected = std::clamp(model_.selectedIndex, -1, count - 1); nav_.followOnBuild = nav_.selected >= 0; nav_.followPending = false; nav_.syncToProps(listRect, listProps_.rowHeight, rowGap, count, listProps_);
// Materialise only the visible window of rows. const int windowCount = std::min({nav_.visibleRows, count - nav_.top, kMaxWindow}); for (int i = 0; i < windowCount; ++i) { const int index = nav_.top + i; windowLabels_[i] = model_.rowText ? model_.rowText(index) : std::string(); windowValues_[i] = model_.rowValue ? model_.rowValue(index) : std::string(); fui::ListItem item; item.label = windowLabels_[i].c_str(); item.value = windowValues_[i].empty() ? nullptr : windowValues_[i].c_str(); item.actionValue = static_cast<int16_t>(index); windowItems_[i] = item; } listProps_.items = windowItems_; listProps_.itemsWindowFirst = static_cast<uint16_t>(nav_.top); listProps_.itemsWindowCount = static_cast<uint16_t>(std::max(0, windowCount)); listProps_.valueText = tokens.bodyText; listProps_.valueText.bold = true; if (count > 0) { screen.list(listProps_); }
const int pageRows = nav_.pageRows(); const int totalPages = pageRows > 0 ? (count + pageRows - 1) / pageRows : 0; if (totalPages > 1) { char buf[16]; snprintf(buf, sizeof(buf), "%d/%d", nav_.top / pageRows + 1, totalPages); fui::TextStyle pageStyle = tokens.smallText; pageStyle.align = fui::TextAlign::Right; screen.target().text(pageIndicatorRect_, buf, pageStyle); }}