AI-assisted ESP-IDF + LVGL firmware for the JC3636K518C round knob display
The reference firmware ships a navigation model that took several design rounds to get right. Copy the model, not the screens. It gives a new app an “ok” UX in an hour and keeps every later screen consistent, because there is exactly one way to move, one way to commit, one way back, and one place for anything that must not be interrupted.
The knob only turns. There is no press. So the two jobs a smartwatch crown does are split permanently:
| Input | Role | LVGL source |
|---|---|---|
| Turn the knob | motion only: walk the spine, scroll a list, seek a value | knob_init(on_step) |
| Swipe | the same motion, for thumbs-only use | LV_EVENT_GESTURE on the screen |
| Tap | the only commit: open, select, toggle | LV_EVENT_SHORT_CLICKED on the target |
| Back chevron | up one level | a 40 px circle button at the top-left of the safe circle |
| Hold ~1 s | reserved for the one consequential action (start / stop) | LV_EVENT_LONG_PRESSED |
Rules that follow: the knob never commits, so a stray nudge can never confirm anything; every
knob motion has a swipe twin, so the device works without the knob; the knob gets no LVGL
focus group (lv_indev_set_group is never called), which removes the “am I scrolling or
editing?” ambiguity that encoder groups create.
Three redundant routes home, none needing the knob: the chevron pops one level; keep swiping and the wrap lands on Home; after ~20 s of no input a timer returns to Home.
Surfaces are lv_screen objects, swapped with a slide. Not lv_tileview: it does not
wrap and its momentum scroll reintroduces commit ambiguity.
static lv_obj_t *s_surface[SURFACE_COUNT];
static int s_index; // which surface is showing
static int s_depth; // 0 = surface, 1 = detail
#define SLIDE_MS 200 // the one place a full-screen redraw is acceptable
static lv_obj_t *new_surface(int idx) {
lv_obj_t *scr = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scr, lv_color_hex(HUD_BG), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); // the SCREEN never scrolls; lists do
add_dots(scr, idx);
wire_inputs(scr); // gesture + click, see below
return scr;
}
static void move_spine(int dir) { // +1 next, -1 previous; wraps
s_index = (s_index + dir + SURFACE_COUNT) % SURFACE_COUNT;
lv_screen_load_anim(s_surface[s_index],
dir > 0 ? LV_SCR_LOAD_ANIM_OVER_LEFT : LV_SCR_LOAD_ANIM_OVER_RIGHT, SLIDE_MS, 0, false);
lv_timer_reset(s_idle_timer);
}
One gesture handler on every screen, central, depth-aware.
static void on_gesture(lv_event_t *e) {
(void)e;
if (overlay_active()) return; // a mode is up: swipes do nothing
lv_indev_t *indev = lv_indev_active();
lv_dir_t dir = lv_indev_get_gesture_dir(indev);
if (s_depth == 1) { // inside a detail: right = back
if (dir == LV_DIR_RIGHT) { nav_back(); lv_indev_wait_release(indev); }
return;
}
if (dir == LV_DIR_LEFT) { move_spine(+1); lv_indev_wait_release(indev); }
else if (dir == LV_DIR_RIGHT) { move_spine(-1); lv_indev_wait_release(indev); }
}
static void wire_inputs(lv_obj_t *scr) {
lv_obj_add_flag(scr, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(scr, on_gesture, LV_EVENT_GESTURE, NULL);
}
lv_indev_wait_release() matters: it swallows the rest of the touch so a swipe that began on
a clickable row does not also fire that row’s click on release. Without it, swiping a list
opens whatever your finger landed on.
The knob drives the same axis. In the knob callback (it runs on the LVGL task): at depth
0 call move_spine(dir); at depth 1, or on a surface that owns a list, scroll the list
(§4). Also call the “activity” hook so a turn resets the idle timer like a touch does.
Details are pushed, never stacked deeper than one.
void nav_open_detail(lv_obj_t *content) { // content is a screen with no dots
lv_obj_t *back = lv_obj_create(content); // 40 px circle, HUD_PANEL fill, 1 px HUD_LINE
lv_obj_set_size(back, 40, 40);
lv_obj_set_style_radius(back, LV_RADIUS_CIRCLE, 0);
lv_obj_add_flag(back, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_ext_click_area(back, 8);
lv_obj_align(back, LV_ALIGN_TOP_MID, -85, 62); // inside the 340 px safe circle
lv_obj_add_event_cb(back, on_back_click, LV_EVENT_CLICKED, NULL);
lv_label_set_text(lv_label_create(back), LV_SYMBOL_LEFT);
wire_inputs(content);
lv_screen_load_anim(content, LV_SCR_LOAD_ANIM_OVER_LEFT, SLIDE_MS, 0, false);
s_depth = 1;
}
void nav_back(void) {
if (s_depth == 0) return;
lv_screen_load_anim(s_surface[s_index], LV_SCR_LOAD_ANIM_OVER_RIGHT, SLIDE_MS, 0, true);
s_depth = 0; // auto_del = true frees the detail
}
A row that opens a detail builds its content screen and calls nav_open_detail() itself; no
per-feature callback plumbing. Mark a detail “keep” when the user reads or waits on it, so the
idle timer does not pop it mid-read; the chevron and swipe-right still leave.
The idle timer, re-armed on every input.
static void to_home(lv_timer_t *t) {
(void)t;
if (s_depth == 1 && !s_detail_keep) nav_back();
else if (s_index != 0) { s_index = 0; lv_screen_load_anim(s_surface[0], LV_SCR_LOAD_ANIM_OVER_RIGHT, SLIDE_MS, 0, false); }
if (!overlay_active()) backlight_set_percent(BACKLIGHT_IDLE_PCT); // the panel is the power draw
}
s_idle_timer = lv_timer_create(to_home, 20000, NULL); // reset it in every gesture/click/knob handler
The protected layer. Build modal content on lv_layer_top(). It survives every
lv_screen_load_anim underneath, so “browse while a mode runs” and “idle timeout never ends
the mode” hold structurally: navigation has no code path into the mode. Show it with a fade,
not a slide, and gate the gesture handler on overlay_active() while it is up. The state
machine owns the mode; the overlay only mirrors it (direction is app → ui, never back).
A surface that lists things is a vertical axis. The screen itself is not scrollable; one container is.
lv_obj_t *list = lv_obj_create(scr);
lv_obj_remove_style_all(list);
lv_obj_set_size(list, 260, 240); // inside the safe circle
lv_obj_center(list);
lv_obj_set_layout(list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(list, LV_DIR_VER); // vertical only: horizontal stays the spine's
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_OFF);
Rows: 260 × 42 px, flex row, space-between, a 1 px bottom hairline in HUD_LINE, a left label
and an optional right value. LV_OBJ_FLAG_CLICKABLE plus LV_EVENT_SHORT_CLICKED opens the
detail. Rows never scroll themselves (lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE)).
The knob scrolls the list in detents, bounded, animated, through one shared primitive so every list feels the same:
void scroll_by_detents(lv_obj_t *cont, int steps, int32_t unit_px) {
if (!cont || steps == 0) return;
lv_obj_scroll_by_bounded(cont, 0, -steps * unit_px, LV_ANIM_ON); // negative dy = down
}
Pick unit_px per screen: one row height for a list, three line-heights for body text (a
readable nudge, not a pixel crawl, not a page jump). Hard stop at both ends, no wrap, no
overscroll: a list is not a ring. Facet pagers (Info / Transcript / Summary) are the one case
of horizontal paging inside a detail; then hide the chevron off the first page and let a
right-swipe on the first page be “back”.
HUD_SAFE_DIA from the tokens.theme_state_color(), never a literal hex
in a screen file. The state motion period from theme_state_motion_ms() drives the only
animation on the screen.new_surface(idx) and a faint title, add it to
s_surface[]. Dots update themselves from SURFACE_COUNT.lv_obj_t *screen_x_create(void) returning a content screen; the opening
row calls nav_open_detail(screen_x_create()). No back-button code in the screen.unit_px.| Temptation | Why not | Do instead |
|---|---|---|
lv_tileview for the spine |
no wrap → press-less dead end; momentum scroll | screens + lv_screen_load_anim |
| Encoder indev with a focus group | scrolling vs editing ambiguity, knob can commit | knob = motion only, tap commits |
| Edge-swipe to go back | unreliable on the rim, collides with the spine axis | chevron + right-swipe in details |
| Tap to stop or pause a session | one stray tap ends it | hold → confirm → hold, on the overlay |
| Put the mode on the spine | navigation can reach and break it | lv_layer_top() overlay |
| Scrollable screen object | vertical and horizontal gestures fight | one scrollable container, LV_DIR_VER |
| Per-screen gesture recognisers | inconsistent thresholds and double handling | one central handler, depth-aware |
| Blank empty list | user thinks it is broken | designed empty state with a route home |