SDK
kui, the UI kit
The Kode Dot's UI kit on LVGL: kshell's colours, type and line glyphs, the focus ring, six components, the glass, touch maths, a keypad, and Kody.
What it is
kui is how an app gets the look of the Kode Dot. It is written in plain C on top of LVGL 9.6, the graphics library that draws the interface. It holds everything the system's own screens are made of:
- the named colours and sizes of the design
- the shared styles
- eighteen fonts and fifty-seven images, among them forty-three line glyphs
- six components
- Kody, the Kode Dot's character, with a player for his animations
The kit also holds the maths a touch screen needs, in plain C with no LVGL: the shape of the glass, a recogniser that tells a tap from a swipe, a page that follows a finger and lands on a spring, and the text rules of a phone-style keypad. They are host-tested, and any tool can use them.
The look is kshell's, Luismi's shell for the Kode Dot: a black page, dark rows, one Bold Yellow ring for the cursor, Outfit for words and DM Sans for numbers. The tokens, the type and the line glyphs come from there.
kui depends on LVGL and on three SDK layers, kbsp, khal and klog, and on nothing above them. A firmware with no Active Objects draws with it exactly as kodeOS does, and an app that does not draw never takes LVGL into its image.
The display itself (LVGL on the panel, the flush, the touch feed, the render pass) belongs to sdk/kscreen. An app that names kscreen gets kui with it, and kscreen_open() calls kui_init().
How it is connected
flowchart LR app["Your code<br/>or kscreen"] --> kui["kui"] kui --> lvgl["LVGL 9.6"] subgraph sdk["SDK layers"] kbsp["kbsp"] khal["khal"] klog["klog"] end kui -- "glass size" --> kbsp kui -- "PSRAM heap,<br/>ROM inflate" --> khal kui -- "refusals" --> klog kui -. "kui_pack.c only" .-> cjson["cJSON"] class app app
One thread, no lock
Everything in the kit runs on the one thread that drives LVGL, so it needs no lock and has none.
kui_init() once
kui_init(disp) puts LVGL's simple theme on the display and initialises the shared styles. Call it after lv_init() and after the display exists, before any style or component. A second call does nothing.
You pay flash for what you name
The kit has no table of names. Every font, every image and every animation is its own object file, and the linker keeps only what code names. The shared styles name seven of the eighteen faces (caption, body, button, name, title, heading, code), and six of those fall back on a digits face, so kui_init() links those seven and the four digits faces into any firmware that calls it. The clock face, each glyph, each animation and the player itself are linked only when something names them.
LVGL's heap is in PSRAM
The board file selects LVGL's custom allocator (CONFIG_LV_USE_CUSTOM_MALLOC=y), and kui_mem.c provides the hooks, so naming kui is all an app does. The hooks take PSRAM through khal and never fall back to internal RAM: a widget tree that does not fit returns NULL to LVGL and leaves the pool that DMA buffers and task stacks need alone. Blocks are aligned to 8 bytes.
An app that picks another allocator in its sdkconfig.defaults gets a kui_mem.c that compiles to nothing. CMake names one hook to the linker (-u lv_malloc_core), because libkui.a reaches the linker before liblvgl.a asks for it.
Build changes kui makes to LVGL
Three changes:
- LVGL compiles at
-O2whatever the firmware is optimised for, because an app built at-Ogor kodeOS at-Oswould leave a full-screen redraw well over the panel's 16.8 ms. - LVGL's draw threads get their stacks in PSRAM through the one
xTaskCreate()its FreeRTOS layer makes. - ThorVG, LVGL's vector engine, builds without its SVG loader and without its own threads. The loader brings libstdc++'s locale machinery for 207,088 bytes of flash and 5,000 bytes of internal RAM (measured on kodeOS), and ThorVG's threads overflow their 3 KB default stack.
Faces and glyphs follow the brand
Words are Outfit and numbers are DM Sans. An Outfit face leaves out the digits 0 to 9 and falls back on the digits face of its own size, so LVGL draws each digit from DM Sans on the baseline of the line. The signs between digits (colon, point, percent, degree) stay Outfit.
The glass has round corners
The panel is 410 x 502 behind a cover glass with 114 px corners, so anything inside a corner square but outside its circle is under the silk screen and never seen.
- Nothing sits in the corners.
- A full-width row stays between y 53 and y 448 with 18 px side margins.
- No hairline runs along the very edge.
kui_panel_check() walks an object tree and reports whatever draws under a corner.
Kody
The player draws Lottie animations with lv_lottie over ThorVG on the LVGL thread. The kit compiles one scene in, the clock scene, as the floor a board falls back on.
Every other animation belongs to Kody's pack, a flat folder /kode/ on the microSD card or the internal FAT that holds a manifest kode.json and the Lottie files it names. The pack reader in the kit parses and checks; the orchestration that finds the pack and loads it lives in kgui, above the SDK.
Specifications
The files
The kit's files, in five groups. This group is the core: the components, the styles, the tokens and the allocator.
| File or folder | What it does |
|---|---|
core/include/kui.h | The one header an app includes: the components, the shared styles, the faces and the images. Includes the glass, panel and token headers |
core/include/kui_glyphs.h | The declarations of the 43 line glyphs. Generated by tools/glyphgen.py; never edited by hand |
core/include/kui_tokens.h | The tokens as KUI_* macros. Generated from core/assets/tokens.json by tools/gen_tokens.py; never edited by hand |
core/src/kui.c | kui_init() |
core/src/kui_styles.c | The shared styles, 29 of them |
core/src/kui_flex.c | The flex box every layout is built from |
core/src/kui_pill.c, kui_cat_pill.c, kui_sheet.c, kui_level.c, kui_swatch.c, kui_lamp.c | One component each |
core/src/kui_mem.c | LVGL's allocator hooks on the PSRAM heap |
core/src/kui_internal.h | What kui's own files share; an app never sees it |
The files: glass, panel and touch maths
The shape of the glass, the corner check and the three touch modules.
| File or folder | What it does |
|---|---|
glass/include/kui_glass.h, glass/src/kui_glass.c | The shape of the glass as plain integers |
panel/include/kui_panel.h, panel/src/kui_panel.c | The corner check and the desktop bezel |
drag/include/kui_gesture.h, drag/src/kui_gesture.c | The recogniser: tap, claimed drag, or a named rejection |
drag/include/kui_spring.h, drag/src/kui_spring.c | A damped spring in closed form |
drag/include/kui_drag.h, drag/src/kui_drag.c | A page that follows a finger along one axis and lands on a spring |
multitap/include/kui_multitap.h, multitap/src/kui_multitap.c | The phone keypad's text rules |
test_dot/include/kui_test_dot.h, test_dot/src/ | The E5 test's operator screens and the full-glass patterns its camera photographs: kui_td_screen_build(), kui_td_pattern_build() and kui_td_grid_set(). Each call paints one 410 x 502 canvas on its parent, with no text. Compiled only with CONFIG_KODE_TEST_DOT (sdk/kaos/test-dot) |
The files: Kody and his pack
The player, the built-in scene and the pack reader.
| File or folder | What it does |
|---|---|
kody/include/kui_kody.h, kody/src/kui_kody.c | The Kody player on lv_lottie |
kody/include/kui_kody_anchor.h, kody/src/kui_kody_anchor.c | Anchors and crossings, as plain integers |
kody/include/kui_kody_anims.h | The three built-in animations, one descriptor each. Generated |
kody/src/kody_reloj.c, kody_despierta_reloj.c, kody_reloj_te_mira.c | The built-in clock scene, its wake-up and its look. Generated by tools/kodygen.py, compiled only with Lottie |
kody/assets/ | The sources: 18 Lottie files and the manifest kody.json |
kody/pack/ | The official pack: kode.json and 69 .json.z files. Generated |
pack/include/kui_pack.h, pack/src/kui_pack.c | The reader of Kody's pack: the manifest, the limits and the checks. The one file that includes cJSON |
The files: fonts and images
The generated faces and images.
| File or folder | What it does |
|---|---|
fonts/src/ | The 18 faces, one generated kui_font_*.c each (eight Outfit, ten DM Sans); sources and licences in fonts/assets/ |
images/src/ | The 57 images, one generated kui_glyph_*.c or kui_wordmark.c each; PNG sources in images/assets/ |
The files: build, tools and simulator
The build configuration, the generators, the simulator and the component's own files.
| File or folder | What it does |
|---|---|
thorvg/kui_tvg_config.h | ThorVG's build configuration for the firmware, forced into LVGL's C++ sources |
tools/gen_tokens.py, fontgen.sh, glyphgen.py, imagegen.sh, kodygen.py, tools/tests/test_kodygen.py | The generators, and the tests of the Kody one |
sim/ | The desktop simulator: main.c, device.c, device.h, keys.c, keys.h, kui_sim.h, lv_conf.h, CMakeLists.txt, embed.cmake, README.md and the picture of the Kode Dot |
README.md, CMakeLists.txt, idf_component.yml | The component's guide, build and manifest (LVGL 9.6.0~1, cJSON ^1.7.19) |
The components
Each call returns an LVGL object that already wears the kit's styles. You place it, fill it and listen to its events. Every object carries a name with its own counter, for example pill_3. The six components, and the flex box under them:
| Call | Returns | What it is |
|---|---|---|
kui_flex_create(parent, flow, gap, grow, main_place, cross_place, track_place) | A transparent flex box | The one container every layout uses: no padding, no theme background or border, sized to its content until you set a size, scrollbar in kui_style_scrollbar |
kui_pill_create(parent, title, subtitle) | An lv_button named pill_# | The row: a full-width capsule with a round slot, a name and a line under it. Children: fill (a level bar, hidden), slot (the disc, holding hidden slot_img and slot_text), name, sub. Pressed it wears kui_style_pill_pressed; with LV_STATE_USER_1, kui_style_pill_off; checked, kui_style_ring |
kui_cat_pill_create(parent, title) | An lv_button named cat_pill_# | A category tab: an outlined capsule, filled when checked |
kui_sheet_create(parent) | A transparent root named sheet_# | A card that covers the glass (410 x 502 at 0, 0, radius KUI_RADIUS_GLASS). Every child is created empty and, apart from the header, hidden. Children and places: see below |
kui_level_create(parent, colour) | An lv_slider named level_# | A level the finger drags from 0 to KUI_LEVEL_MAX (100): a track, a fill in colour and a knob, a pill's width until you set another. Sends LV_EVENT_VALUE_CHANGED. kui_level_set_colour() repaints the fill |
kui_swatch_create(parent, colour) | An lv_obj named swatch_# | A round colour to tap; LV_STATE_CHECKED rings it as it rings a pill |
kui_lamp_create(parent, diameter) | A holder named lamp_# | A disc, child disc, in a holder padded by the glow's reach so the parent never cuts the glow. kui_lamp_set(lamp, colour, on) lights it or turns it off |
A sheet carries at most one answer button. Its children, all of card:
handlehdr_disc, holdinghdr_imgandhdr_texthdr_titleandhdr_subbodyline1toline3notecode,code_l1andcode_l2actions, holdingbtnandbtn_text
The header disc is at (32, 56), the body at (32, 188) and the answer button at y 395.
The macros KUI_RADIUS_GLASS (the glass's corner radius, from kbsp), KUI_OPA_PCT(p) and KUI_LEVEL_MAX come with kui.h.
The shared styles
All are extern lv_style_t kui_style_*, initialised by kui_init(). They come in five groups:
| Group | Styles |
|---|---|
| Page | screen (the page, Light Grey type, the body face), scrollbar, scrollbar_hidden |
| Pill | pill, pill_pressed, pill_off, pill_name, pill_sub, slot, slot_text, fill |
| Cursor | ring (on the page), ring_dark (on a light surface) |
| Sheet | sheet_card, handle, handle_on_bg, hdr_disc, hdr_text, hdr_title, hdr_sub, hdr_title_on_bg, sheet_line, sheet_line_c, sheet_note, sheet_code |
| Action button | action, action_text, action_pressed, action_ring |
The focus ring is the cursor of the pad. kui_style_ring is an outline KUI_RING_W (4 px) wide and KUI_RING_GAP (4 px) off the object, outside it, in KUI_COLOR_FOCUS (Bold Yellow). It rings a checked pill, a swatch, a circle or a row, and kgui leaves 8 px of room round a ringed object so its container never cuts the ring (gui_ring_room()). ring_dark is the same outline in Soft Black for an object on a light surface, and action_ring is the ring on the answer button of a sheet.
The faces
All are 4 bits per pixel. The brand sets words in Outfit and numbers in DM Sans, and the kit follows that rule in the faces themselves. Eight faces are Outfit, in three weights. Ten are DM Sans Bold: the clock, the codes, the two big figures and six faces that hold the digits 0 to 9 and nothing else. kui_font_caption and kui_font_code also hold the four arrows, for a hint that spells a code with them. TEXT is ASCII less 0-9; LATIN1 adds 0xA0-0xFF, so an accent in an app's name has a glyph. The eighteen faces:
| Face | Typeface and size | Holds | Falls back on |
|---|---|---|---|
kui_font_caption | Outfit Regular 22 | LATIN1, and ← ↑ → ↓ | kui_font_digits_22 |
kui_font_body | Outfit Regular 28 | LATIN1 | kui_font_digits_28 |
kui_font_button | Outfit Bold 26 | TEXT | kui_font_digits_26 |
kui_font_name | Outfit Bold 28 | LATIN1 | kui_font_digits_28 |
kui_font_title | Outfit Bold 34 | LATIN1 | kui_font_digits_34 |
kui_font_heading | Outfit ExtraBold 34, a screen's title | TEXT | kui_font_digits_34 |
kui_font_display_40 | Outfit ExtraBold 40 | LATIN1 | kui_font_digits_40 |
kui_font_title_60 | Outfit ExtraBold 60 | TEXT | kui_font_digits_60 |
kui_font_code | DM Sans Bold 64 | Space, 0-9 and ← ↑ → ↓ only | none |
kui_font_clock | DM Sans Bold 104 | 0-9, the colon and the hyphen only; no tabular figures | none |
kui_font_code_80 | DM Sans Bold 80 | Space, %, +, -, ., /, 0-9 and the colon only | none |
kui_font_hero_160 | DM Sans Bold 160 | Space, % and 0-9 only | none |
kui_font_digits_22, _26, _28, _34, _40, _60 | DM Sans Bold | 0-9 only | none |
Text on Kody Blue or Bold Yellow is Soft Black (KUI_COLOR_ON_ACCENT): Light Grey would read 2.7:1 on the blue and 1.3:1 on the yellow.
The faces are under the SIL Open Font License 1.1, which travels with any firmware that ships them.
The images
The kit has 57 images, each its own object file. All are extern const lv_image_dsc_t.
The thirteen glyphs are 40 x 40 ARGB8888, black on transparent, recoloured by C code:
| Image | Shows |
|---|---|
kui_glyph_wifi, kui_glyph_ble | Wi-Fi, Bluetooth |
kui_glyph_sun, kui_glyph_battery, kui_glyph_power | Brightness, battery, Turn off |
kui_glyph_info, kui_glyph_check, kui_glyph_x | About, a tick, a cross |
kui_glyph_lock, kui_glyph_usb, kui_glyph_bolt | Secured, USB power, voltage |
kui_glyph_sd, kui_glyph_radio | The microSD card, the radio |
The 43 line glyphs are one stroke of 2.2 px with round ends on a 24 x 24 grid, drawn at the size where a screen sets it, in LV_COLOR_FORMAT_A8: the image holds only an alpha and the screen gives the colour with lv_obj_set_style_image_recolor() at LV_OPA_COVER. The name is kui_glyph_<name>_<size>, and core/include/kui_glyphs.h declares them all. The bodies of the drawings come from kshell and live in tools/glyphgen.py. The 96 px glyphs are drawn at their own size, because the 40 px ones come out soft when scaled up.
| Size | Names |
|---|---|
| 16 | sd |
| 28 | cup |
| 30 | clock, wifi, bt, moon, watch, scene, chip, info, lock, check, phone, x, power, apps, share, chev_l, chev_r |
| 40 | wifi, bt, torch, share, cup, power, more, spk0 to spk3, vib, sun0 to sun3, games |
| 64 | torch, apps |
| 96 | bt, wifi, chip, power, share |
The games glyph is a gamepad. kgui draws it on the Games folder of Apps, beside more (a cog) for Tools, wifi for Radios, chip for Sensors, spk3 for Medias and apps for every other folder and for an app with no icon. A firmware keeps only the glyphs a screen names.
The wordmark, kui_wordmark, is 300 x 90 RGB565A8.
The tokens
core/assets/tokens.json is the source. A role is what a style asks for and a palette entry is what it gets, so re-pointing a role repaints every style that names it. tools/gen_tokens.py --check exits non-zero when the header does not match the source.
The palette is a black page, dark rows, and one yellow for what you press and where the cursor is. The first six entries are the page and the brand manual's five colours; the rest are the greys and greens of kshell's look. Each entry with the role it serves:
| Palette | Value | Role it serves |
|---|---|---|
KUI_PALETTE_PAGE | 0x000000 | The AMOLED page and a sheet's card: black costs nothing |
KUI_PALETTE_SOFT_BLACK | 0x191919 | Dark ink: text on a lit fill, never a ground |
KUI_PALETTE_LIGHT_GREY | 0xDED7CD | Type on the page or on a row |
KUI_PALETTE_MEDIUM_GREY | 0xC2BAB3 | Secondary text, borders, tracks, scrollbars |
KUI_PALETTE_BOLD_YELLOW | 0xFFAC00 | A level, the answer button, the focus ring, and a warning: the page has no orange |
KUI_PALETTE_KODY_BLUE | 0x028AC2 | Kody and connections: a radio that is on, the chosen thing |
KUI_PALETTE_ROW_DARK | 0x212121 | A row on the page, a shade up from Soft Black, so it reads as a surface and not as a hole |
KUI_PALETTE_OFF_GREY | 0x2A2A2A | A control at rest, lighter than a row: a switch that is off |
KUI_PALETTE_REST_GREY | 0x5C5A56 | The track a level fills over, a pill held down, a disabled thing |
KUI_PALETTE_OK_GREEN | 0x31C45A | Done and healthy, on a dark ground |
KUI_PALETTE_CHARGE_GREEN | 0x67EA94 | Charging and paired: lighter than the OK green, so the two are never read as one |
KUI_PALETTE_DANGER_RED | 0xC9303D | An ending, and what cannot be undone: Turn off, Remove, a critical battery |
The roles are KUI_COLOR_BG, _TEXT_ON_BG, _TEXT_MUTED, _SURFACE, _SHEET, _TEXT, _ON_ACCENT, _OFF, _REST, _ACCENT, _LEVEL, _ACTION, _TRACK, _FOCUS, _OK, _CHARGE, _WARN and _DANGER, each an lv_color_hex() of a palette entry. _SURFACE is Row Dark, _SHEET is the page, _LEVEL, _ACTION, _FOCUS and _WARN are Bold Yellow, and _ACCENT is Kody Blue. Include kui.h, which brings LVGL first.
The sizes, in pixels:
| Size token | Pixels | Meaning |
|---|---|---|
KUI_SPACE_SM, _MD, _LG | 4, 8, 12 | Spacing units; _LG is the gap between two pills, anywhere |
KUI_BORDER_WIDTH | 1 | Default border |
KUI_SCREEN_SIDE, _TOP, _BOTTOM | 32, 40, 40 | Side margin; where the first row begins; where the last row ends |
KUI_PILL_H, KUI_PILL_W | 80, 346 | A pill at rest; a full-width pill (410 less two side margins) |
KUI_ICON_SLOT | 56 | The round slot on a pill's left |
KUI_CAT_H | 44 | A category tab's height |
KUI_HDR_DISC | 112 | The disc in a sheet's header |
KUI_PROGRESS_H | 138 | A progress pill |
KUI_ACTION_H, KUI_ACTION_W | 60, 160 | An action button |
KUI_RING_W, KUI_RING_GAP | 4, 4 | The cursor ring's width, and the gap between an object and its ring, outside it |
KUI_LEVEL_H, KUI_LEVEL_KNOB_PAD | 12, 8 | A level's track; how far the knob stands out of it |
KUI_SWATCH | 48 | A colour swatch's diameter |
KUI_LAMP_GLOW, KUI_LAMP_GLOW_SPREAD | 60, 12 | How far a lamp's glow reaches; where it starts |
KUI_OPA_MUTED | 114 (45 %) | Dimmed content |
The glass and the panel
The calls that know the shape of the glass.
| Call | What it does |
|---|---|
kui_glass_kode_dot() | The glass from kbsp: 410 x 502, corner radius 114 |
kui_glass_inset(glass, depth) | How far in from the side the glass starts, depth pixels from the top or bottom edge, rounded up. At depth 0 it is the whole radius, from the radius on it is 0. With radius 114: depth 4 gives 85, 18 gives 53, 53 gives 18, 80 gives 6 |
kui_glass_rect_inside(glass, x1, y1, x2, y2) | Whether a rectangle (inclusive) lies entirely on the visible glass |
kui_panel_check(glass, root, cb, user) | Walks an LVGL tree and calls cb once per object that paints under a corner, with its worst corner and how far it enters. Returns the count. A background that spans the panel and runs off an edge is exempt, and so is a surface whose name begins bleed_; what such a surface carries is still checked |
kui_panel_draw_bezel(glass, layer, colour, edge_line) | Paints the four corners over on layer (an object named kui_bezel), so a desktop render shows what the glass shows |
kui_panel_corner_name(corner) | top-left, top-right, bottom-left, bottom-right, or ? |
Touch maths
All three modules are plain C with no LVGL, integer or float arithmetic only, and times are a free-running 32-bit millisecond counter that may wrap (kui_age_ms() is the one rule for the age of a stamp).
The calls of each module:
| Module | Calls |
|---|---|
kui_gesture | kui_gesture_press(g, x, y, allowed, blocked), kui_gesture_move(g, x, y), kui_gesture_release(g, x, y), kui_gesture_name(state) |
kui_spring | kui_spring_start(s, pos, vel, target, period_ms, zeta, t_ms), kui_spring_at(s, t_ms, &pos, &vel), kui_spring_rested(s, t_ms, pos_eps, vel_eps), kui_spring_retarget(s, target, t_ms), kui_age_ms(stamp, now) |
kui_drag | kui_drag_init, _set, _grab, _catch, _follow, _release, _aim, _step, _finish, _offset, _speed_px_s, _would_open, _phase, _is_open |
How each module behaves:
kui_gesture
- A press lands in a zone that takes some directions (
KUI_GESTURE_DIR_UP,_DOWN,_LEFT,_RIGHT). - The drag is decided once, at
KUI_GESTURE_CLAIM_PX(12 px) from the landing: an axis under 3:2 over the other isDIAGONAL; a direction the zone takes isCLAIMED; any other is rejected (AXISorZONE). - A press within
KUI_GESTURE_TAP_PX(16 px) of the landing on both axes is still aTAP. - Every sample is measured from the landing.
kui_spring
- Critically damped (
KUI_SPRING_CRITICAL1.0) or underdamped, evaluated in closed form from the clock, so the motion does not depend on how often it is looked at. - A damping above 1 is 1.
- A period of 0 is at the target from the start.
kui_drag
- A page is one offset from closed (0) to open (the span), in phases
IDLE,HELD,FOLLOW,SETTLE. - From rest, the first
KUI_DRAG_RESIST_FROM_PX(48) of travel moves it 35 % of the distance, growing back to the whole finger byKUI_DRAG_RESIST_TO_PX(160). A page caught in flight skips the resistance. - Release opens when flung toward open faster than
KUI_DRAG_FLING_PX_S(800), goes back when flung the other way, otherwise opens from halfway. - The speed is measured over the last
KUI_DRAG_VEL_WINDOW_MS(80) of 16 samples. - The spring has a period of
KUI_DRAG_SPRING_MS(250) and lands no later thanKUI_DRAG_LAND_MAX_MS(320) after it started.
The caller owes a follower one answer to every catch: kui_drag_release(), kui_drag_grab() then its release, kui_drag_finish(), kui_drag_aim() or kui_drag_set(). A page left HELD is not stepped and stays frozen. Positions are projected onto the axis and signed toward open, and the point where the finger lifted is fed through kui_drag_follow() before kui_drag_release(), which takes no position.
The multitap keypad
The text rules of the phone keypad: ten character keys, a mode key and Delete. The module logs and prints nothing, because the text may be a secret, and kui_multitap_clear() zeroes every byte.
How the keypad behaves:
| Thing | Value |
|---|---|
| Calls | kui_multitap_init(m, cap), _press(m, key, t_ms), _tick(m, t_ms), _text, _len, _pending, _show_plain(m, t_ms), _mode, _chars(key, mode), _clear |
| Cycle | A press of the same key less than KUI_MULTITAP_MS (800) after the last cycles the pending character; another key, the mode key, a tick or a late press commits it. A key with one character in the current mode commits at once |
| Modes | lower case, upper case, digits, symbols, then lower again; together they reach every printable ASCII character |
| Results | TYPED, CYCLED, DELETED, STEPPED, FULL (the cap would be exceeded; cycling at the cap is allowed), EMPTY, IGNORED |
| Limits | Text up to KUI_MULTITAP_TEXT_MAX (63, the longest Wi-Fi passphrase), a network name KUI_MULTITAP_SSID_MAX (32) |
| Display | The last character shows plain while pending and for KUI_MULTITAP_SHOW_MS (1000) after its press; every other character shows as a dot |
| Key 4, lower case | ghi4 |
Kody's player
kui_kody.h plays animations one at a time on one draw buffer of its own, w x h ARGB8888, taken through LVGL's allocator on the first play and freed with the object.
It exists only with Lottie on, so a firmware without these three options has none of the functions, and a call fails at link time:
CONFIG_LV_USE_THORVG=y
CONFIG_LV_USE_THORVG_INTERNAL=y
CONFIG_LV_USE_LOTTIE=yThe calls of the player:
| Call | What it does |
|---|---|
kui_kody_create(parent, w, h) | A player for one canvas size, named kody_#, w x h at (0, 0), transparent, neither clickable nor scrollable. Nothing is drawn and no buffer is taken until the first play. NULL when a size is 0 |
kui_kody_play(k, anim, loop) | Plays from frame 0, dropping whatever played, queued or stopped. The JSON is inflated, parsed and the first frame drawn before it returns. A one-shot holds its last frame. False for a different canvas, an allocation failure or data that does not inflate |
kui_kody_then(k, next, loop) | Queues what plays when the current pass ends. The hand-over is on the last frame, so a wake-up whose last frame is its scene's frame 0 shows no jump. A second call replaces the first |
kui_kody_react(k, reaction) | Waits for the loop's next anchor, plays a one-shot there and picks the loop up again at that anchor. Needs a loop playing; one reaction at a time |
kui_kody_freeze(k) | Stops on the next anchor, at once when on one or when there are none |
kui_kody_pause(k), kui_kody_resume(k) | Stop now, on the frame on the glass; go on from there |
kui_kody_stop(k) | Lets go of every animation the player was given and deletes the widget, so the caller may free their data |
kui_kody_is_still(k) | True when nothing rasterises: frozen, paused, holding a one-shot's end, or empty. A player only out of sight is not still |
kui_kody_is_reacting(k) | True while a reaction waits or plays |
A player that is not visible (hidden, on a screen not on the glass, scrolled out of view) stands on the last frame it drew and goes on when seen.
An animation is its Lottie JSON, minified and deflated into one zlib stream (kui_kody_anim_t: zlib, zlib_len, json_len, canvas w and h, frames, fps 30, anchor, deflated), or plain JSON as a user's .json arrives. Each start inflates the JSON into a PSRAM block of exactly that size with the ROM inflater (khal_zlib_decompress()), hands it to lv_lottie, and frees it before the start is over.
An anchor is a frame of a loop with the same pose as frame 0, every anchor frames; reactions start and end on one, and the player switches only on an anchor or the last frame of a pass. kui_kody_next_anchor() and kui_kody_crossed() are the plain-integer answers behind that.
The three animations built into the kit:
| Built-in animation | Canvas | Frames at 30 fps | Anchor | JSON inflated |
|---|---|---|---|---|
kody_reloj, the clock scene, loops | 410 x 300 | 540 | every 45 | 40,124 B |
kody_despierta_reloj, his wake-up; its last frame is reloj's frame 0 | 410 x 300 | 57 | none | 26,765 B |
kody_reloj_te_mira, he looks at you, anchor to anchor | 410 x 300 | 45 | none | 26,003 B |
A player plays only animations drawn for its own canvas. Scenes, wake-ups and reactions are 410 x 300; duda is 410 x 369; portatil and despide are 410 x 160; cargando is 264 x 264. All sit on the bottom edge of the glass except cargando, which is centred.
What a player costs:
| Cost of Kody | Value |
|---|---|
| Flash | The ThorVG engine, lv_lottie, the player, and the stream of each built-in animation you name: 5,735, 4,449 and 4,400 bytes. Animations of a pack take no flash |
| PSRAM | One buffer per player: 492,000 B at 410 x 300. At each start, 11 KB for the inflater's state and a block the size of the JSON, both freed before the start ends |
| Internal RAM | None from the model. ThorVG builds it from many small objects (22 to 34 KB a live model) with new, and the board's malloc() takes PSRAM first, so define no operator new |
| Stack | The rasteriser takes about 18 KB of stack in one rleRender call on the task that runs lv_timer_handler(), plus LVGL's own |
| Time | A play, hand-over or reaction inflates, parses and draws the first frame in the caller's step; the inflate is 1.9 ms for the largest animation, measured on a Kode Dot |
Kody's pack
A pack is a flat folder: kode.json and the Lottie files it names, on the microSD card (/sdcard/kode/, wins) or the internal FAT (/storage/kode/), else the built-in scene (kui_pack_src_t).
The official pack in kody/pack/ has ten scenes, tren, parque, camping, azotea, madrugada, desayuno, taller, lluvia, playa and reloj. Its face has no windows and picks tren by default. Every scene has a wake-up, a loop and reactions for look, hour, notice and plug, and the first five also a joke. It also names the four roles outside the face.
The manifest has:
format1,nameandversionscenes, each withname,wake,loop,anchorandon, a list of files per trigger (look,hour,notice,plug,joke)face, withdefaultandhoursoffrom,toandscene- the roles
empty,writing,goodbyeandloading - a
sha256map of file name to digest
The canvas each role plays on:
| Role | Canvas | Plays |
|---|---|---|
| scene | 410 x 300 (KUI_PACK_SCENE_W, _H) | The face: a wake-up, a loop and reactions |
empty | 410 x 369 | The launcher with no apps |
writing | 410 x 160 | The Opening card while an image writes |
goodbye | 410 x 160 | The wave before a run |
loading | 264 x 264 (KUI_PACK_LOADING_SIDE) | The loading screen |
The reader checks before anything plays. Unknown keys are ignored at every level, so a pack made for a newer firmware reads here. A window naming no scene is dropped, and a default naming none leaves the first scene the default, each with a warning.
The limits it enforces:
| Limit | Value |
|---|---|
| Manifest | 256 KB (KUI_PACK_MANIFEST_MAX) |
| One file as stored | 64 KB (KUI_PACK_FILE_MAX) |
| A Lottie's JSON inflated | 96 KB (KUI_PACK_JSON_MAX) |
| JSON nesting | 32 (KUI_PACK_MAX_DEPTH) |
| Precomp chain | 2 (KUI_PACK_MAX_PRECOMP) |
| Frames in one pass | 1 to 1800, a multiple of a non-zero anchor |
| Frame rate | A whole 1 to 60 |
| Scene name, file name, pack name and version | 15, 40, 31 characters |
| Scenes, reactions | 32, 128 |
| File name | 1 to 40 of [A-Za-z0-9_.-], not starting with ., ending .json or .json.z |
A refusal is one of size, depth, json, format, schema, name, file, hash, canvas, frames, fps, precomp or memory (kui_pack_err_word()).
The calls are:
kui_pack_parse()andkui_pack_free()kui_pack_scene_find()andkui_pack_file_find()kui_pack_scene_for_hour(): the first window holding the hour, else the default, also for a negative hour while the time is unknownkui_pack_hashed(): an installer's preconditionkui_pack_role_canvas()andkui_pack_file_canvas()kui_pack_check_anim(),kui_pack_json_depth()andkui_pack_file_name_ok()
Parser arrays live in PSRAM, and cJSON appears in kui_pack.c and nowhere else.
The generators and the simulator
Five scripts regenerate the generated files. Run each from the kit's directory.
The five scripts:
| Script | Writes | Notes |
|---|---|---|
tools/gen_tokens.py | core/include/kui_tokens.h from tokens.json | --check exits non-zero on a mismatch. Building needs no Python |
tools/fontgen.sh | The 18 faces in fonts/src/ | Fetches lv_font_conv at a pinned commit through npx; the first run builds it and takes minutes. FONT_CONV overrides it. The npm releases up to 1.5.3 do not write the cap_height, x_height and static_bitmap fields these faces carry |
tools/glyphgen.py | images/assets/glyph_<name>_<size>.png and core/include/kui_glyphs.h for the line glyphs | Draws each glyph from its SVG body. --check exits non-zero when a file on disk is not what the script draws. Needs cairosvg and Pillow; on macOS with Homebrew run it with DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib. The PNGs are checked in, so building never needs it |
tools/imagegen.sh | images/src/kui_*.c from the PNGs | Runs LVGL's LVGLImage.py (path in LVGL_IMAGE); needs pypng and lz4. The thirteen glyphs come out ARGB8888 and the line glyphs A8. The pixel data reproduces byte for byte |
tools/kodygen.py | kody/pack/ and the built-in kody/src/kody_*.c and kui_kody_anims.h from kody/assets/ | Deflates each Lottie with zlib, drops the keys meta, mn and nm, checks the pack block with the firmware's rules, and lints each source. Plain Python 3. Options and lint rules: see below |
The lint of kodygen.py refuses pucker and bloat, zig-zag, an effect that is not a blur, an expression, an image layer, a text layer or a 3D layer, naming the animation, layer and property. --check compares every stored stream with a fresh one and fails a stream over 5 % longer. --lint prints one line per animation and writes nothing.
python3 -m unittest discover -s components/kui/tools/tests runs the generator's tests.
The simulator in sim/ draws a consumer's screens on a desktop with LVGL 9.6 over SDL2: kui_sim <screen> [--snapshot OUT.png] [--set SUBJECT=VALUE]... [--device] [--bezel]. A consumer hands it kui_sim_screens[], kui_sim_setup() and kui_sim_set() (kui_sim.h) and builds with kui_sim_add().
It does not simulate an Active Object: no selection, scrolling, sheets or live values, and no Lottie, because its LVGL is built in C alone, so kui_kody.c compiles to nothing there. Timing on the desktop says nothing about the board.
Gotchas
Example code
The part of kui that needs no screen: the keypad's text rules. Press key 4 twice for "h", wait a second, then three times for "i". The console prints text: hi. The visual half of the kit has its example in sdk/kscreen.
/*
* multitap_hi.c - 2026-10-28 - from Kode with love
*
* Types "hi" on the UI kit's phone keypad with no screen. Key 4 twice is
* "h"; a second later, key 4 three times is "i". The console prints hi.
*/
#include "kode.h"
#include "kui_multitap.h"
/* Press key 4 count times, 100 ms apart, starting at start_ms. */
static void press_four(kui_multitap_t * const keypad, uint32_t start_ms, uint32_t count)
{
for (uint32_t i = 0U; i < count; ++i) {
(void)kui_multitap_press(keypad, KUI_MULTITAP_KEY_4, start_ms + (i * 100U));
}
}
/* Bring the system up, type the text and log it. */
void app_main(void)
{
kui_multitap_t keypad;
kode_init();
kui_multitap_init(&keypad, KUI_MULTITAP_SSID_MAX);
press_four(&keypad, 0U, 2U);
press_four(&keypad, 1000U, 3U);
KLOG_I("multitap", "text: %s", kui_multitap_text(&keypad));
kui_multitap_clear(&keypad);
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kui: the kit, one folder per module.kui.h: the components, styles, faces and images.components/kui/README.md: using the kit from an app, and regenerating the assets.