A C17 UI toolkit for d-pad-first, small-screen interfaces - drawn to a handheld's framebuffer or to a desktop window, on Linux, macOS and Windows.
"Handheld" is the design constraint, not the platform. Everything here is shaped by a screen a
few inches across and a pad with no pointer: focus answered against the rectangles actually
drawn, width measured in columns of text rather than pixels, a type scale in quarter steps whose roles carry their own tracking and leading, a
d-pad that always lands somewhere. The same interface then runs unchanged on a TrimUI Brick's
/dev/fb0 and in a resizable window on a laptop, and a window dragged wide gets a layout that
used the width rather than a magnified handheld. Think of it as a console or TV UI toolkit that
happens to be small enough to read.
It was extracted from mesh-client, a Meshtastic client for the TrimUI Brick, and stands on inkwell, the single-threaded runtime under it: the loop, the clock, the log, the environment knobs and the codecs.
| Themes | Colours by role, not by name. Four themes ship; a new one is a table. |
| Fonts | A 5x7 pixel face and a proportional UI face in two weights, all as coverage rather than 1-bit masks, resampled into whatever cell the theme asks for. |
| Type | Seven roles - display, headline, title, body, supporting body, label, caption - and a role is a size, a weight, a letter-spacing and a line height together, not a size with the rest left to the renderer. Sizes are offsets from the body scale, so a reader who turns the text up keeps the hierarchy. Figures can be set tabular, so a reading redrawn once a second does not move sideways under the eye. |
| Glyphs | Icons and font tables, and the emoji pack - outlines drawn at any size - generated (scripts/gen-*.py) and committed. The pack is optional (-DINKCELL_EMOJI=OFF) and can be handed over at run time instead. |
| Layout | Lines measured in cells, scroll windows, text wrapping done once for both the measure and the draw pass. |
| The content column | One answer to "how wide should this be", which stopped being "how wide is the surface" the moment a window could be dragged. Content sits in a column capped at the reading measure; fills still bleed edge to edge. |
| Stacks | A row or a column of unlike things, declared and then resolved: a basis, a share of what is left over, a floor to shrink to. What is placed adds up to the room exactly, and what will not fit is given up from the tail rather than drawn past the edge - so a screen states its shape instead of advancing a y cursor by hand. |
| Width classes | Compact, medium, expanded - measured in columns of body text rather than in pixels, so the same answer covers a 3.2" panel, a window dragged wide, and a reader who turned the text up. Compact is one column and is left alone; above it, the column caps and centres. |
| Grids | A home screen: tiles laid out across and down, a window that scrolls by rows of them, and a press that knows a row's width. A list is the same window one column wide. |
| Focus | A d-pad answered against the rectangles the components drew - "right from here lands on that" - so a grid, a card with two verbs on it or a form with a chip row in it is a layout rather than an index somebody maintains. The cursor is one ring, and it travels; a focused row lifts a tone off its ground rather than turning into a bar. Focus, selection, press, hover and disabled are separate states (struct inkcell_interaction), because where the d-pad is and what the application has chosen are different facts. |
| Motion | Durations and curves as tokens, so a set of controls moves as one system - and the two things that move without being a control: the focus ring travelling between boxes, and a list gliding between windows instead of flicking between them. |
| Lists | A look chosen per list, not per row: plain, inset-grouped or card-group; compact or comfortable; a cursor marked as a fill, a leading accent or a ring; headline, supporting line and metadata set in three type roles. Rows end in a reserved accessory column - a chevron for a row that opens something, a check for the chosen option - so values line up whatever each row does. A zeroed look is the list as it always was. |
| Widgets | Buttons, chips, app bars (icon actions with one emphasized, an overflow menu for what does not fit, a status mark, search and selection modes, a collapsing large title), a compact action bar with a first-use tip, floating action buttons, list rows, tile grids, chat bubbles, cards, switches, segmented buttons, meters, progress dials and steps, charts, dialogs, menus, bottom sheets, snackbars, QR codes. |
| Layers | One z-stack for everything drawn over a screen: a box from a placement, an entrance and a shorter exit, a scrim over what is behind, and an answer to which overlay owns the press. A new overlay is its content and nothing else. |
| Scrolling | A body positioned in pixels rather than windowed by row index - so it can rest between two rows, give at its ends the way every touch platform does, and drive a large title that collapses into the app bar as it moves. |
| Shapes | Anti-aliased rounded rectangles, rings and arcs, in integers - so a curve is the same curve on every host that draws it. |
| Depth | Opacity and elevation as tokens. One opacity table carries the state layers, the disabled fade and the scrim; four elevation levels (flat, raised, floating, modal) each name a shadow's offset, softness and depth. A shadow is drawn by reading back the frame already on the panel and darkening it - the scrim's trick with a shape - so it works on a display with no alpha to composite. A theme can be flat all the way up, and the high-contrast one is. |
| Framebuffer | /dev/fb0, the page flip, damage tracking, a glyph cache, and an off-screen renderer for screenshots. |
| Window | The same frame in an SDL window: damage as texture uploads, a keyboard, and a desktop application rather than a picture of a device. Optional - no SDL2, no window, everything else unchanged. |
| Input | evdev to a logical key, hat axes, analogue triggers, key repeat, per-device button profiles. |
| i18n | A catalog mechanism with plural rules and format-string validation. |
| Gallery | Every component, in every theme, rendered with no device attached - and the golden sheet that keeps them that way. |
These are authoring rules — breaking one compiles and looks fine.
-
Nothing is spelled out in a renderer. A screen names an id and something else answers: a string (
INKCELL_STR_*), an icon (INKCELL_ICON_*), a tone, a family, a role, a shape, an opacity, an elevation.scripts/check-strings.pyfails the build on prose in a component. -
A widget takes a tone, never a colour — the same reason a stylesheet has a token called "danger" instead of the hex for red: it is what lets a theme change the answer.
-
A scale is not a pixel count. It is counted in quarters of a glyph step (
INKCELL_SCALE_UNIT), so that a type role can sit half a step above the body rather than a whole one - which is the difference between three type roles and Material's fifteen. Anything turning a scale into pixels goes throughinkcell_scale_px(steps, scale), and the very common "one step" case throughinkcell_step_px(scale). A bare scale in a pixel expression compiles, looks plausible and draws everything four times too large: it was the right arithmetic back when a scale was a whole multiplier, and every one of those sites is a bug now. -
Text is measured, never counted. A name written with one emoji is four bytes and one cell, and on the proportional face that cell is not the same width as the one beside it - so a
strlen, a%-12s, or a cell count multiplied by the advance are all the same bug. Measure withinkcell_fb_text_width(), wrap and fit against pixels, and useinkcell_fb_text_cols()where a layout genuinely reserves whole columns.inkcell_fb_char_adv()is the nominal advance: an estimate, and exact only while the face is monospace. -
A region of the frame has coordinates of its own. The drawing layer has one transform for the frame - a screen sliding in - and a stack of them for regions inside it (
inkcell_fb_view_push()). A scrolled body is content drawn at its own coordinates and cut where its window ends; an overlay is a panel that must not paint on the chrome while it is half way in. Both nest. The focus map goes through a view and deliberately not through the frame transform, and the difference is the whole reason there are two: a slide moves every box by one dx and changes no answer the map is asked for, while a view can move part of the frame out of sight - and a row scrolled past the top of its viewport is not a place to stand. -
What is drawn is what can be reached. A frame collects the box of everything focusable as it draws it, and a press is resolved against those boxes (
inkcell_focus_find()). The components do the collecting - a card registers the verbs it had room for, a strip the pills that fitted, a list the rows in its window - so a screen pushes a map in (inkcell_fb_set_focus_map()), gives things ids, and holds one id rather than a map of itself. A screen keeping a cursor index instead is a screen that will eventually walk onto the verb a card dropped for want of room, because an index cannot tell what came out on the panel and a registered rectangle is nothing but that. What is not drawn - the three hundred rows of a list that is longer than its window - is astruct inkcell_focus_run, two numbers the screen already has, andinkcell_focus_step()answers a press against both halves at once. A grid says a third: how many of its ids are side by side, so that down from the tile the reader is on is a row further and not the next number. One ring is then drawn over the lot (inkcell_fb_draw_focus_ring()), and because it is one object rather than a property of each component it can do the thing none of them can: slide from the box the cursor left to the one it arrived at, taking that box's own shape as it lands. -
Button hints are (button, string id) pairs, never a sentence. A keycap is untranslated — it is what is printed on the case.
Add it as a submodule and add_subdirectory it:
git submodule add https://github.com/mcereal/inkcell third_party/inkcell
git submodule update --init --recursive # inkcell carries inkwelladd_subdirectory(third_party/inkcell)
target_link_libraries(myapp PRIVATE inkcell::inkcell)inkcell stands on inkwell, the systems layer under it -
the loop, the clock, the log, the environment knobs, the codecs - and carries it as a submodule
of its own. An application that uses inkwell directly (most do: the loop is down there) should
add_subdirectory() its own copy before inkcell and link inkwell::inkwell itself; inkcell
only brings one in when nothing else has, so the whole tree builds one inkwell rather than two
targets of the same name.
Headers are inkcell/ui/..., inkcell/i18n/... and, for what moved down, inkwell/base/....
The drawing toolkit
(inkcell/ui/fb_draw.h) and the components (inkcell/ui/widgets.h) are public: an application
built on inkcell writes the screens and nothing else, so what they are written against is the
library's surface.
inkcell never reaches into an application. Four things are pushed in rather than read out:
/* 1. Your knobs and inkcell's share one namespace. Do this first. The prefix is inkwell's,
because the environment is - inkcell reads `THEME` through it like everything else. */
inkwell_env_set_prefix("MYAPP"); /* MYAPP_THEME, MYAPP_FB_SCALE, ... */
/* 2. Your words continue inkcell's. Both halves, one table - see inkcell/i18n/strings.h. */
inkcell_i18n_set_catalog(&my_catalog);
inkcell_i18n_init();
/* 2b. And so do your icons. inkcell's are the ones a widget reaches for; `album` is yours. */
inkcell_icon_set_app_table(&myapp_icon_table);
/* 3. inkcell does not own an event loop; yours registers the input descriptors. */
struct inkcell_input_host host = {
.ctx = &my_loop, .add_fd = my_add_fd, .remove_fd = my_remove_fd,
.request_stop = my_request_stop,
};
inkcell_input_init(&input, &host);
/* 4. The frame is yours to draw. The snapshot is a void * inkcell never looks inside. */
inkcell_fb_set_app(state, &(struct inkcell_fb_app){ .ctx = app, .render = my_render });Two facts a frame needs that no snapshot carries are pushed in the same way: which way the
reader just moved (inkcell_fb_transition_begin() — what counts as "further in" is your
question) and which theme they chose (inkcell_fb_state_set_theme_by_id()).
The components are not a list you have to take on trust. examples/gallery is a program built
on inkcell the way an application is - it links inkcell::inkcell, installs its own string
catalog, and writes screens - and it draws every component the library ships, in every theme, at
two scales.
make gallery # every page into build/gallery/, plus build/gallery/contact.pngIt renders through inkcell_capture, which is the fb backend with the device taken out of it,
so this works in a container with no framebuffer anywhere near it.
The same program is the test suite for the widgets under src/fb/widgets_*.c. "The button
looks right" is not a unit test anybody can write; "the button looks like it did yesterday, and
here is the picture of what changed" is.
make test # includes inkcell_golden: every page, against tests/golden/manifest.txt
make gallery # when one differs, the pictures to look at
make gallery-update # once they have been looked at, record themThe manifest holds a digest per page rather than the images themselves, so a clone does not pay for hundreds of 1024x768 pictures. CI renders and uploads the pictures on failure, which is the only time anybody wants them. The clang job runs the whole sheet under ASan and UBSan, so the widget code is sanitizer-covered by the same pass.
make gallery-update is the one command in this tree that can quietly approve a mistake. A
manifest regenerated without looking at make gallery is a test that agrees with whatever it is
handed.
Three systems, each one a program ships on. The rasteriser, the widgets and every screen are the same code on all of them; what differs is which backend presents the frame and where a press comes from.
| Linux | macOS | Windows | |
|---|---|---|---|
| Loop underneath | inkwell's epoll | inkwell's kqueue | inkwell's waitable handles |
/dev/fb0 backend |
yes | refuses | refuses |
| evdev input | yes | refuses | refuses |
| SDL window, keyboard | yes, with SDL2 | yes, with SDL2 | yes, with SDL2 |
| Off-screen capture | yes | yes | yes |
| In CI | gcc, clang, ASan+UBSan, no SDL | clang | gcc, no SDL |
Where a backend refuses, it does so the way everything here does: inkcell_backend_fb_is_available()
is false, inkcell_input_init() watches nothing, and nothing above needs an #ifdef. The key
codes a window sends are the evdev numbers all the same, from inkcell/ui/input_codes.h, so a
keycap means one thing everywhere. On macOS, brew install sdl2 ninja is the setup.
SDL2 is the one optional dependency, and optional by presence: with it you get the window
backend, without it inkcell/ui/sdl.h still exists and reports itself unavailable. A plain
git clone builds and tests either way, and CI has a job for each - INKCELL_WITH_SDL=OFF
forces the second on a machine that has the library.
make test # debug build + ctest: the unit suite, the no-prose check, the golden sheet
make debug # build only
make gallery # the pictures (see above)
make format # clang-format, skipping the generated glyph tablesSanitizers: cmake -S . -B build -DINKCELL_ENABLE_ASAN=ON -DINKCELL_ENABLE_UBSAN=ON. CI runs
the suite under both.
As a subdirectory of another project, the tests are off by default and inkcell installs nothing.
A backend is six function pointers (inkcell/ui/backend.h) and the frame it presents is a
struct inkcell_surface - a pointer, a stride and a channel layout. Three of them ship:
inkcell_backend_fb() |
/dev/fb0. The device UI: changed spans copied into the mapping, FBIOPAN_DISPLAY, and the Brick's two-page mirror. |
inkcell_backend_sdl() |
An SDL window. The same rows, gathered into rectangles and uploaded to a streaming texture. |
inkcell_capture_*() |
No panel at all - a malloc'd page, which is what the gallery and the golden sheet render into. |
They share everything above the surface, which is the point: the rasteriser, the widgets and
every screen an application writes are the same code in all three, and the golden sheet holds
them to it. What differs is the last step, and inkcell_fb_damage_rects() and
inkcell_fb_copy_damage() are the two shapes that step comes in.
The SDL backend is a presenter, not a GPU renderer. The glyphs, the rounded rectangles and
the anti-aliasing are still the CPU's work; what moves to the GPU is the blit. Two
things about SDL do not fit one epoll loop and neither is hidden: it has no descriptor to wait
on, so its queue is drained from an inkwell timer registered through struct inkcell_input_host, and
SDL_RenderPresent() blocks under vsync, so vsync is off unless <PREFIX>_SDL_VSYNC asks for
it. See the header.
A window re-measures when it is dragged. The surface is reallocated at the window's new size
and the application is asked for a frame that shape, so the width classes
(inkcell/ui/stack.h) see the room the window actually has, and the content column
(inkcell_fb_content_column()) keeps the text in it readable rather than setting it across the
whole of a maximised window. It still opens at the device's geometry, so a layout that only
works at desktop proportions is caught the moment it comes up. <PREFIX>_SDL_FIXED pins the
frame at its opening size and scales it to the window instead, which is what you want when the
window is standing in for the device rather than being a surface of its own.
- An event loop. An application has one already; a UI library that brought a second would be
asking every app to run two. See
struct inkcell_input_host. - A scene driver for screenshots.
inkcell_capture_*renders a frame off-screen, which is the reusable half. Driving an app through a scripted sequence of presses is a script against that app's navigation, so it lives with the app. - A GPU rasteriser. The SDL backend uploads a software-rendered frame; it does not draw glyphs or shapes on the GPU. That would be a glyph atlas, signed-distance-field rounded rectangles and a second renderer to keep in step with this one, and it is worth doing only once there is a measurement on the device saying the upload is not enough.
- A pad over SDL. SDL's game-controller layer brings a button mapping of its own, and
whether it agrees with the profile in
src/input/input_profile.cabout a given handheld is a question for that handheld. A window is driven from a keyboard; a device is driven from evdev. - Translations. The mechanism is here and so are inkcell's own handful of strings. A translation covers the catalog in force — both halves at once — so it belongs with the application.
- Your vocabulary. Strings and icons work the same way: inkcell ships only what a widget
needs to put on a panel, and an application's own continue the ids from there
(
inkcell_i18n_set_catalog,inkcell_icon_set_app_table).scripts/gen-icons.pywill rasterise an application's ownicons.def—--def,--macroand--symbol— so there is no copy of the generator to keep in step. - Anything that knows what your app is about. No store, no navigation model, no screens.
MIT. See LICENSE. What was generated from somebody else's work carries its own
licence in licenses/: the emoji are Twemoji's, under CC-BY 4.0, which asks an
application that shows them for an attribution line.