#include "FrontlightPanelActivity.h" #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "components/UIThemeTokens.h" #include "components/icons/customListIcons.h" #include "components/icons/listIcons.h" namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_BRIGHTNESS = 1; constexpr fui::ActionId ACTION_WARMTH = 2; constexpr fui::ActionId ACTION_TOGGLE = 3; constexpr fui::ActionId ACTION_BRIGHTNESS_STEP = 4; constexpr fui::ActionId ACTION_WARMTH_STEP = 5; constexpr fui::ActionId ACTION_TILE = 6; // value = tile index // iOS-style geometry. The panel is a card hanging from the top of the screen: // a grabber, full-width slider pills, then a 2-column tile grid. The chrome // itself is fui::sheet / fui::sliderRow / fui::tileGrid; these constants only // size the bands, and computePanelBottom() mirrors them. constexpr int16_t kPanelSideMargin = 16; constexpr int16_t kGrabberHeight = 5; // fui::SheetProps default, mirrored here constexpr int16_t kSliderRowHeight = 56; // the pill itself (finger-sized) constexpr int16_t kTileHeight = 84; constexpr int16_t kTileGap = 16; constexpr int kTileCols = 2; // One percent per press, on the -/+ buttons and on the physical Left/Right keys // alike (both repeat while held), so a level can be set exactly. constexpr int BRIGHTNESS_STEP = 1; // The dimmest setting is 1%, not 0: turning the light off is what the lamp // button next to the slider is for, so a 0% "on" level would only be a second, // worse way to reach the same place. constexpr uint8_t MIN_BRIGHTNESS = 1; uint8_t percentFromPermille(const int16_t permille) { int value = (static_cast(permille) * 100 + 500) / 1000; if (value < 0) value = 0; if (value > 100) value = 100; return static_cast(value); } } // namespace FrontlightPanelActivity::FrontlightPanelActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity("FrontlightPanel", renderer, mappedInput), UiAppHost(renderer) {} void FrontlightPanelActivity::onEnter() { Activity::onEnter(); // A stored 0% predates the 1% floor (or came from the web settings): show it // as the floor rather than a level the slider can no longer produce. onExit // persists that, which is the intent — 0 is not a brightness any more. brightness = std::max(MIN_BRIGHTNESS, Frontlight.brightness()); warmth = Frontlight.warmth(); lightOn = Frontlight.isOn(); lightOnChanged = false; // Seed the touch tile's restore mode from the live setting, so toggling off // and back on within this session returns to the mode the user had. if (SETTINGS.touchReaderControls != CrossPointSettings::TOUCH_READER_OFF) { touchModeRestore = SETTINGS.touchReaderControls; } resetUi(); app.on(ACTION_BRIGHTNESS, &FrontlightPanelActivity::onBrightnessEvent, this); app.on(ACTION_WARMTH, &FrontlightPanelActivity::onWarmthEvent, this); app.on(ACTION_TOGGLE, &FrontlightPanelActivity::onToggleEvent, this); app.on(ACTION_BRIGHTNESS_STEP, &FrontlightPanelActivity::onBrightnessStepEvent, this); app.on(ACTION_WARMTH_STEP, &FrontlightPanelActivity::onWarmthStepEvent, this); app.on(ACTION_TILE, &FrontlightPanelActivity::onTileEvent, this); app.setScreen(&FrontlightPanelActivity::panelScreen, this); requestUpdate(); } void FrontlightPanelActivity::persistLightSettings() { // brightness/warmth are always restored unconditionally on boot (see // main.cpp), so they never diverge from SETTINGS at onEnter() — comparing // against SETTINGS here only fires on a genuine user change. lightOn has // no such guarantee (see lightOnChanged's declaration), so it's gated on // the user actually having touched it this session instead. const bool changed = SETTINGS.frontlightBrightness != brightness || SETTINGS.frontlightWarmth != warmth || (lightOnChanged && SETTINGS.frontlightOn != (lightOn ? 1 : 0)); if (changed) { SETTINGS.frontlightBrightness = brightness; SETTINGS.frontlightWarmth = warmth; if (lightOnChanged) SETTINGS.frontlightOn = lightOn ? 1 : 0; SETTINGS.saveToFile(); } } void FrontlightPanelActivity::onExit() { persistLightSettings(); Activity::onExit(); } void FrontlightPanelActivity::onBrightnessEvent(const fui::ActionEvent& event, void* user) { auto* self = static_cast(user); if (event.dragPermille < 0) return; self->brightness = std::max(MIN_BRIGHTNESS, percentFromPermille(event.dragPermille)); Frontlight.setBrightness(self->brightness); if (!self->lightOn) { self->lightOn = true; self->lightOnChanged = true; Frontlight.setOn(true); } } void FrontlightPanelActivity::onWarmthEvent(const fui::ActionEvent& event, void* user) { auto* self = static_cast(user); if (event.dragPermille < 0) return; self->warmth = percentFromPermille(event.dragPermille); Frontlight.setWarmth(self->warmth); } void FrontlightPanelActivity::onToggleEvent(const fui::ActionEvent&, void* user) { static_cast(user)->toggleLight(); } void FrontlightPanelActivity::onBrightnessStepEvent(const fui::ActionEvent& event, void* user) { static_cast(user)->adjustBrightness(event.value); } void FrontlightPanelActivity::onWarmthStepEvent(const fui::ActionEvent& event, void* user) { static_cast(user)->adjustWarmth(event.value); } void FrontlightPanelActivity::onTileEvent(const fui::ActionEvent& event, void* user) { static_cast(user)->runTile(event.value); } void FrontlightPanelActivity::runTile(const int idx) { switch (idx) { case 0: // Night mode (inverted output polarity, applied to the whole UI) SETTINGS.screenInverted = SETTINGS.screenInverted ? 0 : 1; SETTINGS.saveToFile(); // Inversion rewrites every pixel; take the clean waveform so the panel // does not keep a ghost of the old polarity. cleanRefreshPending = true; requestUpdate(); break; case 1: // Ghost-cleanup refresh of the whole frame // Refreshing with the panel still up would clean a frame the user is // about to dismiss anyway: drop the panel first and let the repaint of // the screen underneath carry the clean waveform instead. renderer.promoteNextRefresh(HalDisplay::FULL_REFRESH); close(); break; case 2: // Cycle the reading orientation SETTINGS.orientation = static_cast((SETTINGS.orientation + 1) % 4); SETTINGS.saveToFile(); // Only the setting changes: turning the renderer cropped the portrait-only // screens the panel opens over. The reader reflows on its next loop(). requestUpdate(); break; case 3: // Touch reader controls (for reading with the palm on the glass) // Toggles the existing Settings -> Controls option, nothing lower-level: // that setting only governs the reader's tap/swipe handling, so the // panel's own gestures (including the swipe that reopens it) keep // working while it is off. Off remembers the mode (Tap/Swipe/Inverted // Tap) so toggling back does not stomp the user's choice. if (SETTINGS.touchReaderControls != CrossPointSettings::TOUCH_READER_OFF) { touchModeRestore = SETTINGS.touchReaderControls; SETTINGS.touchReaderControls = CrossPointSettings::TOUCH_READER_OFF; } else { SETTINGS.touchReaderControls = touchModeRestore; } SETTINGS.saveToFile(); requestUpdate(); break; default: break; } } void FrontlightPanelActivity::adjustBrightness(const int delta) { int next = static_cast(brightness) + delta; if (next < MIN_BRIGHTNESS) next = MIN_BRIGHTNESS; if (next > 100) next = 100; if (next == brightness) return; brightness = static_cast(next); Frontlight.setBrightness(brightness); if (!lightOn) { lightOn = true; lightOnChanged = true; Frontlight.setOn(true); } requestUpdate(); } void FrontlightPanelActivity::adjustWarmth(const int delta) { int next = static_cast(warmth) + delta; if (next < 0) next = 0; if (next > 100) next = 100; if (next == warmth) return; warmth = static_cast(next); Frontlight.setWarmth(warmth); requestUpdate(); } void FrontlightPanelActivity::toggleLight() { lightOn = !lightOn; lightOnChanged = true; Frontlight.setOn(lightOn); requestUpdate(); } void FrontlightPanelActivity::close() { finish(); } bool FrontlightPanelActivity::handleHomeGesture() { close(); return true; } void FrontlightPanelActivity::loop() { const auto touch = routeTouch(mappedInput, false, /*routeHeld=*/true); if (touch.routed) { if (app.invalidated()) requestUpdate(); if (touch) { if (touch.event.dragPermille >= 0) draggingSlider = true; return; } // Swipe up dismisses the sheet, the way it was pulled down from the top // edge. draggingSlider keeps a fast slider flick from closing it. if (!draggingSlider && mappedInput.wasSwipe() == MappedInputManager::SwipeDir::Up) { close(); return; } // panelBottom > 0 guards the frame the sheet opens in: the release that // opened it (a status-bar tap) is still in the input snapshot when the panel // runs its first loop(), and panelBottom is only known once render() has // measured the layout — so at 0 that release read as "tapped below the // sheet" and closed it again before it was ever drawn. if (touch.snap.touchReleased && !draggingSlider && panelBottom > 0 && touch.snap.touchY >= panelBottom) { close(); return; } } if (draggingSlider) { if (!touch.snap.touchHeld) draggingSlider = false; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { close(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { toggleLight(); return; } buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustBrightness(-BRIGHTNESS_STEP); }); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustBrightness(BRIGHTNESS_STEP); }); } int FrontlightPanelActivity::computePanelBottom() const { const auto tokens = uiThemeTokens(uiTarget); const auto& metrics = UITheme::getInstance().getMetrics(); const int16_t lineHeight = uiTarget.lineHeight(tokens.smallText.font); // Slim battery band + the air around it (mirrors buildPanelScreen). const int y0 = std::max(metrics.batteryHeight, lineHeight); int y = tokens.spaceMd + y0 + tokens.spaceMd; if (Frontlight.present()) { // Screen::sliderRow reserves caption + spaceMd + control band, then a // spaceMd gap; addSliderRow() adds one more spaceMd of air after each row. y += lineHeight + tokens.spaceMd + kSliderRowHeight + 2 * tokens.spaceMd; // brightness if (Frontlight.hasColorTemperature()) { y += lineHeight + tokens.spaceMd + kSliderRowHeight + 2 * tokens.spaceMd; // warmth } y += tokens.spaceSm; } // Tiles are touch targets, so a buttons-only board gets no grid and the // sheet is exactly the frontlight controls. const int tileCount = mappedInput.hasTouch() ? kTileCount : 0; y += fui::tileGridHeight(static_cast(tileCount), kTileCols, kTileHeight, kTileGap); // The sheet's grabber band: content margin + grabber + air to the edge. // buildPanelScreen() feeds the same theme spacings into SheetProps. y += tokens.spaceLg + kGrabberHeight + tokens.spaceLg + tokens.spaceMd; return y; } void FrontlightPanelActivity::panelScreen(UiScreen& screen, void* user) { static_cast(user)->buildPanelScreen(screen); } void FrontlightPanelActivity::addSliderRow(UiScreen& screen, const char* label, const uint8_t value, const fui::ActionId sliderAction, const fui::ActionId stepAction, const bool showToggle) { // Live percentage readout. The row draws before this call returns // (immediate mode), so borrowing a stack buffer is safe. char pct[8]; snprintf(pct, sizeof(pct), "%u%%", static_cast(value)); // rowProps is a member (fui::SliderRowProps embeds a 324-byte StyleSet, well // past the 256-byte budget a local gets — AGENTS.md). Every field that // varies between the two rows is reassigned here; the rest keep their // constructed defaults, which already match the panel's card language. rowProps.label = label; rowProps.value = pct; rowProps.sliderValue = value; rowProps.sliderAction = sliderAction; rowProps.decrement = stepAction; rowProps.increment = stepAction; rowProps.decrementValue = -BRIGHTNESS_STEP; rowProps.incrementValue = BRIGHTNESS_STEP; if (showToggle) { // Lamp on/off after the +: the sliders set the level, this kills the light // outright. Filled glyph = on, outline = off. rowProps.toggleAction = ACTION_TOGGLE; rowProps.toggleIcon = fui::bitmapFromIcon(lightOn ? icon_sun_filled_32 : icon_sun_32); } else { rowProps.toggleAction = fui::NO_ACTION; rowProps.toggleIcon = fui::BitmapRef{}; } screen.sliderRow(rowProps, kSliderRowHeight); // The wrapper's own trailing gap is one spaceMd; double it so the rows // breathe — a control band this tall reads cramped at the list cadence. screen.spacer(screen.theme().spaceMd); } void FrontlightPanelActivity::buildPanelScreen(UiScreen& screen) { const auto& theme = screen.theme(); // Sheet chrome first: the card body, the 2px rule along its bottom edge, and // the grabber on the edge the sheet is dragged from. Screen::sheet() also // clamps the content area to the sheet, so every band below lays out inside // it. (No header: the panel is a floating card, and its own grabber says // what it is.) fui::SheetProps sheetProps; // A roomy band above the bottom rule: the grabber gets a full spaceLg of // air on both sides so the last row of content never crowds the sheet edge. sheetProps.grabberMargin = theme.spaceLg; sheetProps.grabberInset = static_cast(theme.spaceLg + theme.spaceMd); screen.sheet(sheetProps, static_cast(panelBottom)); screen.insetContent(fui::Insets{0, kPanelSideMargin, 0, kPanelSideMargin}); // Reuse the exact battery renderer and header rectangle used by Home. Call // the base implementation directly because RoundedRaff suppresses its // untitled Home header. { const auto& metrics = UITheme::getInstance().getMetrics(); screen.spacer(theme.spaceMd); const int16_t bandH = std::max(static_cast(metrics.batteryHeight), screen.target().lineHeight(theme.smallText.font)); screen.takeTop(bandH, theme.spaceMd); UITheme::getInstance().getTheme().BaseTheme::drawHeader( renderer, Rect{0, metrics.topPadding, renderer.getScreenWidth(), metrics.homeTopPadding - metrics.topPadding}, nullptr); } if (Frontlight.present()) { addSliderRow(screen, tr(STR_BRIGHTNESS), brightness, ACTION_BRIGHTNESS, ACTION_BRIGHTNESS_STEP, /*showToggle=*/true); if (Frontlight.hasColorTemperature()) { addSliderRow(screen, tr(STR_WARMTH), warmth, ACTION_WARMTH, ACTION_WARMTH_STEP, /*showToggle=*/false); } screen.spacer(theme.spaceSm); } // Quick-setting tiles. Two columns of finger-sized cards; a tile whose // setting is currently on draws filled (StateChecked -> selected style). // Touch boards only — the tiles are touch targets. if (mappedInput.hasTouch()) { static constexpr StrId kOrientNames[4] = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_ORIENTATION_INVERTED, StrId::STR_LANDSCAPE_CCW}; // The orientation tile is labelled with just the current mode ("Portrait"): // the mode names say what the tile is about on their own. const char* orientLabel = I18N.get(kOrientNames[SETTINGS.orientation % 4]); // "Touch On" / "Touch Off", from the existing state strings: the label // names the current state of the touch-reader-controls setting. const bool touchOn = SETTINGS.touchReaderControls != CrossPointSettings::TOUCH_READER_OFF; char touchLabel[48]; snprintf(touchLabel, sizeof(touchLabel), "%s %s", tr(STR_TOUCH_TOGGLE), I18N.get(touchOn ? StrId::STR_STATE_ON : StrId::STR_STATE_OFF)); const char* labels[kTileCount] = {tr(STR_NIGHT_MODE), tr(STR_FORCE_REFRESH), orientLabel, touchLabel}; const fui::State states[kTileCount] = {SETTINGS.screenInverted ? fui::StateChecked : fui::StateNormal, fui::StateNormal, fui::StateNormal, // Filled when touch reader controls are OFF — the non-default, // attention-worthy state. touchOn ? fui::StateNormal : fui::StateChecked}; for (int id = 0; id < kTileCount; ++id) { gridItems[id].label = labels[id]; gridItems[id].value = static_cast(id); gridItems[id].state = states[id]; } gridProps.items = gridItems; gridProps.count = static_cast(kTileCount); gridProps.action = ACTION_TILE; gridProps.tileHeight = kTileHeight; gridProps.gap = kTileGap; screen.tileGrid(gridProps); } } void FrontlightPanelActivity::render(RenderLock&&) { panelBottom = computePanelBottom(); // fui::sheet draws the card body, its bottom rule, and the grabber during // renderUi(); the battery band at the card's top is part of the screen build. renderUi(); // A tile that rewrote the whole frame (night mode) re-drives every pixel // once; ordinary repaints stay on the fast path. HALF: strong enough to // flip the whole frame's polarity without the FULL waveform's blackout // flash. Any faint residue clears with the panel's dedicated refresh tile // or the next scheduled clean refresh. renderer.displayBuffer(cleanRefreshPending ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH); cleanRefreshPending = false; }