# 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

```mermaid
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
```

*What kui uses*

### 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 `-O2` whatever the firmware is optimised for, because an app built at `-Og` or kodeOS at `-Os` would 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`:

- `handle`
- `hdr_disc`, holding `hdr_img` and `hdr_text`
- `hdr_title` and `hdr_sub`
- `body`
- `line1` to `line3`
- `note`
- `code`, `code_l1` and `code_l2`
- `actions`, holding `btn` and `btn_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 is `DIAGONAL`; a direction the zone takes is `CLAIMED`; any other is rejected (`AXIS` or `ZONE`).
- A press within `KUI_GESTURE_TAP_PX` (16 px) of the landing on both axes is still a `TAP`.
- Every sample is measured from the landing.

**`kui_spring`**

- Critically damped (`KUI_SPRING_CRITICAL` 1.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 by `KUI_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 than `KUI_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:

```text
CONFIG_LV_USE_THORVG=y
CONFIG_LV_USE_THORVG_INTERNAL=y
CONFIG_LV_USE_LOTTIE=y
```

The 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:

- `format` 1, `name` and `version`
- `scenes`, each with `name`, `wake`, `loop`, `anchor` and `on`, a list of files per trigger (`look`, `hour`, `notice`, `plug`, `joke`)
- `face`, with `default` and `hours` of `from`, `to` and `scene`
- the roles `empty`, `writing`, `goodbye` and `loading`
- a `sha256` map 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()` and `kui_pack_free()`
- `kui_pack_scene_find()` and `kui_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 unknown
- `kui_pack_hashed()`: an installer's precondition
- `kui_pack_role_canvas()` and `kui_pack_file_canvas()`
- `kui_pack_check_anim()`, `kui_pack_json_depth()` and `kui_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

> **WARNING**
>
> **A string draws blanks in a face that lacks its characters.** `kui_font_clock` holds only 0-9, the colon and the hyphen, `kui_font_code` only the space, the digits and four arrows, `kui_font_code_80` only digits and a few signs, and `kui_font_hero_160` only digits, a space and `%`. Put a letter through any of them and nothing is drawn for it. Use the face that holds the text: the Outfit faces for words, which draw their digits through the DM Sans fallback of their own size.

> **WARNING**
>
> **Calling Kody without the Lottie options is a link error.** The player and the built-in animations compile only with `CONFIG_LV_USE_THORVG`, `CONFIG_LV_USE_THORVG_INTERNAL` and `CONFIG_LV_USE_LOTTIE`. Without them `kui_kody.c` is empty and the build stops at link time naming `kui_kody_create`. Add the three lines to your `sdkconfig.defaults`.

> **NOTE**
>
> **A player refuses an animation drawn for another canvas.** `kui_kody_play()` returns false with a log line when the sizes differ, and a switch the player makes later on its own (a hand-over or the loop after a reaction) that cannot be allocated or inflated leaves the player empty. Make one player per canvas size, and check the return value.

> **NOTE**
>
> **Anything in a corner is under the silk screen.** The cover glass hides the space outside each 114 px corner circle. Place a row with `kui_glass_inset()` and check a screen with `kui_panel_check()`. A surface may bleed off an edge on purpose if its name begins `bleed_`, and the words on it still may not.

## 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`.

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kui): the kit, one folder per module.
- [`kui.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kui/core/include/kui.h): the components, styles, faces and images.
- [`components/kui/README.md`](https://github.com/kodediy/kode-sdk/blob/main/components/kui/README.md): using the kit from an app, and regenerating the assets.
