Repository navigation
feat(app): round display preview for every knob app page - #242
Merged
Merged
Conversation
The knob's C port hops for 1 s at 75 % squash every 4.5 s median and takes its sleepy delay from the knob settings. A Tuning value lets the Settings preview run the same behaviour; the default (.mac) keeps the menu-bar and Dock bot bit-identical.
SwiftUI replicas of cinder's 466 px round pages (bot Head style, Pomodoro bezel ring, procedural weather sky), ported from the firmware so the app can preview a page the knob renders on-device. The numbers both sides share live in one JSON resource; the tests pin them to the firmware's values and render every face to PNG (KNOB_SNAPSHOT_DIR).
Settings › Knob › Apps shows each page's face like PanelPreview does for the clock: live mood/host, Pomodoro, weather and brightness from the feeds the app already polls (the view endpoint is device-token only), dimmed with Off when the page is off. Pages shows all faces in the knob's order. Closes #240
The dimmed face let the bezel gradient show through as grey.
Rendered by KnobFaceTests (KNOB_SNAPSHOT_DIR) for review of #240.
tarakanof
commented
Oct 4, 2026
tarakanof
left a comment
Owner
Author
There was a problem hiding this comment.
Review: fidelity against cinder main (dc84b8e), then code. swift test --package-path macos passes (792 tests).
0. PR body is wrong. It holds cinder's provisioning PR text ("Closes #6", NVS, factory reset). #240 won't auto-close on merge, and the body describes nothing in this diff. Please replace it.
Fidelity (compared with the firmware source and docs/img/knob-previews.png)
- The rim has a glow the knob doesn't draw.
KnobFaceRender.botstrokes the outline in a layer with.shadow(radius: rimPx*1.5, opacity 0.8).bot_view.cdraws a plain 6 px AA ring; the glow was removed on purpose (header comment: "3-layer glow ~250 ms per refresh"). It's clearly visible on working/waiting/error/done in the contact sheet. Dropping it also removes the most expensive operation in each 20 fps frame (an offscreen layer plus a blur). - The rim scales on the mood-change pop; the knob's doesn't. Swift maps the ring through the full pose (
scaleX/scaleY), so the uniform pop scales the outline. Firmware picks an outline variant by thescale_y/scale_xratio only ("Pop (uniform scale) is ignored"), so only the eyes pulse. Fix: build the outline transform from the squash ratio, not from the pop. - Fonts. The firmware fonts are LVGL
lv_font_montserrat_{14,24,48}built from Montserrat-Medium. The preview uses SF Medium at the same px sizes. Vertical placement matches within ~1 px for the Pomodoro time and the host label. The weather temperature sits about 5 px low (in 466 px space): firmware's label top is y 236 with a 52 px line and baseline 9, so the digit centre is about y 262; Swift centres the line box at 236 + 0.65×48 ≈ 267. Glyph shapes and widths differ too (SF digits are narrower, and--:--renders as spaced dashes). Bundling Montserrat-Medium is worth it: OFL 1.1 allows embedding, it's one ~200 KB TTF registered from EmberKit's bundle (CTFontManagerRegisterFontsForURL), it needs only the OFL notice in Acknowledgements, and it makes text widths match the firmware's fixed label boxes (host 300, phase 300, time 260). If you'd rather keep SF, change the temperature offset tosky.maxY + lineHeight/2using Montserrat metrics (≈ 26, not 31). - Checked and matching:
bot_shape.c(triangle table, R/sagitta/blur, ring, eye capsules, happy quadratic, REST_X 0.67),to_screen, FILL/HOP_SCALE/EYE_SCALE/RIM_PX, idle/sleepy rim gain 0.55, badge (0.98 / 0.3 / 0.22, rides with the hop), host label (Montserrat 24, centre CY + 0.8r, mood colour, waiting/error only), seed "EMBR". Pomodoro: r 222 / w 12, clockwise from 12 o'clock with round caps, track ×0.18, paused/parked ×0.45, every COL_*, label dy −6/46/−54, paused blink on odd seconds, OFFLINE / PUSH TO START / "X NEXT" / note override. Weather: every sprite size and stroke, cloud fill/outline, layout, periods, drop counts, bolt timing, still grey, the full palette, sky 200×140 at y 96. The renderers are a faithful port apart from items 1–3. BotBehavior.Tuningmatchesbot_behavior.h/.c: hop 1.0 s with the curve time-scaled by 0.62/1.0, squash 0.75 applied to sx/sy only (not dy), interval lognormal(4.5, 0.35, 2.5…9), sleep 300 s with 0 meaning never (→.infinity), first waiting hop at +0.6 s.
Code
- The drift guard pins literals, not the firmware.
knobThemePinsFirmwareConstantscompares the JSON against numbers typed into the test, so nothing fails if cinder changes until cinder#34 lands. It also skips several values:hop.interval_sigma,triangle.blur_deg,badge.*,host.width_px, the pomo labeldy_px/width_px,colors.other/idle_track/text/text_dim/note, and 12 of the 15 weather colours. Suggestion: pin every key, plus an opt-in test that parses the sibling../cinderheaders (#defines inbot_view.c,pomo_view.c,weather_scene.c) when the checkout exists, similar to how the knob vectors are shared. - The "every shared number lives in knob-theme.json" claim (ARCHITECTURE.md, cinder#34) isn't true yet.
KnobWeatherScenehard-codes sprite sizes, layout positions, star positions, drop counts, periods and bolt timing.KnobBotShapehard-codes the eye geometry. cinder#34's generator list omits the same values, so both sides can drift with no check. Either move these into the JSON (and into #34's list) or reword the docs to say only the theme numbers are shared. macTuningIsUnchangedis tautological. It comparesinit(seed:now:)withinit(…, tuning: .mac), which run the same code, so wrong.macvalues would still pass. Pin.mac == Tuning(sleepAfter: 300, hopLength: 0.62, hopSquash: 1, 15, 0.4, 8...40), or check a few poses against golden values from main. (The code paths themselves are equivalent:hop(u, squash: 1)returnshop(u), andcu == uwhen the lengths are equal.)- Bot CPU use.
TimelineView(.animation(minimumInterval: 1/20))redraws every 50 ms even while the pose is static, which is most of the time between blinks and saccades. Each frame rebuilds the 192-point ring, the shadow layer and aTextresolve.BotBehavioralready exposesisAnimating/nextEventAt(the firmware redraws only whenbot_pose_sameis false). Pausing when nothing is animating, or using a schedule driven bynextEventAt, plus caching the ringPathper (shape, mood), would cut most of that. Pausing ononAppear/onDisappearis fine, and Reduce Motion behaves as documented: bot blinks only, weather still. - The docs say the Pages overview is static, but the Pomodoro tile still ticks.
KnobPomoLivealways runs.periodic(by: 1)and passes.nowwhenanimated == false, so it re-renders every second (paused blink included). Either gate the timeline onanimatedor reword the doc. - Turning on "follow Ember" brightness waits up to 60 s.
KnobPreviewFeedssleeps 60 s per loop and checksfollowEmberonly inside it. The firmware fetches immediately when it's turned on. Minor: key the.task(id:)onfollowEmber. - Checked and fine: the mood/host composition from
/statesessions (pickWinningpriority waiting > error > running > done, newest wins; peers that disagree blank the host, as inlegacyRender/#213 andember_host_label; printable ASCII, 10 characters, uppercase). One small difference: when Ember is unreachable the knob keeps its last mood while the preview falls back to idle. Accessibility (one element with the title as label, caption as value, "Off." prefix,.isImage) and the string catalog entries (positional%1$@/%2$@, comments) look good.
Review of #242: the outline had a glow and scaled with the pop, while the firmware draws a plain 6 px ring and picks a squash variant by sy/sx. Labels now use Montserrat Medium (OFL, bundled) placed by LVGL's line box, which also puts the temperature at the firmware's y. Eye geometry and the weather scene's sizes, layout and timing move into knob-theme.json; every key is pinned, and a test checks each against cinder's source when a checkout is next to this repo.
A 20 fps timeline redrew between blinks too. The schedule now follows BotBehavior: frames while it animates, else the next scheduled event, and equal quantized poses skip the Canvas. The Pomodoro tile only ticks when animated, so the Pages overview has no timers.
Follow Ember brightness is read as soon as it turns on instead of after up to a minute, and a failed /state keeps the last mood instead of falling back to idle, as the firmware does.
Owner
Author
|
Review fixes, pushed in bcba5b2..1ce7c30:
Verified: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #240
Each knob app pane in Settings › Devices › Knob › Apps (Bot, Focus, Weather) now shows a live round 466 px preview of the knob's display, like
PanelPreviewdoes for the TC001. Pages shows every face in the knob's order, dimmed with "Off" when a page is off, and marks the home page.Renderers (EmberKit
KnobFace/, SwiftUI Canvas, ported from cinder's firmware)bot_view.c's Head style:bot_shape.ccurved triangle and round-capped eye strokes, the badge, and the host label in waiting/errorpomo_view.c: the ring (radius 222, 12 px) drains clockwise from 12 o'clock. Covers paused (dim and blinking), parked "FOCUS NEXT", idle "--:--" / "PUSH TO START", OFFLINE, and the POMODORO OFF note.weather_face.cWMO/MET mapping with day/night, theweather_scene.csprites, particles and bolt, and grey when stale.Live data:
GET /v1/devices/self/viewtakes a device token only, so the preview builds the same inputs from feeds the app already polls:/statesessions, giving mood and host by the firmware's rules; the last mood stays while/statefails/v1/pomodoro/state/v1/weather/state, tracked while a preview is on screen/v1/display/brightness, read as soon as "Follow Ember brightness" turns onIt also reads the draft knob settings, so toggling a page or a bot timing shows at once.
Animation
BotBehaviorwith a newTuning..knobgives a 1 s hop at 75 % squash, every 4.5 s median, with the knob's sleepy delay..macis the default and stays identical to main, pinned by golden poses.isAnimating, otherwise atnextEventAt, and equal quantized poses skip the Canvas.Drift guard:
macos/Sources/EmberKit/Resources/knob-theme.jsonholds everything the renderers share with the firmware: geometry, palette, label layout, LVGL font metrics, eye geometry, and the weather scene's sprite sizes, layout, particles and timing. The sprite outlines and the hop curve stay in code.KnobThemeTestspins every key.themeMatchesCinderSourceparses cinder's source and checks each key against the constant it mirrors. It runs when a../cindercheckout exists orCINDER_DIRis set, and skips otherwise.Snapshots:
KnobFaceTestsrenders every face throughImageRendererand checks pixels at firmware positions: rim colour, no glow, ring and track, sun, and the temperature centre at about y 262.KNOB_SNAPSHOT_DIR=<dir> swift test --package-path macos --filter knobFaceswrites the PNGs.Verified
swift test: 794 pass.main, and fails when a theme value is changed.xcodegenand an unsigned Releasexcodebuildsucceed, and the font and theme are in the EmberKit bundle.scripts/strings.sh checkpasses.This doesn't touch the Dashboard (#239) or
KnobModel. Outside the new files, the app-side change is two lines inKnobDetail.swift.