| 1 | # The interface kit and the renderer |
| 2 | |
| 3 | Snowbound draws its own interface. The section tabs, toolbar, page tabs, |
| 4 | sidebar, menus and command palette are all painted by the app through one GPU |
| 5 | renderer, the same one that paints the page. This essay explains why, and how |
| 6 | the `ui` and `draw` crates are built to make that pleasant rather than |
| 7 | heroic. |
| 8 | |
| 9 | ## The look: half OneNote, half now |
| 10 | |
| 11 | The shell is a deliberate homage. Section tabs lean over one another at 45° |
| 12 | with a faint highlight along their tops. The open tab lies on top, in its |
| 13 | section's colour, and frames the page. That colour shades gently down the |
| 14 | window and becomes the accent for the whole interface, easing to the next |
| 15 | section's colour when you switch. Page tabs sit on the right, and the open |
| 16 | one joins the page with rounded inside corners; OneNote's Display options move |
| 17 | them to the left and the notebooks to the right. It is OneNote 2010's layout, |
| 18 | drawn with soft shadows, concentric corner radii and a dark appearance. Call |
| 19 | it half-skeuomorphic: the shapes that made OneNote's notebook metaphor |
| 20 | legible, without the 2010 chrome. |
| 21 | |
| 22 | The motion comes from [File Pilot](https://filepilot.tech): things move |
| 23 | quickly and never feel like they're waiting on an animation. Animated values |
| 24 | ease exponentially toward their targets with a short half-life. That is |
| 25 | frame-rate independent, a retargeted animation continues smoothly from where |
| 26 | it is, and it settles fast. Popups open and close on short timed curves, |
| 27 | slow enough to follow: a menu swings out of the pointer or its button as it |
| 28 | fades in, a combo's field widens into its list, and a dialog swings up into |
| 29 | place near the window's top over a dimmed window, as Windows opens a window; the |
| 30 | command palette swings in the same way, undimmed, as does a dialog docked inside the |
| 31 | page it changes, such as the backgrounds gallery. A filtered list shows its new |
| 32 | results at once. On GNOME and KDE, menus instead |
| 33 | look and move as the desktop's own (see [platforms](platforms.md)). A segmented control |
| 34 | slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on |
| 35 | Mac OS X 10.6 and Windows 7 presses one of a row of joined buttons. |
| 36 | |
| 37 | ## Why not native widgets |
| 38 | |
| 39 | - **The page has to be custom-drawn anyway.** OneNote-faithful layout needs |
| 40 | Windows line metrics, OneNote's grid, and its tag and list geometry. No |
| 41 | platform text view does that, so a renderer and a text-editing core exist |
| 42 | regardless. Drawing the chrome with them costs little more. |
| 43 | - **One renderer, one pass.** The page and the chrome share a glyph atlas, an |
| 44 | image cache and a frame. A text field in the toolbar edits with exactly the |
| 45 | keys, chords, click counts and caret movement the page uses, because both go |
| 46 | through `draw::edit`. |
| 47 | - **The shell is not a platform idiom.** Leaning section tabs and a |
| 48 | colour-framed page don't exist in AppKit or GTK. The app would be fighting |
| 49 | its toolkit. |
| 50 | - **Portability.** The same interface runs on macOS and Linux today, and the |
| 51 | project aims further (Windows, and old versions of OS X). |
| 52 | |
| 53 | The platform still owns what it's best at and what people expect to be |
| 54 | native. That means file pickers, alerts and date pickers (AppKit's sheets on |
| 55 | macOS; the portal on Linux, which otherwise asks with the kit's own), none of |
| 56 | which blocks the window, the caret and selection colours, each platform's |
| 57 | editing chords, the keychain, the traffic lights and window frames. On iOS the |
| 58 | split goes further (see [platforms](platforms.md)). |
| 59 | |
| 60 | ## Immediate mode, with memory |
| 61 | |
| 62 | `ui` is an immediate-mode kit in the sense Casey Muratori and Ryan Fleury use |
| 63 | the term. The *API* is immediate: every frame, builder code declares boxes |
| 64 | from application state, and there's no widget tree to keep in sync with the |
| 65 | model. The *implementation* remembers plenty: |
| 66 | |
| 67 | ```text |
| 68 | frame N |
| 69 | route input ─── against frame N-1's layout (hover, press, focus, drags, wheel) |
| 70 | build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…) |
| 71 | solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children |
| 72 | overflow taken from space, then by folding groups, then by strictness |
| 73 | paint ───────── Layers: primitives under a clip, or a Custom box the host paints |
| 74 | after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame |
| 75 | ``` |
| 76 | |
| 77 | - **Identity** is a hash of the parent's id and a part the builder chooses. A |
| 78 | cache keyed by those ids keeps hover, press, focus, scroll and animation |
| 79 | state between frames. |
| 80 | - **Input is answered one layout late.** Events route against the previous |
| 81 | frame's boxes before building, so a box can read what happened to it while |
| 82 | it is being declared. It sounds odd and is invisible in practice. |
| 83 | - **Layout is solved after building**, per axis. A box can be sized in fixed |
| 84 | pixels, by its label, as a fraction of an ancestor, or by its children. When |
| 85 | siblings overflow, space sized from an ancestor gives way first. Then a row |
| 86 | *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by |
| 87 | priority, reaching into its boxes sized by their children, so a group can |
| 88 | fold inside another. Only then do boxes give up room by their *strictness*, the least |
| 89 | strict first. That one knob covers most of what flexbox is usually needed |
| 90 | for. A box filling across a parent sized by its children stretches to what |
| 91 | its siblings make it, and a popup that isn't strict gives way to the window, |
| 92 | so a dialog is as tall as its contents up to the window and scrolls within. |
| 93 | - **The toolbar is one row that never overflows.** Both forms of every group |
| 94 | are built each frame and the solver picks, so a group folds in the frame the |
| 95 | window narrows, with no widths remembered or worked out by the app. A folded |
| 96 | group is a dropdown of the same command-table rows, and a gallery it lists |
| 97 | opens beside its row as a submenu, on hover, Right or a click. The |
| 98 | section tabs scroll sideways past the row's room, fading out where cut. |
| 99 | - **Nothing runs while nothing changes.** The kit reports whether it wants |
| 100 | another frame (queued input, an animation still settling) and when a timed |
| 101 | change such as a caret blink is due. The app sleeps in between. |
| 102 | - **Lists are virtual for free.** `ui::list` builds only the rows in view from |
| 103 | the source data, and holds its place on the selection as items arrive and |
| 104 | leave above it. No separate model sits between the data and the rows. |
| 105 | |
| 106 | The page is a **custom box**. `ui` routes it the pointer, wheel, key and |
| 107 | input-method events that land on it, in order. The host hands them to |
| 108 | `canvas`'s `PageView` and paints the page into a clipped layer of the same |
| 109 | frame. The page's scrollbars are ordinary `ui` widgets; the canvas only |
| 110 | reports its scroll bounds. |
| 111 | |
| 112 | `ui::popup` builds menus, filterable lists, a colour grid, galleries and a fuzzy command |
| 113 | palette on a popup layer that takes input above everything else. `ui::shell` |
| 114 | has the OneNote-specific controls: section tabs and compact toolbar buttons. |
| 115 | The kit doesn't know what a notebook is. `snowbound` assembles the window from |
| 116 | these parts. |
| 117 | |
| 118 | ## Accessibility and the keyboard |
| 119 | |
| 120 | The interface presents itself to assistive technology as an AccessKit tree |
| 121 | built from the same boxes as the frame. A box with a `Spec::role` is a node; |
| 122 | the kit's widgets choose their own, and `Ui::access` adds states, values and |
| 123 | names to any box. A box without a role lets its children through, and its |
| 124 | text reads as a label. |
| 125 | |
| 126 | - **Names come from what a sighted user reads.** A control without one is |
| 127 | named by its text and its children's, or by its tooltip: the title names |
| 128 | it, the chord becomes its shortcut, and the description its description. |
| 129 | An icon button's command-table title is its name, and a split button's arrow |
| 130 | is that name with "Options", so no two toolbar controls share one. |
| 131 | - **Popups are menus and dialogs.** A menu's highlighted row is the focus |
| 132 | within it, so arrows read as they move; a palette is a dialog whose field |
| 133 | keeps the focus while its list's selection moves. Opening a popup moves the |
| 134 | focus into it, and closing it gives the focus back. A command menu opens at |
| 135 | its top with nothing highlighted; a value picker (fonts, sizes, a combo's |
| 136 | list) opens on its current value. |
| 137 | - **Actions are input.** A press or a new value from assistive technology |
| 138 | arrives as an event and is answered next frame, as a click would be. |
| 139 | - **Only changes are sent**, and only while something listens. |
| 140 | |
| 141 | The page keeps its own tree (see [canvas](canvas.md#accessibility)). The |
| 142 | host grafts it at the page's box, sending the interface's tree first, since |
| 143 | it holds the graft, then the page's. |
| 144 | |
| 145 | ```text |
| 146 | Window |
| 147 | ├─ Toolbar buttons named by their tooltips, combos with their values |
| 148 | ├─ TabList section tabs |
| 149 | ├─ Group "Page" the page's own tree, grafted |
| 150 | ├─ TabList page tabs |
| 151 | └─ Menu | Dialog open popups |
| 152 | ``` |
| 153 | |
| 154 | The keyboard reaches the same controls. Tab steps through them, within an |
| 155 | open popup or across the window; a toolbar, tab list or tree is one step, |
| 156 | entered at its selected control, and arrows move within it. F6 steps between |
| 157 | those groups and the page, as Windows and GTK step between panes, and on |
| 158 | macOS Control-F5 goes to the toolbar. Space or Enter presses, Escape closes a |
| 159 | popup or returns the focus to where the keyboard took it from, and the |
| 160 | focused control wears a ring until the pointer is used. The page keeps Tab |
| 161 | for itself, so F6 leaves it. |
| 162 | |
| 163 | ## `draw`: the renderer |
| 164 | |
| 165 | `draw` is a small renderer submitting through wgpu, or Direct3D 11, OpenGL 2.1 |
| 166 | or a browser's 2D canvas where wgpu can't; the backends share everything but |
| 167 | their device layer. A frame is a list of layers, each with its own transform and |
| 168 | clip. A layer is a flat sequence of primitives: glyph runs, |
| 169 | SVG icons (tinted or in their own colours), SVG paths filled or stroked, |
| 170 | rounded or gradient rectangles, pen strokes and raster images. |
| 171 | |
| 172 | - **Glyphs** are rasterized with Swash into an atlas, at quarter-pixel phases, |
| 173 | so text placed at fractional positions stays crisp and doesn't shimmer as it |
| 174 | moves. Icons and paths rasterize once per size and phase into the same |
| 175 | atlas. A rounded box's soft shadow is worked out in the shader instead, so |
| 176 | popups of any size, widening or not, cost the atlas nothing. |
| 177 | - **The renderer never measures text.** Text reaches it through a trait that |
| 178 | visits positioned glyph runs. Line metrics stay with whoever laid the text |
| 179 | out, which is how the page keeps its Windows metrics while the chrome uses |
| 180 | ordinary ones. |
| 181 | - **Batches** merge consecutive primitives that share a texture and clip, |
| 182 | without reordering paint. |
| 183 | - **A popup in motion is one picture.** Layers sharing a motion draw |
| 184 | offscreen together, then fade and lean back as a whole, so nothing beneath |
| 185 | or within shows through a row. A popup's contents clip to its rounded |
| 186 | outline, and one opening over a combo lays its rows out at their final |
| 187 | width while only the outline widens. |
| 188 | - **Caches are bounded.** The image cache has a fixed budget. A full glyph |
| 189 | atlas is cleared and rebuilt with only what the current frame needs; where |
| 190 | one frame alone needs more than half of it, it doubles, up to what the GPU |
| 191 | allows. Images are filtered in linear light with premultiplied alpha. |
| 192 | |
| 193 | GPU readback tests pin the rasterization down: successive quarter-pixel |
| 194 | translations have to move the ink's centroid in quarter-pixel steps, and |
| 195 | repaint, cache eviction and a new renderer must all produce identical pixels. |
| 196 | |
| 197 | ## Testing an interface you can't click |
| 198 | |
| 199 | A covered window gets no redraws, so interaction is scripted. |
| 200 | `SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot |
| 201 | steps, and `accessibility` steps that write the whole window's tree as text, so a |
| 202 | hidden window's tree can be checked without a screen reader. `--screenshot` draws the whole window offscreen in each appearance |
| 203 | (the README's screenshots are made this way). `ui`'s own tests build frames |
| 204 | headlessly and assert on layout, routing and signals. |