kilim.
Retained-mode GUI library for the iyi language, written from scratch on SDL3
v0.2.0Get started
iyi get github.com/Tuntii/kilim
iyi get adds it to iyi.mod and fetches its newest version tag. Import it as github.com/Tuntii/kilim::*.
README
Why kilim
- Draws its own widgets. kilim does not wrap the operating system's controls, so an app looks the same on Windows, Linux and macOS.
- Small, declarative API. You build a tree of widgets once and change them directly (
label.text = "...",list.add(row)), in the spirit of V'sui. - Plain iyi. No
--crystaland no Crystal standard library. On Windows kilim uses only the system's Win32, Direct2D and DirectWrite, so there are no DLLs to ship; on Linux and macOS the native dependencies are SDL3 and SDL3_ttf. - Unicode text that works. Turkish and other non-ASCII text displays and edits correctly, with an embedded Noto Sans font. On Windows, characters it lacks (emoji, CJK) come from the system's font fallback.
- Idle means idle. kilim redraws only when something changes, and the layout follows live while you drag-resize the window.
- Tested on three platforms. Every commit runs the tests, a smoke test and a self-driving to-do app on Windows, Linux and macOS.
Quick start
Install iyi, add one line to your iyi.mod, and a counter app is about ten lines of code: see the quick start.
For a bigger example, see examples/todo.iyi: a to-do list with a textbox, buttons, checkboxes, scrolling and Turkish text.
Widgets
import github.com/tuntii/kilim gives you these builders under Kilim:
| Builder | What it is |
|---|---|
window(title, width, height, child) | The app window; .run starts the event loop. |
column(children, padding:, spacing:, sizing:) | Lays its children out top to bottom. |
row(children, padding:, spacing:, sizing:) | Lays its children out left to right. |
label(text) | Text; set label.text = ... to change it. |
button(text, on_click:, sizing:) | A clickable button. Space and Enter work when it has focus. |
checkbox(text, checked:, on_change:, sizing:) | A checkbox with a label. Checked items are drawn struck through. |
textbox(placeholder, on_change:, on_submit:, sizing:) | A single-line text field with copy and paste. |
text_area(placeholder, on_change:, sizing:) | A multi-line text field that wraps words and scrolls on its own. Enter starts a new line; Up, Down, Home and End move by visual line. |
dropdown(options, selected:, on_change:, sizing:) | Shows the chosen option; a click, Space or Enter opens the list as an overlay. Up and Down change the choice, Enter or a click picks, Escape or a click outside closes (that click does not reach the widget under it). |
confirm(ui, title, message, ok_text, &on_ok) | A modal confirmation: centred over a dimmed window, Cancel / ok_text. OK has the focus, so Enter confirms (Enter on a focused Cancel cancels); Escape cancels. Input behind it is ignored, Tab stays inside, window shortcuts do not fire, and focus goes back to the previous widget on close. The block runs after the dialog closes. Underneath: Context#show_modal(widget) / #close_modal; a dropdown inside a modal opens above it. |
scroll(child, sizing:) | A vertically scrolling viewport with a scrollbar. |
list_view(on_select:, sizing:) | A selectable list; set rows = to ListRows (title, optional detail line, header rows). Up, Down, PageUp and PageDown move the selection; long text ends in an ellipsis. Put it inside scroll(...): on its own it neither scrolls nor keeps the selection in view. rows = keeps the selection on the row with the same key, so read selected after it. |
Sizing. Each child of a row or column sizes itself along that box's direction:
Kilim.fit(default) takes the size of its content.Kilim.fixed(v)takesvlogical pixels.Kilim.fill(weight)shares the space that is left, in proportion to its weight.
Across the other direction, children stretch. A textbox with fill belongs inside a row; inside a column it would grow vertically.
Types. The widget types live in submodules (kilim/label, kilim/button, kilim/box, â¦). With only the package import, write them with their qualified names (Kilim::Widget::Widget). For short names, also import github.com/tuntii/kilim/kilim/<name>::{Type}, as the quick start does for Widget
Timers. win.every(5000) { ... } runs a block on the event loop every five seconds and win.after(ms) { ... } runs it once; both return an id for win.cancel_timer(id). The loop sleeps until the next timer is due, so idle windows use no CPU, and the block runs on the UI thread and may change widgets.
Background work. Slow work (a network sync, a file scan) runs in a spawn_parallel task and hands its result to the UI through an inbox:
results = win.inbox(String) { |r| label.text = r; nil } # handler: UI thread
group do |g|
g.spawn_parallel { results.send(scan_files) } # another thread
win.run
end
send wakes the loop, which passes each value to the handler, in send order. send is the only kilim call allowed from another thread: a parallel task cannot touch the window or its widgets (the compiler refuses it), only the inbox. Sent values must be Share: strings, numbers, Share structs. After the window closes, sends are dropped. On Linux a send cannot wake the loop (iyi's worker threads there must not call C libraries such as SDL), so while a window has an inbox its loop checks for values every 10 ms instead.
Keyboard. Tab and Shift+Tab move focus. In a textbox, use the arrow keys, Home/End, Backspace/Delete and Enter. Click and drag, or hold Shift with the arrows, Home and End, to select; Ctrl/Cmd+A selects all, Ctrl/Cmd+C copies the selection (nothing is copied when it is empty), Ctrl/Cmd+X cuts it and Ctrl/Cmd+V pastes over it. window.shortcut(Key::N) { ... } runs a block on Ctrl/Cmd+N before the focused widget sees the key; with primary: false (a plain key such as Delete) it applies only while no text field is focused. Key is Kilim::Event::Key; for the short name, import github.com/tuntii/kilim/kilim/event::{Key}.
Installation
iyi 0.20.0
kilim is pinned to iyi 0.20.0. The official installers pin the version with the IYI_VERSION environment variable (without it they install the latest release). Fetch the installer from the same tag:
# Windows (needs the Visual C++ build tools: "Desktop development with C++")
$env:IYI_VERSION = '0.20.0'
irm https://raw.githubusercontent.com/iyilang/iyi/v0.20.0/install.ps1 | iex
# Linux / macOS (needs cc)
curl -fsSL https://raw.githubusercontent.com/iyilang/iyi/v0.20.0/install.sh | IYI_VERSION=0.20.0 sh
SDL3 and SDL3_ttf
The versions are pinned in scripts/sdl-versions.txt. On Windows SDL is only needed for the SDL backend, which a program selects with -Dsdl (iyi run -Dsdl main.iyi); the default Windows build uses the native backend.
| Platform | Setup |
|---|---|
| Windows | . .\scripts\fetch-sdl.ps1 (the leading dot matters). It downloads the VC development packages into vendor/windows/, checks their SHA256, and sets IYI_LIBRARY_PATH and PATH for the current session. Ship SDL3.dll and SDL3_ttf.dll next to your exe. |
| Linux | bash scripts/build-sdl-linux.sh builds from source and installs to /usr/local (needs cmake, ninja and the X11 development packages; see .github/workflows/ci.yml). |
| macOS | brew install sdl3 sdl3_ttf and export IYI_LIBRARY_PATH=$(brew --prefix)/lib. |
iyi looks for SDL3.lib (libSDL3 on Unix) in the directories listed in IYI_LIBRARY_PATH. On Windows the DLLs must also be on PATH at run time.
Working on kilim itself
iyi test # core tests, no SDL needed
iyi run examples/todo.iyi # the to-do example
KILIM_SELFTEST=todo.bmp iyi run examples/todo.iyi # hands-off acceptance test, writes todo.bmp
iyi run examples/smoke_win32.iyi # Windows: native backend end to end
iyi run -Dsdl examples/smoke.iyi # SDL backend end to end (-Dsdl only on Windows)
Benchmarks print Markdown tables (median and 95th percentile per case, for 100, 1,000 and 10,000 list rows). CI runs them on every platform and shows the results in the job summary:
iyi run bench/core.iyi # layout, draw traversal, hit-testing, typing; no SDL
iyi run bench/render.iyi # one full frame in a real window, text included
To use a local clone from another project, add replace github.com/tuntii/kilim => ../kilim to that project's iyi.mod. The module path is lower-case because iyi does not allow upper-case paths.
In CI (SDL_RENDER_DRIVER=software), a run that cannot open a window is retried with SDL_VIDEO_DRIVER=offscreen. Screenshots are uploaded as BMP artifacts.
Status
kilim is at v0.1: small, tested, and young. Not there yet:
- menu bar and multiple windows (one window per process);
- image widget, theme system (light theme only), animation, drag and drop;
- text selection and multi-line text;
- inline IME composition: text typed through an input method arrives when it is committed (on Windows the input method shows it until then in its own window at the caret);
- accessibility (screen readers);
- mobile and web.
Every change still redraws the whole window, but rows scrolled out of view are skipped, widget sizes are cached and rendered text is reused. On a Windows desktop a full frame of the to-do list takes about 0.4 ms with 100 rows, 0.6 ms with 1,000 and 3 ms with 10,000 (19 ms, 184 ms and 1.8 s in v0.1.1). Partial redraws come next in the roadmap. Progress is tracked on the project board.
Contributing
Issues labelled good first issue are a good place to start, for example Linux arm64 and Intel macOS support in CI.
Commit messages follow Conventional Commits (feat:, fix:, perf:, docs:, ci:, bench: and so on), because releases are cut from them. There is one release a month, run by hand: open the Actions tab, pick the CI workflow, "Run workflow" on branch master. Once the smoke jobs pass, semantic-release tags the next version and publishes the release notes. feat and breaking changes (feat!: or a BREAKING CHANGE: footer) make the minor release; fix and perf alone make a patch. Other types release nothing. Pull requests are merged with a merge commit rather than squashed, so each commit keeps its message.
CI runs without a visible display, so check these by hand on a real screen:
- Typing Turkish text from a real keyboard, and AltGr characters such as
@. - Scrolling with the mouse wheel, and the scroll direction on macOS with natural scrolling.
- Resizing the window by dragging: the layout should follow without freezing.
- CPU use while idle should be close to 0 % (Linux with an inbox: about 100 wakeups/s).
- On Windows at 125-150 % display scaling, the UI should have the same physical size as other apps, also after moving the window to a monitor with a different scale. (With
-Dsdl, SDL may report a pixel density of 1 and put the scale inSDL_GetWindowDisplayScale.) - With a Japanese, Chinese or Korean IME on Windows, the composition and candidate windows open next to the caret and do not cover the text.
- On AltGr layouts (Polish AltGr+C, Hungarian AltGr+V), the character is typed and copy/paste is not triggered.
License
kilim is licensed under the Apache License 2.0.
The embedded font is Noto Sans Regular (v2.015), under the SIL Open Font License 1.1 (assets/fonts/OFL.txt). It is embedded in every built program (~620 KB).