# kscreen

LVGL on the Kode Dot's glass for an Active Object of your own: the display, the flush, the touch feed and the render pass, with the display_lvgl example.

## What it is

kscreen puts an LVGL screen on the Kode Dot. LVGL is the library that draws the interface, and getting it onto the panel takes the same four jobs in every firmware that draws:

1. Create the display.
2. Hand each rendered rectangle to the owner of the panel.
3. Feed the touch samples to LVGL.
4. Run the render pass.

kscreen does those four, so you write the screens and nothing else.

kscreen is a struct inside an Active Object, the **owner**: the Active Object of your app that builds the screens. The owner hands kscreen the five events it needs and keeps everything else for itself, including its state machine, its buttons and its data. Only the owner's thread calls LVGL, so LVGL needs no lock.

An app that names kscreen gets the UI kit (`sdk/kui`) and LVGL with it, and `kscreen_open()` calls `kui_init()` on the display it creates, so the kit's styles are ready when the owner builds its screens.

kodeOS's own interface does not use kscreen: kgui drives LVGL itself. The example `examples/display_lvgl` runs on it.

## How it is connected

```mermaid
flowchart LR
  touch["AO_Touch"]
  subgraph mine["Your app"]
    owner["Your owner AO"] -- "kscreen_touch()" --> ks["kscreen"]
    owner -- "kscreen_tick()" --> ks
    ks -- "lv_timer_handler()" --> lvgl["LVGL + kui"]
  end
  touch -- "TOUCH_EVT_SIG" --> owner
  ks -- "DISPLAY_FLUSH_SIG<br/>+ FlushEvt" --> disp["AO_Display"]
  disp --> panel["CO5300 panel"]
  class touch ao
  class disp ao
  class owner app
  class panel chip
```

*Who talks to whom when the glass draws*

The owner touches kscreen in five places, and the rest of the owner is yours:

1. **The constructor** calls `kscreen_ctor(&me->screen, &me->super, APP_TICK_SIG)`, binding the renderer to its owner and naming the owner's private tick signal (a number from `MAX_SIG` up).
2. **The initial transition** calls `kscreen_start(&me->screen)`, which subscribes the owner to `DISPLAY_STATE_SIG`, `DISPLAY_REPAINT_SIG` and `TOUCH_EVT_SIG` in that order, then asks AO_Display to announce the panel. The subscriptions come first because the answer can arrive the instant the request is posted.
3. **On `DISPLAY_STATE_SIG`** the owner calls `kscreen_open(&me->screen, Q_EVT_CAST(DisplayStateEvt))`. On `KODE_OK` it builds its screens and moves to the state that draws.
4. **While drawing**, `APP_TICK_SIG` goes to `kscreen_tick()`, `TOUCH_EVT_SIG` to `kscreen_touch()` and `DISPLAY_REPAINT_SIG` to `kscreen_repaint()`.
5. **At start**, the owner is started at `KODE_PRIO_USER_GUI`, the level the SDK keeps for a renderer, after `kode_init()` has returned.

### Ask, do not wait

The panel is announced once, from inside `kode_init()`, and the bus keeps no copy. `kscreen_start()` therefore asks, and the answer says whether the panel came up at all, which is how "late" and "never" are told apart. A later `DISPLAY_STATE_SIG`, the answer to someone else's `display_state_request()`, finds the renderer open, and `kscreen_open()` returns `KODE_EXISTS`.

### The display

LVGL gets a display of the glass's size in RGB565. When the panel offers two framebuffers, LVGL draws straight into them in direct mode: one is on the glass while LVGL draws the next frame in the other, and kscreen starts the two in step so that LVGL always draws in the buffer the panel is not showing. LVGL repaints only what changed and copies the rest over itself.

A panel driver with a single framebuffer gets two draw buffers instead, a tenth of the glass each, in PSRAM, and LVGL renders into them one part at a time.

### The flush

kscreen posts one `FlushEvt` to AO_Display for each frame in direct mode. It names the framebuffer and the rows that changed. AO_Display owns the panel, flips to that framebuffer at the next refresh, and copies nothing. The rows tell it what to write back from the cache and what to give the screenshot and the mirror.

LVGL waits for a flush inside its own render call, so this thread is not reading its queue while it waits. The wait is a blocking take on the semaphore of `kdrv_display_flush_wait()` instead of a spin loop, and it is one of the SDK's three documented exceptions to "everything is an event". It lasts 500 ms at most.

When the end of a flush never comes, the frame is lost, one error line is logged and the screen keeps working. The alternative is a renderer that never runs again with the board healthy underneath.

### The touch feed

kscreen creates a pointer input in LVGL's event mode: LVGL reads the pointer when kscreen asks, once for each `TOUCH_EVT_SIG` sample, and once more in each render pass while a finger rests on the glass.

The default 33 ms read timer against AO_Touch's 30 ms sampling would read some samples twice and skip others, so a drag's measured speed would jump between 0, 1 and 2 times the real one. With the event mode, a drag that stops before the lift does not throw.

### The render pass

A one-shot timer starts each pass. `kscreen_tick()` runs `lv_timer_handler()`, which says how long until LVGL's next timer is due, and re-arms the timer for that long plus one tick, never later than `KODE_GUI_TICK_MS` (10 ms).

It is never periodic. A periodic timer would keep posting while a slow frame renders, and a full queue is a QP assert. LVGL's clock is `khal_time_ms()`, so a pass that overruns costs nothing and animations do not drift.

### The frame rate

The panel refreshes every 16.8 ms (59.4 Hz), and a frame reaches the glass only at a refresh. A frame drawn in under 16.8 ms keeps the refresh rate, and one that needs longer waits for the next refresh.

LVGL draws a frame on both cores with two draw threads that start in `lv_init()`, each with a 32 KB stack in PSRAM, at `KODE_PRIO_USER_GUI`. A `_Static_assert` in `kscreen.c` keeps `LV_DRAW_THREAD_PRIO` equal to that level, because a draw thread above it would stall everything the renderer yields to.

### The repaint

AO_Display publishes `DISPLAY_REPAINT_SIG` after the display row of the production test (`test component=display`). During that row it ends every flush without showing its pixels, so the glass holds the row's last field while LVGL believes it is up to date. `kscreen_repaint()` realigns the buffers, invalidates the screen and the top layer, and the next pass paints everything back.

## Specifications

### The files

The files of the component.

| File | What it does |
| --- | --- |
| `include/kscreen.h` | `kscreen_t` and the seven calls |
| `src/kscreen.c` | The display and its modes, the flush and its wait, the pointer, the render pass, the repaint and the rotation |
| `Kconfig` | `KSCREEN_ROTATE_PAD` |
| `README.md`, `CMakeLists.txt`, `idf_component.yml` | The component's guide, build and manifest |

`kode` and `kui` are required publicly, because `kscreen.h` speaks their types: `QActive` and `QTimeEvt`, the panel's events, `lv_display_t`.

kdrv, khal and kbsp are private: the flush completion is `kdrv_display`'s semaphore, the clock and the PSRAM heap are khal's, and the glass's size is a board fact. `kscreen.h` includes `kode_ao.h` and `lvgl.h`.

### The calls

The seven calls, in the order an owner uses them.

| Call | When | What it does |
| --- | --- | --- |
| `kscreen_ctor(s, owner, tick_sig)` | The owner's constructor | Binds the renderer to its owner and to the owner's private tick signal |
| `kscreen_start(s)` | The owner's initial transition, which runs after `kode_init()` has returned | Subscribes to `DISPLAY_STATE_SIG`, `DISPLAY_REPAINT_SIG` and `TOUCH_EVT_SIG`, then calls `display_state_request()` |
| `kscreen_open(s, d)` | On `DISPLAY_STATE_SIG` | `lv_init()`, the display, the flush, the touch feed in event mode, then `kui_init()`, and arms the tick for `KODE_GUI_TICK_MS`. `KODE_OK` when LVGL is up; `KODE_DENIED` when `d` says the panel is not up; `KODE_EXISTS` when already open; `KODE_NO_RESOURCES` when the draw buffers, the display or the input could not be had. Every failure is logged, leaves nothing allocated and does not arm the tick |
| `kscreen_tick(s)` | On the tick signal | One extra input read while a finger rests, one `lv_timer_handler()`, then re-arms the one-shot. Does nothing before a successful open |
| `kscreen_touch(s, t)` | On `TOUCH_EVT_SIG` | Feeds one sample to LVGL |
| `kscreen_repaint(s)` | On `DISPLAY_REPAINT_SIG` | Invalidates the screen and the top layer |
| `kscreen_set_rotation(s, rot)` | Any time after open, from the owner's thread, outside `kscreen_tick()` | Turns the glass to `LV_DISPLAY_ROTATION_0`, `_90`, `_180` or `_270`, back to 0 included. The screen is redrawn whole. `KODE_OK` when the glass is turned that way (already, or now); `KODE_DENIED` before open or for another value; `KODE_NO_RESOURCES` when the buffers could not be had, with the glass left as it was |

### The E5 test's screens

A build with `CONFIG_KODE_TEST_DOT` adds one call. `kscreen_start()` also subscribes the owner to `TEST_DOT_SCREEN_REQ_SIG`, and the owner passes each request to `kscreen_test_dot_event(s, e)`. The call deletes the canvas the last request built, builds the screen or the pattern the request names on LVGL's top layer with `kui_test_dot` (`sdk/kui`), draws and flushes it at once, and answers with `test_dot_screen_done(kind, seq)`. An automatic screen over an automatic screen repaints only the cells that changed. The request for no screen deletes the canvas and answers nothing. Before `kscreen_open()` has succeeded nothing is shown and nothing answers, and AO_TestDot's wait runs out. The test itself is on `sdk/kaos/test-dot`.

### The renderer

`kscreen_t` lives inside the owner's struct. The owner may read some fields and never writes any. The table lists the fields.

| Field | For the owner? | Meaning |
| --- | --- | --- |
| `disp` | read | The `lv_display_t`; NULL until `kscreen_open()` succeeds. For settings of your own, a scroll throw or a theme |
| `indev` | read | The pointer input, in event mode |
| `pressed`, `x`, `y` | read | Whether a finger is down, and the last sample's coordinates, for gestures |
| `tick` | kscreen's | The `QTimeEvt` of the render pass |
| `owner` | kscreen's | The Active Object this lives in; the sender of every `FlushEvt` |
| `direct`, `posted`, `band_y1`, `band_y2` | kscreen's | Direct mode, a flush with AO_Display not yet waited for, the changed rows of the frame being drawn |
| `rot`, `part_buf[2]`, `rot_buf` | kscreen's | The turn of the glass, the two partial-mode draw buffers, and where a band is turned before it goes to the panel |

### The display modes

The three ways LVGL draws and flushes.

| Mode | LVGL draws into | The flush |
| --- | --- | --- |
| Direct (panel with two framebuffers, glass unturned) | The panel's back framebuffer, a whole frame (410 x 502 x 2 bytes, about 402 KB) in PSRAM | One `FlushEvt` per frame with the changed rows, `last` set. AO_Display flips at the next refresh and copies nothing |
| Partial (a panel with one framebuffer) | Two draw buffers of `KBSP_LCD_V_RES / 10` rows, 41,000 bytes each, in PSRAM | One `FlushEvt` per band |
| Turned (90, 180 or 270 degrees) | The same two partial buffers | Each band is turned into a third buffer, packed at the turned band's own width, then flushed as the rectangle it covers on the panel |

A turned screen redraws more slowly: a change of the whole screen runs at about 20 frames a second against 59 unturned, and a smaller change faster. The three buffers, 41,000 bytes each in PSRAM, are taken on the first turn and kept.

Touch follows by itself, because LVGL turns every point. The pad follows when `CONFIG_KSCREEN_ROTATE_PAD` is on: at 270 degrees the pad's right reads as up, through `buttons_rotation_request()`. TOP and BOT are side buttons and never turn.

The partial buffers are PSRAM and never internal RAM, whatever it costs a redraw, because the radio stack needs the internal pool. The launcher's full redraw measured 54 ms with 2 x 40 KB in PSRAM against 37 ms in internal RAM.

### The numbers and the option

The timings, sizes and the one Kconfig option.

| Thing | Value |
| --- | --- |
| `KSCREEN_FLUSH_TIMEOUT_MS` | 500: the longest LVGL waits for the panel to finish one flush |
| `KODE_GUI_TICK_MS` | 10: the longest between two render passes |
| Panel refresh | 16.8 ms, 59.4 Hz |
| `LV_DEF_REFR_PERIOD` | 16 ms (board file) |
| LVGL draw units | 2, each with a 32 KB stack in PSRAM, at priority 3 (`KODE_PRIO_USER_GUI`) |
| Owner priority | `KODE_PRIO_USER_GUI`, 3. The level holds a single Active Object, so one kscreen per image |
| `KSCREEN_ROTATE_PAD` | y: the four pad buttons publish the direction they point on a turned glass. Off, the app gets the buttons as wired and maps them itself |

### The example

`examples/display_lvgl` shows a title, a counter at 0 and a hint. TOP or a tap anywhere adds one, and BOT goes home to kodeOS. With the console up, `screenshot` shows what the Active Object drew. It needs no SD card, radio or network.

Its files:

| File | What it does |
| --- | --- |
| `main/display_lvgl_main.c` | `app_main()`: `kode_init()`, then the constructor and start of the one Active Object. Declares `KODE_APP_MANIFEST("Display", "example")` |
| `main/display_lvgl_ao.c`, `main/display_lvgl_ao.h` | The Active Object: a `running` parent that sends BOT home, a `waiting` state until the panel is announced, a `drawing` state that builds the screen and feeds kscreen |
| `main/CMakeLists.txt`, `main/idf_component.yml` | `REQUIRES kode`, `PRIV_REQUIRES kdrv kscreen`; pins `kodediy/kode` and `kodediy/kscreen` at one tag |
| `sdkconfig.defaults` | The partition table path, the app boot role, the console kept, `KODEOS_RADIO=n`, `ESP_HOSTED_ENABLED=n`, `BT_ENABLED=n`, and Montserrat 28 and 48 for the example's own labels, because the board compiles only Montserrat 14 |

## Gotchas

> **WARNING**
>
> **A state that handles the tick signal and never calls `kscreen_tick()` stops the screen for good.** The tick is a one-shot that `kscreen_tick()` re-arms, so skipping the call ends the render pass while the Active Object keeps running and answers other events. A child state that needs the tick returns `Q_SUPER()` to the state that calls `kscreen_tick()`, and never transitions away while handling it.

> **WARNING**
>
> **Turn the glass through `kscreen_set_rotation()` only.** Calling `lv_display_set_rotation()` on `disp`, or replacing its flush callback, draws a sheared or striped screen, because kscreen turns each band itself before it goes to the panel.

> **NOTE**
>
> **Call `lv_*` from the owner's thread only.** A task of yours that wants the screen to change sends the owner an event, or leaves a copy of the data that the owner reads on its tick. LVGL has no lock here. The same holds for the fields of `kscreen_t`: read them from the owner and write none.

> **NOTE**
>
> **One kscreen per image.** LVGL has one clock and one display here, and `KODE_PRIO_USER_GUI` holds a single Active Object. A second `QACTIVE_START` at a taken level is a QP assertion and a reset.

## Example code

An Active Object that renders with kscreen and draws one pill of the UI kit in the middle of the glass. The Kode Dot shows a rounded grey row reading "Hello" over "from the kit". It handles the panel's announcement and the tick. A full owner also routes `TOUCH_EVT_SIG` to `kscreen_touch()` and `DISPLAY_REPAINT_SIG` to `kscreen_repaint()`, as `display_lvgl` does.

```c
/*
 * kscreen_hello.c - 2026-10-28 - from Kode with love
 *
 * Renders with kscreen from an Active Object of your own and draws one pill
 * of the UI kit in the middle of the glass: "Hello" over "from the kit".
 */
#include "kscreen.h"
#include "kui.h"
enum { TICK_SIG = MAX_SIG };
static QActive     s_ao;
static kscreen_t   s_screen;
static QEvtPtr     s_queue[KQP_AO_QUEUE_LEN];
static StackType_t s_stack[KQP_AO_STACK_BYTES / sizeof(StackType_t)];
/* Open the display on the panel's announcement, then render on each tick. */
static QState Hello_running(QActive * const me, QEvt const * const e) {
    (void)me;
    if ((e->sig == DISPLAY_STATE_SIG) &&
        (kscreen_open(&s_screen, (DisplayStateEvt const *)e) == KODE_OK)) {
        lv_obj_add_style(lv_screen_active(), &kui_style_screen, 0);
        lv_obj_center(kui_pill_create(lv_screen_active(), "Hello", "from the kit"));
    } else if (e->sig == TICK_SIG) {
        kscreen_tick(&s_screen);
    }
    return Q_HANDLED();
}
/* Subscribe, and ask the panel to announce itself. */
static QState Hello_initial(QActive * const me, void const * const par) {
    (void)me;
    (void)par;
    kscreen_start(&s_screen);
    return Q_TRAN(&Hello_running);
}
/* Bring the system up, then start the renderer at its own priority. */
void app_main(void) {
    kode_init();
    QActive_ctor(&s_ao, Q_STATE_CAST(&Hello_initial));
    kscreen_ctor(&s_screen, &s_ao, TICK_SIG);
    QACTIVE_START(&s_ao, KODE_PRIO_USER_GUI, s_queue, Q_DIM(s_queue),
                  s_stack, sizeof(s_stack), (void *)0);
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kscreen`](https://github.com/kodediy/kode-sdk/tree/main/components/kscreen): the component, `include/kscreen.h` and `src/kscreen.c`.
- [`examples/display_lvgl`](https://github.com/kodediy/kode-sdk/tree/main/examples/display_lvgl): a counter on the glass, with touch, a button and BOT to go home.
- [`components/kui`](https://github.com/kodediy/kode-sdk/tree/main/components/kui): the UI kit that kscreen brings with it.
