# rune Rune's core crate: views, layout, animation, rendering, text and the native shell. `examples/settings` draws a small Settings window entirely with Rune's own code. ![dark](../../demo/settings-dark.png) ## What exists - **View API** (`el.rs`): `fn view(s: &S, cx: &mut Cx) -> El`, with `row()/col()/el()/text()`, `.key(..)`, layout and style methods, `.on_press(|s: &mut S| ..)`, `.on_press_tx(|s, tx| ..)` and `.on_drag(|s, drag, tx| ..)`. Handlers are queued and run between frames with `&mut S`. Then the view re-runs, and the diff retargets property tracks. Hover and press only retarget tracks; they never re-run the view. - **Tokens** (`tok.rs`): colours in OKLCH (dark and light themes, six accents), plus space, radius, type and the motion palette (snappy .25/0, default .3/0, calm .4/0, a .2/0 lead, a .15 fade). - **Layout**: Taffy 0.15 at the target state. Text sizes round up to whole device pixels, so layout stays on the pixel grid. Keyed nodes retain Taffy's constraint cache between commits; only changed styles, children or text measurements dirty it. Text colour does not invalidate measurement. Three gotchas, all CSS flexbox behaviour: - `.grow(g)` is `flex: g 1 0`: flex-basis 0, so the element is only as big as the free space its parent leaves. In an auto-height column there is none, and a grown `.scroll(..)` viewport collapses to 0 pt. Bound an ancestor's height (`.h(..)`, `.h_full()`) or set `.min_h(..)`. - Flex items default to a minimum of their content size, so a nested flex column overflows its parent instead of shrinking and the scroll inside never scrolls. Put `.min_h(0.0)` on each column between the bounded ancestor and the viewport. - `.virtualize(..)` only works on a growth stack: call `.growth(GrowthSpec)` on the same element, and make it the scroll viewport's first child. Debug builds warn once per call site when a scroll viewport inside a `.grow()` container resolves to 0 pt while its content is taller; `.virtualize` without `.growth` panics with the element's path. - **Animation** (`rune-motion`, `runtime.rs`): analytic springs (`Spring::new(duration, bounce)`), tweens, decay and transitions driven through `rune_motion::Scene` and `AnimationStore`. Retargets keep exact position and velocity. - Layout animates per edge, so stationary edges never move. - `.lead(lead, trail)` makes the edge in the direction of travel lead; `.width_leads()` speeds up X by `0.78×`. - Children ride their parent's left edge. `.ride_trailing()` keeps an element against its parent's right edge instead while the parent's width animates, so right-aligned content holds still whatever springs the rows around it use (a trailing `RollingText` does this). - Presence (`Transition`): elements added or removed after the first frame animate in and stay drawn until their exit settles at opacity 0 (including `.transition(Transition::blur_replace())` and mid-flight revival). - Colours animate in premultiplied OKLab, and text colour leads its background (`0.5×` duration) so theme switches keep label contrast through the transition. - Nothing animates on the first frame. - `Tx::instant()` applies a change with no animation (drag moves are instant by default). - `El::instant_changes()` does the same for one subtree in any commit (a popup of search results), while the element's own entrance and exit and its colours keep their motion. - `Tx::color_motion(spring)` sets the spring for that change's colours (the theme switch uses calm). - In reduced motion, spatial travel snaps while opacity, presence and colour take `REDUCED_FADE` (`0.15 s`). Growth-stack rows also keep this fade on insertion/removal; closing gaps land after the exit fade instead of animating their height. - The animation clock has a time scale, which the Settings speed slider drives. - **Renderer** (`render.rs`, `shader.wgsl`): one instanced uber-quad pipeline and one draw call per frame. - Kinds: rounded rects (per-corner radius, a border drawn over the fill as CSS draws one, per-side widths), analytic Gaussian shadows (outer and inset), vertical and angled linear gradients, circular and elliptical radial gradients (`KIND_RADIAL`), dashed border rings (`KIND_DASHED`), RGBA images, and glyphs from the atlas. - **Gradients** (`.gradient(top, bottom)`, `.radial_gradient(center, radius, stops)`, `.linear_gradient(angle, stops)`): painted over `.fill(..)` and under hover/press overlays and borders, clipped to `.radius(..)`, and composited with `.opacity(..)` and `.blur(..)`. `radial_gradient` takes normalized `center: [f32; 2]` (`[0.5, 0.5]` = element centre), `radius` in points (`f32` for a circle or `[rx, ry]` for an ellipse), and up to 8 `stops` as `(t, Color)` pairs in `0.0..=1.0` (inner radial bands shrink their quads to the band bounding box). `linear_gradient` takes `angle` in radians (CSS convention: `0.0` up, `PI / 2` right, `PI` down) and up to 8 `stops`. Stop colours spring in Oklab when changed and interpolate in premultiplied colour across the quad. - **Shadows** (`.shadow(y, blur, color)`, `.box_shadow(Shadow)`): `blur` follows CSS `box-shadow` (`blur = 2σ`, so the Gaussian standard deviation is `σ = blur * 0.5`). `CALayer.shadowRadius` and Sketch / Figma shadow blur specify `r ≈ σ`, so pass `blur = 2.0 * r` to match a `CALayer` shadow radius (whereas `.blur(sigma)` takes `σ` directly). - Vector shapes (`shape.rs`): `El::shape(Shape)` and `canvas(shape)` paint strokes (polylines, round or butt caps, open or closed) and fills (simple polygons, ear-clipped) in the element's own points, or in a `view_box` stretched to the element's size; `Path` flattens quadratic and cubic Béziers. Strokes are one analytic capsule/box per segment (`KIND_SEGMENT`); fills are triangles (`KIND_TRIANGLE`) antialiased only on polygon edges, so they have no seams. No atlas, so a shape can change every frame (`examples/shapes`). Translucent strokes blend twice where segments overlap at a joint; fills do not overlap. - Every instance has a soft-edge clip (`clip_soft`, `clip_fade`). - Windows blend in sRGB-encoded space by default, as Core Animation, CoreText and CSS do. `.srgb_blend(false)` on `App`, `WindowSpec` or `ConfiguredOverlay` opts out into linear-light blending (colour premultiplied in linear light, drawn into an sRGB target). sRGB blending encodes premultiplied colours in sRGB (`Color::premul_srgb`) and renders into a non-sRGB target view (`format.remove_srgb_suffix()`). With a `Compositor`, `Frame::srgb_blend()` and `Frame::format` tell it which encoding and view its own passes must match. A runtime the app draws itself (`Runtime::with_text`, a headless snapshot) opts in with `Runtime::set_srgb_blend(true)`, then draws with `Renderer::set_srgb_blend(true)` into a non-sRGB target cleared to `clear_color()`; hover and press tints follow the mode. A handler switches a running window with `Tx::set_srgb_blend(bool)`: the frame that switches reconfigures the surface and rebuilds the renderer once (the web canvas keeps its format and changes its view). - The Retina scale is applied throughout. - **Text** (`text.rs`): Parley 0.12 lays out embedded Inter 4.1 (OFL, `assets/`). swash 0.2 outlines are rasterised through a port of CoreGraphics' glyph pipeline into a 2048² R8 shelf atlas, at CoreGraphics' subpixel pen steps, with a 1 px zeroed border per glyph and pixel-snapped baselines. **Font smoothing** (`FontSmoothing`, `text/coretext_match.rs`) picks how glyphs are rasterised and placed, app-wide with `App::new(spec).font_smoothing(FontSmoothing::Off)` (or `WindowSpec::font_smoothing`), or while running with `Tx::set_font_smoothing(mode)`, which rasterises the new mode's glyphs on the next frame (each mode keeps its own atlas entries, so a switch back is free). Modes: - `On`, the default on macOS and in a Mac's browser: CoreText's text with font smoothing on. Apple's pipeline (pens floored to CoreGraphics' subpixel step, which depends on the ppem: fifths of a device pixel below 8⅓ ppem, quarters to 11.1, thirds to 16.7, halves to 33.3 and whole pixels above; coverage drawn as is; sRGB blending; CoreGraphics' curve flattening and its y autohinter at 7–18 ppem) plus font smoothing's outline dilation, a radius set by the ink's luminance and the device ppem (`text/macos/smoothing.rs`, `ApplePipeline::SMOOTHED`). - `Off`: what macOS browsers draw for `-webkit-font-smoothing: antialiased`, CoreText with font smoothing off. Apple's pipeline without the dilation (`ApplePipeline::UNSMOOTHED`); lighter than `On`. Coverage takes an exponent keyed to the ink's luminance, the curve of Skia's grayscale text gamma. - `DirectWrite`: Chromium's text on Windows. DirectWrite rasterises each glyph as Skia's `SkScalerContext_DW` asks it to (the rendering mode the `gasp` table picks, grid fitting on, grayscale antialiasing, the face at the run's variation coordinates, read in place from Parley's bytes), pens sit on Skia's quarter pixels, and Skia's gamma tables (sRGB, contrast 1, eight luminance buckets) weigh each mask by its ink (`text/windows/`). Off Windows it draws as `Off`. - `Windfoil`, the default everywhere but macOS and a Mac's browser: Rune's own text, the same on every OS. Each glyph's exact pixel coverage from its outline, Windfoil's closed-form box filter over xy-monotone quadratics (DesLauriers 2026), with CoreText's dilation (times `TEXT_WEIGHT`, 0.93, which puts its ink within 1% of CoreText's) and y autohint applied per pixel, pens on quarter pixels, coverage drawn as is (`text/outline/`, `text/windfoil.rs`, `outline.wgsl`). Masks at rest are filled on the GPU by the shader that draws text whose scale moves (a zoom, a presence scale, a text flight) straight from its outlines, at its resting pen and size magnified, so it stays sharp and lands on the mask it rests as. A text's raster path (`text/outline/path.rs`: still, moving, landing), each mask's residency (`text/atlas_fill.rs`: uploaded, pending, filled) and the renderer's shader (`render/variant.rs`: Classic, Windfoil, unavailable) are state machines. CJK faces up to 24 ppem draw their TrueType-hinted outlines from whole-pixel pens, at half the dilation, so dense ideographs stay legible at 1x. Renderers start with the Windfoil shader everywhere but macOS, where the Classic shader keeps drawing until text asks for outlines; where it can't be built, the CPU fills the same masks (`text/outline/cover.rs`) and moving text draws from them. `On` and `Off` work on every OS, since they are arithmetic on outlines. Blending is separate (`srgb_blend`). `cargo run -p rune --example coretext_compare [-- --smoothing on|off]` measures each mode against CoreText references (`tests/coretext/`, `scripts/coretext-reference.sh`). The atlas recycles a two-page steady ping-pong pool across frames without zeroing 4 MiB buffers, spills to overflow pages when a single frame exceeds one page, and never caches an allocation failure as an empty glyph. Colors share the same atlas masks. The [CoreText comparison and phase sweep](../../research/23-text-quality.md) measure weight, structural similarity and motion brightness variation at 1×/2×. - **Input**: hit-testing on presented rects, pointer capture, hover and press overlays, press on release inside the element, and drags with a per-gesture scratch value. `.on_secondary_press(|s: &mut App, point, tx| ..)` receives window-point coordinates on right-click (also control-click on macOS), without invoking primary actions; `.on_secondary_press_local` gives the element's own points too (see [coordinate spaces](#canvas-handlers-live-only-events-and-coordinate-spaces)). Scripts accept `secondary-down`, `secondary-up`, and `secondary-click`. - **Naming elements**: `Cx::layout`, `Tx::fly_text` and the `Tx` scroll requests (`scroll_into_view`, `scroll_to_bottom`, `scroll_page`, `scroll_to`, `scroll_to_with`, `scroll_to_instant`, `scroll_to_x`, `scroll_to_y`) take a key, which matches the first element with it in tree order, or a key path such as `"inspector/label"` whose leading keys are the element's nearest keyed ancestors. Keys are one namespace, and rune-kit keys its parts (`label`, `value`, `icon`, `control`, …), so scope an app's own keys with a path or a prefix. - **Inert subtrees**: `.inert()` draws and lays out an element and everything inside it, but presses, hovers, cursors, scrolls, drops and tooltips go to whatever is under it, nothing inside takes focus, and `ElementInfo::inert` reports it (a document drawn under an editor's canvas). - **Cursors**: `.cursor(Cursor::Pointer)` and the other system shapes; children inherit their nearest ancestor's. `Cursor::Image(CursorImage::png(include_bytes!("pencil.png"), [2.0, 30.0]))` shows an app's own PNG, drawn at 2x, with its hot spot in points from the top-left. The OS draws it, so it keeps up with the pointer and reaches the window's edge, where a view drawn after the pointer lags a frame and is clipped (`examples/sketch`). A PNG that doesn't decode shows the arrow. The web host sets no cursors yet. - **Double press**: `.on_double_press(|s: &mut S| ..)` (or `_tx`) runs when a second press on the element lands within the double-click interval of the first and within 4 pt of it, and releases inside. `on_press` still runs for every press, the first one right away, so a row selects on the first click and opens on the second, as in AppKit; there's no delay to wait out a possible second click. A third press starts a new pair, and a press anywhere else breaks one. Only clicks pair: a press that travels more than 4 pt, or is held past the interval, is neither half of one. The interval is the system setting on macOS and Windows and 0.5 s elsewhere. Headless runs use the virtual clock, so a script's `double-click` (or two `click`s inside 0.5 s) is deterministic. Pointer only: the keyboard runs `on_press` (Space, and Return on a row). - **Click count in a drag**: `Drag::clicks` says which click of a run an `on_drag` press is, on every phase: 1, then 2, 3 and on for presses on the element each within the double-click interval and 4 pt of the one before, as AppKit's `clickCount`. A press that drags past 4 pt ends the run. An app-drawn text view selects by words on 2 and lines on 3 and extends by them as the drag moves; `on_double_press` keeps pairing as before. A script's `double-click`, `triple-click` and `down`/`move`/`up` drive it on the virtual clock. - **Press scale**: `.press_scale(0.97)` shrinks an element (and its children) about its centre while a press on it is held, following the pointer in and out like an iOS tile, and springs back with a slight bounce on release. Interrupting a spring retargets it without a jump. Layout and hit testing keep the resting size, so a press near the edge stays inside and keeps its hover. Scrolling the list under a held press (wheel or fling) cancels it, as a `UIScrollView` cancels its content's touches: the element springs back and the release doesn't press. Elements without it pay nothing, and Reduce Motion turns it off. rune-kit's `Button` and `IconButton` don't use it: they follow `@opencode/ui` and AppKit, whose buttons don't scale. - **Scale, rotation and origin**: `.scale(0.5)` draws an element and everything in it at half size about its `.origin(x, y)` (default centre `(0.5, 0.5)`), like SwiftUI's `scaleEffect`. Changes spring on the element's layout spring or `.scale_motion(anim)` (landing at once on mount, in an instant transaction and under Reduce Motion). `.rotate(radians)` turns the element and its subtree clockwise about the same origin on the layout spring or `.rotate_motion(anim)`, drawing into a layer whose composite turns; hit testing follows the turned box while reported rects use its upright bounds. Layout keeps the unscaled upright size, so siblings don't move. `scale` multiplies with `press_scale`, `live_scale` and scale transitions (`origin` applies to all of them; `offset` ignores it), and nested scales and turns compose. Under all of them, text, icons and images transform with their box (link hit boxes and a text field's caret follow). Text under a still scale rasterizes at the scaled size, so it stays crisp. While a scale springs (or a presence transition runs), text samples glyphs rasterized at its nearest still ancestor's scale (1, or an app's zoom), filtered, and turns crisp when the spring settles. That way a spring doesn't rasterize a glyph at every frame's size, and text that only fades or slides inside a zoomed window draws exactly as it will at rest. Text under an actively moving `live_scale` rasterizes at logarithmic $2^{1/4}$ scale buckets and re-rasterizes crisp at the exact resting scale once it holds still for 0.1 s; a changing `scale` samples rest-size glyphs while moving and re-rasterizes crisp at the resting scale after 0.1 s. A matched flight's own label keeps its rest size so it doesn't pop when it lands. - **Cached subtrees** (`.cache()`, `.cache_key(u64)`): rasterizes a settled subtree once into an offscreen `Rgba16Float` layer texture (reusing the isolated-layer compositor in `render/layers.rs`) and composites that single quad on subsequent frames without re-emitting or re-drawing its descendants, including while an ancestor's `live_offset` or `live_scale` moves or scales it. - **Invalidation:** derived automatically from the subtree's relative draw output (primitives, text, layout, hover/focus/selection, theme and scale) with an optional `.cache_key(u64)` hint. - **Live fallback:** while any node inside the subtree animates (springs, transitions, loops, growth, word reveals, flights) or hosts a live compositor `El::effect` slot, `NativeSurface`, native `SystemMaterial`, image pixels or focused text input, the subtree bypasses the cache and draws live, then re-caches once settled. - **Scale and budget:** while an ancestor's live scale or scale spring is changing without view commits, the existing cached texture is composited with clamped bilinear filtering and re-rasterized at the new device-pixel resolution once the scale holds still for 0.1 s. Scale changes through view commits draw live until settled: a commit may also change content. Textures are capped at `4096×4096` device pixels and managed under a configurable LRU byte budget (`Renderer::set_cache_budget`, `layers::DEFAULT_CACHE_BUDGET` = 64 MiB). Hit testing, accessibility, `elements()` and `on_layout` are unchanged. - **Focus and modals**: `.focusable(true)`, `.tab_index(n)` (positive overrides, 0 document order, negative pointer-only), `.focus_group()` for arrow-key roving (Up/Down only, plus type-select, for roles `menu`, `listbox`, `list` and `tree`; Left/Right too for other groups such as segments and tabs; `list`, `tree` and `grid` stop at their ends while menus and segments wrap), `.focus_follows()` to press each item it reaches, `.focus_selected(on)` for a group's initial Tab stop, `.focus_ring(width, offset, color)` for keyboard-only rings, and `.focus_highlight(color)` for a menu row's shared pointer and keyboard highlight. Enter/Space activates non-text controls. `.modal(click_outside, close)` traps Tab, handles Escape, makes the background inert and restores the opener. Nested modals restore in stack order. Inspector tree flags (`focusable`, `focused`, `focusVisible`, `inert`) are the future platform AX hook. - **Key routing**: a key the OS delivers to the window goes to the first of these that takes it. Global shortcuts and native menu key equivalents are handled before the window sees the key. 1. Return and Escape while a field composes (IME preedit), or while `App::on_ime`'s editor does, go to the composition and no further. 2. Return, with no modifiers, presses the window's (or open modal's) `default_button()` unless the focused element takes Return (see the table below). 3. Nothing focused: the window's `on_key`, with `tx.focused()` `None`. 4. The focused field's `on_input_key`. 5. Focus navigation: Escape dismisses the open modal, Tab and Shift-Tab move focus, a focused slider's arrows and Page Up and Down step it (`on_step`) and Home and End take it to its ends (`on_step_limit`), arrows, Home, End and type-select move inside a `focus_group`, Space or Up and Down open a focused popup button (role `combobox`), Space presses the focused control, and Return presses a focused row or link. Past Tab, keys with Shift do none of this (typed capitals still type-select); they go on. 6. The focused text field's editor. 7. The window's `on_key` again, now with `tx.focused()` naming the focused element (or link), for keys the focused control passed on. 8. Selected text, for its copy keys (plain Cmd+A and Cmd+C): an app's `on_key` that takes them wins, as a menu key equivalent wins over an AppKit text view. A focused text field keeps its own at step 6. 9. Page Up, Page Down, Home and End scroll the nearest scroll view around the focused element, else around the pointer, else the first in the tree, skipping any whose content fits: a chat's short composer passes them to the message list beside it. A view that overflows keeps them, even at the edge a key moves toward (where it does nothing), as an NSTextView does. To page another view, route the keys yourself with `tx.scroll_page(key, pages)`. Return follows AppKit, where it belongs to the default button and Space presses the focused one: | Focused | Return | | --- | --- | | Nothing, a button, checkbox, switch, radio, segment, popup button or slider | The default button, else `on_key` | | A text field with `on_submit` | `on_submit` (in a multi-line field Shift- or Option-Return types a newline) | | A text field without `on_submit` | The default button | | A row of a `menu`, `listbox`, `list`, `tree` or `grid` group, or a `focus_highlight` row | Presses the row | | A link in text | Opens the link | A field's `on_input_key` sees Return before any of these. A handler's `KeyEvent` carries the character the key types, and with Shift held that moves between keyboard layouts: `⌘⇧7` arrives as `Key::Command('&')` on a US layout and `Key::Command('/')` on a German one. `tx.physical_key()` names the key itself (`Some(PhysicalKeyCode::Digit7)` on both), so match shifted shortcuts on it. It is `None` for a key with no physical origin: `Runtime::key`, or a script `"key"` without a `"code"`. `tx.key_base()` is the character the key types under the current layout with no modifiers (`Some('a')` for ⌥A, which types `å`), for bindings by letter that follow the layout, as macOS's key bindings do; `None` where winit can't say (the web, iOS) and with no physical origin. `Mods::cmd` is the primary shortcut modifier, reported with its key: ⌘ on macOS (with `logo`), Ctrl elsewhere (with `ctrl`). So Ctrl+W on Windows is `{ cmd, ctrl }`, and a test such as `cmd && !ctrl` never passes there. Bind shortcuts with a `Chord` instead, which resolves both sides to the keys held on the platform: ```rust let reopen = Chord::parse("mod+shift+t")?; // ⇧⌘T on macOS, Ctrl+Shift+T elsewhere let next = Chord::parse("ctrl+tab")?; // ⌃⇥ on macOS (a literal Control), Ctrl+Tab elsewhere let home = Keybind::new("mod+b").on(KeyPlatform::Windows, "alt+home"); if reopen.matches_tx(ev, tx) { /* … */ } reopen.label(); // "⇧⌘T" or "Ctrl+Shift+T" ``` `mod` is the primary modifier; `ctrl`, `alt`, `shift` and `super` (`cmd`, `win`) are keys. Off macOS Ctrl is the primary, so "mod+b" and "ctrl+b" are the same chord there (`Chord::same_keys` finds such clashes); give one of them another chord on that platform with `Keybind::on`. A character chord names the key unshifted ("mod+shift+[") and `matches_tx` follows the layout through `tx.key_base()`; on a layout whose keys type no ASCII (Cyrillic, Greek) an ASCII chord falls back to `tx.physical_key()`. A chord on a shifted character ("mod+plus") matches with or without the Shift that types it. `matches_on` and `Shortcut::parse_on` take the platform explicitly, for tests and keymap pickers. `Display` writes the canonical string back for settings files; `Keybind` parses alternatives ("mod+t, mod+n") and `Keybind::spec_on(platform)` writes one platform's chords. A text field's edit commands (copy, cut, paste, select all, undo, redo, and Ctrl+Y redo off macOS) take only the plain chord: Ctrl+Alt+V, Ctrl+Shift+V or ⌃⌘C reach `on_key`. On Windows, Left Ctrl + Left Alt is AltGr, so a character typed with both inserts, and Home and End move to the line's edge in a multi-line field (Ctrl+Home and Ctrl+End to the text's ends). - **Periodic motion**: `.looping(rune_motion::LoopSpec::opacity(..))` or `LoopSpec::rotation(period)` (icon quads). Tracks use animation time, preserve phase when retimed and only request frames while visible through ancestor clips. Removal uses the existing spring presence fade. Reduced motion holds the first pose; time scale 0 pauses. Occluded windows stop drawing and building. Commit layout runs through a `Measurement` capability that can access text/layout caches and virtual geometry, but cannot access the scene, animation tracks, draw instances, or mutable app state. It finishes all measurement retries before returning an owned `Measured` result. Scene mutation consumes that result; motion policy changes are applied in the same mutation phase. ### Rich text, scrolling and app hooks ![session](../../demo/opencode/session-bottom.png) - **Rich text** (`rich(text, spans)`): per-range weight, colour, family, background and underline (`Span`), wrapping with absolute line height and letter spacing, `.truncate()` with an ellipsis, and inline-code backgrounds that merge where they touch. `.align(..)` and `Node` insets keep text inside padding and borders. - **Fonts as data**: `AppSpec::fonts` embeds faces and `AppSpec::families = (ui, mono)` names them. `TextStyle` picks a `Family`. - **Icons** (`icon(&Icon, size)`): SVG icon bodies (as in `@opencode/ui`'s catalog) rasterised into the glyph atlas. - **Themes as data** (`theme.rs`): `Theme::from_json(src, dark)` reads named tokens (hex, rgba or references, plus numbers). `"blend": {"mode": "srgb", "over": token}` says translucent tokens are authored for a browser's sRGB blending: they resolve as authored in a window that blends in sRGB (the default) and re-solved over `over` in one that blends in linear light, following the mode of the window building the view (`theme::blends_srgb`, `theme::for_blend`, `Theme::alpha`). An app's own theme lives in the app; core ships only the format. - **Scrolling** (`.scroll(ScrollSpec { stick_bottom })`): a runtime-owned fractional offset, wheel 1:1, fling decay, and follow-the-end. A new viewport opens at its end on its first frame. - **Virtual rows** (`.virtualize(estimated_row_height)` on a growth stack directly inside a scroll viewport): only an overscanned window has Taffy, shaped text and scene nodes. Rows need unique keys and internal vertical padding rather than margins or stack gaps. Unvisited heights are estimates; measured corrections preserve the visible key and the bottom stays exact. Historical rows remount settled. The measurement pass expands the window before drawing if estimates leave a gap. Render coordinates are rebased near the window and scroll extents use double precision. View construction still visits every row; a lazy view API is a remaining optimization. `tx.scroll_into_view(key)` reveals a row, including one outside the realized window. `tx.scroll_page(key, pages)` pages the viewport keyed `key` (or containing it) by `pages` × 90% of its height, as Page Up and Page Down do; a `stick_bottom` viewport that lands on its end follows it again. `.scroll_edge_fade(w)` fades a viewport's top and bottom only while content is hidden under them (strength = hidden extent / 15 pt), and `.scroll_margin(pts)` stops revealed rows clear of it. - **Sticky headers and bars** (`.sticky_top(inset)`, `.sticky_bottom(inset)`, `.stuck_shadow(shadow)`, `tx.keep_on_screen(key)`, `runtime/scroll/sticky.rs`): an element inside a vertical scroll viewport pins to the nearest scroll viewport's top or bottom edge (offset inward by `inset`) while its parent stays in view, and is pushed out by its parent's content box (inside the parent's padding) as the parent scrolls away, as CSS `position: sticky` does. Sticky children draw and hit-test above later normal siblings of their parent and below `.above()` / `.modal()` overlays, move in the same frame as the scroll offset without re-running Taffy, and report their vertical stuck displacement on `ElementInfo::stuck` (`stuck` in `--tree` JSON when non-zero) and in `.on_drawn_layout(|s, r|)`'s drawn rect. `.stuck_shadow(Shadow)` appends shadows (such as a 1 pt hairline divider and a soft drop shadow) whose alpha scales linearly over the first 8 pt of `stuck.abs()`. When clicking a stuck header collapses or expands its section, `tx.keep_on_screen(key)` adjusts the enclosing scroll viewport's offset each frame so the header stays under the pointer while layout and growth springs run, remounting virtual rows if needed. Explicit scroll requests and subsequent wheel input win. In `virtual_rows`, put the header and its section in the same row: a header in a separate row disappears when that row is unmounted. Horizontal-only ancestors are skipped; without a vertical scroll ancestor sticky positioning is a no-op. Cached subtrees containing sticky content draw live rather than reusing a raster at a stale offset. `tx.scroll_into_view(key)` clears headers stuck at the edge it reveals to, and `El::anchored(key, ..)` follows a sticky target's drawn rect. See `cargo run -p rune --example sticky` ([still](../../demo/sticky-headers.png)). - **Horizontal scrolling** (`.scroll_x()`, `.scroll_xy()`): a filmstrip, a wide code block or table, a tab strip that overflows. The content (the first child) takes its natural width, unwrapped, and at least the viewport's, unless it sets a width other than `w_full`; `.scroll_xy()` also scrolls vertically and needs a bounded height like `.scroll`. Each axis has its own offset, momentum and clamping, and on the web its own rubber band. A wheel or trackpad gesture moves one axis, picked by its first delta and held to its end (momentum included), as AppKit's predominant-axis scrolling does: mostly horizontal goes to the innermost horizontal viewport under the pointer, anything else to the innermost vertical one, so a vertical swipe over a filmstrip scrolls the page around it and a diagonal never moves both. On the web a touch pan does the same from its first 8 pt. A gesture keeps its axis wherever the pointer ends up, as AppKit latches a gesture to the view it began on. Without phases, a mouse wheel tick wholly on the other axis picks afresh, so Shift + wheel and then the plain wheel each scroll at once. Shift with a mouse wheel scrolls horizontally on Windows, Linux and the web; macOS turns the delta itself. `tx.scroll_into_view(key)` scrolls both axes, `.scroll_edge_fade(w)` fades the left and right edges, `tx.scroll_to(key, x, y)` glides a viewport to an offset (clamped, rechecked as virtual rows measure, taken over by the wheel), `tx.scroll_to_with(key, x, y, spring)` glides there on the app's spring, and `scroll_to_instant`, `scroll_to_x` and `scroll_to_y` jump there. A request sent mid-glide keeps its speed, so key repeat moving a selection a row at a time scrolls as one motion. A still pointer's hover holds while an app steps its own glide with `scroll_to_instant` each frame, and resolves once after. Left and Right arrows don't scroll a viewport (text fields own them), and Page Up and Page Down stay vertical; AppKit scrolls an `NSScrollView` with Left and Right only when nothing focused takes them. Virtual rows are vertical only. Platforms report wheels through `Runtime::wheel_xy` with `wheel_began` and `wheel_ended` around trackpad gestures, or `Runtime::wheel_event` with modifiers and phases; `Runtime::wheel` stays the vertical path. - **Canvas input** (`input/canvas_input.rs`): what an infinite canvas, map or image viewer needs. `.on_wheel(|s, e: WheelEvent, tx| -> bool)` hears wheel and trackpad deltas over the element before any scroll view does, innermost first up to the nearest scroll view, with `dx`, `dy`, the pointer in the element's own layout points (under any `live_scale`), `mods`, `phase`, `momentum` and `precise`. Returning true takes the delta, and the element then hears the rest of that gesture, its momentum included, wherever the pointer goes; false lets it scroll, and a gesture that scrolled stays a scroll. `.on_magnify(|s, e: MagnifyEvent, tx| -> bool)` hears trackpad pinches (scale by `1 + delta`) at the pointer: macOS reports them, and on Windows, Linux and the web a pinch arrives as Ctrl + wheel, which goes to `on_magnify` (`-dy / 200` per point) when one is under the pointer. `.on_rotate` and `.on_smart_magnify` (a two-finger double tap) are macOS only. `.pointer_buttons([PointerButton::Middle])` lets the middle (or back, forward) button press and drag the element through its own `on_press` and `on_drag`, from anywhere inside it; `tx.button()` says which button. Without it those handlers stay primary-only. `.on_key_up(|s, e: &KeyEvent, tx| -> bool)` hears releases with the same logical keys `on_key` sees: the focused element's first, then its ancestors', and with nothing focused the root's, so a handler on a window's root hears every release. A focused `on_physical_input` that takes a release keeps it from `on_key_up`. Hosts map releases to logical keys only while some element has `on_key_up`. Under a camera (`live_offset` and `live_scale` on the world), hit tests follow the drawn rects, `Drag` points and the pointer in these events are in the element's own (unzoomed) points, and `.on_drawn_layout(|s, r|)` reports where an element shows in the window, on each frame a pan or zoom moves it. A handler that moves only the camera says `tx.live_only()`, and its event doesn't re-run the view (see [below](#canvas-handlers-live-only-events-and-coordinate-spaces)). `cargo run -p rune --example canvas_input` drives a canvas through all of it headlessly: wheel pan, pinch about the pointer, Space + drag, middle drag and a Focus button. - **Timeline growth** (`.growth(GrowthSpec { spring, edge })`, `growth.rs`): rows of a stack ease their heights render-side on `GROWTH` (0.32 s, bounce 0) in fixed 1/240 s substeps, so 30, 60 and 120 fps give the same curve. Desktop's per-frame caps are per-second here (start, ramp, max, long, carrying-tail, near-target), and every row eases into its target. Positions come from the presented heights above, on the 1/64 px grid. Leaving rows close after 0.15 s and fade in place. `.streaming(true)` marks the growing tail. Its last wrapped line holds back up to .52 line heights (one line) or .82 (multiple lines), proportional to unfilled width. Measurement reuses Parley's shaped layout. A width rewrap can shrink the row; completion lands at its full measured height. - **Stream edges** (`edge::{FEATHER, FAST_FEATHER, NO_MASK, WORD_FADE}`): the feather mask under a growing row and the word-fade duration. `.reveal(word_fade)` fades appended words in, staggered from 20 ms down to 5.5 ms as a backlog builds. - **Settling** (`Tx::settle()`): no animation, every track lands, and removed nodes unmount at once. Opening a session lands at the bottom on its first frame with nothing animating in. - **Turn actions**: a transcript view can reserve a stable tail and crossfades Working into the Desktop copy icon over .34 s, without removing and re-adding the tail's height. `Tx::copy(text)` requests a shell clipboard write. Requests follow handler order; headless runs never write the system clipboard. - **Text input** (`text_input(&edit, style)`, `edit.rs`): caret, selection, word and line motion, select all, copy, cut and paste, undo, and IME preedit through winit (see [Text fields](#text-fields)). `.on_edit(..)` binds the `TextEdit` in app state and `.on_submit(..)` runs on Enter. - **App hooks**: `AppSpec::setup` (once, with a `Waker` for background threads), `poll(state, clock, tx)` before every frame, `on_key`, and `extra_args()` for the app's own flags, ready before `run` (to choose the file a window opens). `tx.wake_after(Duration::from_millis(250))` schedules a poll on the unscaled app clock, even when poll returns false. Equal deadlines coalesce; the shell sleeps until the earliest deadline without a timer thread. Every deadline due by a frame collapses into that frame's one poll, so a poll that asks for a wake each time it runs costs one poll a frame, never more. Coarse headless steps stop at deadlines at least a frame interval apart. For debounce, store the latest due time in state and reschedule its remainder when an older wake arrives. - **Modifier keys alone**: `App::modifier_keys()` (also on `WindowSpec` and `ConfiguredOverlay`) sends Shift, Control, Option/Alt, Command/Super and Fn going down and up on their own to `on_key` and the focused field's key handler, as `Key::Modifier { modifier, pressed }`, with the side (`Modifier::AltRight.side() == Some(KeySide::Right)`). `mods` hold the state after the change, as AppKit's flagsChanged does. Off by default; they never type, select, move focus or fire shortcuts, and a chord still arrives as before. Fn reaches macOS windows and overlays only: Windows and Linux keyboards handle Fn in firmware and never report it. - **Tooling**: scripts gain `type`, `key`, `paste`, `scroll` (`dy`, and `dx` for a horizontal swipe) and `fling` (`v`, and `vx`). Traces carry motion-trace intents (settle cuts, growing rows, word reveals) and clipped visible rects. ### Canvas handlers: live-only events and coordinate spaces Every event a handler hears re-runs the view, diffs and lays it out. A canvas camera lives in [`Live`](#high-frequency-values) values (`live_offset` and `live_scale` on the world), which the frame draws without any of that, so a pan or pinch handler that only sets them can say so with `tx.live_only()`: ```rust,ignore .on_magnify(|s: &mut Canvas, e, tx| { s.zoom_about(e.x, e.y, 1.0 + e.delta); // sets s.cam_pan and s.cam_zoom, Live values tx.live_only(); true }) ``` That event then skips the view, the diff and layout. The frame still draws the new live values, hit tests against where they put elements, and reports `on_drawn_layout`; whatever else the handler asked of its `Tx` (a scroll, a copy, `wake_after`) still happens. It works in `on_wheel`, `on_magnify`, `on_rotate`, `on_smart_magnify`, `on_drag`, `on_pointer_move_local`, `on_key`, `on_key_up`, a field's key handler, `on_physical_input`, the press and hover handlers, `on_layout_tx` and `on_drawn_layout_tx`, so a toolbar that floats over the selection by its own live offset follows a pan with no view re-run either. A handler that declines its event (returns false) takes its `live_only` back, and an event several handlers take is live-only only if each says so. It's the handler form of `Runtime::update_if` returning false, and the same rule holds: change state the view reads and call `live_only`, and the screen stays stale until something else commits. Debug builds look for that. A live-only event a quarter second or more after the last commit re-runs the view anyway, and if the elements, layout, text or colours it builds differ from what's on screen, Rune prints the handler. `rt.check_live_only(false)` turns the check off for a test that counts commits; `rt.live_only_events()` counts the events that skipped the view. The debug check compares committed targets, not animated pixels or caret paint, and ignores retained exits. It is a heuristic: a view that reads a clock or external state can differ without a handler changing it; disable the check for such a view. Release builds compile it out. Other model changes in the same frame still commit normally. Editing, paste and file-drop actions always commit Rune's own changes, even if a callback calls `live_only`. Virtual rows select their mounted window from the scroll offset and viewport height, not from an independently transformed content stack. Transform the scroll viewport together with its rows; use scrolling, rather than `live_offset` on the virtual stack alone, to move which rows are visible. On 2,000 cards (about 6,000 elements) at 2×, a 120-frame pinch with a toolbar following the selection, on Apple silicon (`--profile perf`, `cargo run --profile perf -p rune --example pinch_bench --features frame-profile`; thread CPU per frame, median of 7 runs): | | view runs per frame | CPU per frame | allocations per frame | | --- | --- | --- | --- | | Handlers re-run the view | 1.0 | 13.7 ms | 42,124 | | `tx.live_only()` | 0 | 2.0 ms | 78 | Where each handler's points are measured from. *Local* is the element's own layout points: from its top-left as drawn (after scrolling, `live_offset`, `live_scale` and `El::scale`), divided by any scale it draws at, so they hold on a zoomed canvas. *Window* is points from the window's top-left, which is what popups and context menus anchor to. | Handler | Point it gives | Space | | --- | --- | --- | | `on_drag` | `Drag::x`, `y`; `start_x`, `start_y`, `w`, `h` captured at press | local | | `on_drag` | `Drag::dx`, `dy` (travel since the press) | window | | `on_wheel` | `WheelEvent::x`, `y` (`dx`, `dy` are the scroll in points) | local | | `on_magnify`, `on_rotate` | `MagnifyEvent::x`, `y`, `RotateEvent::x`, `y` | local | | `on_smart_magnify` | `x`, `y` | local | | `on_pointer_move_local`, `on_secondary_press_local` | `PointerEvent::x`, `y` | local | | | `PointerEvent::window_x`, `window_y`, `window()` | window | | `on_pointer_move`, `on_secondary_press` | `PointerPress::x`, `y` | window | | `on_layout`, `on_layout_tx` | `[x, y, w, h]`, as laid out, after scrolling (before live values) | window | | `on_drawn_layout`, `on_drawn_layout_tx` | `[x, y, w, h]`, as drawn | window | | `on_screen_layout` | `[x, y, w, h]` | primary display | | `Cx::layout`, `layout_with`, `layout_size`, `memo::layout` (view reads) | `[x, y, w, h]`, as laid out, every scroll viewport at its origin, so a scroll doesn't re-run the view | window | | `Cx::window_layout`, `window_layout_with`, `memo::window_layout` | `[x, y, w, h]`, as laid out, after scrolling | window | | `context_menu`, `PopupAnchor::from(PointerPress)` | the anchor they take | window | `on_press`, `on_double_press`, `on_hover`, `on_hover_change`, `on_drop_files` and `on_drop_hover` give no point; a tooltip (`El::help`) anchors itself to the element as drawn. `on_pointer_move` and `on_secondary_press` stay in window points, as apps written against them expect. ### App lifetime (`app.rs`, for apps that outlive their window) `rune::run(spec)` opens one window and quits when it closes. `rune::App` gives the app its own lifetime (`examples/background` exercises all of it): ```rust let app = rune::App::new(spec) .init(move || Ui::new(host)) // first state from a closure (scenes still apply) .args(Vec::new()) // Rune flags from here, not the process argv .windowless() // start with no window (implies keep_running) .keep_running() // closing the window keeps the process, state and loop .release_gpu_when_closed() // optional: drop the GPU device while closed .accessory() // macOS: menu-bar app, no Dock icon (h.set_dock_icon(true) shows it) .standard_menus() // macOS: Edit, Minimize ⌘M and Close ⌘W (no Quit); regular apps get them anyway .on_close(|s, tx| ..) // runs before every close; tx.quit() quits instead .on_reopen(|s, has_visible, tx| false); // macOS: Dock icon clicked; true skips the default // .window_options(WindowOptions { max_size, resizable, .. }) for this window let handle = app.handle(); // AppHandle: Clone + Send + Sync, usable before run std::thread::spawn(move || { handle.activate(token); // show + focus with a forwarded XDG activation token handle.open(); // show + focus without one handle.update(|s, tx| ..); // change state on the UI thread; commits on the next frame handle.close(); // as the close button (on_close runs) handle.minimize(); // as the minimize button handle.quit(); // run returns; on web, unmounts }); app.run(); ``` - Every handle call wakes the loop; nothing polls. Updates run in call order on the main thread inside the event loop, even while no window shows, so a closed app keeps its state current and an update may do main-thread native work (a status item, a tray). Window requests collapse to the latest. - A window that's hidden, minimized or fully covered (another window over all of it, or another Space; macOS and X11 report covering, Wayland neither covering nor minimizing) runs updates, `poll`, `wake_after` timers, `on_close` and queued input as usual, but not its view or layout, and draws nothing. The frame that shows it again builds once with the latest state, with motion already landed where it would have ended. A window any part of which shows draws as usual. Headless runs always see a current tree. - Handlers ask the shell with `Tx::close_window()`, `Tx::minimize()` and `Tx::quit()` (quit wins, and either wins over minimize). Minimize does nothing for a hidden window, a panel, a launcher, an overlay, a borderless window or a window that can't minimize (debug builds say so). - `Tx::set_title("Draft — Notes")` sets the window's title from a handler or `update`. The window opens with `AppSpec`'s title, keeps the last one set when it closes and opens again, and headless `--tree` output shows it as `"title"`. - The `Runtime` (state, scene, text, scroll offsets) belongs to the app, so a reopened window shows the same UI, settled on its first frame. The GPU device and renderer stay too, unless `release_gpu_when_closed` (then closing destroys the window and device everywhere). - Closing hides the window on macOS, Windows and X11, and destroys it on Wayland (no hidden toplevel exists there); `open` recreates it with the same app id (`set_app_id`). - Activation: the first window takes the launcher's `XDG_ACTIVATION_TOKEN`/`DESKTOP_STARTUP_ID` and clears them from the environment. A new window gets a forwarded token through winit's `with_activation_token`. An open Wayland window is activated with `xdg_activation_v1` bound on winit's own display (`platform/activation.rs`), since winit 0.30 can't. Without a token Wayland can only request attention (most compositors mark it urgent); X11, macOS and Windows use `focus_window`. - Headless tooling runs ignore window requests and apply updates before each frame. - `AppHandle::is_visible()` reports whether the window is on screen; `AppHandle::window_error()` says why it couldn't be created (no display, no GPU adapter or device). A failed creation is logged and leaves the app windowless; the next open tries again (`Gpu::try_new`). - `run` panics when the event loop can't start: no Wayland or X11 display, or a Mac no one is logged in to (over ssh or in CI). `App::try_run`, `try_run_windows_with` and `try_run_windows_with_args` return that error instead. - `App::on_window(|w: &WindowHandle| ..)` runs on the UI thread after every native creation, for icons or native styles through `WindowHandle::with_raw_handle`. - `AppSpec::min_size` sets the smallest window (None keeps 420×400). - `AppHandle::set_dock_icon(bool)` switches macOS between the regular and accessory activation policies at runtime. - `App::standard_menus()` (macOS) adds the items every Mac app has: Edit with Undo ⌘Z, Redo ⇧⌘Z, Cut, Copy, Paste and Select All, and Window with Minimize ⌘M and Close ⌘W, all sent to the first responder. A regular app whose menu bar is still winit's default gets them without asking. An accessory app's menu bar is hidden, but its key equivalents still work, so AppKit windows in the process (an open panel, an updater) get copy, paste, select-all and close back. In a Rune window the keys still reach fields and `on_key` first; an untaken ⌘W or ⌘M closes or minimizes as the title-bar button would. It never adds Quit. Details in `src/shortcuts/README.md` under native menus. - Reopening (macOS): a click on the Dock icon, or opening the app again from Finder, Spotlight or `open` while it runs, brings back a window when none shows, as in other Mac apps. Rune shows and activates a minimized window, else the window it hid last (closed with `keep_running`, or `hide`), else the main window, and unhides an app hidden with ⌘H. Windows that already show stay as they are; the activation that comes with the reopen brings them forward. It never brings back a launcher or other nonactivating panel, an overlay, a window that closed for good, or anything in an accessory app (`accessory()`, or `set_dock_icon(false)` at the time). `App::on_reopen(|s, has_visible_windows, tx| -> bool)` runs first, on the main thread with the app's state; returning true skips the default. Every window's hook runs, and `has_visible_windows` counts only windows reopening could bring back. The default looks again after the hooks, so a window a hook opened counts as showing. Rune adds `applicationShouldHandleReopen:hasVisibleWindows:` to winit's app delegate, which lacks it. An app that installs its own reopen Apple event handler replaces AppKit's, which is what asks the delegate, so Rune's reopening never runs; use `on_reopen` instead. Other platforms have no reopen, so the hook never runs there. Headless scripts send it with `{"reopen": true}`. - Links opened with the app (macOS): `App::on_open_url(|s, url: &str, tx| ..)` runs on the main thread for each `myapp://...` link the system hands the app (`open`, a browser, another app), including the one that launched it. winit 0.30 doesn't forward them, so Rune installs a get-URL Apple event handler before the event loop starts and keeps links until a window whose app has the hook starts; that link then runs before the window's first frame. If no window has the hook, the handler is removed once launch is over, and an app that installed its own handler before `run` keeps it. Each link runs as an `AppHandle::update`, in arrival order, so its changes commit and redraw, and later ones wake the loop. With several windows whose apps have the hook (`run_windows`, `App::window`), the first still running receives links. The string is passed through untouched: it comes from anyone who can run `open`, so parse and validate it before acting. Rune doesn't bring a window forward; call `AppHandle::open()` if the app should. Rune doesn't bundle apps, so the app's `Info.plist` declares the scheme: ```xml CFBundleURLTypes CFBundleURLName com.example.myapp CFBundleURLSchemes myapp ``` Launch Services learns the scheme when the bundle is opened or registered (`lsregister -f MyApp.app`); then `open 'myapp://x'` launches or reaches the app. A bare `cargo run` binary has no bundle and gets no links. iOS isn't wired yet (winit owns the scene delegate), and Linux passes a link to a new process as a launch argument, so the hook never runs there. - Links and relaunches on Windows: Windows starts a new process for each launch, passing a link as an argument. The hook receives the links among the first process's arguments (`scheme://…`) like the launching link on macOS. Call `rune::single_instance("com.example.myapp")` first thing in `main` to keep one process: a later launch hands its arguments to the running process over a per-user named pipe and lets it take the foreground, and `single_instance` returns false so `main` returns without opening a window. Its links reach the hook; a launch without links reopens the app (`App::on_reopen`, then the default, which also brings the front window forward, restored). Register the scheme for the user, usually at install: `HKEY_CURRENT_USER\Software\Classes\myapp` with an empty `URL Protocol` value and `shell\open\command` set to `"C:\path\to\myapp.exe" "%1"`. `scripts/mac-app --scheme myapp` writes that entry and registers the bundle for you (see "Relaunching a Mac app" below). - Edge overlays (HUDs): see `App::overlay` below. ### Launching fast A small app puts its first window, with content, on screen about 300 ms after `open` on Apple silicon. The full page, with the measurement method, is "Launch fast" on the docs site (`site/src/pages.tsx`). Staying fast afterwards is "Fast apps" (`site/src/FastApps.tsx`) and "Smooth scrolling" below; `scripts/app-audit ` greps an app for the patterns they list. - **`rune::prewarm_gpu()` first in `main`.** On macOS it makes the adapter, device and uber pipeline on a thread while AppKit starts; the first window or overlay opened at launch takes them, waiting if the thread is still working. A second call does nothing. With no window at launch the result is dropped; a window that needs features the prewarmed device lacks gets a new device. Elsewhere it does nothing. The run functions call it too, but only once the event loop starts. It starts on the backend the app draws with by default: on macOS, Rune's own Metal backend (the `metal-backend` feature, on by default; an app built with `default-features = false` gets wgpu unless it adds the feature). An app that opts out with `App::gpu_backend(GpuBackend::Wgpu)` calls `rune::prewarm_gpu_on(GpuBackend::Wgpu)` instead, which chooses the backend and then starts the thread, so it doesn't start on the native backend and start over when `App::gpu_backend` runs; that call afterwards changes nothing. `RUNE_GPU_BACKEND=wgpu` or `=metal` overrides both without a rebuild. A compositor whose `Compositor::features` asks for more than the native backend offers (`TIMESTAMP_QUERY` and `IMMEDIATES`) draws with wgpu instead, every frame: log `rune::describe_gpu(frame.device)` once to see which backend a window got and why, or set `RUNE_GPU_LOG=1`. - **Precompiled Metal uber shader.** `crates/rune/src/uber.metallib` is loaded through wgpu's passthrough shaders, skipping WGSL translation and Metal's compile (`Renderer::new` 232 ms to 85 ms on a cold shader cache). The WGSL path is the fallback when the adapter lacks passthrough support, when `RUNE_DISABLE_METALLIB=1`, when the fingerprint doesn't match or when Metal rejects the library. Rune's own Metal backend (`metal-backend`) loads `uber_native.metallib`, built from the MSL that backend emits (first `Renderer::new` 222 ms to 90 ms cold, 4.0 ms to 0.9 ms warm). After editing `clip.wgsl` or `shader.wgsl`, run `cargo test -p rune --lib --features metal-backend precompiled_uber_metallib_is_fresh` on a Mac. If it says the MSL is byte-identical, paste the printed lines into `uber/metallib_hash.rs`. If it says the MSL, flags or library hash changed, run `scripts/precompile-metal-shaders.sh` (needs Xcode's Metal toolchain) and commit both libraries and `uber/metallib_hash.rs`. Effect, lighting and blur shaders and iOS aren't precompiled; binary archives would need wgpu support. - **The first frame draws during window creation** on macOS for visible windows (not launcher windows or panels). The first view build and layout run inside creation, right after the window-created callbacks, so keep the initial state, `view` and `on_window` cheap. - **Defer the rest to `on_ready`.** It runs once per native creation after the first frame is ready (for a launcher, while hidden). A window that isn't on screen (covered, occluded, hidden or on another Space) doesn't wait to draw: it runs at the first frame skipped for that, or a second after the window opens. Capture `app.handle()` and call `handle.update(|s, tx| ..)` there to start status items, HUDs, secondary windows, network and indexing; updates run on the UI thread even with no window showing. There is no state-level first-frame hook yet. - **A launcher can wait for readiness.** With `RUNE_READY_DIR` set, the process writes `$RUNE_READY_DIR/.` (the main bundle's identifier on macOS, else the executable's name) right after its first `on_ready` returns, and removes it when it exits normally. It's one line of JSON, `{"pid":123,"bundle_id":"com.example.app","ready_ms":412,"rune":"0.1.0"}`, with `ready_ms` counted from the process's start; the directory is made 0700 if it's missing. A process killed outright leaves its file, so treat one whose PID isn't running as stale. Without the variable nothing is written. - **Keep the first second still.** Rune animates nothing then, so every frame is the app's. Land state that arrives right after launch (a system setting, a first network answer) with `tx.instant()`, or don't animate it. One settings window's launch fade drew about 85 frames in its first second instead of 4 to 8, and drawing back to back while the window came on screen made macOS keep an extra window-sized drawable: 14 MB more settled footprint. Rune now holds a new window to two drawables until it rests, or for its first second. - **Draw nothing when idle.** A due `wake_after` on a resting window runs `poll` first and draws only if it returns true, so return false when nothing changed. A custom compositor returns `animating() == true` only while it moves (the default is false); a still one lets its window rest too. One settings window went from 77 presents per 20 s idle to none, 2.45% CPU to 0.85%, and 77 MB to 70 MB. - **Relaunch in place while developing.** `scripts/mac-app` swaps a rebuilt bundle in and starts it before the old process quits, so with `rune_mac::remember_frame` the new window lands over the old one (see "Relaunching a Mac app" below). - **Reuse a hidden window.** `keep_running()` / `windowless()` plus `AppHandle::hide()`, `show()` and `open()` (or `WindowOptions::launcher_panel()` for a retained panel that reveals cached pixels) make a re-summon cost a show instead of a launch. - **Measuring**: launch with `open` on a quiet Mac, interleave base and changed builds for 5 to 10 rounds, and report medians for window-visible and first-content times, cold and warm. Cold means deleting the app's Metal shader caches under `getconf DARWIN_USER_CACHE_DIR`: `com.apple.metal/` and `/{com.apple.metal,com.apple.metalfe,com.apple.gpuarchiver}`. Read settled memory last. Count frames in the first second after content. Measure idle too: redraws, presents and CPU over 20 s with the window shown and untouched, plus settled RSS and footprint, and count `poll` returns separately from frames. | Median ms from `open` (loaded laptop, 5 interleaved rounds) | Before | After | | --- | --- | --- | | Cold shader cache, content on screen | 599 | 312 | | Cold shader cache, window on screen | 422 | 277 | | Warm shader cache, content on screen | 466 | 299 | | Cold shader cache, `Renderer::new` alone (10 rounds) | 232 | 85 | Absolute times were high because the machine was busy; the differences are what matter. ### Smooth scrolling A scroll costs Rune no commit: it moves the content, mounts `virtual_rows` and paints `El::hover` styles without running `view`. A frame that commits while a viewport scrolls is the app's, and on a long page each one can cost more than the 8 ms a 120 Hz frame has. Before and after code is in "Smooth scrolling" on the docs site's Fast apps page (`site/src/FastApps.tsx`); `scripts/app-audit ` greps for the patterns below. - **`poll` returns true only when visible state changed.** To act on where a scroll stops (loading the next page, saving the position), wait until it has been still a moment with `Tx::wake_after`; follow it frame by frame with `El::on_scroll_offset` and live values. - **Paint hover with `El::hover`, `hover_group` and `group_*` styles**, not state set from `on_hover_change`: a hover handler on each row commits the view for every row the pointer crosses, and a scroll moves rows under a still pointer. - **Layout handlers that follow a scroll say `Tx::live_only`.** `on_layout` and `on_drawn_layout` report every frame of a scroll for an element inside it; their `_tx` forms can say `live_only` when the handler only moves live values or bookkeeping. - **Read `Cx::layout_size` or `Cx::layout`, not `Cx::window_layout`,** unless the view needs where the window shows the element; then read `window_layout_with` and only what it decides. - **Glide with `Tx::scroll_to_with(key, x, y, spring)`**, on Rune's clock, rather than an instant scroll stepped by a `wake_after` loop, which commits every frame. - **Keep the reading position with `El::scroll_anchor()`.** On a scroll viewport it holds the row at the top of the view when a commit changes the content above it (rows inserted, grown or reflowed by a new width), like CSS `overflow-anchor`. It holds only while the viewport is still, off its top and not following its bottom. `virtual_rows` lists already keep their first visible row. For one commit, `Tx::keep_on_screen(key)` holds a chosen element instead. Debug builds name the source when one keeps committing through a scroll: `poll`, a handler with its element and where it was set, or the layout read that moved. Each warns once, on stderr, when it commits on 6 or more frames and a quarter of a scroll's frames; `RUNE_PERF_WARN=0` silences them. They also warn when an app steps a viewport with an instant scroll on 8 committing frames in a row, and when one hover handler commits for 8 different elements: ```text rune: `on_layout_tx` on "#0/feed/rows/r3" (set at src/feed.rs:43:35) committed the view on 6 of 7 frames of a scroll: when the handler only moves live values or bookkeeping, say `Tx::live_only` [...] ``` `RUNE_COMMIT_BUDGET_MS=N` prints a line for each commit over `N` ms, in any build, with where its time went and what made the frame commit, at most once a second: ```text rune: commit took 14.2 ms, over RUNE_COMMIT_BUDGET_MS=8: view 9.0, layout 3.0 (text 1.0), apply 1.5 ms; most in the view (63%); committed for poll ``` ### Requests off the main thread (`rune::Latest`) A search box, a mention list or a path completer asks once per keystroke and wants only the newest answer. Doing it in the handler blocks the window for the whole round trip; debug builds warn when a handler or `poll` spends 8 ms waiting. `Latest` runs the request on a worker and lands the newest answer: ```rust // init: build it once, with its debounce. results: Latest::default().debounce(Duration::from_millis(60)), // setup: the waker runs `poll` when an answer lands. Pending state and debounce stay. s.results.set_waker(waker); // A handler, on every keystroke: s.results.request(query.clone(), |q| index.search(&q)); // poll: true only when an answer landed. let Some(hits) = s.results.take() else { return false }; s.hits = hits; true ``` - **Latest wins.** Each request supersedes the earlier ones. One that hasn't started never runs; one already running finishes, and its answer is dropped, even if it finishes after the newer one. - **One wake per answer.** The waker fires once when an answer lands, and `take` returns it once, so `poll` returns true once and an idle window stays idle. `is_pending` drives a spinner. - **Skip without waking.** `request_opt(input, |q| -> Option)` lands nothing when the work returns `None` and doesn't wake the window. It still supersedes older requests, so their answers stay dropped. - **Call it from `poll`.** `request_if_changed(&key, |k| ..)` does nothing when `key == ` the last such request's key (`K: Clone + PartialEq`), so work isn't restarted nor its debounce pushed back. `request`, `request_opt` and `cancel` forget the key. - **Debounce** (optional) waits for that long without a newer request before starting one. - **No thread per request.** Up to four shared workers, started on demand, exit after ten idle seconds. On the web the work runs on the main thread from `spawn_local`, after the debounce. - **Cancel** drops pending and running requests; dropping the `Latest` cancels it. State outlives a closed window, so call `cancel` from `App::on_close` to stop requests on close. Only the closure runs on the worker; the answer reaches state in `poll`, on the UI thread. ### State machines (`rune::machine`) Anything with modes (a hover delay, a drag, a request and its retries, a dialog flow) is one `enum State` whose variants carry only what is valid in them, and one transition that returns what should happen instead of doing it: ```rust use rune::machine::{Attempt, Effects, Seq}; enum Tip { Idle, Waiting { attempt: Attempt, due: f64 }, Open } enum Event { Enter { now: f64 }, Leave, Due { attempt: Attempt, now: f64 } } enum Effect { WakeAt(f64) } fn step(seq: &mut Seq, tip: &mut Tip, event: Event) -> Effects { match (&*tip, event) { (Tip::Idle, Event::Enter { now }) => { let (attempt, due) = (seq.next(), now + 0.4); *tip = Tip::Waiting { attempt, due }; Effects::one(Effect::WakeAt(due)) } (Tip::Waiting { attempt, due }, Event::Due { attempt: a, now }) if *attempt == a && now >= *due => { *tip = Tip::Open; Effects::none() } (_, Event::Leave) => { *tip = Tip::Idle; Effects::none() } _ => Effects::none(), } } // A handler steps with `tx.clock()` and carries the effects out: for e in step(&mut s.seq, &mut s.tip, Event::Enter { now: tx.clock() }) { match e { Effect::WakeAt(due) => tx.wake_at(due) } } ``` - **Illegal states don't exist.** `Waiting` without a deadline, or `Open` with one, can't be built, so no code has to clear a flag another forgot. - **Timers are events.** Deadlines are on `Tx::clock` (the clock `poll` gets, virtual in headless runs); `Tx::wake_at(due)` wakes `poll`, which feeds `Due` back into `step`. - **Attempts fence answers.** `Seq::next` issues an `Attempt` per request, timer or dialog; an answer carrying an attempt the state no longer holds falls through to `_` and changes nothing. - **Effects stay inline.** `Effects` holds up to two effects without allocating; `push` and `extend` add more, and it iterates in order. ### Relaunching a Mac app (`scripts/mac-app`, `rune-mac`) `cargo run` starts a bare binary: no bundle, so no icon, no links, no microphone prompt, and a window that opens at its default place every run. `scripts/mac-app` installs a built binary as `~/Applications/NAME.app` and relaunches it without the window blinking: the new bundle is built beside the running one, swapped in and started, and the old process quits once the new one is up. ```sh cargo build --release -p example scripts/mac-app --name Example --id com.example.app --exe example --bin target/release/example \ --icon assets/icon.icns --scheme example --mic "To take dictation" -- --some-app-flag ``` - `--name`, `--id`, `--exe` and `--bin` are required: the bundle's name, its bundle id, the executable's name inside it, and the binary to copy in. `--icon` is an `.icns`. Arguments after `--` go to the app. - `--scheme NAME` declares `NAME://` links in the bundle and registers it with Launch Services, so `App::on_open_url` receives them at once. - `--mic "WHY"` adds the microphone usage string macOS shows in its first-run prompt. - `--wait-idle SECS`, for unattended rebuilds (a file watcher, an agent), holds the swap while the app is in front and the keyboard or mouse was used in the last SECS seconds. When the app isn't in front, it relaunches behind the app that is, without taking focus. - `MAC_APP_SIGN="Developer ID Application: …"` signs with that identity, so macOS remembers permission answers across rebuilds. Without it the bundle is signed ad hoc, which macOS treats as a new app each build. The `rune-mac` crate holds the app's side (all no-ops off macOS): - `rune_mac::launch()`, first thing in `main`, keeps a relaunch behind the app in front from activating. winit activates every app it starts, `open -g` or not. - `rune_mac::remember_frame(name)`, called from `poll` until it returns true, has AppKit save the window frame and restore it next launch; `rune_mac::saved_size(name)` gives that size for `AppSpec::size`, so the layout doesn't animate to it at launch. - `rune_mac::busy(true)` holds the script's swap, for up to 15 minutes, while the app is doing something a relaunch would lose; `busy(false)` lets it go. The marker is the process's pid under `~/Library/Caches/mac-app/busy/`, so a crash can't hold the swap. - `rune_mac::toggle_full_screen()` and `is_full_screen()` drive the key window's full screen. ### Logging frame and input timing For an app's own perf log, with nothing changed about how its windows draw: - **`App::on_frame_encoded(|f: &FrameEncoded| ..)`** (or `WindowSpec::on_frame_encoded`) runs on the UI thread after each frame of a desktop window. `f.outcome()` is `Drawn` (encoded, submitted and presented; the first frame and a hidden launcher's count), `Unchanged` (a timer's frame that drew what was on screen, so no drawable was taken) or `Skipped` (covered, minimised, occluded or no drawable). `f.at()` is when encoding finished, `f.seq()` numbers the window's reports and `f.presents_at()` is the display link's target refresh when it paced the frame (macOS; `None` for a frame drawn at once after the window rested, such as the first after a key press). Headless runs, overlays and the web don't call it. - **`Tx::input_time()`** in an input handler is when that event arrived: the `NSEvent` timestamp on macOS, the moment the window took it from winit elsewhere. Events that pile up between frames keep their own times. It is `None` in timers, polls, updates and layout callbacks. The gap from it to the next `Drawn` frame's `at()` is the app's input-to-encode latency. - **`App::on_frame_drawn(|rt: &Runtime| ..)`** runs after each `Drawn` frame with the runtime as it presented that frame, so an app can watch its own motion in real use (`rt.elements()`, `rt.state`). **`rune::last_input()`** is when the thread last took an input event from the platform, stamped at dispatch: input later than the previous frame landed in this one. ### Compact tray-controlled windows Use `WindowOptions` on a `WindowSpec`. Existing single-window defaults remain resizable, decorated, with a 420×400 minimum unless `AppSpec::min_size` is set. ```rust spec.size = (320.0, 160.0); let app = rune::App::new(spec).windowless().keep_running(); let tray = app.handle(); // Any thread, including a tray callback: tray.set_position((-320, 80)); // physical desktop pixels tray.show(); // no focus request; recreates a destroyed window tray.focus(); // a separate, explicit activation request // tray.hide() keeps state without invoking on_close; tray.open() shows and activates. rune::run_windows_with_args(rune::RunOptions { accessory: true }, [], move |_| { vec![rune::WindowSpec::from_app("call", app).options(rune::WindowOptions { min_size: Some((320.0, 160.0)), max_size: Some((320.0, 160.0)), resizable: false, borderless: true, always_on_top: true, ..Default::default() })] }); ``` Size constraints use logical points. Placement and topmost work through the native window manager on macOS and X11; they are no-ops on Wayland. Runtime changes survive close/recreation. On Wayland, use `activate(token)` for authorized focus; tokenless `focus()` requests attention. `show()` leaves minimized windows minimized; `open()` also restores them. On macOS, no-focus show uses `orderFrontRegardless`, rather than winit's `makeKeyAndOrderFront`. Window-manager focus policy still applies. `WindowHandle` also provides show/hide/focus, placement and topmost control on the event thread. The app owns its tray; Rune supplies the window lifetime and handles. `RunOptions::accessory` applies the macOS process policy before the event loop is created: no Dock icon/default menu and no activation at launch. Other platforms ignore it; `run_windows` keeps its regular-launch default. ### Resizing and centring a window ```rust // In a handler: the next onboarding step is taller. tx.motion(rune::tok::motion::SNAP); // optional; tok::motion::DEFAULT otherwise tx.resize_window((480.0, 600.0)); // content size in points tx.center_window(); // optional: centre the new size, on the same spring // tx.resize_window_instant(size) or tx.instant() jumps. // From outside a handler, on the event thread: window.resize((480.0, 600.0)); // WindowHandle; also resize_instant, center // Or let the content set the height, like SwiftUI's `.windowResizability(.contentSize)`: rune::App::new(spec).fit_content_height().run(); ``` - The top edge stays put and the window stays on its screen: growing past the bottom lifts it just enough, and a window the user dragged partly off screen moves no further off. - Sizes stay inside the window's `min_size` and `max_size` (`WindowOptions`, else `AppSpec::min_size`) on every platform. Headless runs apply only limits the app set. - `center_window` matches AppKit's `center`: centred across, with a quarter of the spare height above and three quarters below. - On macOS the frame moves on a Rune spring, one display frame at a time. Each frame sets the window's frame before taking its drawable and lays the content out at the new size, and drawables present inside the frame's Core Animation transaction while it moves, so content pinned to an edge follows it with no stretched, stale or blank strip. Content at rest follows the window; layout springs and matched flights already running keep going. A resize mid-flight keeps its speed. Dragging the window mid-flight wins: it stays where the user puts it and the size carries on from there. Hiding or minimising it lands the frame. Reduced motion jumps. - Windows and Linux resize at once with `request_inner_size`. Wayland can't see or set a window's position, so it resizes without being kept on screen and ignores centring. - Headless runs move the viewport on the virtual clock, so `--tree` and scripts see each step; frames keep the launch canvas size, and centring does nothing. - A requested size outlives the window: a closed window reopens at it. - `fit_content_height` lays the root out at its natural height for the window's width (give it no fixed or `fill` height), opens at that height and follows the content on each change's spring. A width change jumps, and a window the user resizes goes back to its content's height. Unless the app sets a minimum, its minimum height is 0 rather than the default 400 pt. - Overlays ignore these; use `OverlayHandle::resize`. ### Window chrome and shell controls (OpenCode Desktop fidelity) ![sidebar](../../demo/opencode/sidebar-status.png) - **`App::chrome` / `WindowSpec::chrome`** (`Chrome { full_size_content, traffic_lights, drag_regions }`, or `Chrome::DEFAULT`): on macOS, `full_size_content` gives a transparent titlebar with content under it, and `traffic_lights: Some((x, y))` places the close button's top-left at `x, y` points (Electron's `trafficLightPosition`). Rune places the buttons again after every resize, scale change, focus change and un-occlusion. On Windows and Linux both options do nothing; the system titlebar stays. - **`App::titlebar` / `WindowSpec::titlebar`** (`Titlebar::new(height)`): the app draws its own titlebar `height` points tall and the window keeps its controls over it. macOS gets a full-size content view with drag regions and the traffic lights centred in the band (unless `Chrome::traffic_lights` places them). Windows loses its native title bar but keeps resize edges, Aero Snap, the snap layouts flyout over maximize, the window menu (Alt+Space, or a right-click on a `.window_drag()` region) and its animations; Rune draws the caption buttons over the trailing edge (Electron's `titleBarOverlay`: 46 points wide, close turns red), as elements named Minimize, Maximize or Restore, and Close for assistive technology, still in reach while the view holds a modal. A window that isn't resizable never maximizes: its maximize button is dimmed, with no snap layouts. `Cx::chrome_insets()` says what the controls take (`leading`, `trailing`, `height`; zero where the native frame stays, as on Linux; memos rebuild when it changes), `Cx::caption_color(c)` colours the caption symbols for an app theme, `Cx::titlebar_height(h)` changes the band's height as the app runs (a UI zoom), and `Tx::toggle_maximized()` maximizes or restores. `cargo run -p rune --example custom_titlebar` shows it. - **`AppSpec::new(title, size, init, view)`** fills every other field with its default; literals that set a few more end with `..AppSpec::new(..)`. Window chrome lives on the builders, keeping existing exhaustive `AppSpec` literals source-compatible. - **Window drag regions** (`.window_drag()`): a press starts winit's `drag_window` and a double press zooms the window (macOS follows the user's `AppleActionOnDoubleClick` setting, so it may minimise instead; elsewhere it toggles maximised). With `Chrome::drag_regions` on macOS, only marked regions move a borderless or full-size-content window, so titlebar content stays clickable. Rune subclasses only that window's content-view instance; ordinary titled windows and NSPanel overlays retain their native behaviour. - **Hover groups**: `.hover_group()` (or any `.hover(color)`) tracks hover for a subtree, and `.hover_motion(enter, exit)` sets its curves (for example a delayed reveal). Descendants read the nearest group with `.group_opacity(rest, hovered)` and `.group_color(c)`. A group stays hovered while one of its descendants holds the press, and only counts the part of it that shows inside a clipping parent. `.hover_motion_within(enter, exit)` sets the default curves for every hover track below it without its own `.hover_motion` (the nearest ancestor wins), which reaches kit controls' inner elements; put it on the view root (`rune_kit::root` carries it to popup layers). - **Loops** (`.cycle(Cycle { period, phase, stops, still, fps })`): an opacity loop sampled from the clock through `El::looping`, with one loop engine in rune-motion, so every loop with the same period stays in phase. Reduced motion holds `still`. While only loops move, the window wakes at `fps` instead of every display frame, and a still window does no work. - **Drags** report `dx, dy`, the pointer's travel since the press. - **`--chrome`** (headless) paints the traffic lights and a 4 pt clearance box onto snapshots and prints any element that overlaps them. ### Finite choreography `El::sequence` uses the same render-clock tracks as loops, then holds the final pose and sleeps. No app phases, poll callback, or `wake_after` calls are needed. ```rust,ignore use rune::{Beat, Sequence, el}; const MOMENT: &[Beat] = &[ Beat::new(0.0).opacity(0.0).scale(0.96), Beat::new(0.3), Beat::new(0.6).opacity(0.6).scale(1.04), Beat::new(0.9), ]; el().key("moment").sequence(Sequence::new(MOMENT).delay(0.1)) ``` Each beat is a full opacity/scale pose at elapsed seconds; omitted values are 1. `Beat::easing` sets the arriving segment's easing (default ease-in-out). Unchanged specs keep their progress across commits; changing the spec or mounting a new key replays it. Reduced motion and explicit settle land the final pose immediately. Scale changes presentation, not layout. This finite API is separate from the lower-level `rune_motion::Keyframes` property-track builder. ### Shared-element motion `El::matched(id)` is SwiftUI's `matchedGeometryEffect` (Motion's `layoutId`). When an element with an id leaves the tree in the same commit that another with that id mounts somewhere else, the new one starts where the old one was seen, with its velocity, corner radius, fill and opacity, and springs to its own layout. Both places are ordinary layout: ```rust,ignore fn tile(s: &State, i: usize) -> El { let id = format!("tile-{i}"); el().key(id.as_str()).matched(id.as_str()).grow(1.0).radius(20.0).clip_rounded(20.0) .child(el().w_full().h_full().pixels_fit(&s.pictures[i], SurfaceFit::Fill)) } if s.zoomed { stage.child(tile(s, s.picked)) // the whole stage } else { stage.child(col().key("grid").grow(1.0).gap(12.0) // a 2×2 grid .transition(Transition::scale(0.96)) .child(row().key("top").grow(1.0).gap(12.0).child(tile(s, 0)).child(tile(s, 1))) .child(row().key("bottom").grow(1.0).gap(12.0).child(tile(s, 2)).child(tile(s, 3)))) } ``` `examples/matched` is this Grid → One zoom (1–4 pick, ↩ zoom, Esc back). - **Scale, not reflow.** The flying element's children keep their final layout, scaled uniformly to cover the moving frame (centred) and clipped to it, so a filled picture zooms without distorting and nothing re-lays out per frame. Text moves with its box but keeps its size, as under any scale, so a label doesn't pop at the swap. - **Above everything.** A flight paints after the rest of the window, outside its ancestors' opacity, scale, blur and rounded clips, so an entering or exiting parent (the grid's scale-and-fade above) never hides it. - **Clipping.** It keeps its destination's rectangular clips, a scroll viewport's included, and starts from where the source was seen after its own viewport's scroll. - **Interruptible.** Matching back mid-flight starts from the presented rect with its velocity; a commit that leaves its layout alone doesn't re-time it. - **Spring.** `tok::motion::REFLOW` unless the commit's `Tx::motion` or the element's own `.motion()` sets one. Under reduced motion nothing is matched: the old element exits and the new one enters by their own transitions, in place. Settle and first frames swap. - **Nesting and removal.** A matched element inside a flying one flies its own path. A flight lands once the ancestors it sits in stop moving, scaling and fading. One removed mid-way exits by its own transition, still above everything. - **Ids are per window.** When two present elements share an id, the last in tree order is matched; `--tree` lists a warning and debug builds print one. - Elements without a counterpart play their own transitions. Without any `matched` element, the runtime does no extra work and draws the same pixels. A flight costs a scan of the flying list per drawn node, and its subtree paints in tree order. ## High-frequency values `LiveText` updates a plain text run without rebuilding the view or moving its siblings. Reserve a box with a placeholder or an explicit size: ```rust,ignore let clock = rune::LiveText::new("00:00"); text("00:00").live_text(&clock).font(TextStyle::new(14.0, 400.0).features("'tnum' 1")); clock.set("00:01"); ``` The placeholder determines layout even after a view rebuild. Only the changed run reshapes; overflow clips to that box. Equal strings do not shape or wake a host. Hidden hosts keep the latest value and sample it on return. Publication uses a short mutex and can allocate; it is not an audio-callback API. Live text supports plain single-line labels, including `.truncate_fade()`, but not rich spans, wrapping, editors, ellipsis truncation or streaming word reveals. `TextStyle::features("'tnum' 1, 'ss01' 1")` enables OpenType features, and `.variations("'wght' 500, 'opsz' 14")` selects variation axes. Both use CSS syntax and participate in the layout cache key. Tokens remain `Copy` and const-friendly; these strings have static lifetime. `.optical_size(value)` accepts a runtime value. Register extra font bytes in `AppSpec::fonts`, then select their embedded family name with `TextStyle::new(32.0, 400.0).family(Family::Named("Anton"))`. The name is a literal family name, not a CSS list or filename. Named families fall back to the sans slot when absent or missing a glyph, followed by the existing symbol/script fallbacks. `Family::Sans`, `Family::Mono`, and `.mono()` keep their existing meaning; mono rich spans still use the mono slot. Named styles remain `Copy` and const-friendly and the family name participates in the layout cache key. Monospace text (mono rich spans, `Family::Mono` and `Family::SystemMono`) turns off `liga`, `clig` and `calt`, so `->` and `!=` stay two characters, and a tab in it advances to the next 4-column stop, counted from the line or the mono span's start. A tab stays one byte, so byte ranges, carets and copies are unchanged; elsewhere a tab is one space wide. `.family(Family::SystemMono)` uses the platform's monospaced UI font without bundling it: SF Mono on macOS (the face AppKit's `monospacedSystemFont` returns, matched to within 0.05 pt per glyph at 8 to 34 pt), Cascadia Mono, then Consolas, on Windows, and whatever fontconfig resolves `monospace` to on Linux (then DejaVu Sans Mono, Liberation Mono, Noto Sans Mono). Its files load the first time a text asks for it. Where none is installed it falls back to the mono slot. SF Mono has no `trak` table, so it is untracked, as in AppKit; `tracking` still adds, and glyphs that fall back to the system font get that font's own tracking. Mono rich spans keep using the mono slot. To put the mono slot itself (so `Family::Mono`, `.mono()` and mono rich spans) in that font, name CSS's `ui-monospace` in it: `families: ("Inter", "ui-monospace")`. It stands for the same families as `Family::SystemMono`, and the rest of the list follows it, so `"ui-monospace, Menlo"` falls back to Menlo where none is installed. Inline code then matches a code block set in `Family::SystemMono`. `tx.set_font_families(sans, mono)` swaps what's behind the slots while the app runs, for a UI or code font picker; it takes the same lists as `AppSpec::families`. Every window and overlay follows on its next frame, and one opened later starts in them. Each drops the layouts it shaped in the old families, re-runs memoized subtrees, lays out again and draws one frame. `rune::text::measure` follows too. A cache of measured sizes outside Rune should key on `rune::text::font_epoch()`. A family that isn't installed falls back as it would at launch, and asking for the families the slots already hold costs nothing. Optical sizing defaults to each run's point size, including rich spans with a different size. An explicit `opsz` variation wins over the default; `.optical_size` wins over both. `.optical_sizing(OpticalSize::FontDefault)` restores Parley's old selection. The point-size default tracks CoreText more closely in measured SF samples. Fonts without an `opsz` axis are unaffected. `.truncate_fade()` shrinks a single-line label and fades its trailing 1.5 em edge only when text overflows (`.truncate_fade_width(em)` sets another width). It keeps the full string and paints no ellipsis. For end-aligned labels the overflow fades on the left. `.truncate_middle()` cuts like `.truncate()` but keeps both ends, "…" replacing the middle (AppKit's `NSLineBreakByTruncatingMiddle`), for paths and file names; `.truncate_head()` keeps the end. Cuts fall between graphemes and refit on resize, and selecting, copying and `--tree` keep the whole string. `.max_lines(n)` wraps and stops after `n` lines, "…" ending the last when more would follow (CSS `line-clamp`). The element measures at most `n` lines tall; the cut falls between graphemes (inside a word too wide for the box too), spans and links keep their looks up to it, and copying and the accessibility text keep the whole string. Text fields ignore it. Native hosts populate `cx.system_reduced_motion()` at creation and refresh it on window focus changes. Rune uses it by default; `cx.reduced_motion(value)` overrides it **for the current commit only**. Call it each time the view runs to keep an override; otherwise the next commit uses the host preference. macOS reads the accessibility preference, Windows reads client area animation, and Linux reads KDE's duration factor or GNOME's enable-animations setting when available. The preference lives on Runtime/Cx, so existing `Env { system_dark, scale }` literals remain valid. Headless runtimes default to false; tests can call `rt.set_system_reduced_motion(value)` and inspect `rt.system_reduced_motion()`. A root `.on_window_focus(|state: &mut State, focused, tx| ...)` receives native activation/deactivation in order. It applies to regular windows and interactive overlays, independently of text-field focus. Headless tests inject the same events with `Runtime::window_focus(bool)`. `Live` is a cloneable, thread-safe latest-wins handle (`f32`, `[f32; 2]`, `[f32; 4]`, `Color`). Call `set(value)` from a producer thread; Rune coalesces a wake for each visible subscribed window. Equal values and hidden windows do not wake. Bind with `.live_opacity(&level)`, `.live_offset(&cursor)`, `.live_scale(&scale)`, `.live_fill(&color)` or `.live_rect(&rect)`, and colours with `.live_text_color`, `.live_border`, `.live_shadow` (the first shadow's colour) and `.live_gradient(&top, &bottom)`. A rebuild keeps what a colour binding last set. `cursor.smooth(Spring::new(0.16, 0.0))` opts a binding into rune-motion smoothing. Views and layout do not rerun. Rects are presentation `[x,y,w,h]` in parent points, so text does not rewrap and siblings do not reflow. See [`examples/live-values`](../../examples/live-values) for background producers, deterministic snapshots/traces, allocation gates and the rebuild comparison. The native shell samples after acquiring a drawable. This is still the main-thread renderer, not a dedicated late-uniform/render-thread path. `Live::new` requires lock-free 128-bit atomics on the host CPU. Publication allocates nothing; platform wake callbacks are not hard-real-time guarantees. For a countdown, keep a `Tween` in app state and bind it through a live property: ```rust,ignore let bar = rune::Tween::linear( [0.0, 0.0, 272.0, 4.0], [0.0, 0.0, 0.0, 4.0], std::time::Duration::from_secs(30), ); // In the view: el().size(272.0, 4.0).live_rect(&s.bar) ``` The track starts on first attachment to a runtime and uses elapsed real time (virtual time in headless runs). Rebuilds retain progress; replacing the `Tween` starts a new track. It needs no app ticks, layout, shaping, or timer wakes. Hidden windows stop sampling and resume at the elapsed value. At the endpoint the renderer sleeps. This is a timed value, including under reduced motion; it does not use the decorative motion spring or jump to the end on settle. The shared handle retains each runtime's start across unmount/remount (including virtual-list culling). Replacing the handle, rather than remounting the view, starts a new countdown. ## Text fields `TextEdit::focused` is maintained by the runtime. Set it once to request focus, then let the runtime clear it when another field takes focus; do not force it on every view rebuild. A handler drops focus with `Tx::blur()` (Enter in an inspector field handing keys back to the window, say); the field's `focused` turns false through its `on_edit` accessor. A field the view builds only once it can read a rect (`Cx::layout`, on the commit's re-run) still takes the focus it asks for in that commit. Text fields support Cmd/Ctrl-Z and Cmd/Ctrl-Shift-Z. Consecutive typing is one undo group; selection changes, navigation, paste, cut and IME commits separate groups. Undo restores selection direction as well as text. History shares storage with view snapshots and retains at most 128 edits / 1 MiB of text (oversized edits drop history). Call `TextEdit::clear()` after submission to clear history while retaining focus; call `break_history_group()` when setting a selection from custom pointer handling. Use `TextEdit::new("").secure()` with `text_input(&edit, style).single_line()` for passwords, or mark the element `.single_line().secure()`; kit's `TextInput::secure()` passes it through. Each Unicode scalar displays as one bullet, with model-byte selection and caret positions mapped to those bullets. Secure fields have no undo, copy, cut, IME composition, or inspector text. macOS Secure Event Input is held only while their native window is focused and visible. An editor the app draws itself (not a `text_input`) gets the input method too: mark the caret it draws with `.ime_cursor()` while the editor has the keyboard, and hear the events with `App::on_ime(|s, ime, tx| ..)`. While that caret is drawn and no field has focus, the window turns its input method on and places the candidate window at the caret. `Ime::Preedit` carries the composition and its cursor range (draw it, don't insert it; empty ends it), `Ime::Commit` the text to insert, and `Ime::Enabled` and `Disabled` the input method turning on and off. Typing arrives once: a plain character is a key (`Key::Text` in `on_key`), and `Ime::Commit` carries only what an input method composed (dead keys, CJK, the emoji picker, dictation). A focused field takes the events instead, and a focused secure field keeps the input method off. Model buffers and history wipe on drop; secure edits wipe replaced allocations. Use `replace_text(..)` or `clear()` for programmatic replacement. Direct assignment to the public `text: String` field bypasses replacement wiping. For secure notes or keys, use `rune_kit::TextArea::new(&edit).secure()` or a multi-line `text_input(&edit, style).secure()`. Enter inserts a newline; Cmd/Ctrl+Enter submits, and paste preserves newlines. Hidden non-empty values show twelve bullets on one line regardless of length. Navigation moves to the end (Shift selects all); typing and paste append, or replace the whole value if selected, and Backspace deletes at the end. `.revealed(true)` permits interior edits while retaining all secure protections. Toggling alone preserves selection; hiding snaps geometry and resets ancestor scrolling. `TextArea::max_height(..)` or `auto_grow(..)` bounds revealed content. Revealed secure lines wrap long tokens automatically; other text can opt in with `El::wrap_anywhere()` (add `.shrink()` for flex-row shrinking). Code that lays out text itself passes `RichLayoutOptions::new().wrap_anywhere()` to `TextSystem::layout_rich_with(..)` for the same breaks. `tx.copy_secret(text, Duration::from_secs(30))` writes with the platform's clipboard history-exclusion marker and expires on Rune's shared clipboard worker. Expiry compares the current text and, on macOS/Windows, the clipboard revision; copying something else cancels the old expiry. Linux's portable API exposes no revision, so it compares text. Clipboard managers must honor the platform marker themselves. Put `.on_paste(|s: &mut State, text: &str, tx| { ... })` on the view root (or modal root) to handle Cmd/Ctrl-V when no field is focused. `tx.request_paste()` requests the same asynchronous clipboard read from a button, including in overlays. Focused editors take precedence; late reads are discarded if the target changed. Headless `--script` `paste` supplies text directly through the same routing and records `paste window` or `paste field` in `--trace`, without recording contents. #### Rich clipboard A `ClipboardContent` is plain text plus, optionally, HTML and RTF. Copy formatted text with `tx.copy_rich(ClipboardContent::new("Ship it").with_html("Ship it").with_rtf(rtf))`; plain text is always written, so every app can paste it. `copy_secret` stays plain text only. Pasting delivers the richest form the source offered. A field hears it before its editor with `.on_input_paste(|s, content, tx| -> bool)`: return true when the app took the paste (say, a rich composer parsing `content.html()` into its model), false to let the editor paste `content.text()` as before. The root's `.on_paste_content(|s, content, tx| ..)` is `on_paste` with every form. `tx.read_clipboard(|s, content, tx| ..)` reads without pasting; it always answers, with empty content when there's nothing to read. A rich `TextEdit` copies and pastes HTML without a hook (see Rich text editing below). Plain fields without a hook and text `on_paste` handlers behave exactly as before, and a clipboard with HTML but no plain text reaches only the rich hooks. Reads run on the clipboard worker, never the event thread. Hooks also hear a pasted image (a screenshot, an image copied from a browser or an editor): `content.image()` is a `ClipboardImage` holding PNG bytes (`png()`) and the pixel size (`size()`), whatever form the clipboard held it in. Rune fetches it only for a target with a hook (`on_input_paste`, `on_paste_content`, `read_clipboard`), so pasting text into other fields never decodes one, and leaves out images over `clipboard::MAX_IMAGE_BYTES` (32 MiB as PNG). An image alone, with no text, reaches only hooks. Copies don't write images yet. ```rust text_input(&s.prompt, style).on_edit(|s: &mut S| &mut s.prompt).on_input_paste(|s: &mut S, content, _| { let Some(image) = content.image() else { return false }; let id = s.attachments.add(image.png().to_vec()); s.prompt.insert_pill(format!("image:{id}"), "Image"); true }) ``` Clipboard HTML is another app's untrusted input. Rune runs it through `sanitize_html` before any hook sees it: scripts, event handlers, frames, embeds, `javascript:` and other non-web URLs, comments and CSS that loads anything are removed; text, tags, `class`, inline styles and inert `