kdos

Writing desktop software

This chapter is for developers who write a new piece of the KDOS desktop (a panel applet, a popup, a settings page, a window of its own) or change one that exists. In KDOS such a piece is called a surface: a grid of character cells that the toolkit can put on a Wayland output, on a terminal or into a text file. The chapter explains how a surface reaches the screen, how its event loop is shaped, which kind of window to ask for, input and drawing, the shared chrome and the keys contract, pictures and colour, a minimal surface that puts these together, the rules the libraries already keep for you, how to add a new name to kdos-shell or a program of its own, and how to test what you drew without a screen.

Read three things first:

A finished surface draws the same at a prompt, in a window and in a test, is reachable from the desktop, and has committed reference frames that fail when its layout regresses.

What a surface is

A surface is a grid of character cells drawn by libktui onto one of three kinds of backend. A backend is a KtuiBackend vtable: it presents a frame, reports the grid size and the terminal capabilities, and delivers events.

Backend Used by
The built-in terminal backend Anything run at a prompt: kdos-res --tty, the installer, the build screen
libkwl Anything under the compositor, kdos-comp
An offscreen buffer --dump, --dump-cells and the committed reference frames

A committed reference frame is called a golden; the goldens live under testing/goldens/, one text file per surface and size, named <name>-<cols>x<rows>.txt. Nothing above the backend knows which backend it is drawing on, and that is what makes a program identical at a prompt, in a window and in a test fixture.

kdos-shell is a single binary that answers to many names; each name, with its own entry point, is a front end. Every surface on the KDOS desktop is one of these grids: the panel and the other kdos-shell front ends (including the file chooser that the desktop portal opens), the resource monitor, the terminal and the lock screen, each handed to kdos-comp as an ordinary Wayland surface. The installer (src/system/kdos-installer) and the build screen (src/devtools/kdosbuild) are the same grids drawn on a terminal. Two things on the screen are not grids. The compositor draws its own chrome with Pango (window titlebars, the root menu and the window-switcher OSD) at a size matched to the cell grid, and an application, whether natively ported or running in a box, draws whatever its toolkit draws. The frame around your window is therefore not yours to lay out, and the pixels inside another program’s window are not cells.

The toolkits the applications use (GTK, libadwaita, WebKitGTK, Qt, KDE Frameworks, wxWidgets, FLTK and Tk) are not available to a surface. A program of KDOS’s own under src/ draws through the libk* libraries and links no GUI toolkit and no Xlib; testing/preflight.sh reads the NEEDED entries of every ELF file those ports install and fails the run when one names such a library.

Most new surfaces are a new front end inside kdos-shell (see Adding a name to kdos-shell). A program with a separate life of its own, such as kdos-res or kdos-term, gets its own directory under src/desktop/, or under another src/ area when it is not a surface, with a recipe like the others there (see A program of its own).

Reaching a display

A surface does not call a backend by name. libkdisp owns the choice and the whole surface lifecycle: opening and closing, overlay resize and autohide, cell size and scale, the clipboard and drag, the pointer shape, input regions, the window title, the session lock, keyboard focus, the window list and the display’s fonts. Each program states once which implementations it links:

extern const KDispImpl kwl_impl;            /* libkwl */
const KDispImpl *const kdos_disp[] = { &kwl_impl };
const int kdos_disp_n = 1;

Order is the policy. kdisp_init() walks the array and uses the first implementation whose probe succeeds and whose init then succeeds. A probe must be cheap and free of side effects, because kdisp_init() probes implementations it does not go on to use.

Every call site is then the same few lines, whatever the display server:

KDispConfig cfg = { .role = KDISP_ROLE_TOPLEVEL, .app_id = "kdos-thing", … };
if (kdisp_init(&cfg, kdos_disp, kdos_disp_n) != 0)
        return 1;                            /* say so and exit, do not run blind */

kdisp_init() returns −1 when no implementation can draw, and the caller prints why and exits. Passing zero implementations is not a failure: it returns 0 and leaves libktui on its built-in terminal backend, which is what a --tty flag means. kdos-res shows the whole choice: with --gui, or with $WAYLAND_DISPLAY set and no --tty, it calls kdisp_init(); otherwise it calls ktui_backend_set(NULL), which selects the terminal.

app_id must equal the file name of the program’s .desktop entry without the suffix, because that is how the panel and the window list match a window to its launcher. kdos appid checks the match against ~/.local/share/kdos/observed-app-ids, which kdos-comp appends to whenever a window maps, so an entry is reported ok only after its window has been seen.

libkdisp names no implementation and links none, so the kdos_disp array is what pulls Wayland into a program, and it is the one line that changes when a display server is added or removed. kwl_impl is the only implementation in the tree. The indirection still earns its place as the test seam: the dump harness (testing/fixtures/shell/dumpmain.c, the test build that links every front end and records its goldens) replaces the kdisp_* entry points so that kdisp_init() answers −1 and every front end takes its offscreen path with no compositor and no display libraries. In src/desktop/kdos-shell alone there are 47 calls to kdisp_init(), one of them the surface runner’s on behalf of ten front ends; branching on the display server at each of them would be the same decision written 47 times in one program and again in the next, which is why the lifecycle is an interface.

A cell dump does not go through libkdisp. --dump-cells installs its own KtuiBackend with ktui_backend_set(): most front ends use the backend that sh_cells_backend() in kdos-shell/cells.c returns, and kdos-settings and kdos-teams carry their own. It is a backend rather than ktui_offscreen_init() because the cell buffer is private to libktui, the backend vtable is its documented seam, and offscreen mode skips the flush that the cell dump needs to see.

The frame protocol

Drawing is immediate mode. Each pass through the loop draws the whole surface, and the toolkit compares the result with what it last presented and sends only what changed.

while (!kdisp_should_close()) {
    ktui_draw_clear();
    draw_everything();
    ktui_draw_flush();        /* diff and present */

    KtuiEvent ev;
    if (!ktui_backend()->poll_event(&ev, timeout_ms)) {
        if (ktui_resized) {   /* apply a resize before the next draw */
            ktui_resized = 0;
            ktui_draw_resize();
            ktui_draw_invalidate();
        }
        continue;             /* a timeout, a resize, or nothing to read */
    }
    /* handle ev */
}

Events come through the backend vtable rather than a free function: ktui_backend() returns the backend the surface was initialised with, and poll_event is the one entry point every backend implements. It returns 0 on a timeout, which is also where a surface with a clock or a meter does its periodic work.

A kdos-shell front end need not write this loop. sh_run() in shell.c owns it, together with the argument pass, the theme and the dump branch, and a front end on it states only what it draws and how it answers (see A minimal surface). The loop above is for a program of its own, and for the front ends whose loop has a shape of its own: the panel, the desktop, the savers, the bezels and anything that holds two surfaces.

The loop above runs no immediate-mode frame, and a surface drawn with draw, key and hit functions of its own needs none. A surface drawn with the immediate-mode controls (ktui_button(), a Tab ring, ktui_modal) wraps each event and the draw that follows it in ktui_frame_begin(&ev) and ktui_frame_end(), which is where a press is matched to a control and Tab walks the ring. The installer’s loop does this; on the runner it is the descriptor’s .frame flag.

Five rules hold:

Choosing a role

KDispConfig.role says what kind of surface to create. The roles use four Wayland terms. A layer surface is a surface of the wlr-layer-shell protocol (zwlr_layer_shell_v1): it is placed by anchoring it to edges of an output rather than as a window. Its exclusive zone is the strip of the output it reserves, so that windows are laid out clear of it. xdg-shell is the protocol for ordinary windows. A configure is the compositor’s message that gives a surface its size; the client acknowledges it and then commits, which presents the buffer it has attached.

Role Is Notes
PANEL A layer surface with an exclusive zone Per output; set .cells, not .cols/.rows
BACKGROUND A layer surface behind every window, anchored on all four edges Per output; no exclusive zone
OVERLAY A layer surface above windows Menus, popups, tooltips, toasts
TOPLEVEL An ordinary window (xdg-shell) See below
LOCK A session-lock surface Covers every output
SAVER The whole screen, above windows, taking no input See below
NONE Connect and bind the globals, install no backend, create no surface The panel’s --dump, and kdos-display --list / --apply

The other fields that matter most are app_id, title (toplevel only), font, output (the compositor’s name for a screen, such as eDP-1; NULL leaves the choice to the compositor, which chooses exactly one), keyboard, dismiss_on_unfocus, corner with margin_x/margin_y (overlay only), floating (a toplevel that opens centred at the size it asked for), opacity (body opacity in percent; 0 means 100) and manage (the surface intends to act on other programs’ windows). kdisp.h documents each one.

A panel gives its thickness and nothing else. .cells is the depth across the edge; the extent along it belongs to the display, because a layer surface is anchored to three sides. Set .cols/.rows on a panel and a server may attach at that size instead, which produces a bar sized like a window at a position the compositor picks.

The lock role covers every output, because the protocol will not report the session locked until every output has a surface. libktui has one cell buffer, so the prompt is drawn on the first output and the others are filled with the background colour. That is a limitation of the toolkit, not of the protocol.

Each role also asks things of the display that libkwl already does for every surface: the decoration request, the layer-shell version, the lock surface’s first commit. They are described in What libkwl and libkcell do for a surface.

Sizing a toplevel

Ask for a size that leaves the window frame somewhere to go, and treat it as a default rather than a demand: the compositor’s first configure carries the size it wants, and that size wins. With .cols and .rows left at zero, libkwl commits 80×22 cells.

Declare the smallest grid the surface can compose on in .min_cols/.min_rows. The front ends in kdos-shell that declare one use between 40×12 (kdos-audio) and 60×16 (kdos-store); most of the settings-style windows use 56×12 or 56×14. Zero means no minimum, which is right for anything that reflows to whatever it is given. The fields are a request and never a guarantee: no display in the tree acts on them, so the surface’s own ktui_toosmall() check is the only thing that stops it composing nothing on a grid narrower than it needs.

A saver asks for no size and takes no input

SAVER covers every pixel of one output, takes its size from the display, and passes all input through to what is underneath.

It asks for no size. An overlay is centred and sized in cells by the client, and a client cannot see the output; one that measured the screen itself would have to round pixels into cells, and a row rounded down is a strip of desktop along the bottom edge of a surface whose job is to cover it. The display sends the size in the first configure, exactly as it does for BACKGROUND.

It takes no keyboard and claims no pointer region (kdisp_input_cells(NULL, 0)). Every keystroke and every click goes to what is underneath, which is what lets the display’s idle policy see the activity and take the surface away; a saver that took input would hide that activity and never be removed. It is asked to close rather than killed, so the loop must exit when kdisp_should_close() answers true; a saver that ignores the request stays on the screen.

Anchoring a popup

Layer surfaces have no coordinates. “At x” is an anchor corner plus a margin in pixels. KDispConfig.corner takes one of five values:

Corner Anchored to Used for
KDISP_CORNER_CENTER Nothing (centred) The launcher, dialogs
KDISP_CORNER_TOP_RIGHT Top and right A toast
KDISP_CORNER_TOP_LEFT Top and left A dropdown under a word on a top bar
KDISP_CORNER_BOTTOM_LEFT Bottom and left A menu above a bottom bar
KDISP_CORNER_BOTTOM_CENTER Bottom only The volume and brightness bezel

Which corner a popup uses depends on the bar’s edge (kdisp_edge_bottom()), because a popup belonging to a bar on the other edge has to grow the other way. A client cannot express “just above the taskbar” by anchoring to the top with a computed margin, because it does not know the output’s height in pixels.

When a margin is given, libkwl sets the exclusive zone to −1. A zone of zero means the surface is arranged inside the usable area, which already has the panel’s zone removed, so a margin computed from the panel’s height would apply the offset twice and the popup would float one bar height away from the bar it belongs to. A zone of −1 means “do not move me out of anyone’s exclusive zone”, so the anchor is the output edge and the margin is the only offset. The zone is left alone when no margin was given: a centred dialog, a notification at the default corner margin and the bezel are asking to be placed, and being kept clear of the panel is right for them.

An overlay that should close when the keyboard focus goes elsewhere sets .dismiss_on_unfocus. That is right for a menu, the launcher and the run box, and wrong for a dialog: the file chooser and the yes/no prompt stay up when a person clicks back to the application mid-choice. The dismissal is gated on having seen the keyboard enter first, because the compositor decides when an on-demand surface gets the keyboard, and a leave before any enter would close the surface while it appears.

Input

Motion arrives as a mouse event with btn == KT_MB_MOVE and press == KT_MP_DRAG (2). The protocol reports plain and dragged movement identically, so a handler that tests press for truth turns every movement of the mouse into a click; test for KT_MP_PRESS and KT_MP_RELEASE by value, and remember the button across events. A slider arms on press, tracks on motion and commits on release, and a launch happens on release, because a launch on press fires before a drag can begin.

Everything else about input is cleaned up before it reaches the surface: a click arrives once, a wheel notch moves one row, a held key repeats. The rules that make that true are in Input the backend cleans.

Drawing

A widget announces itself. Every toolkit control states its role, its label where it has one and its position in its set at the point it computes focus, so a surface built from them needs no accessibility code of its own. What a widget cannot know it does not invent: a list and a table take their rows from your callback, so they state “3 of 9” and leave the name to you. If your rows have text a reader should hear, call ktui_announce() from the row callback for the selected row. ktui_frame_begin() clears the queue (at most 16 entries) every frame, so what you announce is only ever about the current one. See Accessibility for who reads the queue.

How a finished frame reaches the compositor is libkwl’s concern; see Presenting a frame.

Use the control, never a printed value

libktui carries one control of each shape (see the design language for the table), and they are the reason a surface is usable with a mouse without your writing any pointer code. A number is ktui_slider_*, a choice is ktui_dropdown_*, a line of text is ktui_field_* (or ktui_input() inside a frame), several lines are ktui_textarea_*. A value the surface prints and changes with Left and Right is a value nobody with a pointer can set at all.

A surface that draws its own rows still uses the toolkit’s pointer rule. ktui_rows_event() for a list and ktui_table_event() for a table answer the same four things: the wheel walks, a press moves the caret, a press on the row the caret is already on picks it, and the right button is Back. Each returns MOVED, PICKED, CLOSE or NONE (KTUI_ROWS_*, KTUI_TABLE_*), and both are the rule kdos-pick keeps. A surface that writes its own answer ends up with a pointer rule different from the rest of the desktop, often one that ignores the event.

A list with columns is a ktui_table, and its header is part of that rule. Mark a column KT_COL_SORT and a press on its title returns KTUI_TABLE_SORT. Answer it with ktui_table_sort() over an index array that your cell callback reads the records through, and bind a key to ktui_table_sort_next() so the keyboard reaches the same orders. Mark a number column KT_COL_RIGHT and draw its cells with ktui_table_text() so they line up under the title. Mark a column KT_COL_RESIZE and pass motion and release events as well as presses, and its edge drags. A table on a KT_SURFACE window sets page, and one that should select by the selection rule sets selrule. kdos-trash is the worked example: four sortable columns, a resizable name, sizes right-aligned.

Each control is a draw/key/hit trio with a frame call on top. A surface that runs ktui_frame_begin() calls the one-line frame call; a surface with its own event loop, such as kdos-display or kdos-audio, calls the three and routes the press and the key itself. Both reach the same code, so neither is a second implementation.

A text field in a surface with its own loop is a KtuiField over the surface’s buffer. Act on the keys that are the surface’s (Enter, Esc, the list keys) and hand every other key to ktui_field_key(); a paste arrives as a queue rather than as an event, so also call it with NULL on every wake while the field has the focus. A surface with a buffer and its own Backspace and printable arms is a field without the caret keys, the paste, the secret masking, the chord guard or the UTF-8 boundaries.

Laying out a page

A form is a column of rows, and a page that keeps its own y and writes its label column out at every field (b.x + 17) has a column one edit can move for one row and not the next. Take the rows from a KtuiLay instead:

KtuiLay l;
KRect f;

ktui_lay_begin(&l, body);
ktui_lay_section(&l, "YOU");
f = ktui_lay_field(&l, "username", 0);     /* label in KT_MID, control after it */
f.w = 34;
ktui_input(f, cfg.username, sizeof(cfg.username), 0, "kdos");
f = ktui_lay_field(&l, NULL, 0);           /* the same column, no label */
ktui_pw_meter(f.x, f.y, f.w, cfg.userpass);
ktui_lay_gap(&l, 1);
f = ktui_lay_row(&l, 1);
ktui_check(f.x, f.y, f.w, "Administrator", &cfg.user_wheel);
ktui_lay_indent(&l, 0);                    /* a note under the box, by its mark */
f = ktui_lay_row(&l, 1);
ktui_note(f.x, f.y, f.w, "may use sudo");
ktui_lay_unindent(&l, 0);

The label column is 16 cells, the key column ktui_kv() uses, so a form and a key/value list beside it align; pass another width where a page needs one. A row with several controls side by side is ktui_lay_cols() over a KtuiCol array, with the table’s rule: fixed widths kept, one cell between, the first zero-width column takes the rest. ktui_lay_left() is what is left for a paragraph or a list at the bottom. The cursor only does arithmetic and draws labels through the calls you would have made, so moving a page onto it changes no cell; the installer’s Accounts page is the worked example. It is not a frame control and needs no frame.

Two frame controls go with it, both named rather than counted, so each keeps its state in ktui_state() whatever is drawn above it:

Two of either with one name in one id scope are one control; wrap repeats in ktui_id_push_int().

Chrome

Chrome is the part of a surface that is not its content: the box, the header band, group headings, the button bar, the hint row, scrollbars. Use libkchrome for all of it. A second implementation of any of these diverges from libkchrome as soon as either one changes.

ktui_draw_box(krect(0, 0, w, h), " Updates ", KT_ACCENT, KT_BG, 1);
int top = kch_header(w, "system-software-update", "Updates", sub, icons_on);
kch_group(2, top, w - 4, "Behind");
int left = kch_buttons(w, h - 3, buttons, nbuttons, focus);  /* its own left edge */

kch_header() draws the band in rows 1 to 3 of a boxed window and returns the first body row. kch_buttons() draws at most eight buttons (SH_MAX_BTN), right-aligned; when the bar is too wide for the window it drops whole buttons from the right, so callers order them most useful first. It returns its left edge because the status line shares that row: draw the bar first and clip the status text to what it left.

The bar clears its whole span, including the column between two buttons, which belongs to neither; otherwise a leftover glyph shows through the gap. It clears to the surface’s own body slot, because half of the desktop’s surfaces use KT_SURFACE as their background and a hard-coded KT_BG would paint an opaque band across a translucent window. Feed pointer motion to kch_hover() so a button lights under the pointer, and ask kch_button_at() which button a press landed on.

A hint row is drawn whole or not at all. A message takes whatever room there is.

The keys contract

Every surface answers the same keys (see the design language), and the contract is two calls and one descriptor. A surface holds a file-scope KtuiKeys, calls ktui_keys() first in the dispatch it already has, and ktui_hint_row() last in the draw it already has.

static KtuiKeys keys;

static int pane_up(void *user)    { (void)user; return detail_open; }
static void pane_close(void *user){ (void)user; detail_open = 0; }

/* Once, before the loop AND before any --dump branch: a dumped frame reads
 * the same Esc verb the live surface does. */
keys.doc = "settings";                 /* fs/usr/share/kdos/doc/settings.txt */
keys.help = sh_help;
ktui_keys_layer(&keys, "Back", pane_up, pane_close, NULL);

/* In the draw, last: */
ktui_hint_if(nrows > 0, "Enter", "open");
ktui_hint("Esc", ktui_esc_verb(&keys));
ktui_hint_row(&keys, krect(2, h - 2, w - 4, 1), KT_SURFACE);

/* In the dispatch, first: */
int r = ktui_keys(&keys, &ev);
if (r == KTUI_KEY_CLOSE) goto done;
if (r == KTUI_KEY_TAKEN) continue;

ktui_keys() returns one of four values:

Value Means
KTUI_KEY_PASS Not its event; the surface handles it
KTUI_KEY_TAKEN Handled; draw again
KTUI_KEY_CLOSE Esc arrived with no layer up; the surface closes itself, because the toolkit does not own the program’s lifetime
KTUI_KEY_MENU An item of the surface’s menu was picked; keys.menu_id names it

It takes every event, not only keys, because a surface with a menu would otherwise need a second call site in its pointer path, and two call sites for one widget disagree about which of them saw the click. Everything it returns PASS for reaches the surface’s own dispatch unchanged.

Esc is a ladder of layers: raised states such as an open detail pane or a typed query, each declared with ktui_keys_layer() as a verb, an “is it up” callback and a “take it down” callback. Layers are registered once at start, innermost last, because Esc unwinds them from the end; at most KTUI_LAYER_MAX (6) are allowed. The “is it up” callback is asked at the moment the key arrives and never cached, so a state dismissed by a click does not leave a flag that swallows the next Esc. ktui_esc_verb() returns the verb of the topmost open layer, or “Close”.

ktui_hint_row() must run on every path that draws, including --dump. It clears the pool of pushed hints as its first act, before it measures the rectangle, so a zero-width rectangle is the right way to drain a frame where a message owns the row. A frame that skips it carries its hints into the next one, and kdos-shell is one binary with 54 front ends sharing that pool. It draws nothing in a window shorter than eight rows or too narrow for one whole hint. Both strings passed to ktui_hint() are copied, so their lifetime is the caller’s concern for the length of the call only.

Push only what the surface answers right now. ktui_hint_if() exists so that a key which does nothing in the current state does not appear; that is the entire value of the row over a fixed string.

kch_buttons() already pushes Enter <label> for its focused, enabled button, so do not push a second one for it. A bar drawn with focus -1 pushes nothing, and a surface that uses one that way must push its own Enter hint.

F1 is not pushed. It comes from keys.doc and is drawn first; keys.help is called with that name when F1 is pressed (sh_help() in kdos-shell). A surface with no page in fs/usr/share/kdos/doc/ leaves doc NULL, and F1 is then neither advertised nor answered. testing/preflight.sh refuses a keys.doc that names no file there.

A surface with a menu bar or a context menu declares a KtuiMenu (panes of KtuiMenuItems, each with a label, an id, an optional accelerator string and an enabled flag) and points keys.menu at it. ktui_keys() then routes into the menu before the Esc ladder: F10 opens the bar, Alt+letter opens a pane by its marked letter, Shift+F10 pops the context pane where the surface’s ctx_at callback says its focus is drawn, and Esc closes an open pane before it reaches any layer. A pick returns KTUI_KEY_MENU with the item’s id in keys.menu_id. ktui_menu_draw() draws the bar and the open pane and pushes the menu’s own hints. kdos-pick and kdos-desk use it.

Lists and the wheel

The list, wheel and scrollbar rule is libkchrome’s and has exactly one implementation. When a list fits its window, the wheel is a cursor step, the same as ↑ and ↓. When it scrolls, the wheel moves the viewport by three rows (SH_WHEEL_ROWS) and the cursor stays on its row: kch_list_wheel() returns 1 in that case, and the caller must not move its cursor. kch_list_clamp() keeps the viewport inside the list and pulls the selection into view only when its follow flag is set. Set that flag from everything that moves the cursor and from nothing that scrolls the page, or the next frame undoes the scroll.

After the clamp, kch_list_view(x, y, w, h, top) declares where the rows are and which item is in the first of them: the rows’ cells alone, with no header and no scrollbar column. It is called on every draw, and a list not declared in a frame stops gliding. Where the display has a frame clock and motion is on, a change of top is then presented as the list gliding there: libkwl slides the list’s pixels from the picture on the screen to the new one over 100 ms (KWL_GLIDE_MS), eased out, committing the frames in between itself, so a wheel notch’s three rows arrive as a slide rather than a jump and a second notch continues the slide from wherever it is. Nothing about the list moves by less than a row: top, the hit map, the pointer’s row and every dump are whole rows, and the last frame of a glide is the cells’ own picture. A move as long as the list or longer is a jump, and so is a change of top that is not a move — a filter typed, a group opened — which libkwl tells from the cells: at least half the rows the two frames share must be the same rows shifted. On a terminal and in a dump the call does nothing.

kch_scrollbar() draws a draggable bar (up to KCH_SCROLLBARS, four, per surface) and nothing at all when everything fits. Call it every frame, including frames where the list fits, so that a bar which has stopped being drawn stops being grabbable.

Pictures

A sprite is a picture registered in one of libktui’s numbered sprite slots (not to be confused with the palette slots under Colour); a cell refers to the picture by its slot number. The set of sprite slots is the sprite table. See also the glossary.

A picture is an enhancement layer over the cell grid, never a replacement, and every consumer must draw correctly when the picture is unavailable. kicon_slot() answers −1 on a terminal, with icons turned off (icons = no in comp.conf, or the --no-icons flag that the panel and several front ends take), with no artwork, for a name nothing has a picture for, and when the sprite table is full. Each of those is a normal state.

int slot = kicon_slot(name, 2, 1);           /* two cells wide, one tall */
if (slot >= 0) ktui_draw_sprite(krect(x, y, 2, 1), slot, KT_TEXT, KT_BG);
else           ktui_draw_text(x, y, 2, fallback_glyph, KT_MID, KT_BG, 0);

A sprite is two cells wide and one tall wherever it sits beside text. A cell is 16×32 pixels on this desktop, twice as tall as it is wide, so two by one is a square on the text’s own line. A two-by-two box next to a single row of text centres the picture across the row boundary and makes the whole surface look misaligned. kicon_slot() draws the largest square that fits the box and never changes an icon’s aspect.

Pixel tiles

For content that cannot be a row of text, such as a chart or a control that mixes text at a size other than the cell’s with drawing of its own, draw a canvas and hand it to the toolkit as a sprite through libkchrome’s tiles: kch_tile_begin() returns a KCellCanvas to draw into (or NULL), kch_tile_commit() publishes it, kch_tile_slot() says whether it is up (a slot, or -1) and kch_tile_draw() writes its cells into a rectangle of the grid. Four rules apply:

A tile can be any size up to the grid. One sprite covers at most 16×16 cells, because the sub-cell coordinate is four bits each way, so a larger tile is published as a grid of sprites, one per 16×16 block, each a view onto the same canvas: publishing it copies no pixels, and kch_tile_draw() writes every block’s cells. That is why a tile is drawn with kch_tile_draw() and not with ktui_draw_sprite() on the slot, which covers the first block only. A program holds at most 24 tiles (TILE_MAX), and each tile’s two halves take one sprite slot per block out of the table’s 4,096 (KTUI_MAX_SPRITES). A tile that stops being drawn keeps its two canvases until kch_tile_drop(); drop it only once a frame that does not draw it is flushed, and invalidate the next frame, for the reason given in libkchrome.

A chart is not drawn by hand. kch_plot() draws an area chart, or a mirrored pair on one axis, as a tile of its own and answers -1 where the caller must draw its cells; kch_plot_draw() draws the same chart into part of a canvas the caller already holds. Both follow the style in the design language, and the marks they are built from, kcell_canvas_series() and kcell_canvas_line(), are there for anything else that plots data. The panel’s meters and the charts in kdos-res are both drawn this way, each with a cell chart for the -1 answer; see kdos-res.

A program that draws tiles turns its pixel tier on after kdisp_init(), because the cell size and the output scale are the display’s: kicon_init(kdisp_cell_w(), kdisp_cell_h(), kdisp_scale()), which refuses a display with no pixel cell and so leaves every tile off, and kch_tile_enable() from the program’s icons setting. The cell and scale answered then are the font’s at 1 until the surface is on a screen: a whole-number output moves the scale and a fractional one moves the cell, to the font’s at the device size. So the program also registers a function with kdisp_on_scale() that calls kicon_recell() with the cell read again, which rebuilds the icons when either arrives or changes; in kdos-shell, sh_pic_backend() does it. Tiles follow both by themselves. A pixel position handed to another surface, such as a popup’s anchor, is a logical one and goes through kdisp_px_logical(); see The output scale. A dump never makes the call, so its frame is the cell layout. On a theme change such a program calls kicon_retint() and kch_tile_reset() before it redraws: icons are tinted when they are loaded and tiles are rasterised in the palette in force, and both are cached by what they show, not by palette, so a program that skips either comes up in the new theme wearing the old accent.

Display text

A heading or a figure that is only text, taller than a row, is not a tile. Draw it with kch_display_text():

/* in the draw, after kch_px_reset() */
kch_display_text(krect(x, y, cols, 2), "62%", KT_SURFACE, KT_ACCENT, KCH_ALIGN_RIGHT);

It is one op on the pixel layer, rows of the cell tall (two rows of a 16×32 cell are Terminus doubled, pixel for pixel), in the cell font’s face, cut to its rectangle and aligned in it. The rectangle’s cells go blank on the slot the backdrop owns and bg, when it is another slot, is laid under the text as a flat rectangle, so the text sits on whatever band the cells were. It costs no sprite slot and no canvas; a changed string re-rasterises and repaints only its own rectangle.

It needs a backdrop, and a slot cleared for the backdrop to show through. A popup has one (kch_px_popup()); an ordinary window hands its page to kch_px_flat(page_slot) after kdisp_init(), which paints the page in the same slot, opaque, so nothing on screen changes. Where it cannot draw, kch_display_text() fills the rectangle with bg and draws the string as cells on the rectangle’s first row and answers 0; that is the frame tty1 and every --dump show, so lay the rectangle out so that its cell form reads on its own. A figure that only repeats what the cells already say is drawn where kch_display_live() answers 1 and not at all otherwise, as kdos-res draws the number in its band; size its rectangle with kch_display_cols(s, rows). The strings of one frame share a 2048-byte pool and the list’s 256 ops are shared with the plates, and a string that does not fit takes its cell form.

Colour

Colour comes from the eight palette slots and never from a literal: KT_BG, KT_ERR, KT_ACCENT, KT_WARN, KT_DIM, KT_MID, KT_SURFACE and KT_TEXT. Use KT_MID for labels. KT_DIM is a fill and measures below any contrast floor for text.

Emphasis is a fill with swapped slots, never a reverse attribute over a label. The attribute inverts only the cells a glyph covers, so a two-word name comes out as one lit block per word.

A selected row’s colours come from ktui_sel_slots(). A surface that works them out itself (bg = on ? KT_ACCENT : KT_SURFACE is the shape to search for) drags a lit plate under the pointer and puts the background colour on the label:

int fg, bg;

ktui_sel_slots(selected, pane_has_keyboard, KT_SURFACE, &fg, &bg);

There are three states: an ordinary row is KT_TEXT on the page; the caret in a pane without the keyboard is a marker in KT_MID with no fill; the caret in the focused pane is a KT_DIM fill with the marker in KT_ACCENT. ktui_sel_row() gives the same answer with the fill and the ► marker drawn for you; the row’s text starts two cells in. Pass the page slot, KT_BG or KT_SURFACE, rather than letting the control guess, or a translucent window gets an opaque band across it.

A secondary column on a selected row takes ktui_sel_dim(), never KT_MID (the muted colour). KT_MID on the KT_DIM selection fill measures below any reading floor, so a tag, a two-letter code or a units suffix left muted vanishes exactly when the row is selected.

A minimal surface

The pieces above fit together in one descriptor. This is the shape of a boxed kdos-shell window with a --dump path, reduced from src/desktop/kdos-shell/firewall.c (326 lines, one of the smallest complete windows in the tree and a reasonable file to copy). The kdos_disp array is defined once per program, in kdos-shell’s main.c, and declared in shell.h, where ShSurface and sh_run() are declared too.

static KtuiKeys keys;

static void draw(void)
{
        int w = ktui_w, h = ktui_h;

        ktui_draw_fill(krect(0, 0, w, h), KT_BG);
        sh_frame(w, h, "Thing", KT_ACCENT, KT_BG, 1);
        /* … the content, the header band, the button bar … */
        ktui_hint("Esc", ktui_esc_verb(&keys));
        ktui_hint_row(&keys, krect(2, h - 2, w - 4, 1), KT_BG);   /* last */
}

static int on_event(KtuiEvent *ev)     /* after ktui_keys() passed it */
{
        if (ev->type == KT_EVT_KEY && ev->key == 'q')
                return SH_EV_CLOSE;
        /* the surface's own keys and pointer handling */
        return SH_EV_TAKEN;
}

int thing_main(int argc, char **argv)
{
        static const ShSurface s = {
                .cfg = { .role = KDISP_ROLE_TOPLEVEL, .cols = 68, .rows = 16,
                         .title = "Thing", .app_id = "kdos-thing", .keyboard = 1 },
                .keys = &keys,           /* keys.doc stays NULL: no help page */
                .draw = draw,
                .event = on_event,
        };

        return sh_run(&s, argc, argv);
}

sh_run() does the rest, in this order, and a front end on it cannot leave any of it out:

  1. It reads --font NAME and --dump, hands every other word to the descriptor’s arg() and prints usage: <app_id> <usage> with exit status 2 for one that nobody takes.
  2. It calls start(dump), where the surface reads what it shows, and then applies the theme.
  3. With --dump it draws one frame offscreen at .cols × .rows through the same draw() and prints it, before kdisp_init() is reached. That is what makes the golden a picture of the real surface.
  4. Otherwise it calls kdisp_init(), or prints <app_id>: no display server and exits 1, puts the popup plate under an overlay that asks for one (.popup, .popup_bg) and calls ready().
  5. Each pass polls the theme file and draws; the poll waits timeout() milliseconds (1000 without one), and wake() runs after it whether an event came or not. A timeout runs tick() and then the resize step; an animation’s frame tick runs the resize step alone, and the next pass draws. An event goes to ktui_keys() first, where CLOSE ends the loop, TAKEN goes no further and a menu pick goes to menu(id), and then to event().
  6. When the loop ends it calls stop(), where a surface saves what it holds, and kdisp_shutdown(). Neither runs on the dump or the no-display path.

event() answers SH_EV_PASS, SH_EV_TAKEN or SH_EV_CLOSE, and sh_run_close() ends the loop from anywhere else, such as a control drawn inside a frame. While typing() answers 1, a text field owns the keyboard: ktui_keys() is asked about Esc alone, so F1 and F10 do nothing there. A surface that sizes itself from what it found fills .cols and .rows in before it calls sh_run(), and cleanup that holds on every path goes after the call.

.frame = 1 puts every event and the draw after it inside a ktui_frame_begin() and ktui_frame_end() pair, then draws once more with no event so that what the event changed (a focus Tab moved, a page a button opened) is on the screen without waiting for the next key. The dump draws inside a frame too. It is off by default because it changes three things a surface with draw, key and hit functions of its own relies on: ktui_frame_end() walks the Tab ring on any Tab that event() did not answer SH_EV_TAKEN; ktui_id_base() stops restarting the id counter, so a group that points the focus at its own members counts from the page’s ids; and the hit lists swap once per frame. No shipped front end on the runner takes it yet; the runner’s own test surface in the dump harness pins what it does.

Ten front ends run on sh_run(): kdos-about, kdos-chars, kdos-contacts, kdos-firewall, kdos-note, kdos-print, kdos-trash, kdos-update, kdos-users and kdos-verify. The rest keep the loop in The frame protocol, written out with the same steps in the same order; a new front end starts on the runner unless its loop has a shape of its own.

What libkwl and libkcell do for a surface

A surface built on libkdisp gets everything in this section without writing any code. It is written out because each rule fails silently, and it is the list that anyone changing libkwl or libkcell, or adding a second display implementation, must keep.

A toplevel must ask for its frame

libkwl binds the xdg-decoration protocol and asks for a server-side decoration on every toplevel. A client that never binds it has not said which side draws the decoration and gets whatever the compositor guesses, which here is no frame at all: nothing to drag, no close button, and the window controls unreachable by pointer. A compositor that does not offer the protocol has no manager to bind, and the window is then undecorated.

This is easy to miss, because every other role is a layer or lock surface and has no decoration to negotiate.

Bind the layer shell at the right version

On-demand keyboard interactivity is a version-4 request of zwlr_layer_shell_v1. On an older resource wlroots reads the request as exclusive, and the compositor then parks the seat’s keyboard on that surface and refuses focus to every window, so nothing typed reaches any window until an overlay takes the focus and gives it back.

libkwl binds the layer shell at version 4, or at the compositor’s version when that is lower. Below version 4 it says so on standard error. The background layer then takes no keyboard (kwl: layer-shell v<N> has no on-demand keyboard; this surface takes none), and an overlay that wants the keyboard takes it exclusively until it exits (… this surface holds the seat's keyboard until it exits). Nothing else reports the mismatch; the only symptom is that typing stops reaching windows.

A layer surface that wants no keyboard at all must say so. libkwl sends KEYBOARD_INTERACTIVITY_NONE explicitly for an overlay with .keyboard = 0, because a commit that carries no interactivity state is not arranged by the compositor: the surface is created, mapped and given a buffer, and never appears. kdos-tip is the surface that depends on this.

A lock surface takes no pre-configure commit

Layer and xdg surfaces make an empty commit to ask for their first configure. On a session-lock surface that commit is a protocol error (a lock surface committed with a null buffer), and the compositor disconnects the client for it; because the compositor is right to keep the session locked when the lock client dies, the machine is left locked with no prompt on it. libkwl skips the commit for the lock role, which is configured as soon as it is created.

Input the backend cleans

A motion that did not move is not a motion. An absolute pointing device, which is what every virtual machine presents, sends the position again with a wheel event, so a handler would step the selection and then put it straight back under a pointer that has not moved a pixel. The backend drops a motion whose cell is unchanged, and an enter seeds the comparison so that an enter is never swallowed.

An enter carries coordinates, and they are not optional. libkwl reports the enter as a synthetic motion to the entry cell. Discarding them puts the first click after an enter at an impossible position and leaves hover stale until the pointer moves, so a menu opening under a stationary pointer would miss its first click.

A leave must be reported. libkwl sends it as a motion to (−1, −1); hover state with no leave stays lit for the rest of the session.

Key repeat is the client’s job. The protocol has none: the compositor sends the repeat rate and delay, and every client repeats for itself. libkwl does so, falling back to a 400 ms delay and 25 repeats a second when the compositor sends nothing, and it shortens the poll timeout to the next repeat, so holding a key repeats at the configured cadence rather than at whatever interval the loop happened to poll at. A key held when the focus leaves stops repeating.

A wheel tick is not an axis event, and the two sources behave differently:

The axis_source field says which is which. libkwl binds wl_seat at the lower of what the compositor offers and version 9. From version 8 the count arrives as axis_value120, in 120ths of a detent, and a wheel with a high-resolution mode sends one detent as several fractions: they add up across frames, a whole detent is one tick, and a reversal starts the count again. Below version 8 the count is axis_discrete, whole detents. On either path one pointer frame is at most one tick: a front end that turns one host scroll into two delivers a single frame carrying a count of two, and honouring the count would move a list twice as far, so the rest of the frame’s count is discarded. When the same doubling arrives as two frames, a rate limit catches it: a second tick in the same direction within 20 ms of the last (KWL_WHEEL_MIN_MS, overridden by $KDOS_WHEEL_MIN_MS) is dropped, and a change of direction always passes. The limit is for detents: a finger has none, and a touchpad crossing the tick threshold in two frames 15 ms apart is a fast scroll, so finger and continuous sources are never gated. KDOS_WHEEL_DEBUG=1 prints what the compositor actually sends.

A finger that leaves the touchpad while still moving coasts. libkwl measures the release speed over the finger’s last 100 ms of vertical samples; above 0.3 units a millisecond it keeps feeding ticks as though the finger were still moving, slowing by a factor of 0.996 every millisecond (a time constant of 250 ms), until the speed falls under one tick every half second or three seconds have passed. A finger held still before it lifts measures nothing in that window and does not coast. The next scroll, a button press, a key or the pointer leaving the surface stops a coast, and nothing coasts where motion is off. The coast’s steps ride the caller’s wait in pieces, as the overlay’s refreshes do, so a loop never sees its own timeout early. The constants are one #define each in kwl.c and were set by reading the protocol rather than on a touchpad; the coast is not fed into the raw stream, where a pixel guest gets the finger’s own end of gesture and runs its own kinetics.

A serial is the number the compositor attaches to an input event, which a later request must quote to show it answers that event. A serial must be retained from key, button, enter and touch events. Setting the selection, starting a drag and setting the cursor shape each have to present one, so a backend that discards them breaks the clipboard, drag and cursor changes, and the failure does not point at the input code.

The event queue is a ring of 16 events, not one slot. The client library delivers a whole batch of callbacks from a single read, so with one slot a button press followed in the same batch by its accompanying motion would be overwritten before any consumer saw it. Consecutive motion collapses onto motion; a button or a key never overwrites anything.

Presenting a frame

A double buffer needs a shadow per buffer. The paint compares each frame with a record of what the buffer holds so that it redraws only what changed; with two buffers alternating and one shared record, the rows that changed while the other buffer was in flight would never be redrawn in it. Each buffer carries its own shadow, and a full repaint is forced whenever a buffer has none or has been resized. The damage reported to the compositor is still the global comparison with what is on screen, cut to the changed span of each changed row rather than the row’s width; only the paint is per buffer.

A grid that scrolled is moved, not repainted. When a band of rows equals the buffer’s shadow shifted up or down (a terminal taking a line of output, a list stepped by the wheel), the band’s pixels are moved inside the buffer and the shadow with them, and the paint covers only the rows the band exposed. Nothing is asked of the surface: it draws its cells as always, and the row comparison finds the shift. See Scrolling by moving pixels.

A surface with a backdrop (pixel chrome under the cells, see libkchrome) is held to the same rule only when the backdrop can be repainted in part. Each buffer records the key of the backdrop it was last painted over. When the backdrop’s current key is the same, only the changed cells are repainted. When it differs but the backdrop can say which pixels moved since that key (a plate that moved, appeared or vanished), the cells over those pixels are repainted as well. Either way each repainted cell is laid back on its own rectangle of the cached backdrop first. The damage is the changed cells plus the pixels that moved between the picture on screen and the new one. A commit whose backdrop changed its alpha, its edge, the palette, the size or the scale, or moved a pixel no cell covers (the rule, the remainder past the last cell), is painted and damaged in full. A scrolled band over a backdrop is moved only where the picture is the same at both ends of the move: over the bare body that is every row no plate or other drawing op reaches, and over the graded popup and taskbar bodies it is none, so a list in a popup is repainted as it scrolls. KDOS_PAINT_FULL=1 forces that full path for every commit of every surface, which is the comparison to make when a surface shows stale pixels.

KDOS_INSPECT=1 shows it. Every row whose cells a commit changed is tinted in the accent and fades over a second, so a row that repaints on every frame stays lit; a panel in the top right corner gives commits and stashed frames a second, the paint time, the frame callback’s latency, what the last commit changed, the sprite bytes and the hit rects; and each hit rect of a frame surface is outlined, the focused one in the accent. It is the answer to “why is this surface busy” without a rig photograph: a clock that lights its whole row every second, a list that repaints when nothing moved, a surface throttled because it draws faster than the display. The overlay makes every commit whole, so under it the paint time is a full paint’s; read the rows lit, not the damage, and compare the paint time with KDOS_PAINT_FULL=1 rather than with an uninspected run. See libkwl.

The scale and the resized buffer must land in one commit. Split them and the compositor sees a buffer whose size disagrees with its declared scale for a frame.

An unchanged frame is not committed at all, and a frame callback the compositor never answers (an occluded surface, an output that is off) is given up on after 100 ms, so an idle surface costs nothing and a hidden one does not freeze.

Animating

Something that moves on a surface is a KtuiAnim read by the draw, never a timer the loop runs. Start it when the thing happens and ask it for its value where it is drawn:

static KtuiAnim glow;

/* on the click */
ktui_anim_start(&glow, 0.0f, 1.0f, 1100, KT_EASE_IN_OUT, 2);

/* in the draw, on the pixel layer */
if (kch_px_live() && ktui_anim_running(&glow))
        kch_px_round(x, y, w, h, KCH_PLATE_RADIUS, kch_slot_rgb(KT_ACCENT),
                     (uint8_t)(0x66 * ktui_anim_value(&glow)));

The loop needs nothing more if it draws on every return of poll_event, as sh_run() and the panel do. While an animation runs, libkwl returns a KT_EVT_TICK as an event once per display frame, so the draw happens at the display’s rate; one more follows the end, which draws the end value, and then the loop is back on its own timeout. A loop that treats every return of 1 as input, or runs periodic work on every pass, must tell a tick apart by its type: the panel skips its sysfs measurements on one, and sh_run() never calls tick() for one. A loop that waits on the display descriptor itself gets no ticks, and its animations stand at their first frame.

A selected row that should slide rather than jump needs no animation of its own: draw it with kch_px_row_anim(key, item, cx, cy, cw, KCH_T_ACTIVE) instead of kch_px_row(), with one key per list and the selected index as the item, and keep the cell form of the selection for where kch_px_live() is false. The plate eases to a new item’s row and lands at once when the same item has only moved with the page; see libkchrome.

Put the motion on the pixel layer, over or under cells that are already final, and give it an end value that says everything the motion said: on tty1, under --dump and with motion = no in comp.conf the value is the end from the start, and a golden is always the settled picture. Where the motion is the whole message, show a state for as long as it lasts instead: ktui_anim_running() is still true with motion off, and KtuiAnim.still says the value will not move. Durations and curves are in the design language.

Loading a font at a given size

libkcell handles both of these rules when it loads a font for a canvas or for display text. Any new code that asks fontconfig for a sized face has to keep them too.

A repeated fontconfig property appends; it does not replace. The chrome font is Terminus:pixelsize=32, and appending :pixelsize=39 to it yields a pattern carrying two sizes, of which the first wins, so every canvas would come out at the cell’s own size while the text still renders. Strip the size the name carries before appending yours.

A bitmap font cannot be asked for an arbitrary size: fontconfig answers with the nearest strike, scaled by nearest neighbour where it is more than a fifth away, and a fractional scale draws uneven strokes. libkcell asks fontconfig which strike and which factor it chose, and where the bitmap is not exact it takes the family’s outline twin, Terminus (TTF), at the requested size (see libkcell). A face still more than a tenth short is retried with :scalable=true, keeping whichever is closer. A machine with no scalable face keeps the bitmap, which is the correct answer for a minimal install.

Adding a name to kdos-shell

kdos-shell dispatches on the name it was started under: main.c compares the basename of argv[0] with a table and calls the matching entry point. A new front end is therefore wired in several places, and most of them fail silently.

  1. Add src/desktop/kdos-shell/<x>.c with an entry point int <x>_main(int argc, char **argv), which fills an ShSurface and returns sh_run() (see A minimal surface).
  2. Add a { "kdos-<x>", <x>_main } row to the TOOLS table in main.c.
  3. Declare the entry point in shell.h. The table names it and the header is how every other file learns of it.
  4. Add ln -s kdos-shell "$PKG/usr/bin/kdos-<x>" to src/desktop/kdos-shell/build.sh. A name in the table that the build does not link is a program nothing can reach. Until the link exists, the front end is reachable by starting the binary under a name the table does not contain, with the front end’s name as the first argument (bash -c 'exec -a kdos-dev kdos-shell kdos-<x>'): a basename that matches no row falls back to selecting by the first argument. kdos-shell kdos-<x> does not work, because kdos-shell is the panel’s own row and the panel rejects the argument.
  5. Add a route if a person or a script should be able to reach it. routes.c reads /etc/kdos/menu.conf and then $XDG_CONFIG_HOME/kdos/menu.conf (by default ~/.config/kdos/menu.conf), where a route named again replaces the system one. A route is a verb.noun name mapped to an argument vector, run without a shell, such as setup.network = kdos-net; a shipped surface needs its row in fs/etc/kdos/menu.conf.

    A surface missing from any one of the table, the header, the link and the route is a chord that opens nothing, and none of those four fails at build time. testing/preflight.sh checks that every command menu.conf names exists, and checks all four places for kdos-store; for any other name, keeping them together is your responsibility.

  6. Give it --dump and commit goldens. That is four more edits, and a fifth where the loop is worth pinning, and the suite passes with none of them:
    • add <x> to the for s in … candidate list in testing/selftest.sh, so the file is compiled into the dump harness;
    • add both FRONT_END(<x>_main); and a { "<short name>", <x>_main } row to testing/fixtures/shell/dumpmain.c. Every entry point there is declared weak, so missing either makes dumpcheck --have <short name> answer no and the golden block print (skipped — not linked into the harness);
    • add a golden <name> <WxH> <argv…> call to testing/selftest.sh. The candidate list and the golden calls are separate, and being on the first buys no coverage at all;
    • add a fixture if the surface reads anything the host owns. --fixture <dir> replaying recorded output is the idiom: kdos-print over recorded lpstat and lpinfo output, kdos-store over a recorded catalogue, and kdos-disks pointed at a kdos-mountd socket that does not exist. Without one the golden records the machine that wrote it and fails everywhere else;
    • add a keydrive <name> <WxH> "<script>" <argv…> call to testing/selftest.sh for a surface with a loop worth pinning. It runs the surface’s live path over a scripted display (see Looking at it without a screen) and commits the frame it ends on as drive-<name>-<WxH>.txt.

    Every one of those omissions reads as a pass. ls testing/goldens/ | grep <name> after the run is what tells a covered surface from an uncovered one; the exit code cannot.

  7. If another tool starts it, check the flags. testing/preflight.sh verifies that every flag one kdos-shell tool passes another is one the target accepts. An unknown argument prints a usage line to an error stream nobody reads and exits before a surface exists, so the symptom is a control that silently does nothing. A front end on sh_run() takes --font and --dump in shell.c, and the check reads that file for them.
  8. If it opens files by type, give it a desktop entry. The kdos-shell front ends that have one, kdos-peek, kdos-pix and kdos-burn, ship it under fs/usr/share/applications/ with NoDisplay=true and a MimeType= line, so a file of that type can be opened with it while the menu does not list it. The entry’s file name is the front end’s app_id plus .desktop.

A program of its own

A program that is not a kdos-shell front end is a port of KDOS’s own. It sits at exactly src/<area>/<name>/, two levels below src/, because its build.sh reaches the libraries as $PORT_SRC/../../libs and that resolves at no other depth. The area says what the program is; take the first rule that fits:

Area Holds Searched by
src/libs A library. Every one is a libk* directory here, and none is a port Every port’s build.sh, by path
src/devtools A program that is not installed on the target, such as kdosbuild and kdos-portup Nothing: they are not ports
src/desktop A program that draws, or that speaks the session’s Wayland or D-Bus: the compositor, the panel, the terminal, the portal 50_desktop
src/daemons A root daemon whose client is the desktop account, such as kdos-powerd 50_desktop
src/art Pictures, themes and their generators: the theme, icons, cursors, the boot splash 40_lang to 44_apps, 50_desktop, 60_kernel
src/system Everything else: the package manager, the kdos command, packs and boxes, the installer 40_lang to 44_apps, 50_desktop, 60_kernel

A port in src/desktop or src/daemons can be built only by 50_desktop, the one phase whose PORT_REPO names those areas. A new area is not a new directory alone: it goes on the PORT_REPO of every phase that builds from it, in the orphan sweep’s list in script/phases/70_image/040_orphans.sh (a package whose port the sweep cannot find is deleted from the image), and in preflight’s list of areas. The repository layout has every member of each area.

A desktop program is wired in three places:

  1. A directory src/desktop/<name>/ holding the sources, a kpkgbuild and a build.sh. The kpkgbuild names no source, so there is nothing to fetch, and build.sh compiles from $PORT_SRC, the port directory itself, together with the libraries under src/libs (see A port of KDOS’s own). The directory name is the port name, and no other port anywhere in the tree may have it.
  2. A row naming the port in script/phases/50_desktop/packages.txt, which is what puts it in the build (the ports that phase already lists are in The ports catalogue). A port in src/system or src/art goes instead in the list of the phase that installs it, in that phase’s packages.d/src-system.txt or src-art.txt (see Which phase lists a port).
  3. A .desktop file installed by its own build.sh into $PKG/usr/share/applications/, so that the package owns its menu entry (see Desktop entries belong to the port). Its file name without the suffix must equal the program’s app_id.

kdos-res is the example: src/desktop/kdos-res/ holds kpkgbuild, build.sh and kdos-res.desktop, its build.sh installs the entry to $PKG/usr/share/applications/kdos-res.desktop, and script/phases/50_desktop/packages.txt lists kdos-res. Once the window has been opened, kdos appid reports whether the entry and the window match.

The recipe hash of such a port covers the whole port directory and the whole of src/libs, so an edit to any file under the directory rebuilds it, and an edit to any library rebuilds every port of KDOS’s own, not only the ones that link that library. Where the desktop phase sits in the whole build is told in How KDOS is built.

Building and trying a change

Most of the work on a surface happens without a build and without a display.

To Run
Check the libraries, and that every consumer still compiles, on any host testing/selftest.sh
Run the same suite with every surface dump and golden, in the development image (kdos-devdeps) testing/devdeps-image.sh
Rebuild one desktop program in the build tree make build BUILD_ARGS="--phases 50_desktop --rebuild kdos-shell"
See the change on a running desktop without rebuilding the ISO testing/quick.sh kdos-shell -- --sleep 3 --shot build/shots/x.png

The dump harness builds only on a host with libwayland-client, wayland-scanner, wayland-protocols (including ext-workspace-v1.xml) and the fetched wlroots source tarball in the wlroots port’s directory; elsewhere testing/selftest.sh prints front-end dumps (skipped …). A front end whose headers are missing on the host (pixman, fcft, ALSA, PipeWire, the image decoders) is dropped from the harness with a note rather than failing it. A pass on a bare host therefore says little about how a surface draws. testing/devdeps-image.sh builds the kdos-devdeps container image and runs the whole suite inside it, which is the run where nothing is skipped (see Testing).

testing/quick.sh builds the named ports without the image phase, packs exactly the files the package database says those ports own, and patches them into the RAM overlay of a booted ISO (the writable in-memory layer the live system runs on; see Testing and Developing). It needs a built tree and the container image of the rig, the QEMU harness that boots the ISO, drives it and photographs it (Testing, and the glossary); the rig writes a PPM file whatever the extension. A new symbolic link in the recipe travels with the package, but a new row under fs/, such as a menu.conf route, does not unless the file is named in KDOS_QUICK_FILES (a path relative to build/fs) after the bootstrap filesystem step (10_bootstrap:000_file_system.sh) has copied fs/ into the build tree. When in doubt, build the image as well, with make build BUILD_ARGS="--phases 50_desktop,70_image --rebuild <port>". The fast loop is not evidence about the shipped image.

Looking at it without a screen

kdos-start --dump
kdos-menu system --dump-cells
kdos-res --fixture testing/fixtures/res --dump-size 66x10 --dump
kdos-term --dump 44x8 -e /bin/echo hello
Flag Produces Catches
--dump The cell buffer as plain text, drawn in the ASCII glyph tier Geometry
--dump-cells One line per non-blank cell, row col U+XXXX fg bg attr, drawn in the rich tier Colour and attribute regressions as well

Every kdos-shell front end except kdos-ascii, kdos-mediad, kdos-netagent and kdos-ime takes --dump, and so does kdos-res. kdos-term takes --dump COLSxROWS with the size as its argument. --dump-cells is taken by kdos-start, kdos-menu, kdos-keys, kdos-settings, kdos-find, kdos-teams, kdos-doc, kdos-pick, kdos-openwith and kdos-res.

Most kdos-shell front ends draw their dump at a size compiled into the surface. A few take a size on the command line: --dump-size WxH on kdos-res, kdos-keys, kdos-openwith, kdos-settings, kdos-teams and kdos-doc, and --dump-size W H (two arguments) on kdos-desk. The size a golden is taken at comes from $KDOS_DUMP_SIZE=WxH, which only the dump harness reads: testing/fixtures/shell/dumpmain.c wraps ktui_offscreen_init() for it, and no shipped binary reads it. Forcing a common size also checks that a draw pass does not assume the buffer is exactly the size it asked for.

Dump at a size that forces degradation. A layout defect is usually a defect at one width, and a full-size dump will not show it. The design language asks for 80×24 and 132×43 for a window, and the popup’s own size for a popup.

A --dump golden is in the ASCII tier, because ktui_caps is 0 with no terminal, so read the surface at the vt tier (see the glyph tiers) before believing it reads on a console. testing/preflight.sh checks every chrome glyph against the shipped console font.

A --dump never runs the loop, so no dump shows an event handler, the resize step or ktui_keys() being asked first. The dump harness can also run a surface’s live path: with $KDOS_DUMP_KEYS set, kdisp_init() there answers with a display that reads the variable as a script of events, and the frame the surface last presented is printed when it shuts down, followed by -- N of M events read. A surface that closed itself stops short of the total. The script is whitespace-separated steps:

Step Is
up down left right home end pgup pgdn ins del tab btab enter esc bs space f1 … f12 That key
ctrl+K, alt+K, shift+K A named key or one character with that modifier
One character That character
text:WORD One key event per character of WORD
click:X,Y, rclick:X,Y A press and its release at that cell, left or right button
wheelup:X,Y, wheeldown:X,Y One detent
tick A poll that times out
resize:WxH The grid changes size, and the poll times out

The grid is $KDOS_DUMP_SIZE, else the size the surface asked for. Only the dump harness reads the variable; testing/selftest.sh commits these frames through keydrive as drive-* goldens.

Goldens are committed and compared by testing/selftest.sh; KDOS_GOLDEN_UPDATE=1 rewrites them. Regenerating them is described in Testing.

When a surface does not appear

A surface that fails to come up usually exits without drawing anything, and the reason is on its standard error. libkwl prints:

Message Means
kwl: protocol error <code> on <interface>#<id> The compositor refused a request and disconnected the client; the interface names the request
kwl: the compositor closed the surface before it was configured The surface was closed during the handshake; kdisp_init() fails rather than returning success for a window nobody will see
kwl: layer-shell v<N> has no on-demand keyboard; … The compositor offers a layer shell older than version 4; see above

Four environment variables trace what the libraries see. KDOS_WHEEL_DEBUG=1 prints every discrete, high-resolution and continuous axis event, every pointer frame’s result and every coast step. KDOS_PANEL_DEBUG=1 makes the panel say why a pixel tile declined and print one line per meter sample with its scale and value, and makes kdos-tip print the size and position it was placed at. KDOS_PAINT_FULL=1 paints and damages every commit in full (see Presenting a frame): a defect that goes away under it is in the partial repaint or the damage, and one that stays is in the drawing. KDOS_INSPECT=1 draws the changed rows, the frame timing and the hit rects over the surface itself (see Presenting a frame); a frame surface that is on screen but not answering a click shows there whether it registered a hit rect where the click landed.

The checklist

The checklist in the design language, restated as a procedure:

# Do Check with
1 Box the surface with ktui_draw_box(), title on the top edge --dump
2 Header band, group headings and button bar from libkchrome grep kch_
3 Slots for colour, KT_MID for labels; selection through ktui_sel_slots(), secondary columns through ktui_sel_dim() grep KT_DIM in foreground positions; grep 'KT_ACCENT :', where a match is a surface deciding for itself
4 Motion, press, wheel, scrollbar drag and header sort grep -c KT_EVT_MOUSE; zero is the defect
5 A hit map recorded from the draw Resize the window and click the top row
6 Dump at two sizes and commit both goldens testing/devdeps-image.sh
7 Read it at the vt tier, and use no glyph the console font lacks --dump; testing/preflight.sh reads the shipped font
8 One KtuiKeys; ktui_keys() first (on the runner, .keys names it), ktui_hint_row() last, on every path grep -c ktui_hint_row; the dump path counts
9 Every raised state a declared layer, never an Esc branch grep KT_K_ESC; a remaining case is one the ladder should own
10 A help page in fs/usr/share/kdos/doc/ if keys.doc names one, and doc left NULL if not testing/preflight.sh

See also


Part V — Building and developing, chapter 36. Previous: 35. The C libraries · Contents · Next: 37. Testing