Rinch
A GUI framework for Rust that uses HTML and CSS for layout, renders natively, and won’t make you mass a single .clone() at a signal.
Live Demo — All Rinch components, running in your browser via WASM. Or run locally:
cargo run --release -p ui-zoo-desktop
Philosophy
- HTML/CSS layout — The layout system that billions of people have already debugged for you, powered by Servo’s Stylo and Taffy’s flexbox.
- Fine-grained reactivity — Signal changes update one DOM node, not a component tree. No virtual DOM. No diffing.
- Components run once — Your function builds the DOM, closures keep it updated. That’s the whole model.
- Native performance — GPU rendering via Vello/wgpu, or software rendering via tiny-skia. A
gpubuild carries both, picks at run time, and falls back to software when the GPU will not start. - WASM too — Same components, same signals, browser-native DOM. ~3MB binary, zero JavaScript.
Quick Example
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
let count = Signal::new(0);
rsx! {
div {
p { "Count: " {|| count.get().to_string()} }
button { onclick: move || count.update(|n| *n += 1), "+" }
}
}
}
fn main() {
App::new(app).title("Counter").size(800, 600).run();
}
Signal is Copy. No .clone() before closures. The {|| ...} closure creates an Effect that tracks its dependencies and surgically updates that text node when count changes. The app function runs once, builds the DOM, and never runs again.
What’s Included
- 60+ Components — Buttons, inputs, modals, tabs, accordions, color pickers, trees, a rich text editor, and more.
- Theme System — 14 color palettes, dark mode, CSS variables for everything. Mantine-inspired.
- 5,000+ Icons — Tabler Icons with a type-safe enum. Dead code elimination drops the ones you don’t use.
- Native Platform — Menus, file dialogs, clipboard, system tray, transparent windows.
- Developer Tools — F12 DevTools, inspect mode, MCP server for Claude Code integration.
- Game Engine Embedding — Submit GPU textures or CPU pixels into a Rinch UI, or embed Rinch into your own render loop.
Getting Started
From zero to window in about ninety seconds.
Prerequisites
- Stable Rust, edition 2024. Rinch itself is built and tested with the one
version its
rust-toolchain.tomlnames (1.99.0 today); no nightly feature is needed. - A C/C++ compiler (Stylo has native dependencies)
- On Linux:
libfontconfig-devandpkg-config(for font discovery)
Create a Project
cargo new my-app
cd my-app
Add Rinch
# Cargo.toml
[dependencies]
rinch = { git = "https://github.com/joeleaver/rinch.git", features = ["desktop", "components", "theme"] }
What the features do:
"desktop"— windowing, event loop, software renderer. Required for desktop apps."components"— the 60+ component library (Button, TextInput, Modal, etc.)."theme"— CSS variable generation, color palettes, dark mode. Auto-enabled by"components"."gpu"— adds GPU rendering via Vello/wgpu beside the software renderer; the window uses the GPU and falls back to software when it will not start (see Rendering Backends). Optional."clipboard"— clipboard read/write. Optional."file-dialogs"— native open/save dialogs. Optional.
Write Your App
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
let count = Signal::new(0);
rsx! {
Stack { gap: "md", p: "xl",
Title { order: 1, "Hello, Rinch!" }
Text { color: "dimmed", "Your first app. It only goes up from here." }
Group { gap: "sm",
Button { onclick: move || count.update(|n| *n += 1), "+" }
Button { variant: "outline", onclick: move || count.set(0), "Reset" }
}
Text { size: "xl", {|| format!("Count: {}", count.get())} }
}
}
}
fn main() {
App::new(app)
.title("My App")
.size(800, 600)
.theme(ThemeProviderProps {
primary_color: Some("cyan".into()),
..Default::default()
})
.run();
}
Run It
cargo run --release
Always use
--releasefor running apps. Debug mode is slow because Stylo and Parley do serious work. Release builds are fast.
You should see a window with a title, description, two buttons, and a count that updates when you click “+”.
What Just Happened
#[component]injected a__scope: &mut RenderScopeparameter. You never see it, butrsx!needs it.rsx!built a DOM tree: created elements, set attributes, wired event handlers.{|| format!("Count: {}", count.get())}created an Effect that readscountand updates a text node. Whencountchanges, that closure re-runs and updates only that text node.App::new(app)…run()opened a window, loaded theme CSS, mounted the component, and started the event loop.Appis the one entry point: every startup option — title, size, theme, menu bar, window props, GPU device — is a method on it, and they all compose.
Your app function ran exactly once. It will never run again. The closures handle everything from here.
What’s Next
- RSX Syntax — The full macro syntax: elements, attributes, events, control flow.
- State Management — Signals, Memos, Stores, Context.
- Components — The full component library.
- Theming — Colors, dark mode, CSS variables.
- Writing Components — Build your own.
- WASM — Run in the browser.
RSX Syntax
RSX is a JSX-like syntax for building UI in Rust. It lets you write HTML-like markup directly in your Rust code.
How RSX Works
The rsx! macro generates DOM construction code that:
- Creates DOM nodes via
RenderScope - Sets up Effects for reactive expressions
{|| ...} - Wires event handlers
This enables fine-grained reactive updates - when a signal changes, only the affected DOM nodes are updated, not the entire tree.
Component Signature
Components use the #[component] attribute macro and return a NodeHandle:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn my_component() -> NodeHandle {
rsx! {
div { "Hello from my component!" }
}
}
}
The #[component] macro injects the __scope: &mut RenderScope parameter automatically, which is used by rsx! to create DOM nodes and Effects.
Basic Syntax
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn example() -> NodeHandle {
rsx! {
div {
h1 { "Hello, World!" }
p { "This is a paragraph." }
}
}
}
}
HTML Elements
Standard HTML elements are written in lowercase:
#![allow(unused)]
fn main() {
rsx! {
div {
span { "Text content" }
button { "Click me" }
input { type: "text", placeholder: "Enter text..." }
}
}
}
Attributes
Attributes are specified after the element name:
#![allow(unused)]
fn main() {
rsx! {
div { class: "container", id: "main",
a { href: "https://example.com", "Link text" }
img { src: "image.png", alt: "Description" }
}
}
}
Boolean attributes
A handful of HTML attributes carry no useful string: their presence is their
value. disabled, checked, readonly, required, multiple, hidden,
selected, open, autofocus and the rest of the HTML boolean set are all of
this kind, and so are rinch’s own data-disabled, data-nofocus,
data-trap-focus, data-backdrop and data-scroll-lock-exempt.
Write them as a bool — static or reactive — and rsx! renders the right
shape rather than the string:
#![allow(unused)]
fn main() {
let busy = Signal::new(false);
rsx! {
// true → `disabled` (present, no value)
// false → the attribute is removed
button { disabled: {move || busy.get()}, "Rename" }
}
}
This matters because HTML has no falsey spelling: a present disabled attribute
disables the control whatever it holds, so disabled="false" is still
disabled. Writing the string would make the binding a one-way latch — the
control could turn off and never back on (issue #551).
The rule is keyed on the attribute’s name, not on the value’s type, because
several attributes that take a bool-looking value need the literal "false"
kept:
#![allow(unused)]
fn main() {
rsx! {
// `draggable` is an *enumerated* attribute: "true" / "false" are both
// values, and removing it means `auto`. It keeps the string.
div { draggable: {move || on.get()} }
// So do the ARIA states, and rinch's own `data-viewport-ready` — an
// opt-out whose *absence* means ready.
div { aria-expanded: {move || open.get()} }
}
}
A closure that yields a string into a boolean attribute follows a writer’s
truthiness rule: everything is on except "false" (any case) and "0", so the
bare "" that components write stays on.
That rule is the writer’s, and for the HTML set it is nobody’s reader: no
reader of disabled, checked, readonly and their kind has a falsey string on
either backend, because a browser has none (issue #612). rinch’s own
data-disabled, data-nofocus, data-trap-focus, data-backdrop and
data-scroll-lock-exempt — listed as boolean
attributes above — are the exception, and the only one: their readers do honour a
literal "false", on desktop through rinch_core::dom::data_attr_is_on and,
for the ones the web reads at all, through selectors like
[data-nofocus]:not([data-nofocus="false" i]). That escape is narrower
than the writer’s rule, too: "0" is on for those five.
Writing one by hand. set_attribute is the literal primitive on both
backends: it writes the string you give it, so set_attribute("checked", "false") leaves the attribute present, and a present boolean attribute is
true. There are two spellings of off, and neither is a string:
#![allow(unused)]
fn main() {
// Rendering a bool you already have — presence or absence, chosen for you.
node.write_attribute("checked", &flag.to_string());
// Or say it outright.
if flag { node.set_attribute("checked", "") } else { node.remove_attribute("checked") }
}
That was a real divergence until issue #622: the web backend mapped truthiness
onto presence for checked and selected alone, so the same call unchecked a box
in the browser and checked it on the desktop backend.
On the web, checked, <option selected> and a media element’s muted also
drive the control’s live property, not only the attribute — a browser stops
mirroring checked/selected’s attribute into the property once the user has
toggled the control (and never mirrors muted’s dynamically at all; HTML reads
it once, at load, to seed defaultMuted), and rinch has no such flag, so a
programmatic write keeps winning. checked/selected follow the attribute’s
presence, matching what the desktop backend’s :checked reads; muted is
rinch’s own invention — deliberately more dynamic than plain HTML — because
nothing else lets a reactive muted: {|| m.get()} binding mute or unmute a
<video>/<audio> element at all (issue #754). The name is folded
ASCII-lowercase before any of this is decided, so CHECKED/MUTED written in
any case still mirror (issue #758) — desktop already folds the attribute name
this way (#738); on the web the browser folds the written attribute itself, so
only rinch’s own Rust-side matching needed the fold.
These three are also the reason a falsey write of them is never skipped. Turning
a boolean attribute off is otherwise a no-op when the attribute is already gone,
and write_attribute skips it to save a restyle — but on the web a user’s
toggle (or, for muted, a <video controls>’s own mute button) leaves the
property on with the attribute absent, so the skip was exactly the write that
would have corrected it, and a box the user clicked stayed checked against a
binding that said false (issue #687, and #754 for muted). checked,
selected and muted now always reach the backend, which is the only layer
that can see the property.
Component props are unaffected — a component’s disabled: bool is an ordinary
typed field, and the component decides how to render it.
Event Handlers
Event handlers are on* attributes on HTML elements. Pointer/click handlers are
Fn() closures; read the cursor position, button, and modifier keys from
get_click_context():
#![allow(unused)]
fn main() {
use rinch::prelude::*;
rsx! {
div {
onclick: || println!("clicked"),
onmousedown: || {
let ctx = get_click_context();
match ctx.button {
MouseButton::Right => println!("right press"),
_ if ctx.modifiers.shift => println!("shift+press"),
_ => println!("press at {}, {}", ctx.mouse_x, ctx.mouse_y),
}
},
onmouseup: || println!("released"),
onmousemove: || { /* fires on each (coalesced) move over this element */ },
onmouseenter: || println!("hover in"),
onmouseleave: || println!("hover out"),
oncontextmenu: || println!("right-click menu"),
"Interactive"
}
}
}
Supported HTML-element event attributes:
| Attribute | Fires | Closure |
|---|---|---|
onclick | Primary press (dispatched on pointerdown), or Enter/Space on the focused element — see Keyboard activation. A right or middle press does not run it, on either backend (both did until issue #1093); use oncontextmenu, or onmousedown with get_click_context().button. An overlay backdrop marked data-backdrop (rinch_core::events::BACKDROP_ATTRIBUTE) is the exception and runs for every button, so a press of any button outside an overlay dismisses it | Fn() |
onmousedown / onmouseup | Pointer press / release (any button) | Fn() |
onmousemove | Pointer moves over the element — at most once per event-loop batch on desktop, at the newest position (see Pointer moves are coalesced) | Fn() |
onmouseenter / onmouseleave | Pointer enters / leaves the element | Fn() |
oncontextmenu | Right-click, or a 500ms long press on Android (suppresses the native menu when handled) | Fn() |
oninput | <input>/<textarea> value change, per keystroke | Fn(String) |
onchange | Commit boundary: the gesture ends (blur after a modification, Enter, a <select> pick) — fires with the final value | Fn(String) |
onscroll | Scroll container scrolls, on either axis | Fn(ScrollEvent) — ev.scroll_top and ev.scroll_left |
ondragstart … ondrop, ondragend | Element drag-and-drop. A cancelled drag (Escape; on desktop also a left press or a window blur while it is live, which prove its release went missing) fires ondragend (and ondragleave on the target, if it is over one), never ondrop | Fn() |
onfiledrop, onfiledragenter/onfiledragleave | OS → app file drop | Fn(Vec<PathBuf>) / Fn() |
Pointer moves are coalesced
On desktop, winit can report several pointer moves between two frames — a
1000 Hz mouse does. The shell keeps only the newest one and hands it to the app
before the next other event (so a press is judged where the pointer last was)
or at the end of the batch. Hover, onmousemove, data-ondragover /
data-ondragmove and a Drag::absolute() / Drag::percent() on_move all
see one move per batch, at the latest position. This is the browser’s model:
it dispatches pointermove at most once per frame. A component drag’s layout
runs once per frame too, not once per move.
One pointer-capture drag at a time
Drag::start() ends any drag that is already live before arming its own, and
it ends it through that drag’s on_cancel — never its on_end. Usually a
press arming a drag while another is live means the earlier drag’s release was
lost (a context menu or a window-manager grab swallowed it, or it went to
another rinch document on the same thread — a DevTools window, a second
embedded RinchContext), so there is no trustworthy position to commit. On a
touch screen it can also be a second finger: rinch-web dispatches a second
finger’s press, and a drag it arms cancels the first finger’s while that finger
is still down — there is one drag at a time, not one per finger. Keep teardown
in on_cancel and it runs on every ending but a commit — and an unmount of
the component that armed the drag, which drops it without calling back.
A drag whose release never arrives is ended the same way, through
on_cancel at the last position on_move saw. On rinch-web that happens on
the first move that reports the button up. Desktop cannot see the button on a
move, so it ends the drag on the next event that proves the release was
missed: a left press (handled before the press reaches any handler, so
nothing it dispatches sees the old drag) or the window losing focus. Until
one of those arrives, the stranded drag keeps following the pointer. The same
press or blur also ends a stranded element drag (cancelled: ondragend, no
ondrop), a scrollbar-thumb drag and a text-selection drag.
Three consequences on desktop. A window blur mid-drag cancels the drag even
when its release would still have arrived (on X11 and Wayland a global hotkey
or a held Alt+Tab can blur the window during a healthy drag). A drag armed by
the middle or right button is cancelled by a left press made while it is held.
And a drag armed with no button down — from a shortcut or a timer, then placed
with a click — is cancelled by that click, as the web cancels it on its first
move. Arm a Drag from a press (onclick or onmousedown) and none of this
reaches a normal gesture.
A drag ends on the release of the button that started it. On desktop a right
or middle release while the left button still holds a drag is a chord, not the
end of it: a left-armed Drag keeps following the pointer and commits on the
left release, and the same holds for an element drag (it is not dropped), a
scrollbar-thumb drag and a text-selection drag. A drag armed by a right press
ends on the right release, and a drag armed outside any press (a timer) belongs
to whichever button was pressed last. The web gets this from the browser, whose
pointerup fires only once every button is up.
onscroll fires once per container that moved, whichever axis moved it, and
its ScrollEvent payload carries both offsets — so a horizontal-only
scroller reports its position rather than an unchanging scroll_top
(issue #177). ScrollEvent is #[non_exhaustive]: read its fields by name,
and construct one (in a test, say) with ScrollEvent::new(top, left) rather
than a struct literal.
#![allow(unused)]
fn main() {
div {
style: "overflow: auto; width: 200px; height: 100px",
onscroll: move |ev: ScrollEvent| {
column.set((ev.scroll_left / char_width) as usize);
},
// …wide content…
}
}
On desktop a scroll offset is kept on the device pixel grid (issue #1543),
so scrolled text moves by whole device pixels and does not re-space as it
scrolls: at a display scale of 125% the offsets are multiples of 0.8px. The
ScrollEvent offsets and scroll_top() report that snapped value, so
set_scroll_top(10.33) at 125% reads back 9.6. Do not compare an offset you
wrote with one you read back for equality.
oninput and onchange follow HTML semantics and are not aliases (they
were before issue #226): oninput fires on every keystroke with the live
value, while onchange fires once when the typed gesture ends — focus
leaves the control after a modification, Enter commits explicitly, or a
<select> commits a pick — and only if the value actually changed since the
control was focused. Use oninput for live previews and controlled inputs,
onchange for validate-on-commit, autosave-on-leave, and undo bracketing.
On Enter, onchange fires before onsubmit, and the eventual blur does not
re-fire an already-committed value. Enter commits single-line inputs
only — in a <textarea> (as in HTML) the gesture runs until blur, where
Enter inserts a line break instead (see Enter in a
<textarea>). Like the
browser’s change, the event bubbles: data-onchange on an ancestor of the
control receives the commit too.
Mouse and click handlers read per-event data from get_click_context():
mouse_x/mouse_y, element bounds (relative_x(), percent_x(), …),
button (MouseButton::{Left, Middle, Right}), and modifiers
(shift/ctrl/alt/meta). These behave identically on the desktop and web
(WASM) backends.
The element bounds are the box the element is painted in, which is what
getBoundingClientRect() reports on the web — so a CSS transform on the
element or any ancestor moves and resizes them, and a position: fixed
element reports its viewport box rather than one displaced by its ancestors
(#203). That is what makes Drag::percent() and percent_x() read correctly
inside the left: 50%; top: 50%; transform: translate(-50%, -50%) centring
idiom, and inside a transform: scale() zoom container. mouse_x/mouse_y
are unchanged — still plain viewport coordinates, which is what popup
placement in DropdownMenu, Select and ContextMenu reads them as. For a
rotated element the bounds are the axis-aligned bounding box, the same
approximation the browser makes.
Keyboard activation
Enter and Space on a keyboard-focused element run its onclick — the
same handler as a pointer press, on both backends. What Tab can reach differs
by backend: on web the browser makes <button>, <a href> and the like Tab
stops on its own; on desktop only an element with tabindex (or a text input)
is a Tab stop today, so a bare <button> needs tabindex="0" there (issue
#252). The handler receives a ClickContext whose cursor sits at the
element’s centre (mouse_x/mouse_y = the middle of element_x/element_y
- size), with
button: Leftand no text hit, so placement logic that readsget_click_context()(aSelectflipping to fit the viewport, say) keeps working for a keyboard user. Those bounds are the painted box, the same one a pointer press reports, so a transformed element activates to the same numbers either way in. Ctrl/Meta chords are left alone; Shift and Alt ride along inmodifiers.
On desktop, activation latches once per physical press (a held key does not repeat). On web, two routes cover the two kinds of element:
- Natively activatable elements —
<button>and<summary>on Enter or Space,<a href>on Enter, checkbox/radio inputs on Space — use the browser’s own activation: theclickthe browser synthesises is what dispatches the handler. The browser’s semantics apply, so<a href>also navigates on Enter (exactly as a mouse click on it already does), a checkbox still toggles, and a held Enter repeats at the browser’s key-repeat rate. Where the browser has no activation for a key — Space on a link — rinch dispatches it fromkeydowninstead (the handler runs, the page does not scroll, and nothing navigates), matching desktop. tabindexelements (a<div tabindex="0">such as aTreenode) get no browser click, so rinch dispatches fromkeydown: once per press, and the key is consumed for the whole press — including its auto-repeats — so a held Space neither re-activates nor scrolls the page. Elements the browser focuses on its own withouttabindex(a keyboard-focusable scroll container,<video controls>) keep their keys: Space there scrolls or plays, it does not activate the clickable around them.
Neither route double-fires with the mouse: a pointer press dispatches from
pointerdown, and the trailing click of that same gesture — including the
extra click a <label> fires at its control — is suppressed, even if a key
was pressed while the button was held. A press that dispatched nothing has
nothing to duplicate, so its clicks are not suppressed: a <label for="x">
outside every handler, whose <input id="x"> sits inside a clickable
wrapper, activates that wrapper once by mouse as it does by keyboard (issue
#272, web only — the desktop backend does not follow for, so there the
press does nothing). The same rule lets a click through when something else
swallowed the press: a third-party widget (a map, a chart) inside a clickable
card that stops its own pointerdown from propagating now activates the card
on release, as a browser’s own click would — stop click too if the widget
must not. Clicks with no pointer gesture behind
them — assistive technology (whose clicks are trusted, with no pointer or
key event of their own on Firefox/WebKit), element.click() — are honoured
once, and a click a handler raises itself (hidden_input.click()) does not
re-enter that handler. Enter inside an <input>/<textarea>, an editable
region or the rich-text editor is never an activation of a surrounding
clickable; it is the onsubmit gesture described above (or, in a
<textarea> with no onsubmit, a line break). An element with no
live handler in its ancestry is a quiet no-op — the key falls through to the
browser, so Tab and scrolling keep their usual meaning.
A mouse press focuses the nearest focusable ancestor of what it hits, like a
browser — so a clicked tabindex element owns Enter/Space straight away,
without having to be reached by Tab first. A component that wants the keys for
itself (a code editor, a keyboard-driven grid) registers for them; see
Keyboard Focus.
Enter in a <textarea>
Enter has two meanings in a multi-line field, and which one it takes is the author’s declaration:
| Field | Key | What happens |
|---|---|---|
<textarea> | Enter, no onsubmit | inserts a line break |
<textarea> | Enter, with onsubmit | runs onsubmit — nothing is inserted |
<textarea> | Shift+Enter | always inserts a line break |
<input> | Enter / Shift+Enter | commits (onchange, then onsubmit); never inserts |
So a notes field takes line breaks with no ceremony, a chat composer sends on
Enter and takes a break on Shift+Enter, and a single-line <input> is
unchanged — a line break is not representable in its value.
onsubmit is read the way onchange is: from the control, then up its
ancestors, so an onsubmit on the wrapper around a field submits that field.
One backend difference to know about: on web, Shift+Enter in an <input>
does nothing (the browser’s own Enter path is what desktop’s commit stands
in for), while on desktop it commits like a plain Enter.
The break is an ordinary edit: it moves the caret, fires oninput with the new
value (\n included) and is one Backspace away from gone. On Android the soft
keyboard is told per focused field which kind it is serving, so a <textarea>
gets a keyboard whose Enter types a newline instead of an action key that ends
the input session.
Moving between lines
ArrowUp and ArrowDown move the caret one visual line in a <textarea> — a
line the text soft-wrapped onto counts, as in a browser — aiming at the column
the first of a run of vertical moves started from, so a short line in between
does not pull the caret left for good. On desktop this reads the same text
layout a click is hit-tested against, so the caret lands where a click at that
height would put it (except at a soft-wrap point, below). ArrowUp on the first line goes to the start of the text and
ArrowDown on the last line to its end; in a single-line <input> that is all
they do (Chrome’s behaviour on Linux and Windows). Shift extends the selection.
Without Shift a selection collapses, moving from its start (up) or its end
(down). An <input type="number"> is not moved: a browser steps its value
there, which desktop does not do.
Where a soft-wrapped line is shorter than the column aimed at, desktop stops one character before the wrap point, since a caret at the wrap point itself would be drawn at the start of the next line. A browser keeps a caret affinity for that and places it after the last character instead. Where the wrap is at a space the space hangs and nothing looks different; in text with no spaces (CJK) the caret is visibly one column short.
Home and End in a <textarea> go to the start and end of the caret’s
visual line, soft-wrapped lines included; Ctrl+Home and Ctrl+End (Cmd on
macOS) go to the start and end of the whole text. Shift extends the selection.
Without Shift a selection collapses and moves from its head — the end the
caret is at — rather than from its start or end as ArrowUp and ArrowDown do,
which is what Chrome does. In a single-line <input> Home and End go to the
ends of the value. A Home or End moves the caret off the column a run of
vertical moves was aiming at, so the next ArrowUp or ArrowDown aims from where
the caret now is; one that leaves the caret where it was keeps that column, as
in Chrome.
End on a soft-wrapped line follows the rule above: desktop stops one character
before the wrap point, a browser at the wrap point with the caret drawn at the
end of the line. When the wrap is at a space, the caret sits before that space,
so a character typed there goes before it — abcd abcdX abcd where Chrome
types abcd abcd Xabcd.
Sizing a <textarea>
A <textarea> holds its value in its value attribute, and has no content to
size against. Text written as its child — textarea { "hi" }, or a parsed
<textarea>hi</textarea> — is its default value, as in HTML: the field shows
and edits it, and follows it when it changes (a reactive
textarea { {|| draft.get()} }) — until the user edits the field or something
sets its value. That is HTML’s dirty value flag, which focusing and blurring
do not set (#1186); from then on a child change updates only the default value
(defaultValue on the web), and the field keeps what the user typed. The rule
is the same on the desktop and on rinch-web, value writes included (#1222):
a write equal to the shown text sets the flag too, as a script .value write
does in Chrome, and removing the value attribute is a write of "" — the
field empties and stays dirty, since no script write clears the flag again.
(A browser’s form reset does; the desktop has no form reset.) A child change while the field is focused and
unedited keeps the caret at its offset, clamped to the new text, as in Chrome.
A text child is not a controlled value. Until #1206, rinch-web wrote a
textarea’s value from its children on every change (#100), so
textarea { {|| draft.get()} } with draft.set(String::new()) after a submit
cleared the field there even after the user had typed — and did not on the
desktop. It no longer does on either backend. For a field the app keeps in step
with a signal, bind the value itself — a value_fn on the Textarea
component, or a reactive value: on the raw element — which reaches the field
whether or not the user has typed:
#![allow(unused)]
fn main() {
let draft = Signal::new(String::new());
rsx! {
div {
Textarea {
value_fn: move || draft.get(),
oninput: move |v: String| draft.set(v),
}
// or, on the raw element:
// textarea { value: {|| draft.get()}, oninput: move |v: String| draft.set(v) }
button { onclick: move || { send(draft.get()); draft.set(String::new()); }, "Send" }
}
}
}
It sizes the field no more than a value does. An <input>’s
children are not shown at all, and neither is an element child of a
<textarea> — a div built into one through rsx or append_child has no
box, draws nothing and does not size the field (#1178), as in a browser. A control with no width is as wide as its
cols (a textarea, default 20) or size (a text <input>, default 20)
average characters of its font, as in a browser, and a textarea reserves a
scrollbar’s width beside them (#1177). A
textarea’s height comes from rows, which reserves that many lines plus
padding and border — 2 rows when unset, matching HTML.
rows is read as HTML reads it: the leading digits ("3abc" is 3, "2.5" is
2), and 2 when there are none or they come to zero:
#![allow(unused)]
fn main() {
rsx! {
textarea { rows: "6" } // six lines tall
textarea { style: "min-height: 200px;" } // or size it with CSS
}
}
min-height, max-height and an explicit height all work as usual: height
overrides the rows height outright, min-height acts as a floor so the taller
of the two wins, and max-height caps it. The rows height is the textarea’s
content height, as in a browser, so a min-height: 0 textarea in a flex
column can shrink below it. An <input> is sized the same way from one line.
The Textarea component exposes the same thing as min_rows.
Components
Components are custom UI elements written in PascalCase. They implement the Component trait and render directly to DOM nodes:
#![allow(unused)]
fn main() {
rsx! {
Button { variant: "filled", color: "blue", onclick: || println!("clicked"),
"Click me"
}
Alert { icon: TablerIcon::InfoCircle, color: "blue", title: "Info",
"This is an informational message."
}
}
}
Note: Shell-level constructs like windows, menus, and themes are configured at the runtime level via props, not in RSX.
Attributes on a component
A component’s props are the fields of its struct, so Group { gap: "xs" } sets
a field. A hyphenated name can never be a Rust field, so rsx! treats one
written on a component as an attribute for the component’s root DOM
element, applied after Component::render — the same route the universal
style: and class: props take:
#![allow(unused)]
fn main() {
rsx! {
// The whole toolbar takes clicks without taking the keyboard; no wrapper div.
Group { gap: "xs", data-nofocus: "", aria-label: "Formatting",
Button { onclick: move || ed.command("toggleBold"), "B" }
}
}
}
That covers data-*, aria-* and any other hyphenated name (issue #433). The
rules are the HTML element’s rules:
- A boolean attribute — the HTML set and rinch’s own
data-disabled,data-nofocus,data-trap-focus,data-backdropanddata-scroll-lock-exempt— is written as a presence for a truthy value and removed for a falsey one (Boolean attributes). Any other attribute is written as the string. - A reactive value,
data-state: {|| state.get()}, is an effect on the root that rewrites the attribute when its signals change, and re-renders nothing — on a component that re-renders for a reactive struct prop too (issue #1190): the effect belongs to the current render, so a change to the attribute’s signals updates the root in place and the component keeps its local state. When the component re-renders for its struct prop, every attribute is written again on the new root, and a reactive one gets a fresh effect there. The same holds forstyle:,class:and the style shorthands. - The caller writes last. An attribute the component also writes on its own
root takes the caller’s value at mount, and a falsey boolean removes the
component’s. After that the last write wins: a component effect that later
writes the same attribute overwrites the caller’s value, and a reactive
attribute overwrites it back only when its own signals change. That includes
rinch’s own wiring attributes: a caller’s
data-ridon a component replaces the id of the component’s own click handler, so it stops firing. Don’t.
Unhyphenated attribute names — id, tabindex, role — are not routed: on a
component they are still struct fields, and a component that does not declare
one does not compile.
Fragments
Use empty braces to group multiple elements without a wrapper:
#![allow(unused)]
fn main() {
rsx! {
div {
// Multiple children without extra wrapper
span { "First" }
span { "Second" }
}
}
}
Text Content
Text can be included directly in elements:
#![allow(unused)]
fn main() {
rsx! {
p { "This is text content" }
span { "Multiple " "strings " "concatenated" }
}
}
Expressions
Rust expressions can be embedded in curly braces:
#![allow(unused)]
fn main() {
let name = "World";
let count = 42;
rsx! {
p { "Hello, " {name} "!" }
p { "Count: " {count} }
}
}
Reactive Expressions
For fine-grained reactivity, use closure syntax {|| expr} to create expressions that automatically update when signals change. Without the closure, values are captured once at initial render and never update.
Static vs Reactive
#![allow(unused)]
fn main() {
let count = Signal::new(0);
rsx! {
// ❌ STATIC: Captured once, never updates
p { "Count: " {count.get()} }
// ✅ REACTIVE: Creates an effect, updates when count changes
p { "Count: " {|| count.get().to_string()} }
}
}
Reactive Text
Wrap dynamic text in a closure to make it reactive:
#![allow(unused)]
fn main() {
let name = Signal::new("World".to_string());
let count = Signal::new(0);
rsx! {
// Simple reactive text
h1 { "Hello, " {|| name.get()} "!" }
// Reactive with formatting
p { {|| format!("You clicked {} times", count.get())} }
// Conditional text
p { {|| if count.get() > 10 { "Many clicks!" } else { "Keep clicking" }} }
}
}
Reactive Styles
Style attributes can also be reactive:
#![allow(unused)]
fn main() {
let progress = Signal::new(50);
let is_active = Signal::new(false);
rsx! {
// Reactive width
div {
class: "progress-bar",
style: {|| format!("width: {}%", progress.get())}
}
// Multiple reactive style properties
div {
style: {|| format!(
"background: {}; opacity: {}",
if is_active.get() { "blue" } else { "gray" },
if is_active.get() { "1" } else { "0.5" }
)}
}
}
}
Reactive Classes
Dynamically change CSS classes based on state:
#![allow(unused)]
fn main() {
let is_selected = Signal::new(false);
let variant = Signal::new("primary");
rsx! {
// Conditional class
div {
class: {|| if is_selected.get() { "item selected" } else { "item" }}
}
// Dynamic class from state
button {
class: {|| format!("btn btn-{}", variant.get())}
}
}
}
Important: Closure Must Be the Direct Expression
The {|| ...} pattern requires the closure to be the direct expression inside the braces. Block expressions with setup code now work:
#![allow(unused)]
fn main() {
let count = Signal::new(0);
rsx! {
// ✅ CORRECT: Closure is the direct expression
div { style: {|| format!("width: {}px", count.get() * 10)} }
// ✅ CORRECT: Block with setup + final closure — works!
div { style: {
let multiplier = 10;
move || format!("width: {}px", count.get() * multiplier)
}}
// ✅ CORRECT: Compute inside the closure
div { style: {move || {
let multiplier = 10;
format!("width: {}px", count.get() * multiplier)
}}}
}
}
You can compute intermediate values outside the RSX, inside the closure body, or in a setup block before the closure.
Why Closures?
Rust macros operate on syntax, not types. The macro cannot distinguish between:
signal.get()- reading reactive statehashmap.get("key")- reading a regular value
The closure {|| ...} explicitly marks the expression as reactive, telling Rinch to:
- Create an Effect that wraps this expression
- Track which signals are read inside the closure
- Re-run and update the DOM when those signals change
This approach has benefits:
- Zero runtime overhead for static content
- Clear intent - you know exactly what’s reactive
- Works with Rust’s ownership - closures capture values correctly
What Supports Reactive Expressions?
| Context | Fine-Grained? | Notes |
|---|---|---|
| Text content | ✅ Yes | `{ |
style attribute | ✅ Yes | Updates specific element’s style |
class attribute | ✅ Yes | Updates specific element’s class |
| Portal content | ✅ Yes | Content inside portals is reactive |
if/else blocks | ✅ Yes | Native reactive conditional rendering |
for loops | ✅ Yes | Native reactive list rendering with keyed reconciliation |
match blocks | ✅ Yes | Native reactive multi-branch rendering |
.iter().map() | ✅ Yes | Creates a display:contents wrapper for the Vec |
| Window/Menu | N/A | Native OS elements, not DOM |
Control Flow
Rinch supports native Rust control flow directly in RSX. All control flow is always reactive — the conditions, iterators, and scrutinees are automatically wrapped in closures and tracked by Effects. When the underlying signals change, only the affected branches are updated.
if / else
Write standard Rust if/else directly in RSX:
#![allow(unused)]
fn main() {
let visible = Signal::new(true);
let count = Signal::new(5);
rsx! {
div {
// Simple if
if visible.get() {
p { "I'm visible!" }
}
// if/else
if count.get() > 10 {
p { "Big number!" }
} else {
p { "Small number" }
}
// if/else if/else
if count.get() > 100 {
p { "Huge" }
} else if count.get() > 10 {
p { "Big" }
} else {
p { "Small" }
}
}
}
}
Showing a subtree you built earlier
A branch body does not have to build fresh markup. It may yield a NodeHandle
you built once outside the if, and toggling the condition then moves that same
subtree in and out of the page rather than rebuilding it:
#![allow(unused)]
fn main() {
let panel = heavy_panel(__scope); // built once
let open = Signal::new(false);
rsx! {
div {
if open.get() {
{panel.clone()}
}
}
}
}
The subtree keeps its identity across every toggle: its DOM nodes, any state
inside them, and anything else holding a handle into it all survive being
hidden. That is guaranteed on both backends — issue #719 was a period during
which rinch-web lost the subtree on the first hide, silently, while desktop
kept it.
The subtree is kept wherever it sits in the branch’s markup, including
nested inside something the branch builds itself —
if open.get() { div { {panel.clone()} } } — which used to throw it away with
the wrapper (issue #732). The wrapper div is the branch’s own and is rebuilt
on every show and discarded on every hide as usual; panel is detached rather
than discarded along with it, and the next show puts the same panel back
inside a fresh wrapper.
What counts as “built by the branch” is anything minted through the branch’s
RenderScope or through a scope created on its behalf — a for’s rows, a
nested if, a component, a List patching a late row — and anything minted
by raw document access under such a node (an Editor’s blocks, raw HTML). If
your own code builds nodes through a separate RenderScope that belong to the
branch and go with it (an observer patching rows, a timer adding to the
branch’s markup), create it with
RenderScope::with_parent(doc, node, Some(scope.id())), naming the branch’s
scope. Content that must outlive the branch — a cache you show again later
— must not name it: build it with RenderScope::new, or outside the branch,
and it is only ever detached. A RenderScope::new placed inside a branch’s
markup belongs to no branch, so a hide only detaches its nodes; if nothing
shows them again they are never reclaimed.
A cache filled from inside a for
An if can be handed a panel that was built before it. A for row usually
cannot: the row’s subtree depends on the item, so a cache of rows is filled
inside the row’s body, the first time each key is seen. Everything built
through the scope the body is given belongs to the row — its nodes are discarded
when the key leaves the list, and its effects, signals and event handlers are
disposed — so a row cached that way is gone the next time its key appears
(issue #733). On rinch-web nothing is shown for it; on desktop the nodes come
back and their reactive bindings do not.
Build a cached row through a cache scope instead, and keep the scope with the row:
#![allow(unused)]
fn main() {
type TabCache = Rc<RefCell<HashMap<u32, (NodeHandle, RenderScope)>>>;
/// Hand back the cached tab, or build it once and keep its scope with it.
fn cached_tab(scope: &mut RenderScope, cache: &TabCache, id: u32) -> NodeHandle {
if let Some((tab, _keep)) = cache.borrow().get(&id) {
return tab.clone();
}
let mut keep = scope.cache_scope();
let tab = keep.build(|__scope| rsx! {
article { {expensive_tab(__scope, id)} }
});
cache.borrow_mut().insert(id, (tab.clone(), keep));
tab
}
#[component]
fn tab_strip(open_tabs: Signal<Vec<u32>>) -> NodeHandle {
let cache = TabCache::default();
// Let go of every cached tab when the strip unmounts.
let evict = cache.clone();
__scope.on_cleanup(move || {
for (_, (tab, keep)) in evict.borrow_mut().drain() {
keep.dispose();
tab.discard();
}
});
rsx! {
div {
for id in open_tabs.get() {
{cached_tab(__scope, &cache, id)}
}
}
}
}
}
(This block is compiled and run as
crates/rinch-macros/tests/rsx_for_cache_scope_733.rs.)
-
scope.cache_scope()returns a newRenderScopeon the same document that belongs to no row and no branch. Nothing the list does discards what it builds. -
keep.build(|__scope| …)builds through it and makes it the owner of everySignal::new,Memo::new, store and event handler created inside. Do not skipbuildand use the cache scope’s&mutdirectly: the row then comes back with its nodes, but aSignal::new, a memo or anonclickmade while building belonged to the row that happened to be rendering and was freed with it. A write to that signal is then a no-op, the bindings that read it never update, and nothing panics or warns. -
The scope and the node are released together, by you. They are two values, and nothing ties them:
- drop the scope and keep the node, and the node comes back inert — its effects, signals and handlers were disposed;
- discard the node and keep the scope, and its effects go on running against a node that no longer exists, for as long as the scope lives;
- drop both without discarding the node — which is what happens when the
cache itself is dropped, because the component that owns it unmounted — and
the effects stop but every cached node stays in the backend for the life of
the page. Measured on the mock and in Chrome 153: 50 mounts of a list of
two cached rows leave 200 nodes behind without the
on_cleanupabove, and none with it.
So evicting one entry is
scope.dispose()and thennode.discard(), and a cache owned by anything that can unmount drains itself inon_cleanup, astab_stripdoes. A key that never comes back holds its row until then. (One value that owns both and releases them in step is issue #1454.) -
A cached row is not rebuilt when its item changes. The body is called again for a changed item under the same key and hands the same node back, so anything in it that should follow the item has to be reactive.
The same works in an if or match body whose cache is filled lazily.
What tells you when a cached row was built the wrong way. If a body’s
root node — the node the body itself evaluates to — was already discarded,
rinch counts it and emits a tracing warning that names cache_scope, once per
node and for at most 32 nodes per thread. That is narrower than it sounds:
- It is a
tracingevent, and nothing shows it unless a subscriber is installed. The desktop shell installs one;rinch-webinstalls none (issue #1455), so a browser app sees it only with a console subscriber of its own (thetracing-wasmcrate is one), andcargo testprints it only if the test installs one (tracing_subscriber::fmt().with_test_writer()). What a test can assert on without one is the counter,rinch_core::dom::__retired_view_returns()—(returns seen, nodes warned about)on the current thread. - Only a backend that reclaims discarded nodes can notice:
rinch-web, and theMockDomDocumentyour component tests run on. Desktop reclaims nothing yet, so it shows the row and reports nothing. - A discarded node nested in fresh markup is not noticed.
for t in tabs.get() { div { class: "tab", {cached} } }withcachedbuilt through the row’s scope loses the row in the same way — thedivcomes back empty — and nothing is counted or logged, because the body’s root is the freshdiv(issue #1456).
So the absence of a warning proves nothing: test a cached list on the mock or in a browser, not only on desktop, and assert that the row is there.
Two more things to know. The branch’s RenderScope is disposed on every hide,
so effects created inside the branch closure stop; put the reactive wiring in
the same scope as the panel binding, not in the closure. And a subtree that is
out of the page is not laid out, so a measurement taken while it is hidden is
stale.
Plain markup in a branch — if open.get() { p { "hi" } } — is rebuilt on every
show and thrown away on every hide, which is what you want for anything cheap.
if let
Pattern matching with if let is also supported:
#![allow(unused)]
fn main() {
let current_user = Signal::new(Some("Alice".to_string()));
rsx! {
div {
if let Some(name) = current_user.get() {
p { "Welcome, " {name} "!" }
} else {
p { "Please log in" }
}
}
}
}
How if works internally
The if block desugars to show_dom() — the same runtime function used by the Show component. The condition is auto-wrapped in a move || { ... } closure, creating an Effect that watches the signals read inside it. When the condition changes:
- The old branch’s scope is disposed (cleaning up nested effects)
- Old DOM nodes are removed
- New branch content is rendered with a fresh scope
Braces around control flow
A brace in child position is transparent to control flow: { match x { … } }
renders the same reactive match_dom as match x { … }, and the same goes for
if and for, at any depth — including a braced match arm body,
_ => { if … }.
That is worth stating because the two used to differ, silently. A braced construct was parsed as a plain Rust expression and evaluated once, so it kept whichever branch was true when it mounted and never changed again — one brace apart from a fully reactive twin, with no diagnostic (issue #221).
Braced control flow whose bodies are not rsx cannot be made reactive — there is no markup for the runtime to swap — so it is a compile error now instead of a silent freeze:
#![allow(unused)]
fn main() {
// error: `match` wrapped in braces renders once and never updates
{ match selected.get() {
Some(p) => rsx! { div { {p.name.clone()} } },
None => rsx! { div { "—" } },
} }
}
The message names both ways out, because which one is right depends on what you
meant. For reactive markup, drop the braces and write each arm as rsx rather
than as a nested rsx! invocation:
#![allow(unused)]
fn main() {
match selected.get() {
Some(p) => div { {p.name.clone()} },
None => div { "—" },
}
}
For a reactive value, wrap the whole thing in a closure — the reactive expression form from earlier in this guide:
#![allow(unused)]
fn main() {
{|| match selected.get() {
Some(p) => p.name.clone(),
None => "—".to_string(),
}}
}
Everything else in braces is untouched: an expression ({ count.to_string() }),
a reactive closure ({|| count.get()}) and a call ({ section(__scope) }) never
begin with a control-flow keyword, so they are the expressions they always were.
Capturing the same value in more than one branch
Every closure RSX builds is a move closure — a branch, a match arm, a
per-item view, a reactive binding’s effect, an event handler — and show_dom
takes the then branch and the else branch, so both are constructed
whichever way the condition comes out. A non-Copy value named by two of them
would be moved twice.
The macro handles that for you. At each construction site it emits a shadow
clone of what that site shares with a sibling site, or reaches for from outside
a body that re-runs. So this compiles, and label is still yours afterwards:
#![allow(unused)]
fn main() {
let label = String::from("hello");
rsx! {
div {
if label.is_empty() {
p { "empty" }
} else {
p { {label.clone()} }
}
Text { {label.clone()} }
}
}
}
What is not cloned. A value only one site names is moved, exactly as before,
so a type that isn’t Clone keeps working where it always did. A value bound
inside a repeating body — a let at the top of a branch, the for item
itself — is a fresh local on every run and is never cloned. Signal and Memo
are Copy, so where a shadow does fall on one it is a copy, not an allocation.
What still needs a manual .clone(). The analysis is lexical: it cannot see
a value hidden inside a macro (format!("{}", label) hides label from it), and
it compares sites within one construct — two separate if blocks that both name
one String still need one of them to clone. When rustc does report an E0382 or
E0507 from inside rsx!, the fix is the ordinary one: give the site that
complains its own clone.
for loops
Write standard for..in loops directly in RSX:
#![allow(unused)]
fn main() {
let todos = Signal::new(vec![
Todo { id: 1, name: "Buy groceries".into() },
Todo { id: 2, name: "Write code".into() },
Todo { id: 3, name: "Take a walk".into() },
]);
rsx! {
div {
for todo in todos.get() {
div { key: todo.id, {todo.name.clone()} }
}
}
}
}
Keys
Use key: on the first child element or component to enable efficient keyed reconciliation. When the list changes, items with the same key are preserved (not re-rendered), and only new/removed items trigger DOM operations:
#![allow(unused)]
fn main() {
for item in items.get() {
div { key: item.id,
span { {item.name.clone()} }
}
}
}
key: also works on components — place it on the component tag directly:
#![allow(unused)]
fn main() {
for item in items.get() {
TodoRow { key: item.id, label: item.name.clone() }
}
}
If no key: prop is provided, items are keyed by their Debug representation (fallback).
Keys must be unique within one list. The whole reconcile rests on one key naming one item and one DOM node, so a repeat used to leave a row that rendered, swallowed clicks and never updated again (issue #185). What a repeat means depends on who chose the key:
| Rule | |
|---|---|
You wrote key: | A repeat is a mistake in your key. The repeat is not rendered and a warning is logged — the first occurrence wins, the same rule React applies to duplicate key props. |
No key: | The framework fabricated the key from format!("{:?}", item), so a repeated value is not your mistake. Rinch makes the fabricated key unique by its occurrence ordinal instead: every row renders. |
So for tag in ["rust", "rust", "gui"] renders three rows, and
for n in vec![1, 1, 2] renders three rows. Reordering such a list still moves
rows rather than rebuilding them, because the ordinal follows the value, not
the position.
The one thing the fallback cannot give you is stable identity across an edit:
if the first "rust" is deleted, the second one inherits its key and its DOM
node. Where rows carry per-row state, give them a real key::
#![allow(unused)]
fn main() {
for (i, n) in numbers.get().into_iter().enumerate() {
div { key: i, {n.to_string()} }
}
}
Prefer a stable per-row id where the data has one. An index key makes identity follow position, so inserting or removing anywhere but the end re-renders every row after the change — correct, but it gives up the reconciliation the keys are there for. Reach for the index only when the rows genuinely have nothing else to distinguish them.
How for works internally
The for loop desugars to for_each_dom_typed(), which:
- Wraps the iterator in a
move || { ... }closure (making it reactive) - Creates a
<!-- for -->comment marker in the DOM - Evaluates the collection and renders initial items
- Creates an Effect that watches the collection
- When the collection changes, uses LIS-based keyed reconciliation (
diff_keyed()) to compute minimal DOM operations: insert new items, remove deleted items, and reposition moved items - For surviving items (same key), compares data via
PartialEq— only re-renders items whose data actually changed
The loop variable is owned (T, not &T), so you can capture it directly in move closures. let bindings before the element are available in the key: expression too:
#![allow(unused)]
fn main() {
for todo in todos.get() {
let id = todo.id;
div { key: id,
{todo.name.clone()}
button {
onclick: move || todos.update(|t| t.retain(|t| t.id != id)),
"Delete"
}
}
}
}
A binding counts as used if either the key: expression or the body reads it, so an item
used only as a key (for id in ids { span { key: id, "row" } }) does not warn unused variable,
and neither does an if let or match binding its branch reads. A binding nothing reads still
warns, at your pattern. (For a for loop the check is by name, so a binding that both the key
and the body merely spell — as a path segment or a struct-literal field name, say — can go
unreported.)
Note: The item type must implement Clone + PartialEq + 'static for for loops to work. This enables efficient data comparison for selective re-rendering.
Reactivity in for Loops
The for loop expression itself is reactive — it reads a Signal, which creates an Effect. When that Signal changes, the loop re-evaluates and reconciles the list. Items whose data changed (per PartialEq) get their component re-created with fresh props. This means the component function runs again with the new values.
Because of this, closures on plain props inside for-loop components are unnecessary — the whole function runs again with new values when the item changes. Closures are needed for per-item Signals created with Signal::new() inside the loop body, since those change independently of the list data.
#![allow(unused)]
fn main() {
// Props are plain values from the for loop — no closure needed
#[component]
pub fn TodoItem(label: String, completed: bool) -> NodeHandle {
// ❌ Unnecessary: `completed` is a plain bool, not a Signal.
// The closure captures it once, but the component is re-created
// with a fresh `completed` value when the item data changes anyway.
rsx! { Text { style: {|| if completed { "text-decoration: line-through" } else { "" }} } }
// ✅ Simpler: just use the value directly
rsx! { Text { style: if completed { "text-decoration: line-through" } else { "" } } }
}
// Per-item Signal — closure IS needed
for todo in todos.get() {
let editing = Signal::new(false); // Per-item state
div { key: todo.id,
// ✅ Closure needed: `editing` is a Signal that changes independently
span { style: {|| if editing.get() { "outline: 1px solid blue" } else { "" }} }
}
}
}
Rule of thumb: If the value comes from a Signal (.get()), use a closure. If it comes from the for loop variable (a plain value), just use it directly.
Filtering and Transforming Collections
You can filter, map, and transform the collection inline in the for expression. The entire expression is wrapped in a reactive closure by the macro, so all Signals read inside it are tracked:
#![allow(unused)]
fn main() {
let todos = Signal::new(vec![/* ... */]);
let filter = Signal::new(Filter::All);
rsx! {
div {
// Filter the collection inline — .filter(), .map(), .collect() all work
for todo in todos.get().into_iter().filter(|t| {
match filter.get() {
Filter::All => true,
Filter::Active => !t.completed,
Filter::Completed => t.completed,
}
}).collect::<Vec<_>>() {
TodoItem { key: todo.id, label: todo.text.clone() }
}
}
}
}
Both todos and filter are tracked — the loop re-evaluates when either Signal changes. Any iterator chain that produces a Vec<T> (where T: Clone + PartialEq + 'static) works.
match
Write standard Rust match directly in RSX:
#![allow(unused)]
fn main() {
let tab = Signal::new(0);
rsx! {
div {
match tab.get() {
0 => div { "Home page" },
1 => div { "About page" },
2 => div { "Settings page" },
_ => div { "Page not found" },
}
}
}
}
Match with pattern bindings
Patterns that bind variables work — each arm re-evaluates the scrutinee to extract the bound values:
#![allow(unused)]
fn main() {
let result = Signal::new(Ok::<String, String>("Hello".into()));
rsx! {
div {
match result.get() {
Ok(value) => p { "Success: " {value} },
Err(msg) => p { class: "error", "Error: " {msg} },
}
}
}
}
Match with guards
Guard expressions are supported:
#![allow(unused)]
fn main() {
match score.get() {
n if n >= 90 => div { "A" },
n if n >= 80 => div { "B" },
n if n >= 70 => div { "C" },
_ => div { "F" },
}
}
Several nodes in one arm
An arm body in braces can hold several nodes, as an if or for body can —
they are rendered together as the arm:
#![allow(unused)]
fn main() {
match tab.get() {
0 => { h2 { "Home" } p { "Welcome back." } },
1 => { let n = count.get(); b { {n.to_string()} } " items" },
2 => { Badge { "new" } Badge { "beta" } },
_ => { overview_section(__scope) }, // one expression, as always
}
}
What the braces hold is decided by how they start:
letalways starts rsx nodes.- An element or component (
div {,Card {), a string literal,if/for/match, or an interpolation{ … }starts rsx nodes when more nodes follow it:{ {label} span { "!" } },{ if open.get() { "▾" } span { "Menu" } }. - An element, component or string literal on its own is that one node.
- Anything else is a single Rust expression, as it always was: a call, a
variable, a closure, and a method called on a literal or on a struct
(
{ "a".to_string() },{ if a { "x" } else { "y" } .len() }). So{ overview_section(__scope) },{ panel.clone() }and{|| count.get().to_string()}are unchanged. Lone control flow in braces is the transparent brace described under Braces around control flow.
(Until issue #395 only a body starting with let could hold more than one node.)
A mistake inside a multi-node arm is reported by the rsx parser, at the
mistake, like one in any other rsx body — including one inside a leading
if/for/match when another node follows it. (Finding that following node
after a head that failed to parse is a token-level heuristic: a condition that
itself holds braces, such as if let Foo { a } = x, can defeat it and leave you
with the single-node error instead.) A lone braced if/for/match whose
bodies are not rsx still gets the “renders once” error described under
Braces around control flow.
One consequence: { Point { x: 1 } } in an arm is a component named Point,
exactly as Point { x: 1 } unbraced is, not a Rust struct literal. A struct
literal that cannot be an element — shorthand fields ({ Foo { a } }), a ..
base ({ Foo { ..Default::default() } }), or one with a method called on it
({ Point { x: 1 }.into_node(__scope) }) — stays an expression, and so does a
path such as geom::Point { x: 1 }, which is not an element name.
How match works internally
The match block desugars to match_dom(), which generalizes show_dom() to N branches. A discriminant closure returns the index of the active branch (0, 1, 2, …). When the discriminant changes, the old branch is disposed and the new branch is rendered.
Programmatic Conditional Rendering (show_dom)
For cases requiring explicit control (e.g., lazy evaluation of children that contain hooks), use show_dom() directly:
#![allow(unused)]
fn main() {
show_dom(
__scope,
&parent,
move || visible.get(), // Condition closure
|scope| { // Then branch
let div = scope.create_element("div");
div.set_text("Visible!");
div
},
Some(|scope| { // Else branch (optional)
let div = scope.create_element("div");
div.set_text("Hidden");
div
}),
)
}
Programmatic List Rendering (for_each_dom_typed)
For cases requiring the raw list API, use for_each_dom_typed() directly:
#![allow(unused)]
fn main() {
for_each_dom_typed(
__scope,
&parent,
move || todos.get().into_iter().collect::<Vec<_>>(),
|todo| todo.id.to_string(),
|todo, __child_scope| {
let __scope = __child_scope;
rsx! { div { {todo.name.clone()} } }
},
)
}
How Keyed Reconciliation Works
When the list changes, for uses the LIS (Longest Increasing Subsequence) algorithm to find the minimum DOM operations:
#![allow(unused)]
fn main() {
// Before: ["a", "b", "c", "d"]
// After: ["b", "e", "c", "a"]
// Operations:
// - Remove "d"
// - Insert "e" at index 1
// - Move "a" to index 3
// - Items "b" and "c" stay in place (part of LIS)
}
This means:
- Unchanged items keep their DOM nodes (no re-render)
- Moved items are repositioned in DOM (no re-create)
- Changed items are re-rendered if their data differs (via
PartialEq) - Internal state (signals, effects) is preserved for unchanged items
- Only new items trigger component initialization
Note: The loop variable is owned (
T, not&T), so you can capture it directly inmoveclosures without manual field extraction.
Event Handlers
Events use the onevent: handler syntax:
#![allow(unused)]
fn main() {
// Inside a #[component] function:
let count = Signal::new(0);
rsx! {
button {
onclick: move || count.update(|n| *n += 1),
"Increment"
}
}
}
Sharing Event Handlers
You can extract event handlers into variables and reuse them across multiple elements. This is especially useful when a button and a text input should trigger the same action:
#![allow(unused)]
fn main() {
let input_text = Signal::new(String::new());
let todos = Signal::new(Vec::<String>::new());
// Extract shared logic into a closure
let add_todo = move || {
let text = input_text.get();
if !text.trim().is_empty() {
todos.update(|t| t.push(text.trim().to_string()));
input_text.set(String::new());
}
};
rsx! {
// Both TextInput (on Enter) and Button (on click) use the same handler
TextInput {
value_fn: move || input_text.get(),
oninput: move |value: String| input_text.set(value),
onsubmit: add_todo,
}
Button { onclick: add_todo, "Add" }
}
}
This works because Signal implements Copy, so closures capturing signals can be used in multiple places without .clone().
Style Shorthands
The rsx! macro supports CSS shorthand props on both HTML elements and components. Shorthands expand to set_style() calls that merge with existing styles.
Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}).
On a positioned element the top/right/bottom/left shorthands accept any CSS length — px, %, em, var(), calc() — not just the spacing scale. A calc() mixing % with a length (calc(50% - 10px)) resolves correctly on desktop too (#278) — for insets, sizes, margins, padding, gaps, transforms and border radii alike. One named limitation: a non-affine calc() (a min()/max()/clamp() with a percentage inside) resolves as its large-container linearization on desktop rather than tracking the breakpoint.
Available Shorthands
| Shorthand | CSS Property | Shorthand | CSS Property |
|---|---|---|---|
w | width | m | margin |
h | height | mt | margin-top |
miw | min-width | mb | margin-bottom |
maw | max-width | ml | margin-left |
mih | min-height | mr | margin-right |
mah | max-height | mx | margin-left + margin-right |
p | padding | my | margin-top + margin-bottom |
pt | padding-top | display | display |
pb | padding-bottom | pos | position |
pl | padding-left | top | top |
pr | padding-right | bottom | bottom |
px | padding-left + padding-right | left | left |
py | padding-top + padding-bottom | right | right |
Usage
#![allow(unused)]
fn main() {
// On HTML elements
div { p: "md", m: "lg", w: "200px", "Padded and margined" }
// On components
Stack { gap: "md", p: "xl", maw: "600px",
Text { "Constrained content" }
}
// Reactive shorthands
let big = Signal::new(false);
div { p: {|| if big.get() { "xl" } else { "sm" }}, "Dynamic padding" }
// Spacing scale values auto-resolve
div { p: "md" } // becomes: padding: var(--rinch-spacing-md)
div { p: "20px" } // becomes: padding: 20px (passed through)
}
Application Order
style: is merged, not assigned (issue #647). Its declarations are laid over whatever is already on the element — a shorthand prop, something the runtime wrote through set_style(), and on a component every inline declaration the component’s own render wrote — last-wins per property:
#![allow(unused)]
fn main() {
// The component publishes `--rinch-modal-z-index: 517` on its root from
// `render`; the caller's `margin: 0` joins it rather than replacing it.
Modal { z_index: 517, style: "margin: 0" }
// `padding` survives the first signal change. It used to be erased, because
// the re-firing `style:` effect wrote the whole `style` attribute.
div { style: {|| format!("color: {}", tint.get())}, p: "md" }
}
Shorthands are applied through set_style() after component rendering and after the style: prop, so a shorthand wins a collision with style: at mount:
#![allow(unused)]
fn main() {
div { style: "padding: 0", p: "12px" } // padding is 12px
}
A reactive style: takes that back on its first re-fire, and keeps it:
#![allow(unused)]
fn main() {
let css = Signal::new(String::from("padding: 0"));
div { style: {move || css.get()}, p: "12px" }
// padding is 12px at mount, and 0 from the first `css.set(...)` onward
}
The shorthand is applied once, at mount; nothing re-asserts it. Declaring the same property from both props on one element is the mistake — pick one. Everything else composes:
#![allow(unused)]
fn main() {
let css = Signal::new(String::from("margin: 0"));
div { style: {move || css.get()}, mt: "8px" } // margin-top stays 8px, always
}
That second line is the shape the merge has to get right, and it is not a collision: margin-top beats margin only while it stays after it in the block, so a re-run must overwrite the closure’s margin where it stands rather than removing and re-appending it. A reactive style: also never accumulates — a property the closure has stopped naming is removed, or restored to the value it had been overriding — and it leaves alone any property whose current value somebody else has written since, rather than reverting a value it never saw.
An element with no second author on its style attribute keeps the author’s string verbatim, on every fire.
One parser, and what it does with an awkward value
Every author of an inline style — a style: prop, a style shorthand, a set_style() from a component or the runtime — splits and re-joins the attribute with the same parser (issue #670). Four things follow that you can rely on:
- A
;or:insideurl(…),"…"or'…'is part of that value. Abackground-image: url(data:image/png;base64,…)already on the element survives every later write by anybody else.rinch-domused to split on a bare;, so the next unrelatedset_style()on that node cut the URI in half and the image vanished. - A property name is ASCII case-insensitive, as CSS makes it (issue #711).
style: "color: blue; COLOR: red"is one property declared twice, not two properties, and a laterset_style("color", …)replaces it however either was spelled. A custom property is the exception and is compared exactly:--Fooand--fooare two variables, here and in a browser. - A property declared twice collapses at the last declaration’s position, which is what a browser does. That is behaviour and not spelling as soon as a shorthand is involved:
style: "inset: 0px; left: 25px; inset: 4px"computes aleftof4px, because the survivinginsetsits after theleftit overrides. - …but the value that survives is the
!importantone, which is not always the last.style: "color: red !important; color: blue"stays red, as in a browser. The slot still moves to the last declaration’s position; only the value is decided by priority.
One caveat on the last two, and it only bites on invalid CSS (issue #722): rinch collapses duplicates before validity where a browser collapses after. A browser throws an invalid declaration away as it parses, so the duplicate never competes; rinch keeps it, and the property can end up with no usable value. style: "color: notacolor !important; color: blue" computes blue in a browser and falls back to black here. Two valid declarations — the only shape worth writing — behave identically.
What the attribute reads back as afterwards is the CSSOM-touched form: names lowercased, duplicates collapsed. A browser keeps the author’s bytes in getAttribute("style") until something touches the CSSOM and rinch re-serialises on every merge, so rinch reaches that form one write earlier — the two agree on the result, not on when it appears. An element nobody has written to since is untouched either way.
/* … */ comments are removed before any of this — from a property name as readily as a value — everywhere CSS sees one, and each leaves a space behind, because a comment ends the token before it even though it is not one itself. The two places CSS does not see a comment are inside a string and inside an unquoted url(…), which is a single token: url(http://a/*b*/c.png) keeps its /*b*/ and names the image the author wrote, as in a browser. There is one place a browser sees a comment and keeps it where rinch removes it — the interior of a custom property value, where Chrome reads --x: 1px /* c */ 2px back verbatim and rinch reads it back as 1px 2px. The token stream is the same either way, so a var() substitution cannot tell; only the attribute string differs. An !important is carried through the value untouched; it is read, never rewritten, and the reading honours CSS’s own looseness — red!important, red ! IMPORTANT and red !ImPoRtAnT are all important, while a !important inside a string or url(…), or one whose ! is escaped, is ordinary text.
Styling
Inline styles and CSS classes work like regular HTML:
#![allow(unused)]
fn main() {
rsx! {
html {
head {
style {
"
.container { padding: 20px; }
.highlight { color: red; }
"
}
}
body {
div { class: "container",
span { class: "highlight", "Styled text" }
}
}
}
}
}
State Management
There are no hooks. There is no re-rendering. Your component function runs once, builds the DOM, and closures keep it updated forever. Here’s how you manage state.
Core Primitives
| Primitive | What it does |
|---|---|
Signal::new(value) | Reactive state. Read it, write it, closures that read it re-run when it changes. |
Memo::new(closure) | Cached derived state. Like a Signal you can’t write to. |
create_store(value) | Share a store struct across components. |
use_store::<T>() | Access a shared store from any descendant. |
create_context(value) | Low-level shared state (mostly for framework internals). |
For shared state, use stores. A store is a struct with Signal fields and methods that mutate them. Clean, testable, no prop drilling.
Signal
The foundational reactive primitive. Create one, read it in closures, and those closures re-run when the value changes.
#![allow(unused)]
fn main() {
let count = Signal::new(0);
count.get(); // Read
count.set(5); // Write
count.update(|n| *n += 1); // Read-modify-write
}
Signal<T> is Copy. Use it in as many closures as you want — no .clone() needed.
#![allow(unused)]
fn main() {
#[component]
fn toggle() -> NodeHandle {
let on = Signal::new(false);
rsx! {
button { onclick: move || on.update(|b| *b = !*b),
{|| if on.get() { "ON" } else { "OFF" }}
}
}
}
}
Cross-Thread Dispatch
Signal::set() and Signal::update() must be called from the main thread. From a background thread, use send() and update_send():
#![allow(unused)]
fn main() {
let progress = Signal::new(0);
std::thread::spawn(move || {
for i in 0..100 {
std::thread::sleep(Duration::from_millis(50));
progress.send(i); // Dispatches to main thread automatically
}
});
}
For more details, see Signals.
Memo
Cached derived state that recomputes only when its dependencies change.
#![allow(unused)]
fn main() {
let first = Signal::new("Alice".to_string());
let last = Signal::new("Smith".to_string());
let full = Memo::new(move || format!("{} {}", first.get(), last.get()));
// full.get() recomputes only when first or last change
}
Memo<T> is also Copy. Dependencies are tracked automatically — no dependency arrays.
For more details, see Memos.
Effect (Advanced)
Most reactive DOM updates happen through {|| expr} closures in RSX. For the rare case where you need to react to signal changes outside of the DOM (logging, syncing to an external system), use Effect:
#![allow(unused)]
fn main() {
use rinch::reactive::Effect;
let count = Signal::new(0);
Effect::new(move || {
println!("Count is now: {}", count.get());
});
}
Effect is intentionally excluded from the prelude. If you’re reaching for it, ask yourself: can this be a closure in RSX, or a method on a store? Usually the answer is yes.
For more details, see Effects.
Lifetimes: What Owns Your State
State created during a render belongs to the thing being rendered, and dies with it. You rarely have to think about this — it is what stops a list that churns 10,000 rows from leaking 10,000 signals — but it becomes visible the moment a handle outlives its component.
The rule: a Signal, Memo, Effect or event handler created while
something is rendering is owned by that render. When the owner goes away — a
for row is reconciled out, an if branch flips, a component unmounts — every
resource it owned is freed.
No ambient owner means app lifetime. A signal created in main(), in startup
code, or in a detached callback has no owner and lives as long as the thread.
That is why hoisted, app-wide state keeps working with no ceremony.
Reading a freed handle panics; writing to one does not
Signal<T> is Copy, so nothing stops a handle from outliving what created it —
a background thread, a timer, a global registry. Reads and writes deliberately
behave differently:
#![allow(unused)]
fn main() {
count.get(); // panics if the signal was freed
count.set(5); // no-op plus a warning if the signal was freed
}
You may always write to a handle; you may only read a live one.
The asymmetry is not an oversight. A background thread that calls send() every
50ms cannot be asked to notice that the UI moved on, so a write to a dead signal
must be survivable. A read has nothing to return — T need not implement
Default — so it asserts.
When a handle may legitimately be gone, ask instead of assuming:
#![allow(unused)]
fn main() {
if let Some(n) = count.try_get() { // None if freed
println!("still here: {n}");
}
let len = items.try_with(|v| v.len()); // Option<usize> — None if freed
if count.is_alive() { // no subscription is created
schedule_more_work();
}
}
Memo<T> has try_get() and is_alive() too. is_alive() never subscribes the
current observer — liveness is not reactive, and tracking it would resurrect the
dependency you are trying to drop.
Global callback registries are released too
A callback handed to a process-wide registry is the classic way for a component’s
state to outlive the component — the registry keeps the closure, the closure keeps
a Signal, and the next event reads a handle whose storage is gone. Registering
one from inside a render therefore ties it to that render:
#![allow(unused)]
fn main() {
// `set_keyboard_interceptor` is not in the prelude — it is a document-level
// capture-phase hook, not everyday component API. `set_paste_interceptor` is.
use rinch::core::set_keyboard_interceptor;
#[component]
fn shortcuts() -> NodeHandle {
let count = Signal::new(0);
// Released when this component unmounts — the interceptor cannot outlive
// `count` and read it after it is freed.
set_keyboard_interceptor(move |k| {
// Releases arrive here too (`k.is_up()`) — gate on the press or a
// held "j" counts twice.
if k.is_down() && k.key == "j" { count.update(|n| *n += 1); true } else { false }
});
rsx! { div { {|| count.get().to_string()} } }
}
}
This applies to set_keyboard_interceptor, set_paste_interceptor,
set_selection_callback and set_selection_sync_callback (issue #183). Two
consequences are worth knowing:
- Registering with no ambient owner still means app lifetime. A hook installed
from
main(), from startup code or from a detached callback has no owner, so nothing removes it — unchanged, and what app-wide shortcuts rely on. - An earlier component unmounting never clears a later one’s registration.
These are single, last-wins slots — per document, since two documents can
share a thread (issues #340, #478): a registration made while a document’s
code is running — its event dispatch, its mount, or an effect it owns
(issue #295), or a timer,
run_on_main_threadclosure or http/ws callback armed from any of those (issue #963) — belongs to that document, one made outside any document (frommain, a timer armed there, on rinch-web) is the thread-global fallback, and each document is served whichever of its own and the fallback is newer — so the last registration wins in a single-document app wherever it was made. A clear from outside any document clears every entry; one from inside a document leaves that document with nothing. With several documents on one thread that cuts both ways: code outside any document —main, a timer armed there, a closure queued from a worker thread, a native menu or tray callback — overrides every document’s own registration and its clear wipes them all. The release only reclaims the slot if it is still holding the callback that registered it. - Register once per component, not once per event. Each call from inside a live component queues its own release, and those accumulate until the component unmounts. Installing a hook from a render (as above) is the intended shape; re-installing one on every click or on every run of a reactive closure is not.
rinch-ws callbacks work the same way from the outside — an on_message
registered inside a component stops firing once that component unmounts, even if
the WsHandle was parked somewhere longer-lived:
#![allow(unused)]
fn main() {
// `WsHandle` is neither `Clone` nor `Copy`, so a store holds it behind an `Rc`:
// #[derive(Clone)] struct AppStore { socket: Rc<WsHandle> }
#[component]
fn feed() -> NodeHandle {
let lines = Signal::new(Vec::<String>::new());
// The socket outlives this component: the store holds the handle open.
let ws = use_store::<AppStore>().socket;
// Holds `lines`, which this component owns, so it must stop firing when
// this component goes away.
ws.on_message(move |m| {
if let Some(t) = m.as_text() {
lines.update(|v| v.push(t.to_string()));
}
});
rsx! { div { {|| lines.get().join("\n")} } }
}
}
The mechanism underneath differs, and the difference is visible in one place.
The interceptor slots above release the callback when the scope disposes; a
WebSocket callback is checked at dispatch and dropped by the first event that
arrives after the component is gone. That is deliberate: a socket’s callbacks can
be re-registered as often as the app likes, and queueing a release per
registration would grow without bound. The practical consequence is that the
“register once per component” advice above does not apply to rinch-ws — and
that a callback belonging to an unmounted component is released on the next
event rather than at unmount.
Each of a connection’s four callbacks carries its own owner, so one handle may be
shared by two components — a message list registering on_message, a
connection-status badge registering on_close — and each stops on its own
component’s unmount rather than on the other’s. A slot that will never be
dispatched again (on_open after the handshake) is released by the first event
that finds any of the connection’s callbacks dead.
Android platform callbacks
The rinch-android services follow the rinch-ws rule, for the same reason —
they are registered from live components, often more than once:
#![allow(unused)]
fn main() {
use rinch_android::sensors::{DELAY_UI, SensorType};
#[component]
fn compass() -> NodeHandle {
let heading = Signal::new([0.0f32; 3]);
// Stops firing, and is released, when this component unmounts. Before, only
// an explicit `stop()` removed it, and the callback kept reading `heading`
// after it had been freed.
rinch_android::sensors::start(SensorType::MagneticField, DELAY_UI, move |d| {
heading.set([d.values[0], d.values[1], d.values[2]]);
});
rsx! { div { {|| format!("{:?}", heading.get())} } }
}
}
This covers sensors::start, location::start, lifecycle::on_pause /
on_resume, and the one-shot results behind camera::pick_image,
camera::take_photo, file_picker::pick_file / save_file and
permissions::request_permission. Four things follow:
- The one-shots are covered too. They are removed on delivery, which bounds
the leak but not the lifetime: Android delivers a result whatever the user does
— a cancelled picker still reports
RESULT_CANCELED— so a picker opened by a component that has since unmounted used to be delivered exactly once, into that component’s freed state. It is now discarded instead. - Release does not wait for an event. Every one of these registries is
drained once a frame, and each drain also releases what unmounted components
left behind. A sensor that has fallen silent, an activity result that never
comes back and an app that never backgrounds would otherwise pin their
callbacks — and everything captured — for the life of the process. Each
release is logged at
debug, because the symptom is otherwise silent: the callback simply stops firing. - Releasing a sensor or location callback powers the hardware down, because
nothing else can. Once the entry is gone,
sensors::stophas noSensorTypeto be called with and the component that knew it is disposed — so the release callsstopSensor/stopLocationUpdatesitself, rather than freeing aBoxand leaving a radio on. Only what it actually released: a sensor another component is still using stays armed, including one re-registered from inside the released callback’s ownDrop. - Registration from
android_mainkeeps app lifetime, unchanged. That is what an app-wideon_pauseautosave relies on. - Stopping from inside the callback works. “Stop the sensor once the reading
crosses a threshold”, or “stop location once the fix is accurate enough”, used
to panic with a
BorrowMutError, because the registry was borrowed across the call. So did swapping a lifecycle handler from inside one.
sensors::stop and location::stop remain the way to stop early — the moment
you have the fix you wanted, rather than at unmount. What they no longer have to
be is a leak-preventing ritual.
Keeping the screen on: a guard, not a callback
One Android service ties itself to the component by a third shape.
screen::keep_screen_on() returns a guard — the display is held awake
while the KeepScreenOn value is alive, and dropping it releases the hold
(refcounted, so several holders compose). There is no callback to check at
dispatch: a window flag is written once and never dispatched again, so nothing
would ever visit it to notice a dead owner. The guard supplies the release
structurally instead.
Most callers want the reactive form, which is also where unmount release comes from — the effect underneath is scope-owned, and disposal drops the closure holding the guard:
#![allow(unused)]
fn main() {
#[component]
fn player() -> NodeHandle {
let playing = Signal::new(false);
// Held while `playing` is true; released when it goes false — or when
// this component unmounts mid-playback, whichever comes first.
rinch_android::screen::keep_screen_on_while(move || playing.get());
// ...
}
}
keep_screen_on_while(|| true) reads as “while this component is mounted”.
The opt-out for a kiosk binary that genuinely never releases is
keep_screen_on().leak(), from android_main.
Opting out
Sometimes a resource is meant to outlive the component that created it — state handed to a longer-lived store, a registration something else owns:
#![allow(unused)]
fn main() {
let s = Signal::new(0).leak(); // this one signal gets app lifetime
unowned(|| { /* nothing in here is owned by the current render */ });
}
leak() searches the owner stack as it stands now, so call it in the same
render that created the signal; from a later callback the stack is empty and it
does nothing. unowned is a free function at the crate root rather than in the
prelude, deliberately: it is an attractive nuisance that converts a lifetime bug
into a permanent leak. Reach for try_get() / is_alive() first.
Effects are the exception
Dropping an Effect handle does not stop the effect. Disposal is explicit
(effect.dispose()) or comes from the owning scope. See
Effects.
Context
Share state across components without prop drilling. The ancestor creates it, any descendant reads it.
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppConfig {
api_url: String,
}
#[component]
fn app() -> NodeHandle {
create_context(AppConfig { api_url: "https://api.example.com".into() });
rsx! { div { ChildComponent {} } }
}
#[component]
fn ChildComponent() -> NodeHandle {
let config = use_context::<AppConfig>();
rsx! { Text { {config.api_url.clone()} } }
}
}
use_context::<T>() panics if the context is missing (with a helpful message). Use try_use_context::<T>() for an Option<T> instead.
For reactive shared state, use stores — they’re contexts with Signal fields and methods.
Putting It Together
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
let todos = Signal::new(vec!["Learn Rinch".to_string()]);
let input = Signal::new(String::new());
let count = Memo::new(move || todos.get().len());
let add = move || {
let text = input.get();
if !text.is_empty() {
todos.update(|t| t.push(text.clone()));
input.set(String::new());
}
};
rsx! {
Stack { gap: "md", p: "xl",
Title { order: 1, "Todos (" {|| count.get().to_string()} ")" }
Group { gap: "sm",
TextInput {
placeholder: "What needs doing?",
value_fn: move || input.get(),
oninput: move |v: String| input.set(v),
onsubmit: add,
}
Button { onclick: add, "Add" }
}
for todo in todos.get() {
div { key: todo.clone(), {todo.clone()} }
}
}
}
}
}
Signal is Copy, so add captures todos and input without ceremony. count is a Memo that recomputes when todos changes. The for loop is reactive — add or remove a todo and only the affected DOM nodes change.
Reactivity
Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This means that when state changes, only the parts of your UI that depend on that state are updated—not the entire component tree.
Why Fine-Grained Reactivity?
Traditional virtual DOM approaches (like React) re-render entire component subtrees when state changes, then diff the virtual DOM to find what changed. This works well but has overhead.
Fine-grained reactivity tracks dependencies at a granular level. When a signal changes, only the specific effects subscribed to that signal re-run. There’s no diffing step—updates are direct.
Benefits:
- Minimal re-computation
- Predictable performance
- No dependency arrays to maintain
- Automatic subscription management
Core Primitives
Rinch provides three core reactive primitives, plus a store pattern for shared state:
| Primitive | Purpose | When to Use |
|---|---|---|
| Signal | Holds reactive state | For any mutable state |
| Memo | Caches computed values | For derived/computed state |
| Stores | Shared state with actions | For state used by multiple components |
| Effect | Runs side-effects | Advanced: syncing to external systems |
Note: For DOM updates, use
{|| expr}closures in rsx rather thanEffect. For shared state, use stores.Effectis a power-user escape hatch for rare cases like syncing to external systems.
Quick Example
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn counter() -> NodeHandle {
// Create reactive state
let count = Signal::new(0);
// Create a derived value (Memo is Copy, just like Signal)
let doubled = Memo::new(move || count.get() * 2);
rsx! {
div {
// Use closure syntax {|| ...} for reactive updates
// Both Signal and Memo are Copy — no .clone() needed
p { "Count: " {|| count.get().to_string()} }
p { "Doubled: " {|| doubled.get().to_string()} }
button {
onclick: move || count.update(|n| *n += 1),
"Increment"
}
}
}
}
}
Note: The closure syntax
{|| expr}is required for fine-grained reactive updates. Without it, values are captured once and never update. See RSX Syntax - Reactive Expressions for details.
When are closures unnecessary? Inside
forloop bodies, components are re-created when the item data changes (via keyed reconciliation). Plain props from the loop variable don’t need closures — only per-item Signals do. See RSX Syntax - Reactivity in for Loops for details.
How Dependency Tracking Works
- When an Effect or Memo runs, it registers itself as the “current observer”
- When a Signal is read (via
.get()), it checks for a current observer - If there’s an observer, the signal subscribes it
- When the signal’s value changes (via
.set()or.update()), all subscribers are notified
This happens automatically—you never manually specify dependencies.
┌─────────────────┐ .get() ┌─────────────────┐
│ Effect │ ────────────────────► │ Signal │
│ │ │ │
│ (observer) │ ◄──── subscribes ──── │ (subscribers) │
└─────────────────┘ └─────────────────┘
│
.set() / .update()
│
▼
notify all subscribers
│
▼
effects re-run
Dependencies are per-run
The dependency set is rebuilt on every run, not accumulated. An effect ends each run subscribed to exactly the signals and memos it read that time, so a dependency it stops reading stops waking it:
#![allow(unused)]
fn main() {
let show_details = Signal::new(true);
let details = Signal::new(String::new());
Effect::new(move || {
if show_details.get() {
render(details.get()); // subscribed only while the branch is taken
}
});
show_details.set(false); // re-runs; `details` is no longer a dependency
details.set("...".into()); // does not re-run the effect
show_details.set(true); // re-runs, and picks `details` back up
}
Disposal releases the subscriptions too — an effect (or a scope full of them) that is disposed leaves nothing behind in the signals it read, so a long-lived signal does not accumulate dead observers as components mount and unmount.
Execution Order
When several effects observe the same signal, they run in registration order — the order the Effects (or Memos) were created. This is a guaranteed contract, not an implementation detail.
That makes the “run me last” idiom well-defined: an effect registered after a tree of rendering effects observes their writes in the same synchronous flush, so a measuring effect can read the post-patch DOM rather than the previous frame’s.
#![allow(unused)]
fn main() {
let width = Signal::new(100);
// Registered first — writes.
Effect::new(move || resize_panel(width.get()));
// Registered second — reads what the first one just wrote.
Effect::new(move || {
width.get();
record_measurement(measure_panel());
});
width.set(250); // resize_panel runs, then record_measurement sees the new size
}
Two related guarantees follow from the same queue:
- Effects run in the order they were queued. Notifying signal A then signal B runs A’s effects before B’s.
- A signal written from inside an effect queues its observers behind the current flush, not ahead of it. Cascading updates therefore run breadth-first, and an effect already scheduled for this flush is never preempted.
Effects are still de-duplicated per flush: an effect observing two signals that both change in one batch() runs once, at the position of its first enqueue.
An effect does not wake itself
There is one exception to “queued behind the current flush”: a write that reaches the effect that is running is, for that effect, usually dropped rather than queued.
A write made while no batch is open flushes at once — and every effect body a flush runs is in that state, because a flush runs with the batch closed. So the flush happens while the body that made the write is still on the stack. It reaches the running effect, finds it running, and skips it. The write itself lands, and every other effect that reads the signal runs for it. The running effect keeps what it computed on this pass, the wake is not left in the queue for later, and the effect sees the new value the next time something else re-runs it.
“A write the body made” includes code the body calls, and the common case is a scope’s cleanup. An effect that disposes a scope runs that scope’s on_cleanup callbacks inside its own body — rsx!’s if, match and for all do when they swap a branch or remove a row — so a cleanup that writes a signal the disposing effect reads does not re-run it. For example, a for row whose cleanup writes a signal the list’s collection closure reads does not make the list reconcile a second time.
#![allow(unused)]
fn main() {
let trigger = Signal::new(0);
let tally = Signal::new(0);
let child = Scope::new();
child.on_cleanup(move || tally.update(|n| *n += 1));
let child = RefCell::new(Some(child));
Effect::new(move || {
let t = trigger.get();
let seen = tally.get();
if t == 1 {
let scope = child.borrow_mut().take();
if let Some(scope) = scope {
scope.dispose(); // its cleanup writes `tally`, which this effect read
}
}
println!("saw tally = {seen}");
}); // prints "saw tally = 0"
trigger.set(1); // prints "saw tally = 0"; tally is now 1, and this
// effect does not run again for it
trigger.set(2); // prints "saw tally = 1"
}
This is deliberate, and it is what lets such an effect settle. Were the wake queued instead, an effect that disposes a scope on every run, whose cleanup writes a signal the effect reads, would schedule itself again on every run and never stop. So do not rely on an effect observing a write made by its own cleanup: have a different effect read that signal, or read the signal after the disposal in the same body.
The exception has an exception. When the body runs while a batch is open — an Effect::new or Effect::run called directly from an event handler, which is a batch, or inside batch() — its writes are queued rather than flushed, the queue drains only after the body has returned, and the effect does run again for its own write.
Batching Updates
Outside a batch, effects run after each update. To avoid redundant runs, use batch():
#![allow(unused)]
fn main() {
batch(|| {
count.set(1);
name.set("Alice".to_string());
age.set(30);
// Effects only run once, after the batch completes
});
}
A top-level batch() flushes synchronously: by the time it returns, every effect its writes woke has run. Batches also nest — a batch() called inside another batch’s closure joins the outer transaction, and the single flush happens when the outermost batch exits. (A batch() opened from inside an effect run by a flush is its own outermost batch — the flag is restored before the flush begins — so it still flushes before returning. An effect body that runs while a batch is still open is different: Effect::new runs its body immediately, so an effect created inside a batch closure runs inside that batch, and a batch() opened there joins the outer transaction instead of flushing.)
Until the flush, no effect has run: inside the closure — including after a nested batch() returns — effects have not executed, so a signal an effect writes still holds its old value. Memos are different: Memo::get inside the batch returns the new value. A write marks every memo that reads it stale synchronously, and the read recomputes it (see Memos). It used to return the pre-batch value, which made a handler that wrote a signal and then read a memo of it act on stale data. The DOM is different too: any NodeHandle call made in the batch first runs the effects queued so far (see below).
If the closure panics, the panic propagates and the batching flag is restored on the way out, so later writes flush normally. Nothing is flushed during the unwind itself — the effects the aborted batch queued stay pending and run at the next flush (the next unbatched write or outermost batch exit — every event handler is one, whether or not it writes anything; the runtime does not schedule one on its own).
Event handlers run as batches
You rarely need to call batch() yourself: rinch runs each of these as one, on desktop, on the web (the delegated listeners go through the same rinch_core::events::dispatch_* functions) and in embed:
onclickand every otherdata-ridhandler (the element drag attributes included),oninput/onchange,onscroll, file drops;- the keyboard interceptor and the Escape dismiss stack (one transaction for both), the paste interceptor;
Dragon_move,on_endandon_cancel;- menu and tray callbacks, and their shortcuts;
- a registered focus target’s
on_focus_gained,on_focus_lost,on_keyandon_ime; - each callback drained from the main-thread queue —
Signal::send/update_sendandrun_on_main_threadcalled from another thread, and on nativeset_timeout,rinch-httpandrinch-wscompletions — one transaction per callback, not one for the whole drain, because they were queued independently and a later one may rely on an earlier one’s effects; - on the web, a
set_timeoutcallback, arinch-httpcompletion and arinch-wsevent, which arrive straight from the browser.
Everything else that calls into your code is not batched by rinch, and its writes flush one by one as they always did. That includes, for example: run_on_main_thread called on the main thread (it runs its closure there and then, inside whatever transaction is open), set_selection_callback / set_selection_sync_callback, the configuration-change handler, on_child_inserted / on_child_removed observers, a RenderSurface’s event handler, the editor’s EditorHandle::on_change, on_link_click, on_link_hover, on_key, on_selection_change and on_caret_moved callbacks, and effects and memo computations themselves. Wrap any of those in batch() yourself if it writes several signals.
So a handler that writes five signals flushes effects once, when it returns, and the host is told once (on desktop, one ReRender, not five). A handler that writes no signal tells the host nothing — no ReRender, and an embedded context is not marked dirty — unless the theme changed with update_theme() since the host was last told (in this handler, or outside any handler), or effects an earlier batch left queued are still waiting (see the panic note above), which it then runs. A DOM change a handler makes directly, through a NodeHandle, is picked up by the host’s own dirty-node check, not by this notification — except, on desktop today, a scroll_into_view() request from a handler that changes nothing else, which waits for the next layout (#1145). Library code that keeps other state a host reads only when told (not a signal, not the DOM) calls rinch_core::note_host_change() when it changes, so the batch still notifies. What that means inside the handler:
- Memos are current. Write, then read a memo of what you wrote: you get the new value.
- Effects run when the handler returns — or earlier, the moment the handler touches the DOM (next section). Until then a signal an effect derives still holds its old value; read the source signal (or a memo) instead.
- Glitch-free. An effect that reads several of the signals the handler wrote runs once, and sees all of the writes, never a mix of old and new.
Touching the DOM inside a handler
A handler that writes a signal and then works on the DOM itself was written expecting the write to have reached the DOM already — it always had:
#![allow(unused)]
fn main() {
button { onclick: move || {
dialog_open.set(true); // a Modal's effect shows the dialog and focuses its first field
email_field.focus(); // …but this handler wants the email field
}, "Sign in" }
button { onclick: move || {
messages.update(|m| m.push(new_message())); // a `for` effect appends the row
log.scroll_to_bottom(); // …and this should reach it
}, "Send" }
}
So every NodeHandle operation made from handler code first runs the effects the handler has queued so far — focus(), scroll_into_view(), set_scroll_top(), scroll_to_bottom(), reads such as get_attribute, children, scroll_height or get_layout_bounds, and writes such as set_attribute alike. It is the analogue of a browser flushing pending style when script asks for layout. Program order is kept: the Modal’s own focus request is made first and the handler’s focus() overrides it; the new message row exists before the scroll; a set_attribute made after a signal write wins over the effect bound to that signal, instead of being overwritten when the handler returns. A handler that only writes signals still flushes exactly once. rinch_core::flush_pending_effects() does the same thing explicitly, for code that reaches the DOM some other way.
The rule applies to handler code, not to effects: a NodeHandle call inside an effect body or a memo computation never drains the queue, since that code runs inside a flush and must not run effects queued behind it ahead of their turn. A handler dispatched from an effect (an effect that calls dispatch_event, or a web focus() that fires a listener synchronously) is handler code again: its batch is a flush context of its own.
One way to trip over it in your own code. If a handler writes a signal and then makes a NodeHandle call while holding a borrow of a RefCell that an effect of that signal also borrows, the effect runs inside that call — and panics with BorrowError/BorrowMutError. Before handlers were batched the same effect ran at the write itself, before your borrow was taken; the conflict is the same, it just moved from the write to the DOM call. Take the borrow after the DOM call, or drop it before. (Editor queries such as is_mark_active are not DOM calls and run no effects.)
Writing a library that keeps its own RefCell state? A NodeHandle call inside a handler can now run user effects, and a user effect is free to call back into your API. If your code touches the DOM while it holds a borrow of its own state, that is a BorrowMutError waiting to happen. Hold rinch_core::reactive::suppress_effect_flush() for as long as the borrow lives — no effect runs inside it; the pending ones run at the next DOM access outside it, or when the batch ends — and call rinch_core::flush_pending_effects() just before taking the borrow, so the caller’s earlier writes have reached the DOM you are about to work on. The rich-text editor does exactly this around every borrow of its core, so tick.set(1); editor.command("toggleBold") in one handler, or two commands in a row with an on_change that bumps a toolbar signal, behave as they did before handlers were batched.
Two consequences to know about
An effect sees only where the handler ended up, never the steps. show.set(false); show.set(true) in one handler does not remount the if branch; removing a for item and putting one with the same key back keeps that row and its state; closing and re-opening an overlay in one handler neither restores nor re-captures focus. (A NodeHandle call in between flushes, so it does make the step visible — that is what the rule above is for.)
A handler that blocks keeps its transaction open. If a handler shows a native modal dialog (a file picker, a message box) that runs a nested platform event loop — which macOS and Windows dialogs can — its batch stays open until the dialog returns, and anything rinch runs meanwhile, such as a drained Signal::send or a timer, joins that batch: its effects run when the dialog closes, not when it arrived.
Reading Without Tracking
Sometimes you want to read a signal without creating a subscription. Use untracked():
#![allow(unused)]
fn main() {
Effect::new(move || {
// This creates a subscription
let count = count.get();
// This does NOT create a subscription
let name = untracked(|| name.get());
println!("Count: {count}, Name: {name}");
});
// This effect only re-runs when `count` changes, not when `name` changes
}
untracked hides only the observer directly above it. That matters only while
an effect’s run is nested inside another effect’s run — the first run of an
Effect::new written inside another effect’s body, for instance. A read inside
untracked there is recorded on the outer effect. Later re-runs of the inner
effect normally come from a flush with no effect beneath them and are unaffected (issue
#932).
App handlers never subscribe the effect that calls them
A component often calls its app’s handler from inside one of its own effects — a
coordinating effect that ends in onchange, a value_fn binding that reports
back. The handler is app code, and it routinely reads signals: the controlled
idiom onchange: move |v| if v != store.get() { store.set(v) } reads the store
it writes. If that read counted, the component’s internal effect would be
subscribed to the app’s store, and a write to the store from anywhere else would
re-run it out of turn.
So every app-callback type’s invoke — Callback, ValueCallback<T>,
InputCallback, FileDropCallback and ScrollCallback — runs the handler with
no observer in view, however many effects deep the call is (issue #285).
Handlers respond to events; effects respond to signals. A component author does
not need to wrap cb.invoke(..) in untracked any more.
Only tracking changes: batching is untouched, so a handler’s writes inside an event handler still flush once, when the batch ends. An effect the handler creates, or one its writes run, tracks normally.
The same holds for the other app callbacks rinch runs synchronously from a call you can make inside an effect (issue #931):
- the rich-text editor’s
EditorHandlehooks —on_change,on_selection_change,on_caret_moved,on_key,on_link_clickandon_link_hover— so an app effect that callsset_selection, runs acommandor offers a key is not subscribed to a popup hook’s ownopensignal; - the child observers (
rinch_core::dom::on_child_inserted/on_child_removed), which everyforreconcile,ifbranch swap and component re-render reaches from inside its effect; - the selection and selection-sync callbacks (
query_selection_ranges,fire_selection_sync), the keyboard and paste interceptors (dispatch_keyboard_event,dispatch_paste_event), the dismiss stack (dispatch_dismiss) andDrag::cancel’son_cancel.
Editor plugin code is untracked the same way (issue #943): apply,
init_state, decorations, handle_paste, a plugin’s commands and input rules
run under the editor’s own borrow, inside whatever effect called the handle, so
a spellchecker whose decorations() reads an app spellcheck_on signal does not
subscribe every effect that moves the selection. A plugin that should react to a
signal needs something that re-asks it — an effect of its own that dispatches a
transaction when the signal changes. Plugin code your own update closure
calls (below) is part of that closure, and tracked. The collaboration outbound
sink runs untracked too (issue #948): a transport closure that reads a
connected signal does not subscribe the effect that made the edit.
A closure you pass as the call itself is not one of these: the build closure
of EditorHandle::update(|state| …) runs as part of your own code, and a signal
it reads is your effect’s dependency like any other.
If you write a component or library that stores an app’s callback and calls it
later, call it through rinch_core::untracked_handler(|| cb(..)) for the same
reason. The five callback types do this in their invoke; a bare Rc<dyn Fn()>
does not. A closure a component stores and calls itself — an Rc<dyn Fn()>, a
render_fn — is an ordinary call and is tracked like any other code in the
effect; that is what a _fn prop such as value_fn relies on, and why the
choice is yours.
Memory Management with Scopes
Reactive resources continue to exist until something disposes them. Usually that
something is implicit: a Scope::run(f) makes that scope the ambient owner
for f, so every Signal, Memo, Effect and event handler created inside is
attributed to it and freed when it is disposed. Rendering runs under a scope, so
component state is cleaned up without you asking.
#![allow(unused)]
fn main() {
let scope = Scope::new();
scope.run(|| {
let count = Signal::new(0); // owned by `scope`
Effect::new(move || { count.get(); }); // owned by `scope`
});
scope.dispose(); // both are freed
}
scope.add_effect(effect) is the manual path, for an effect built outside the
scope that should still die with it.
With no ambient owner, a resource has app lifetime — which is why signals
created in main() or in startup code keep working untouched. Because handles
are Copy and can outlive their values, reads of a freed handle panic while
writes are warn-once no-ops; use try_get() / is_alive() when a handle may
legitimately be gone. See
Lifetimes for the full story.
Next Steps
- Signals - Reactive state containers
- Memos - Cached computed values
- Stores - Shared state with action methods
- Effects - Advanced: side-effects that track dependencies
Signals
A Signal holds a value and tells everyone who cares when it changes. Signals are the foundation of everything reactive in Rinch.
Creating Signals
#![allow(unused)]
fn main() {
let count = Signal::new(0);
let name = Signal::new(String::from("Alice"));
let items = Signal::new(vec![1, 2, 3]);
}
Signals can hold any 'static type. They’re cheap — a Signal<T> is an index and a
generation counter into a thread-local slot map, not a smart pointer. The generation is
what lets a handle outlive its value safely: a recycled slot carries a generation no
stale handle holds, so a freed signal is detected, never silently confused with a new one.
Signal is Copy
This is the single most important thing about Rinch’s reactive system. Signal<T> implements Copy. You never need .clone() before passing a signal into a closure:
#![allow(unused)]
fn main() {
let count = Signal::new(0);
// Use count in as many closures as you want — it's Copy
Effect::new(move || println!("count = {}", count.get()));
rsx! {
button { onclick: move || count.update(|n| *n += 1), "+" }
span { {|| count.get().to_string()} }
}
}
Same for Memo<T> — also Copy.
Reading Values
.get() — Clone and Return
#![allow(unused)]
fn main() {
let count = Signal::new(0);
let value = count.get(); // Returns 0 (cloned)
}
Inside an Effect or Memo, calling .get() automatically subscribes to the signal. When the signal changes, the Effect re-runs. No dependency arrays. No manual tracking.
.with() — Access by Reference
For types that are expensive to clone:
#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5]);
let length = items.with(|v| v.len());
let first = items.with(|v| v.first().copied());
}
Writing Values
.set() — Replace
#![allow(unused)]
fn main() {
count.set(5); // Notifies all subscribers
}
.update() — Modify in Place
#![allow(unused)]
fn main() {
count.update(|n| *n += 1);
items.update(|v| v.push(4));
}
Liveness
A signal created during a render is owned by that render and is freed when it
goes away (see Lifetimes). Because
Signal<T> is Copy, a handle can easily outlive the value:
| Call | On a freed signal |
|---|---|
get() / with() | panics |
set() / update() / set_if_changed() | no-op, plus a warning |
try_get() / try_with() | None |
is_alive() | false |
Reads assert and writes forgive, because a write can come from somewhere that
cannot be asked to check — a background thread mid-send(), a timer that has not
fired its last tick yet — while a read has nothing to return.
#![allow(unused)]
fn main() {
// From a long-lived callback or a worker holding a Copy handle:
if let Some(n) = count.try_get() {
println!("still here: {n}");
}
}
is_alive() does not subscribe the current observer. Liveness is not
reactive, and tracking it would resurrect the dependency you are trying to drop.
If a signal is deliberately meant to outlive its component, say so explicitly
with Signal::new(0).leak() rather than relying on it accidentally surviving.
Automatic Dependency Tracking
Read a signal inside an Effect or Memo and the dependency is tracked automatically:
#![allow(unused)]
fn main() {
let first_name = Signal::new("Alice".to_string());
let last_name = Signal::new("Smith".to_string());
// This effect depends on BOTH signals — discovered at runtime
Effect::new(move || {
println!("{} {}", first_name.get(), last_name.get());
});
first_name.set("Bob".to_string()); // Effect re-runs
last_name.set("Jones".to_string()); // Effect re-runs
}
Dependencies are dynamic. If a signal is only read conditionally, the subscription only exists when that branch executes.
Cross-Thread Dispatch
set() and update() panic if called from a non-main thread (with a helpful message). For background threads, use send() and update_send():
#![allow(unused)]
fn main() {
let progress = Signal::new(0);
std::thread::spawn(move || {
for i in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(50));
progress.send(i); // Dispatches to main thread
}
});
}
send() requires T: Send. update_send() requires the closure to be Send + 'static.
A worker may start sending before the app does — spawned in main() ahead of
App::run(). Such a write is queued and applied once the app has started
(issue #1541); it used to be dropped. That holds for a thread that has created
no signals of its own: rinch tells the thread that owns a signal from a worker
by whether it has ever created one, until the host registers its main thread.
Best Practices
Keep signals focused. One signal per concern, not one giant state bag:
#![allow(unused)]
fn main() {
// Good
let name = Signal::new(String::new());
let age = Signal::new(0);
// Less good — changing name re-runs everything that reads age too
let state = Signal::new(AppState { name, age });
}
Read once before loops:
#![allow(unused)]
fn main() {
// Read once, loop over the snapshot
let val = items.get();
for item in &val {
// use item
}
}
Use {|| expr} in RSX, not raw Effects. The RSX closure syntax is the right tool for DOM updates. Reserve Effect::new() for side effects outside the DOM (logging, network calls, syncing external state).
Effects
An Effect is a side-effect that runs when its dependencies change. It tracks which signals it reads and re-runs when any of them update. No dependency arrays, no manual subscription — just read a signal and you’re subscribed.
You probably don’t need this. For reactive DOM updates, use
{|| expr}closures in RSX. For state mutations, use store methods. Effect is for the rare case where you need to react to signal changes outside of both — logging, syncing to external systems, etc.
Effect is intentionally excluded from the prelude. Import explicitly:
#![allow(unused)]
fn main() {
use rinch::reactive::Effect;
}
Creating Effects
#![allow(unused)]
fn main() {
let count = Signal::new(0);
// Runs immediately, then re-runs when count changes
Effect::new(move || {
println!("Count is: {}", count.get());
});
count.set(1); // Prints: "Count is: 1"
count.set(2); // Prints: "Count is: 2"
}
Signal is Copy, so you can use count in the closure and still use it elsewhere. No .clone().
When Effects Run
- Immediately on creation — the effect runs once right away
- When dependencies change — whenever a tracked signal is updated
#![allow(unused)]
fn main() {
let a = Signal::new(1);
let b = Signal::new(2);
Effect::new(move || {
println!("a = {}, b = {}", a.get(), b.get());
});
// Prints: "a = 1, b = 2"
a.set(10); // Prints: "a = 10, b = 2"
b.set(20); // Prints: "a = 10, b = 20"
}
Conditional Dependencies
Dependencies are discovered at runtime, not declared statically. If a signal is only read in one branch, the subscription only exists when that branch executes:
#![allow(unused)]
fn main() {
let show_details = Signal::new(false);
let name = Signal::new("Alice".to_string());
let age = Signal::new(30);
Effect::new(move || {
println!("Name: {}", name.get());
if show_details.get() {
println!("Age: {}", age.get()); // only tracked when show_details is true
}
});
age.set(31); // Nothing happens — age isn't tracked yet
show_details.set(true); // Re-runs, now age IS tracked
age.set(32); // Re-runs — age is tracked now
}
Common Patterns
Logging
#![allow(unused)]
fn main() {
let count = Signal::new(0);
Effect::new(move || {
println!("[DEBUG] count = {}", count.get());
});
}
Syncing to External Systems
#![allow(unused)]
fn main() {
let theme = Signal::new("light".to_string());
Effect::new(move || {
update_css_theme(&theme.get());
});
}
Derived Side Effects
#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3]);
Effect::new(move || {
let count = items.with(|v| v.len());
update_badge(count);
});
}
Disposing Effects
An effect stops when it is disposed — explicitly, or by the scope that owns it:
#![allow(unused)]
fn main() {
let effect = Effect::new(move || {
println!("Count: {}", count.get());
});
effect.dispose(); // Effect stops running
count.set(1); // Nothing happens
}
Dropping the handle does not dispose the effect. This is deliberate and it is
what every example on this page relies on: Effect::new(move || …); with no
let drops the returned handle immediately, and the effect keeps running anyway.
The closure lives in a registry keyed by id, not in the handle, because an effect
is queued and run by id long after the caller has forgotten it.
So an effect has exactly two ends:
- Explicit —
effect.dispose(). - Owned — an effect created during a render belongs to that render, and is
disposed when the component unmounts, the
ifbranch flips, or theforrow is reconciled away. This is the usual case, and it is why RSX closures never need cleaning up by hand.
An effect created with no ambient owner and no retained handle — from main(),
from startup code, from a detached callback — runs for the life of the thread.
That is the right default for app-wide wiring and a leak everywhere else, so
prefer creating effects during a render, where something owns them.
Disposal is what reclaims the registry entry, not merely what silences it: everything the closure captured is dropped with it.
Pitfalls
Don’t create effects inside effects. The inner effect gets recreated every time the outer one re-runs.
Don’t modify a signal you read in the same effect. That’s an infinite loop. Use untracked(|| signal.get()) if you need to read without subscribing.
#![allow(unused)]
fn main() {
// BAD: infinite loop
Effect::new(move || {
let val = count.get();
count.set(val + 1); // triggers re-run -> reads count -> triggers re-run -> ...
});
// OK: read without subscribing
Effect::new(move || {
let val = untracked(|| count.get());
do_something(val);
});
}
Memos
A Memo is a cached computed value that only recomputes when its dependencies change. Think of it as a signal you can’t write to — it derives its value from other signals.
Creating Memos
#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);
doubled.get(); // 4
count.set(3);
doubled.get(); // 6 (recomputed)
doubled.get(); // 6 (cached — no recomputation)
}
Memo<T> is Copy, just like Signal<T>. Use it in multiple closures without .clone().
The value type must be Clone + PartialEq. PartialEq is what the equality
cut-off below compares with; a type that cannot implement it meaningfully can
implement it as “never equal” (fn eq(&self, _: &Self) -> bool { false }),
which gives back the old behaviour of waking every dependent on every recompute.
How Memos Work
- Lazy — only computes when you call
.get() - Cached — returns the cached result if dependencies haven’t changed
- Tracked — automatically discovers which signals it reads
- Composable — memos can depend on other memos
- Cut off on equality — a recompute that produces a value equal to the previous one wakes none of the memo’s dependents
- Always current — a read straight after a write to one of its sources sees
the new value, even inside a
batch()or an event handler that has not returned yet
Signal(count) ──► Memo(doubled) ──► cached value
│ │
.set(3) .get()
│ │
▼ ▼
marks memo recomputes if
as "dirty" dirty
The equality cut-off
The pattern it exists for is per-row derived state:
#![allow(unused)]
fn main() {
let selected = Signal::new(3);
for row in rows.get() {
let id = row.id;
let is_selected = Memo::new(move || selected.get() == id);
div { key: id, class: {move || if is_selected.get() { "row selected" } else { "row" }} }
}
}
Every row’s memo reads selected, so a selection change recomputes all of
them — a comparison each. But only two answers change (the row that lost the
selection and the row that gained it), so only those two rows’ class effects
run. Without the cut-off every row’s effect ran on every click.
How it works: a write marks the memos that read it stale at once (so a read
straight after the write is current), and a memo’s dependents are woken as
maybes. Before running a maybe, the flush brings the memos it read up to date
and compares each one’s version — a counter that moves only when a recompute
produces an unequal value — with the version the dependent saw last time. None
moved: it is skipped. An effect that also reads a signal that changed is not a
maybe, and runs as usual; signals have no cut-off (set notifies whether or not
the value changed — use set_if_changed for that).
The cut-off chains: a memo that reads a memo which recomputed to an equal value
is re-validated without running its own computation. A source memo that has
been freed (its scope disposed) counts as changed, so a reader that falls
back with try_get() recomputes once and sees None rather than keeping a value
computed from a memo that no longer exists.
Memos vs Effects
| Memo | Effect | |
|---|---|---|
| Returns a value | Yes | No |
| Runs eagerly | No (lazy) | Yes (immediate) |
| Purpose | Derived state | Side effects |
| Caches result | Yes | N/A |
Use a Memo when you need a computed value. Use an Effect when you need to perform an action.
Chaining Memos
Memos can depend on other memos:
#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);
let quadrupled = Memo::new(move || doubled.get() * 2);
assert_eq!(quadrupled.get(), 8);
count.set(3);
assert_eq!(quadrupled.get(), 12);
}
The derived() Helper
Syntactic sugar for Memo::new():
#![allow(unused)]
fn main() {
let count = Signal::new(5);
// These are equivalent:
let doubled = Memo::new(move || count.get() * 2);
let doubled = derived(move || count.get() * 2);
}
Common Patterns
Computed Strings
#![allow(unused)]
fn main() {
let first = Signal::new("Alice".to_string());
let last = Signal::new("Smith".to_string());
let full = Memo::new(move || format!("{} {}", first.get(), last.get()));
}
Filtering Lists
#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5, 6]);
let show_even = Signal::new(true);
let filtered = Memo::new(move || {
let all = items.get();
if show_even.get() {
all.into_iter().filter(|n| n % 2 == 0).collect()
} else {
all
}
});
}
Expensive Computations
#![allow(unused)]
fn main() {
let data = Signal::new(large_dataset);
// Only recomputes when data changes, no matter how many times you .get()
let analysis = Memo::new(move || {
data.with(|d| perform_analysis(d))
});
}
Memos in RSX
Memos work in reactive closures exactly like signals:
#![allow(unused)]
fn main() {
let count = Signal::new(0);
let doubled = Memo::new(move || count.get() * 2);
rsx! {
p { "Count: " {|| count.get().to_string()} }
p { "Doubled: " {|| doubled.get().to_string()} }
}
}
Both count and doubled are Copy. Both create subscriptions when read in {|| ...} closures. The doubled text node updates only when the memo’s value actually changes.
State Management with Stores
Stores are the recommended way to manage shared state in rinch. A store is a plain struct with Signal fields and methods that encapsulate state mutations.
The Store Pattern
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[derive(Clone, Copy)]
struct CounterStore {
count: Signal<i32>,
}
impl CounterStore {
fn new() -> Self {
Self { count: Signal::new(0) }
}
fn increment(&self) {
self.count.update(|n| *n += 1);
}
fn decrement(&self) {
self.count.update(|n| *n -= 1);
}
fn reset(&self) {
self.count.set(0);
}
}
}
Why this works well:
- State and mutations live together — no scattered signal updates across components
Signal<T>isCopy, so the store struct isCopywhen all fields are signals- Methods are the single source of truth for how state changes
- Easy to test, easy to reason about
Providing and Consuming Stores
Call create_store() in a parent component to make the store available to all descendants. Call use_store::<T>() in any descendant to retrieve it.
#![allow(unused)]
fn main() {
#[component]
fn app() -> NodeHandle {
create_store(CounterStore::new());
rsx! {
div {
CounterDisplay {}
CounterControls {}
}
}
}
#[component]
fn counter_display() -> NodeHandle {
let store = use_store::<CounterStore>();
rsx! {
p { "Count: " {|| store.count.get().to_string()} }
}
}
#[component]
fn counter_controls() -> NodeHandle {
let store = use_store::<CounterStore>();
rsx! {
div {
button { onclick: move || store.decrement(), "-" }
button { onclick: move || store.reset(), "Reset" }
button { onclick: move || store.increment(), "+" }
}
}
}
}
use_store::<T>() panics with a helpful message if no store of that type exists:
Store not found: CounterStore
Did you forget to call create_store() in a parent component?
Use try_use_store::<T>() when a store may legitimately be absent — it returns Option<T>.
A Larger Example: Todo Store
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[derive(Clone, PartialEq)]
struct Todo {
id: u32,
text: String,
done: bool,
}
#[derive(Clone, Copy)]
struct TodoStore {
todos: Signal<Vec<Todo>>,
next_id: Signal<u32>,
filter: Signal<Filter>,
}
#[derive(Clone, Copy, PartialEq)]
enum Filter { All, Active, Completed }
impl TodoStore {
fn new() -> Self {
Self {
todos: Signal::new(Vec::new()),
next_id: Signal::new(1),
filter: Signal::new(Filter::All),
}
}
fn add(&self, text: String) {
let id = self.next_id.get();
self.next_id.set(id + 1);
self.todos.update(|t| t.push(Todo { id, text, done: false }));
}
fn toggle(&self, id: u32) {
self.todos.update(|todos| {
if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
todo.done = !todo.done;
}
});
}
fn remove(&self, id: u32) {
self.todos.update(|t| t.retain(|todo| todo.id != id));
}
fn visible_todos(&self) -> Vec<Todo> {
let todos = self.todos.get();
match self.filter.get() {
Filter::All => todos,
Filter::Active => todos.into_iter().filter(|t| !t.done).collect(),
Filter::Completed => todos.into_iter().filter(|t| t.done).collect(),
}
}
fn remaining_count(&self) -> usize {
self.todos.with(|t| t.iter().filter(|t| !t.done).count())
}
}
}
Components consume the store and call its methods:
#![allow(unused)]
fn main() {
#[component]
fn todo_app() -> NodeHandle {
create_store(TodoStore::new());
let store = use_store::<TodoStore>();
let input = Signal::new(String::new());
rsx! {
div {
TextInput {
placeholder: "What needs to be done?",
value_fn: move || input.get(),
oninput: move |val: String| input.set(val),
onsubmit: move || {
let text = input.get();
if !text.is_empty() {
store.add(text);
input.set(String::new());
}
},
}
p { {|| format!("{} items left", store.remaining_count())} }
for todo in store.visible_todos() {
div { key: todo.id,
Checkbox {
label: todo.text.clone(),
checked_fn: {
let done = todo.done;
move || done
},
on_change: {
let id = todo.id;
move || store.toggle(id)
},
}
button {
onclick: { let id = todo.id; move || store.remove(id) },
"Delete"
}
}
}
}
}
}
}
Side Effects Belong in Store Methods
Keep side effects (logging, persistence, validation) inside store methods rather than scattered across components:
#![allow(unused)]
fn main() {
impl TodoStore {
fn add(&self, text: String) {
let id = self.next_id.get();
self.next_id.set(id + 1);
self.todos.update(|t| t.push(Todo { id, text: text.clone(), done: false }));
// Side effect lives here, not in 5 different components
println!("Added todo #{id}: {text}");
}
}
}
This keeps components focused on rendering and event handling.
When You Don’t Need a Store
For component-local state that no other component needs, just use Signal::new() directly:
#![allow(unused)]
fn main() {
#[component]
fn toggle_button() -> NodeHandle {
let expanded = Signal::new(false);
rsx! {
button {
onclick: move || expanded.update(|v| *v = !*v),
{|| if expanded.get() { "Collapse" } else { "Expand" }}
}
}
}
}
No store needed — expanded is only used by this one component.
Use a store when:
- Multiple components need to read or write the same state
- You want to centralize mutation logic (validation, side effects)
- State has complex update rules that benefit from named methods
Use a plain Signal when:
- State is local to a single component (toggles, input text, hover state)
- No other component cares about this value
Advanced: Explicit Effects
Most reactive DOM updates use {|| expr} closures in rsx — you rarely need Effect directly. For advanced cases like syncing state to an external system, import it explicitly:
#![allow(unused)]
fn main() {
use rinch::reactive::Effect;
let count = Signal::new(0);
// Sync to an external system whenever count changes
Effect::new(move || {
update_external_dashboard(count.get());
});
}
You probably don’t need Effect. If you’re updating the DOM, use {|| expr} in rsx. If you’re updating state, put the logic in a store method. Effect is for the rare case where you need to react to signal changes outside of both rendering and user actions.
Sharing State
There are three ways to share state between components in Rinch: stores (recommended), lifting state up (passing signals as props), and context (low-level implicit sharing).
Recommended: For most shared state, use the store pattern — a struct with Signal fields shared via
create_store()/use_store().
Stores (Recommended)
See the full Stores guide. Quick summary:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct AppStore {
count: Signal<i32>,
}
impl AppStore {
fn new() -> Self { Self { count: Signal::new(0) } }
fn increment(&self) { self.count.update(|n| *n += 1); }
}
#[component]
fn app() -> NodeHandle {
create_store(AppStore::new());
rsx! { div { Counter {} } }
}
#[component]
fn counter() -> NodeHandle {
let store = use_store::<AppStore>();
rsx! {
p { {|| store.count.get().to_string()} }
button { onclick: move || store.increment(), "+" }
}
}
}
Lifting State Up
The simplest approach — move state to the nearest common ancestor and pass it down as props.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
pub fn Counter(count: Signal<i32>, children: &[NodeHandle]) -> NodeHandle {
rsx! {
div {
p { "Count: " {|| count.get().to_string()} }
{children}
}
}
}
#[component]
fn app() -> NodeHandle {
// State lives here, shared with both children
let count = Signal::new(0);
rsx! {
div {
Counter { count,
button { onclick: move || count.update(|n| *n += 1), "+" }
button { onclick: move || count.update(|n| *n -= 1), "-" }
}
}
}
}
}
Signal<T> implements Copy, so you can pass it to multiple children without .clone().
When to use: When only a small number of nearby components need the same state.
Context
Context lets you share state without explicitly threading it through props. Call create_context() in an ancestor component, then use_context::<T>() in any descendant.
Creating Context
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Theme {
primary: String,
dark_mode: bool,
}
#[component]
fn app() -> NodeHandle {
create_context(Theme {
primary: "#007bff".into(),
dark_mode: false,
});
rsx! {
div {
// All descendants can access Theme
Toolbar {}
MainContent {}
}
}
}
}
Consuming Context
use_context::<T>() returns T directly. It panics with a helpful message if the context is absent:
Context not found: Theme
Did you forget to call create_context() in a parent component?
#![allow(unused)]
fn main() {
#[component]
fn toolbar() -> NodeHandle {
let theme = use_context::<Theme>();
rsx! {
nav { style: {|| format!("background: {}", theme.primary)},
"Toolbar"
}
}
}
}
Fallible Access
Use try_use_context::<T>() when a context may legitimately be absent — it returns Option<T> instead of panicking:
#![allow(unused)]
fn main() {
#[component]
fn themed_button() -> NodeHandle {
let theme = try_use_context::<Theme>();
let bg = theme.map(|t| t.primary).unwrap_or("#ccc".into());
rsx! {
button { style: {|| format!("background: {}", bg)},
"Click me"
}
}
}
}
When to use: When a component can render sensibly with or without the context (e.g., a component used both inside and outside a theme provider).
Reactive Context with Signals
For context that changes over time, store a Signal<T> in context so descendants can both read and update it:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct AppState {
dark_mode: Signal<bool>,
user_name: Signal<String>,
}
#[component]
fn app() -> NodeHandle {
create_context(AppState {
dark_mode: Signal::new(false),
user_name: Signal::new("Guest".into()),
});
rsx! {
div {
Header {}
MainContent {}
}
}
}
#[component]
fn header() -> NodeHandle {
let state = use_context::<AppState>();
rsx! {
header {
span { {|| state.user_name.get()} }
button {
onclick: move || state.dark_mode.update(|v| *v = !*v),
{|| if state.dark_mode.get() { "Light mode" } else { "Dark mode" }}
}
}
}
}
}
Because Signal<T> is Copy, the AppState struct itself is Copy when all its fields are signals, making it cheap to access from any descendant.
Which Approach to Use?
| Situation | Approach |
|---|---|
| Shared state with action methods | Store (create_store / use_store) |
| 1–2 nearby components | Lift state up (pass signals as props) |
| Framework-internal shared state | Context (create_context / use_context) |
| Optional / may not always be provided | try_use_store or try_use_context |
| Static configuration (theme colors, locale) | Plain value in context |
Writing Your Own Components
You have two options. The first one is almost always what you want.
Option 1: PascalCase Component Functions
Write a function with a PascalCase name and #[component]. The macro generates a struct, a Default impl, and a Component trait impl. Parameters become props. Done.
The name’s case is what makes it a component.
rsx!reads it to tell markup from a component, soMyThing { … }calls your function whilemy_thing { … }is looked up as an HTML tag. A lowercase#[component]function is still perfectly good Rust — it just has to be called as one:{ my_thing(__scope) }. Writingmy_thing { … }is a compile error that says so (issue #528); before that it rendered an empty<my_thing>element and never called the function.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
pub fn UserCard(
name: String,
email: String,
avatar_url: String,
online: bool,
onclick: Option<Callback>,
) -> NodeHandle {
rsx! {
Paper { shadow: "sm", p: "md",
Group { gap: "md",
Avatar { src: avatar_url.clone(), size: "lg" }
Stack { gap: "xs",
Text { fw: "bold", {name.clone()} }
Text { size: "sm", color: "dimmed", {email.clone()} }
}
if online {
Badge { color: "green", variant: "dot", "Online" }
}
}
}
}
}
}
Use it:
#![allow(unused)]
fn main() {
rsx! {
UserCard {
name: "Alice",
email: "alice@example.com",
avatar_url: "/avatars/alice.png",
online: true,
onclick: || println!("clicked!"),
}
}
}
The Rules
Prop types must be owned. String, not &str. Vec<T>, not &[T]. The macro will give you a clear error if you use a reference type.
The macro wraps things for you. Don’t fight it:
| What you write | What the macro generates |
|---|---|
onclick: || do_thing() | Callback::new(|| do_thing()).into() |
oninput: move |v: String| ... | InputCallback::new(move |v| ...).into() |
icon: TablerIcon::Check | Some(TablerIcon::Check) |
variant: "filled" | String::from("filled") |
disabled: true | true (no wrapping) |
size: 42 | Some(42) |
value_fn: move || text.get() | Some(Rc::new(move || text.get())) |
Don’t manually wrap. Writing onclick: Some(Callback::new(|| ...)) double-wraps and you’ll get confusing type errors.
children: &[NodeHandle] is special. It’s not a struct field — it captures child elements passed in RSX:
#![allow(unused)]
fn main() {
#[component]
pub fn Card(title: String, children: &[NodeHandle]) -> NodeHandle {
rsx! {
Paper { shadow: "sm", p: "md",
Title { order: 3, {title.clone()} }
// children are automatically appended
}
}
}
// Usage:
rsx! {
Card { title: "My Card",
Text { "This is a child" }
Button { "So is this" }
}
}
}
Option 2: Manual Component Trait
For full control, implement Component directly:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[derive(Debug, Default)]
pub struct Countdown {
pub from: i32,
}
impl Component for Countdown {
fn render(&self, scope: &mut RenderScope, _children: &[NodeHandle]) -> NodeHandle {
let remaining = Signal::new(self.from);
// rsx! works inside Component::render too — __scope is just `scope` here
let __scope = scope;
rsx! {
div {
Text { size: "xl", {|| remaining.get().to_string()} }
Button {
disabled: {|| remaining.get() <= 0},
onclick: move || remaining.update(|n| *n -= 1),
"Count down"
}
}
}
}
}
}
You probably don’t need this. The PascalCase function covers 95% of use cases. But it’s there for when you need custom Debug, or want to store state on the struct itself, or are doing something the macro can’t express.
Lowercase Component Functions
Not everything needs to be a reusable component with props. For private helper functions, use a lowercase name:
#![allow(unused)]
fn main() {
#[component]
fn sidebar_link(label: &str, active: bool) -> NodeHandle {
rsx! {
div { class: {|| if active { "nav-link active" } else { "nav-link" }},
{label}
}
}
}
}
Lowercase functions get __scope injected but don’t generate a struct. Call them directly:
#![allow(unused)]
fn main() {
rsx! {
div {
{sidebar_link(__scope, "Home", true)}
{sidebar_link(__scope, "Settings", false)}
}
}
}
Making Props Reactive
Any component prop accepts a closure {|| expr} for reactive updates:
#![allow(unused)]
fn main() {
let active = Signal::new(false);
rsx! {
Button {
variant: {|| if active.get() { "filled" } else { "outline" }},
onclick: move || active.update(|v| *v = !*v),
"Toggle"
}
}
}
When the signal changes, the component re-renders with the new prop value. For the built-in components, style: and class: props get surgical DOM updates without re-rendering the component.
_fn Props for Surgical Updates
Some components offer _fn suffix props that bypass full re-render:
#![allow(unused)]
fn main() {
let text = Signal::new(String::new());
rsx! {
TextInput {
value_fn: move || text.get(), // Updates the DOM input value directly
oninput: move |v: String| text.set(v), // Feeds changes back to the signal
}
}
}
value_fn updates the input’s value attribute without re-rendering the entire TextInput component. Use this for controlled inputs — without it, programmatic signal.set("") won’t clear the input visually.
Style and Class on Components
All components support style: and class: props:
#![allow(unused)]
fn main() {
Button {
variant: "filled",
style: "margin-top: 8px",
class: "my-custom-button",
onclick: || do_something(),
"Click me"
}
}
class: is additive — it merges with the component’s own CSS classes, it doesn’t replace them. Both accept reactive closures:
#![allow(unused)]
fn main() {
Text {
style: {|| if highlighted.get() { "background: yellow" } else { "" }},
"Dynamic styling"
}
}
CSS Shorthand Props
All HTML elements and components support CSS shorthand props. These expand to set_style() calls:
#![allow(unused)]
fn main() {
rsx! {
div { p: "md", m: "sm", maw: "600px", // padding, margin, max-width
Stack { w: "100%", h: "auto", // width, height
Text { px: "lg", my: "xs", "Spaced text" } // padding-x, margin-y
}
}
}
}
Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}).
Keyboard Focus
Exactly one thing in a rinch window owns the keyboard at a time. The runtime
calls that the focus arbiter: an <input>, a <select> with its popup
open, a rich-text editor, a render surface, or a generic focusable DOM node.
Moving focus tears the previous owner down before installing the next, so two
widgets can never both believe they are typing.
Most of the time you never touch this — put tabindex="0" on an element and it
joins the Tab order, paints :focus, and activates on Enter/Space. This page is
about the other case: a custom component that takes keyboard input of its
own (a code editor, a canvas grid, a keyboard-driven list) and needs to know
when it gained the keyboard, when it lost it, and what keys arrived meanwhile.
Making an element focusable
#![allow(unused)]
fn main() {
rsx! {
div { tabindex: "0", "I can be tabbed to" }
}
}
Focusability comes from the tag or from an explicit tabindex, and an
explicit one always wins — the browser rule. The value is read the browser’s
way too (HTML’s rules for parsing integers): leading whitespace is skipped and
the number ends at the first non-digit, so " 3", "2.5" and "3abc" are all
tab indices (3, 2, 3); a value with no leading digits, or past the 32-bit
range, is not a tabindex at all.
| Focusable by tag | |
|---|---|
<button>, <select>, <textarea>, <input> | always |
<a> | only with a non-empty href — a bare <a> is not a link |
<summary> is deliberately not in the set: rinch has no <details>
disclosure behaviour, so a focusable <summary> would be a Tab stop that does
nothing.
A focusable element that is visually hidden but laid out — opacity: 0, or
position: absolute off-screen — is in the Tab order, and that is correct:
.sr-only text and skip links depend on exactly that, as they do in a browser.
What leaves the order is a zero-sized box, display: none, and
visibility: hidden/collapse. Neither is data-rid a focusability signal — the DropdownMenu’s
full-screen dismissal backdrop carries one, and so do clickable cards, table
rows and list items; none of those should be Tab stops.
| Attribute | Effect |
|---|---|
tabindex="0" | Reachable by Tab, focusable by click and by NodeHandle::focus(). Needed on anything that is not focusable by tag — a div you are driving yourself |
tabindex="-1" | Not in the Tab order, but still focusable by click and programmatically — the standard “focus this dialog when it opens” idiom, and the way to take a <button> out of the Tab order |
disabled / data-disabled | Takes no focus at all, and accepts no keyboard edit. Both spellings count — the component library writes the HTML one, the runtime’s own widgets write the data- one |
readonly | Focuses, moves its caret, selects and copies like any other field — and refuses every command that would change its text (typing, delete, cut, paste, undo/redo) |
data-nofocus | A press here takes the click but not the keyboard: whatever is focused stays focused. Read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once |
data-trap-focus | Tab and Shift+Tab cycle inside this element instead of walking the page. What Modal, Drawer and Popover write while they are open — see Containing Tab inside an overlay |
All five are boolean attributes: their presence is their value, so
disabled, disabled="" and disabled="disabled" say the same thing. To say
enabled, remove the attribute — a bool in rsx! does that for you
(button { disabled: {move || busy.get()} }).
The two HTML attributes, disabled and readonly, are read by presence
alone, exactly as a browser reads them: disabled="false" disables and
readonly="false" is read-only, on desktop and on the web alike (issue #612).
That applies to every tag the attribute reaches, so a <textarea readonly="false">
is read-only too.
rinch’s own data-disabled, data-nofocus, data-trap-focus,
data-backdrop (an overlay backdrop that answers a press of any button, #1093)
and data-scroll-lock-exempt (a scroll container an overlay’s scroll lock
leaves alone, below)
are the exception, and the only one: there the literal "false" turns the
attribute off. data-nofocus, data-trap-focus and data-backdrop are read
that way on both backends;
data-disabled is a desktop attribute with no web reader, because the browser
does not know it, and so is data-scroll-lock-exempt, because the web’s lock
has nothing to exempt. Only "false" is excused — "0" is on, matching the web’s
[data-nofocus="false" i] selector. Reach for the escape
when you are writing the attribute by hand with set_attribute and would
otherwise have to branch between writing and removing it; a bool in rsx!
already removes it for you.
A disabled <fieldset> disables every control below it, which is what the
element is for. HTML’s carve-out is honoured: controls inside the fieldset’s
first <legend> stay enabled, so the control that re-enables the section can
live there. No other tag’s disabled reaches its subtree — a disabled
<button> does not disable a <span> inside it.
A press on a disabled control also paints no DOM :focus, so a focus ring
never appears on something that owns no keyboard.
The same rule drives the CSS :disabled and :enabled pseudo-classes,
so a control that refuses input also looks disabled — input:disabled { ... }
matches, and a control below a disabled <fieldset> matches with it (the first
<legend> exemption included). Styling is deliberately narrower than focus
in one way: HTML defines both pseudo-classes over form elements only, so a
<div data-disabled> is still removed from the Tab order but does not match
:disabled — matching it would style on desktop what a browser, and therefore
rinch-web, leaves alone.
A native <select> refuses the whole popup interaction, not just the last
step: a disabled one opens no popup, and one disabled while its popup is
already up dismisses it and commits nothing rather than firing oninput /
onchange.
Disabled is re-checked at edit time, not only at focus time, so a field
that goes disabled while focused — a reactive disabled prop re-rendering
under a live caret — stops accepting keys immediately, and releases the
keyboard, the way a browser moves focus to the body. The one thing that
release does not do is fire the field’s data-onchange commit: everywhere
else that commit is load-bearing (a window blur deliberately keeps the claim so
alt-tabbing cannot fire it), but a control going disabled is not the user
committing an edit, and browsers dispatch no change for it either.
Focus arrives three ways, and all three go through the same arbiter:
-
Tab / Shift+Tab — walks the focusable elements in the browser’s sequential order, and paints the
:focus-visiblekeyboard ring: every positivetabindexfirst, in ascending order, then everything at0or focusable by its tag, ties in DOM order —tabindex="2"before"3"before a plain<button>, wherever each sits in the document. Inside atrap_focusoverlay the same order applies to the overlay’s own controls. Tab from an element that is not in that order (a clickedtabindex="-1"node, even one inside another Tab stop, or one whose box has just gone) goes to the next Tab stop after it in DOM order, and Shift+Tab to the previous one, as Chrome does. What an opening dialog focuses is still its first control in DOM order, not the first in Tab order — HTML picks it that way. -
A mouse press — claims the nearest focusable ancestor-or-self of whatever was hit, exactly as a browser does, and with any button: a right or middle press focuses too, as a browser’s
mousedowndoes (measured in Chrome 153), and it does so before adata-oncontextmenuhandler or the built-in text menu runs. Only:activeis the primary button’s alone, and a press inside a focused rich-text editor leaves the keyboard with the editor whatever the button. Pointer focus does not paint the:focus-visiblering. A press that resolves to something other than the current claim holder moves the keyboard there — a nested focusable inside a focused node counts as “somewhere else” — and a press that resolves to nothing releases it.This applies to
tabindex="-1"too — it keeps an element out of the Tab order, but it does not keep a click from focusing it, in rinch or in a browser. A control that must not steal focus from the field it sits beside — a toolbar button over a rich-text editor, a spinner next to a number input — needsdata-nofocus. -
node.focus()/request_focus(node_id)— programmatic, also no ring.
Selecting a text control’s text programmatically
NodeHandle::select() and NodeHandle::set_selection_range(start, end, direction)
(issue #552) are focus()’s selection counterparts, for the “a rename box
opens with its name filled in and selected, so the first keystroke
replaces it” shape — set_selection_range matches the DOM’s own
HTMLInputElement.setSelectionRange(start, end, direction) exactly,
including its units: start/end are UTF-16 code units, not bytes and
not chars, so a component written once targets desktop and
rinch-web with the same offsets ("🙂".encode_utf16().count() is 2, not
1). direction is SelectionDirection::Forward (the anchor is start),
Backward (the anchor is end), or None (unspecified, applied the same
way Forward is). select() selects the control’s whole text — exactly
set_selection_range(0, <the UTF-16 length>, Forward).
#![allow(unused)]
fn main() {
// `name_input` is the captured `NodeHandle` for the rename `<input>` (built
// once, outside the handler, the way `email_field` is above).
button { onclick: move || {
editing.set(true); // an `if` effect mounts the rename input
name_input.focus();
name_input.select(); // the whole name, ready to be typed over
}, "Rename" }
}
That ordering works because touching the DOM inside a handler flushes the queued effects
first: by the time name_input.focus() runs, the if effect has already
mounted the input editing.set(true) asked for.
Both calls work on a control that does not hold the keyboard yet, the same
way a browser’s own setSelectionRange()/select() do: the range is set
immediately and simply shows up the first time the control is focused — a
component does not need to call focus() first, or in any particular order
relative to select()/set_selection_range(). Like focus(), both are
no-ops before this handle is mounted — and, on desktop, also a no-op on a
node that is not a text control (a checkbox, a <select>, a generic
tabindex node): such a node never installs the state a selection would
apply to, so there is nothing to apply it to or hold it for, the same
divergence a browser’s own thrown-and-discarded InvalidStateError would
be papering over. A mouse click that focuses a field places its own caret
and clears a selection that was pre-armed for it but never applied —
calling set_selection_range() on a field and then clicking somewhere else
in it does what you would expect: the click wins.
A focused <select> is closed, like a browser’s: Enter, Space or Alt+Down
opens its popup, and the popup then owns the keyboard until it commits or is
dismissed — at which point focus returns to the closed control, so Tab carries
on from there rather than restarting. (A click outside the popup is the
exception: it belongs to whatever it landed on.) The pointer opens and picks
with the primary button only, as in a browser: a right or middle press on the
closed control does not open it, and one on an option picks nothing and leaves
the list open — but a press of any button outside the list dismisses it.
Everything else focusable activates the nearest ancestor-or-self
data-rid on Enter/Space, which is what makes div { tabindex: "0", onclick: … }
behave like a button — and what makes Space on a Checkbox’s visually hidden
<input> toggle the <label> that wraps it.
The Select component (not a native <select>) behaves the same way on
both backends (issue #434): its trigger keeps the focus while the list is open,
ArrowUp/ArrowDown move a highlight (wrapping at the ends), Home/End jump,
Enter commits the highlighted option, Escape closes without committing, Tab
closes and moves on, and typing jumps to the first option whose label starts
with what was typed. Space commits like Enter, except while a type-ahead prefix
is being typed, when it extends it (“new y” finds New York). The highlight
starts at the selected option and is announced through
aria-activedescendant. A key with Ctrl, Alt or Meta held is left to the app.
The list owns keys only while the focus is in the Select: if something moves
the focus elsewhere while it is open (a shortcut focusing a search box, a
script), the list closes and the keys go to wherever the focus went.
Taking the click without the keyboard
An editor toolbar has a problem every GUI toolkit has to answer: pressing
Bold must run the command without blurring the editor, or the command
reads a selection that is no longer there. Browsers answer it with
preventDefault() on mousedown, which suppresses the focus change while
still delivering the click.
data-nofocus is that mechanism:
#![allow(unused)]
fn main() {
rsx! {
// The whole toolbar opts out at once — every control inside it takes its
// click without taking the keyboard.
div { data-nofocus: "", class: "toolbar",
button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" }
button { tabindex: "0", onclick: move || ed.command("toggleItalic"), "I" }
}
Editor { editor: ed.clone() }
}
}
The rules:
- It can be written on a component:
Group { data-nofocus: "", … }orPaper { data-nofocus: "", … }puts it on the component’s root element, so a toolbar built from components needs no wrapperdiv(Attributes on a component). - It is read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once instead of every button in it. Put it on the toolbar, not on a big content region — a press inside it suppresses the browser’s default, which includes starting a text selection.
- It protects whatever holds the keyboard — the rich-text editor, an
<input>, a render surface, another focusable node — not just the editor. - The click still fires.
data-riddispatch is untouched. - A text field inside a
data-nofocusregion still focuses normally. A link-URL field in a toolbar has to be usable, so the field’s own claim wins over the region’s opt-out. - Boolean attribute, same rule as
data-disabled: present means on whatever the value, and the explicit"false"opts out — one of rinch’s own three attributes where that escape exists. - It works on both backends. On the web it becomes
preventDefault()on thepointerdown.
Containing Tab inside an overlay
A dialog that lets Tab walk out into the page behind it is not really a dialog:
the keyboard user ends up editing a form they cannot see. Modal, Drawer and
Popover all take a trap_focus prop for this — true by default on the
first two, false on Popover, which is not modal.
#![allow(unused)]
fn main() {
rsx! {
Modal {
opened_fn: move || open.get(),
onclose: move || open.set(false),
trap_focus: true, // the default; `false` lets Tab out
TextInput { placeholder: "Name" }
Button { onclick: move || save(), "Save" }
}
}
}
While the overlay is open its root carries data-trap-focus, and both
backends read it: Tab and Shift+Tab cycle the focusable elements inside that
element and wrap at its ends, instead of walking the whole document. When it
closes the attribute is removed and Tab goes back to the page.
You can put the attribute on anything, not just these three components — it is a
plain contract, the way data-nofocus is:
#![allow(unused)]
fn main() {
div { data-trap-focus: "", class: "command-palette",
input { }
button { "Run" }
}
}
The rules:
- Which trap wins. Whichever one currently holds the keyboard; if none does,
the last one in document order. Nesting therefore resolves inside-out — a
modal opened from a modal is rendered deeper, so later — and when the inner one
closes the outer takes over with focus unmoved.
z-indexis not consulted, so a raised-but-earlier overlay loses to a later one; that matches the dismiss stack, which answers Escape in the same order. - A trap with no box is not a trap. A closed
Modal’s root isdisplay: none, so it is skipped even if the attribute were left on it. Both guards are real, and the component relies on the first: it removes the attribute rather than writing"false"into it. - Only Tab is contained. A click outside the overlay still moves focus
out of it, and so does a scripted
focus(). That is what a non-modal<dialog>does; a browser’sshowModal()goes further and marks the rest of the pageinert, so even a scripted focus behind it is refused — measured in Chrome 150. rinch has noinertand noshowModalsemantics, sotrap_focusis a Tab rule, not a modality barrier. The backdrop’sclose_on_click_outsideis what an outside click is for; full modality would needinert, which rinch does not have. - A trap with nothing focusable inside it swallows Tab. That is what
containment means when there is nowhere to go — and on the web the same is
true of a trap whose every control the browser refuses to focus (all of them
inside a
<fieldset disabled>, say): the key is consumed and focus stays put, rather than the stepping-on above running forever. - Boolean attribute, same rule as
data-nofocus: present means on whatever the value, and the explicit"false"opts out. - Both backends, different code. Desktop starts its own focusable walk at
the trap;
rinch-webcollects the trap’s focusable descendants with a CSS selector and callsfocus()itself. The two sets are therefore computed separately and agree only as far as each is written to — desktop’s notion of focusable is already documented as broader than HTML’s in places. Trapping inherits that difference rather than creating it. On the web the browser is the authority, not rinch’s selector: afocus()it declines (a control inside a<fieldset disabled>, atabindexit parsed differently) is stepped over rather than trusted, so an element rinch listed and the browser will not focus cannot stall the cycle. - Some browser Tab stops are not rinch Tab stops, on either backend:
contenteditableelements,<iframe>,<summary>,<audio controls>and<area href>are reachable by Tab in a browser and are in neither backend’s focusable set. An overlay containing one loses it while trapped — and outside a trap those elements are not desktop Tab stops either, so this is the focusable set’s shape rather than something trapping introduces. Give such an element an explicittabindex="0"if it has to be reachable.
Moving focus in, and giving it back
Containment is half of what a dialog does. The other half is that opening one
takes the keyboard and closing it gives it back (issue #695), and both are
tied to the same trap_focus prop — trap_focus is rinch’s spelling of “this
overlay is modal”, and a browser moves focus for showModal() and not for
show(). An overlay that wants one without the other cannot ask for it.
On open, the overlay remembers whatever holds the keyboard — usually the button that was clicked — and then focuses inside itself:
| Component | What it focuses |
|---|---|
Modal, Drawer | the autofocus descendant, else the first focusable |
Popover | the autofocus descendant, and nothing without one |
Popover’s rule is the HTML popover API’s, not the dialog’s: an auto popover
runs its focusing steps only for an element that asked. A popover that grabbed
the keyboard on every open would interrupt whatever the user was typing in.
On close — and on unmount while still open, which is what
if show { Modal { … } } does — the keyboard goes back to the remembered
element, provided it can still take it (see below). If it cannot, the claim is
released rather than left inside the overlay that has just gone. Focus the user
moved out of the overlay before it closed is left alone entirely: neither
released nor restored over, which is HTML’s own dialog rule.
Nesting needs no special case: an inner overlay remembers whatever the outer one focused, so closing the inner restores into the outer and closing the outer restores to the page.
Three things to know about the machinery:
- Desktop decides both a turn later. An overlay opening or closing is a class
change in the same effect flush, so at that instant its children still carry
the zero-size boxes their
display: noneancestor gave them — and on a close, whether the remembered opener can still take the keyboard is the same kind of question about boxes. The desktop backend therefore parks the request and resolves it after the next layout, which is the turn a signal write already triggers.rinch-webanswers on the spot, because the browser lays out on demand and refuses afocus()it should refuse. The browser’s focus listeners run inside these focus moves (NodeHandle::focus, an overlay opening or closing) with the document free: ablur,focusinoronchangehandler of yours that they run may use anyNodeHandle. The same holds when aNodeHandleverb takes the focused element out of place (see When the focused element goes). - “Still there” means it can still take focus, not merely that it is
attached. An opener that went
disabledwhile the dialog worked, or one that lives inside an outer overlay closed before this one, is connected and unreachable; the keyboard is released instead. What is not checked is identity: a node id freed and handed to a different, attached node would still be focused — the recycled-slot hazard of issue #304, which desktop has independently of this. - A
Popoverthat declines the move is still a Tab trap whentrap_focusis on. Focus stays where it was, and the next Tab enters the popover and is contained there. That is containment’s own rule (the last visible trap wins), not a consequence of the move policy.
The portable API this rests on is three DomDocument methods, reachable on any
NodeHandle: active_element(), focus_into(policy) and restore_focus(opener).
A component that builds its own overlay can use them directly. There is
deliberately no bare blur(): releasing the keyboard is never the whole answer,
only the fallback half of a restore, and a caller that could only blur would have
to make the decision restore_focus exists to make.
Registering a focus target
register_focus_target attaches callbacks to a focusable node. It does not
change who can be focused — it changes what your component is told about it.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn key_grid() -> NodeHandle {
let focused = Signal::new(false);
let cursor = Signal::new(0usize);
let grid = rsx! {
div {
tabindex: "0",
class: {move || if focused.get() { "grid focused" } else { "grid" }},
"cell "
{move || cursor.get().to_string()}
}
};
register_focus_target(
&grid,
FocusEntry::new()
.on_focus_gained(move || focused.set(true))
.on_focus_lost(move || focused.set(false))
.on_key(move |k| match k.key.as_str() {
"ArrowRight" => {
cursor.update(|c| *c += 1);
true // consumed
}
"ArrowLeft" => {
cursor.update(|c| *c = c.saturating_sub(1));
true
}
_ => false, // let the runtime have it
}),
);
grid
}
}
Every callback is optional; FocusEntry::new() with none of them is legal and
registers the node without asking for anything back.
What fires, and when
| Callback | Fires |
|---|---|
on_focus_gained | Tab onto the node, a press on it (or on any of its children), focus() / request_focus, and when the window regains OS focus while this target still holds the claim |
on_focus_lost | Anything else takes the keyboard — another registered widget, an <input>, a <select>, the rich-text editor, a render surface — a press landing outside, and when the window loses OS focus |
on_key | Every KeyDown and KeyUp while this target holds focus, before the runtime’s own handling — read k.kind (or k.is_up()) to tell them apart |
on_ime | Every IME composition event while this target holds focus (see IME below) |
Presses and releases
k.kind is KeyEventKind::Down or Up; k.is_down() / k.is_up() are the
shorthands. OS auto-repeat arrives as Down, and KeyEventData does not
yet distinguish it from a fresh press. The platform event does, since issue
#463 — PlatformEvent::KeyDown carries a three-state KeyRepeat
(Fresh / Repeat / Unknown), filled in by winit on desktop and by
repeat_count() on Android, and that is what the runtime’s own once-per-press
Enter/Space activation reads. Surfacing it to on_key needs the same
three-state answer (a plain bool would report a held key as fresh wherever a
backend cannot see, which is the divergence a document-level hook exists to
avoid) and so needs a home for the type that rinch-core can reach. Tracked as
#797.
A press and its release are spelled by the same rule, from the same fields, so
pairing them by k.key works by construction — which is what “is W still
held” needs. Concretely: the platform event’s logical_key carries the full
layout-produced KeyboardEvent.key value on the press and the release alike,
case included. On AZERTY, the key labelled A is "a" on the way down and on
the way up, not "a" down and "q" up; Shift+A is "A" both ways, and
Shift+1 is "!" both ways — the same strings a browser reports, so the same
consumer code works against rinch-web. Case is identity, so a Shift pressed
mid-hold changes what the eventual release spells ("w" down, "W" up) —
exactly as in a browser; track held keys by the physical k.code, or fold
case at the comparison, if that matters to you.
On Android a hardware key’s release is spelled as its press was reported,
whatever happened in between: "W" down with Shift held comes up as "W" even
if Shift went up first, and a letter that combined with a dead key ("é")
comes up as that same string. A release whose press the app never saw (a key
already held when the app came to the front) is spelled from the layout with
the modifiers held at the release. An auto-repeat is still spelled from what it
types: after a dead key the press is "é", the repeats are "e" and the
release is "é", so track held keys by k.code there too
(#1418). Text the
soft keyboard commits goes through InputConnection and has no key events; a
raw key event an IME sends (sendKeyEvent, typically Backspace and Enter)
comes through the same translation as a hardware key, release included.
Two things to know:
- A release is delivered to whoever holds the claim at release time. A
focus change mid-chord can therefore hand a target a release it never saw
pressed — treat
on_focus_lostas “everything is up” if you track held keys. - A release’s return value is ignored. There is nothing downstream of it to suppress, and the runtime’s own release work (clearing the Enter/Space activation latch) must happen whatever a handler thinks — otherwise a consumed release would strand the latch and swallow the next press.
on_key returns true to consume the key. A consumed key stops there: no
Tab navigation, no Enter/Space activation, no DevTools shortcut. Returning
false (or not setting on_key at all) leaves every one of those working
exactly as it would for an unregistered node, so registering costs you nothing
you did not ask for.
k.key is spelled the way the browser spells KeyboardEvent.key, on desktop
as on rinch-web (which forwards event.key() unchanged), and k.code the
way it spells KeyboardEvent.code. That includes the spacebar: k.key is
" " and k.code is "Space", and k.is_space() answers it on every
backend. (Desktop reported k.key == "Space" until issue #1161; code that
matched that should match k.code == "Space" or k.is_space() instead.) The
key is resolved in four steps:
- A key with a fixed spelling wins over the text it would insert —
"ArrowLeft","Enter","Escape","Tab","PageUp","F1"…"F12","Shift", and the spacebar’s" ", so a release (no text) and a chord (text suppressed) spell it like a plain press. - Otherwise the inserted text wins, so a non-QWERTY layout reports the
letter actually typed rather than the physical QWERTY position: the AZERTY
key at the QWERTY-Q position is
k.key == "a", and Shift+A is"A". - Otherwise the layout’s key value. A modifier suppresses the inserted
text, but the layout-produced
KeyboardEvent.keyvalue survives it — so a chord keeps step 2’s promise: on AZERTY,Ctrlplus the key labelled A isk.key == "a"(andCtrl+Shiftmakes it"A"— the value is case-accurate), the same letter the editor’s own keymap acts on. It also names shifted punctuation (Ctrl+Shift+1is"!"where the layout puts one), a dead key ("Dead"), and keys rinch has noKeyCodeof its own for but the platform names — CapsLock, media keys. - Otherwise the physical key’s US-layout character, for events that carry
no layout value at all (the debug channel, injected and embedded events):
Ctrl+Sis"s",Cmd+1is"1",Ctrl+-is"-".
k.code is always the physical key ("KeyS", "Digit1").
Two things to know.
k.keyis case-accurate, browser-style —Ctrl+Sis"s",Ctrl+Shift+Sis"S"— and a press and its release spell alike (both read the modifier state of their own moment), which is what pairing them byk.keyrelies on. And a key bound to a native menu accelerator is consumed by the menu before the document sees it — but only the press: the release still arrives (the menu consumes nothing on the way up), so it is one more source of a release with no visible press.
The one key that still reports nothing is one rinch has no KeyCode for
(k.code == "Other") arriving with no layout value and no inserted text.
A real keystroke carries the layout value (unless the platform itself cannot
identify the key), so this is mostly the injected regime: the debug channel names only single characters and the named keys, so
an injected Ctrl+/ has no spelling to fall back to and never reaches the
hook. From the keyboard those keys are fine: Ctrl+/ reports "/" through
step 3, and unmodified the character arrives as the inserted text.
Both focus callbacks run after the transition is complete: the arbiter and
the DOM :focus state are already installed, so a callback may re-enter the
runtime freely — move focus again, mutate the DOM, save a document.
Enter and Space
A focused node that does not consume Enter/Space gets the runtime’s default:
they dispatch the onclick handler of the nearest ancestor-or-self that has
one, once per physical press. That is what makes div { tabindex: "0", onclick: … } behave like a button. If your widget wants Enter for itself, consume it in
on_key.
Unmounting
Unmounting the component deregisters silently. on_focus_lost does not
fire, even if the node held the keyboard at the time — by then the component’s
scope has been disposed and its signals freed, and calling back into it would
panic. Do your teardown in the component’s own cleanup path, not in
on_focus_lost.
The arbiter notices the vanished target on its next key dispatch and drops the claim, so keys fall back to the global handlers rather than disappearing.
Window focus
When the window loses OS focus, the focused widget keeps its claim — it is
only notified, and notified again when the window comes back. This is browser
behaviour, and it is deliberate: releasing focus on every alt-tab would fire
onchange on whatever field the user was typing in each time they switched
windows.
So the pair to expect is on_focus_lost on window blur, on_focus_gained on
window refocus, with no key routing in between. Use it to hide a caret and idle
a blink timer. While the window is blurred, rinch reports the OS IME disabled,
so a candidate box follows the window that actually has the keyboard.
The runtime does the same for its own caret: the rich-text editor’s caret stops blinking while the window is blurred and shows solid, resuming from the solid phase on refocus. The blink is the event loop’s only timed wake, so a backgrounded rinch app now idles completely instead of waking twice a second to animate a caret nobody can type into. The selection highlight is unaffected — the claim is still held, so a blurred window still shows what is selected.
IME
A widget with its own text model can take IME composition — CJK conversion,
autocorrect, dead keys, a swipe keyboard — by registering on_ime. That is what
declares the target a text target: the runtime then switches the platform
input method on while the widget holds the keyboard, and routes every one of the
five portable ImeEvent variants to it — the same contract the rich-text editor
and a built-in <input> consume. A registration without on_ime turns nothing on, so a focusable card
or a custom checkbox never pops a candidate window.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
register_focus_target(
&node,
FocusEntry::new()
// Where the OS puts its candidate box, in logical window pixels.
.caret_rect(move || Some(caret.get()))
.on_ime(move |e| match e {
// A transient overlay you render and discard — never document text.
ImeEvent::Preedit { text, cursor } => model.set_preedit(text, *cursor),
// The conversion the user chose. Insert it as one edit.
ImeEvent::Commit(text) => model.insert(text),
// Composition ended with nothing committed.
ImeEvent::Disabled => model.clear_preedit(),
_ => {}
})
.on_focus_lost(move || model.clear_preedit()),
);
}
caret_rect is (x, y, w, h) in logical window space — CSS pixels from the
window’s top-left, the same space layout bounds are reported in. Do not
pre-multiply by the display scale factor; the shell hands the rect to the
platform as a logical size and the platform scales it. Return None when there
is no caret; placement then falls back to the platform’s default. The provider
is polled every time rinch reconciles the window’s IME state (once per
event-loop iteration), so the candidate box follows the caret with nothing to
notify — keep it cheap, and do not mutate the DOM from it.
Two things the runtime deliberately does not do:
- It never fabricates an event. It holds no preedit on your behalf, so a
focus change is not an
ImeEvent::Disabled. When another target claims the keyboard the window’s input method may stay enabled throughout and nothing ends your composition — clear the preedit inon_focus_lost, as above. ImeEvent::DeleteSurroundingis inert on desktop today. Rinch asks winit only for cursor-area support, so no desktop backend advertises surrounding-text and the variant never arrives. Android’sInputConnectiondoes send it.
Everything under Window focus applies: while the window is
blurred, IME reports disabled and no composition is routed, but the claim — and
on_ime with it — comes back on refocus.
The dismiss stack
Escape is the one key an overlay has to answer without holding the keyboard: a
Modal is dismissed by Escape whether the focus is on a field inside it, on the
modal itself, or nowhere at all. That is a different job from both APIs above,
and it has its own registry (issue #474).
#![allow(unused)]
fn main() {
use rinch::prelude::*;
let opened = Signal::new(true);
let handle = push_dismiss_handler(root.doc_key(), move || {
if opened.get() {
opened.set(false);
true // consumed: the key stops here
} else {
false // not open: pass it to the overlay below, then the app
}
});
__scope.on_cleanup(move || drop(handle));
}
- It is a stack, last in first out. The most recently registered handler is
asked first, so two nested modals close innermost-first. The first handler
that returns
trueconsumes the key and the ones beneath it are never asked. - Answer
falsewhile you are closed. An overlay usually stays mounted when it closes —opened_fnonly toggles a class — so the check belongs inside the handler, at dispatch time, not at registration. A handler that always returnstrueswallows Escape for the rest of the session. - It is released on unmount, twice over: the
DismissHandlereleases it when it drops, and an entry whose owning scope has been disposed is dropped as the scan passes it rather than run (issue #183 — its captured signals are already freed). - It is scoped per document, on desktop and embed. Two
RinchContexts on one thread do not answer each other’s Escape (issues #134, #139). Onrinch-webthere is nothing to scope and nothing doing the scoping: a page dispatches with no document marked, and the rule is deliberately permissive when either side is unmarked, so every handler is reached. The explicit key you pass at registration is what carries the distinction where one exists. - Both backends, no branch. Dispatch lives inside
dispatch_keyboard_event, which desktop calls ahead of the focus arbiter andrinch-webcalls from its documentkeydownlistener.
Some things are dispatched before the stack and will take Escape from it: a
set_keyboard_interceptor that consumes the key, and an in-progress drag, which
Escape cancels. Both apply on either backend. rinch-web has one more — a
focused RenderSurface swallows every key there, where on desktop a surface is
routed by the arbiter, i.e. after the stack. That asymmetry is older than the
stack (the interceptor already had it) and is not something this API changed.
Register at mount, or at open
The snippet above registers once, when the overlay mounts, and answers false
while it is closed. That is what Modal, Drawer and Popover do, and it is
right for an overlay whose open state arrives as a prop: render runs once,
so there is no later moment to hang a registration on.
It is the wrong choice for an overlay statically nested inside another one.
A component renders after its children, so Modal { ColorInput { … } } pushes
the input’s entry first and the modal’s on top of it — and the stack is LIFO, so
the modal answers Escape while the colour picker is the thing on screen.
An overlay that owns its open state avoids that by pushing when it opens and
releasing when it closes. Opening happens after every ancestor has mounted, so
LIFO puts the entry where the user expects it with no precedence rule anywhere.
This is what the native <select> popup does (issue #671) and what
ColorInput’s dropdown does (issue #465); in a component,
rinch_components::overlay_dismiss::arm_close_on_escape_while_open is that
policy written out. Such a handler consumes unconditionally — it exists only
while the overlay is open, so it has no closed state to decline in — and the
release must cover unmount-while-open as well as close.
Modal, Drawer and Popover already do all of this for you through their
close_on_escape prop — reach for push_dismiss_handler when you are building
an overlay of your own. Prefer it over set_keyboard_interceptor for
Escape: the interceptor is a single slot per document, so a second overlay
registering there silently disables the first, and when it unmounts it clears
the slot rather than restoring what it displaced.
A popup that owns every key while it is open and focused
#![allow(unused)]
fn main() {
let handle = push_key_handler(
&root, // the owner: the popup's component root
move |k: &KeyEventData| k.key == "ArrowDown" && { step(); true },
move || opened.set(false), // focus left the owner
);
}
push_key_handler puts an entry on the same stack that is offered every key
press (never a release), ahead of the Escape scan; true consumes the key,
which on rinch-web also preventDefaults it. It is for a popup whose focus
stays on something outside the popup’s list — the Select component’s option
list, whose combobox trigger keeps the focus while the arrows move a highlight.
It is offered a key only while the focused element is the owner or inside
it. A field focused while the popup is open — by a shortcut, a script, an
autofocus — keeps its own keys, and on the web a key typed outside the rinch
mount is never touched. When focus moves and is no longer inside the owner, the
entry’s on_focus_leave runs (the backends report every focus move), which is
where a listbox closes; pass || {} if yours should stay open.
Register it at open and release it at close and on unmount —
rinch_components::overlay_dismiss::arm_keys_while_open is that policy.
Decline Escape in it and give Escape its own dismiss entry, so “which overlay is
on top” stays one question.
When the focused element goes
On the web, a NodeHandle verb that removes or moves the focused element, or
an element around it, lets the focus go first: remove, discard,
remove_child, replace_with (for either node), set_inner_html and
set_text over it, and append_child / insert_before / insert_after of a
node that is already in the document. That covers a reactive if hiding a
panel, a keyed for moving a row, and unmounting a root. The focused element
is blurred, with the document free, and then the node goes. So:
- a key entry whose owner is in the part that goes gets its
on_focus_leave(an openSelectin a hidden panel closes). Chrome fires the focus events by itself when a focused node is removed; Firefox fires nothing, and rinch’s ownblur()makes the two agree; - a modified field in it gets its
onchangeonly while its handler is still alive: a field removed directly, or a captured one a branch hides (if show { {field} }). A field the hidden branch built (if show { input { onchange: … } }) has had its handlers freed before it goes, and commits nothing; - a handler those events run may use any
NodeHandle, and the node it is about is still where it was. A handler that keeps putting the focus back inside the part that goes is stopped the fourth time: that part is heldinertfor the last blur, so the focus cannot return to it; - a verb the browser refuses (an
insert_beforewhose reference is not a child of the parent, a node appended into itself) moves nothing and keeps the focus; - a node moved inside the document keeps the focus where the browser has
Node.moveBefore(Chrome 133 and later; check your other target browsers), as it always has on desktop.append_child,insert_beforeandinsert_afterof a node that is already in the document — a keyedformoving a row — are then that call: no focus event fires, an openSelectin the row stays open and an<iframe>in it is not reloaded (measured in Chrome 153 and Firefox 157); a scroller in it keeps its offset in Chrome and is reset to the top in Firefox 157. A running CSS animation or transition in the row carries on and an IME composition in its<input>is not ended (measured in Chrome 153). One exception to “keeps the focus”: a node moved under a parent where it cannot be focused (display: none,hidden,inert,visibility: hidden, a disabled<fieldset>, a closed<details>or<dialog>) keeps it through the move and the browser blurs it afterwards, so a key entry’son_focus_leaveruns then, once. A page’s ownmoveBeforepolyfill written overinsertBeforeis recognised and not used. In a browser without the method the move is a removal and an insertion, the moved node loses the focus and the list above applies; callfocus()on it again after the move if it must keep the keyboard there. A move into or out of a detached subtree, andreplace_with, take that path in every browser.
Three attribute writes can cost the focused element its focus in place:
hidden on it, and removing the tabindex or contenteditable that made it
focusable (Chrome blurs inside the write; disabled, inert and
display: none fire nothing there). set_attribute, write_attribute and
remove_attribute make those writes with the document free too. A tabindex
write that leaves the element focusable (a roving tabindex) keeps the focus.
Not covered: UpdateBatch::apply, which is handed the document already
borrowed, and any other code that calls a DomDocument method directly. A
SetText or attribute write there that unfocuses an element still runs the
browser’s listeners under that borrow.
Locking the page behind an overlay
The other thing an open dialog owes the page behind it is stillness. Modal and
Drawer do it for you through their lock_scroll prop (default on, issue
#474); a custom overlay takes the same lock on its own root:
#![allow(unused)]
fn main() {
// On open — the node matters: it names the subtree that stays scrollable.
root.set_scroll_locked(true);
// On close, and again on unmount, or the page never scrolls again.
root.set_scroll_locked(false);
}
- Locks are counted. Two overlays open and the inner one closing leaves the
page locked. Every
truemust be matched by exactly onefalse. - Release it on unmount as well as on close. An overlay can be taken out of
the tree while still open, which is what
if show { Modal { .. } }does. The effect that would have unlocked it never runs again, so the release belongs in anon_cleanuptoo.ModalandDraweralready do both. - The two backends do different things, and this is the one overlay
behaviour where that is visible. Desktop refuses the gesture: a wheel (which
is what a touch scroll arrives as too) and a scrollbar-thumb press are
rejected for any container that is not inside a locking overlay, so the
dialog’s own
overflow: autobody still scrolls.rinch-websetsoverflow: hiddenon the real<html>, because rinch cannot gate the browser’s own wheel. - So web also removes the page’s scrollbar and desktop does not. A web page with a classic (non-overlay) scrollbar shifts sideways when an overlay opens. Desktop’s bars are overlays with no gutter, and a locked page’s bar stays painted — visible, and inert.
- Input only. Programmatic scrolling (
set_scroll_top, a list the app scrolls itself) is untouched on both. - A drag already in flight ends when a lock arrives. A scrollbar thumb the user is holding when something else opens a dialog is released rather than left to go on scrolling the page.
- In island mode the lock is still the whole page. There is one
<html>, so a rinchModalinside an island freezes the host page, not just rinch’s own region — one lock, counted across every island on the page. That is what every web overlay library does, but an embedder should know it before shipping a dialog into someone else’s page. - On iOS Safari,
overflow: hiddenon<html>is not always enough to stop rubber-band scrolling; libraries there add aposition: fixedbody trick. We have not tested it and rinch does not do it, so treat the web half as “the page stops scrolling in every browser we have measured” rather than as universal.
Do not reach for RenderScope::body_handle() and set overflow: hidden
there: on the web that handle is <div id="rinch-body">, a descendant of the
real <body>, and styling it does not stop the page scrolling.
A scroll container outside the overlay: data-scroll-lock-exempt
On desktop the lock refuses every scroll container that is not inside a locking
overlay’s root. That is the page behind the overlay, which is the point, but it
is also any scroll container of yours that sits outside the overlay in the
tree and is still meant to be used while it is open: a popup portalled to
<body>, a menu hanging from window chrome, a panel that paints over the
dialog. Mark it:
#![allow(unused)]
fn main() {
div {
class: "my-popup-list",
style: "max-height: 300px; overflow-y: auto;",
data-scroll-lock-exempt: "",
// ...
}
}
- The attribute exempts its subtree, so a popup carries it once on its root and every scroller inside scrolls. On a component, write it the same way: a hyphenated attribute goes on the component’s root element.
- It exempts everything under the marked node and nothing else: an unmarked scroll container outside the overlay stays locked.
- It cannot unlock the page. A mark on a node that contains the locking
overlay —
<body>, an app root, a wrapper around the dialog — is ignored, so the page behind the overlay stays locked whatever its ancestors carry. Mark the popup, not something above it. - It is not checked against paint order. A marked scroller that the overlay covers also scrolls when the wheel is over it (the pointer is on the backdrop, and the wheel finds the scroller underneath). Mark only what is shown above the overlay.
- It is one of rinch’s
data-boolean attributes: on unless its value is"false", and a reactivedata-scroll-lock-exempt: {|| flag.get()}removes it whenflagis false.rinch_core::events::SCROLL_LOCK_EXEMPT_ATTRIBUTEis the name, for code that writes it through aNodeHandle. - There is nothing to release: the exemption goes with the node.
- Desktop only, and the web needs none. The web’s lock is
overflow: hiddenon<html>, which stops the page and leaves every scroll container inside it scrolling, so the attribute is inert there.
The DOM menu bar (the in-app bar of a Linux window) marks its own dropdowns and
submenu flyouts, so a menu too long for the window scrolls while a dialog is
open (issue #701). The runtime’s native <select> popup, which it appends to
<body>, is exempt the same way through the runtime’s own list
(NodeTree::push_scroll_lock_exempt); you do not need to mark a <select>.
Undo and redo in a text field
A focused <input> or <textarea> undoes with Ctrl+Z and redoes with
Ctrl+Shift+Z or Ctrl+Y (Cmd on macOS) on desktop, as a browser field
does on the web (issue #288).
oninputfires with the restored text, exactly as for a keystroke, so a controlled field’s signal follows the undo. The eventualonchangecompares what the field shows at the commit with what it showed when focus arrived, so undoing back to the starting text commits nothing.- A
readonlyfield refuses both, like every other text-changing command, and a field that goesdisabledwhile focused releases the keyboard instead. - One key press is one undo step. Each typed character is its own step — desktop does not merge a run of typing into one the way a browser does. Typing over a selection is one step: the selected text comes back whole.
- A keystroke and the rewrite its own
oninputmade are one step. A normalizing field (upper-casing, say) therefore undoes to the text before the keystroke, never to the raw text the handler is about to rewrite again. A keystroke the handler rejects — writes the field back to what it showed, as a digits-only filter does with a letter — leaves no step at all. This is where desktop and the web differ: a browser treats that rewrite as a script write to.valueand drops the field’s whole undo history, so a normalizing field has no undo on the web. - Any other write to the field’s
valueclears its undo and redo history — avalue_fnreset, a clear afteronsubmit, a write from a timer or another handler. It is the app’s change, not the user’s, so Ctrl+Z cannot bring back text the app replaced. That is what a browser does for a script write to.value, so for a write made outside a keystroke desktop and the web agree. - Not with Alt, and not mid-composition. Ctrl+Alt+Z is left alone (it is AltGr on Windows, which types a character on some layouts), and Ctrl+Z does nothing while an input method is showing a composition.
- The built-in right-click menu (below) has no Undo row; the chords are the way in.
Right-clicking a text field
A right press on a text target — an <input> of a text-like type (text,
search, url, tel, email, password, number, or no type at all), a
<textarea>, or the rich-text Editor — opens a built-in Cut / Copy /
Paste / Select all menu on desktop (issue #813). It is a menu the runtime
draws itself, as a DOM overlay in the same shape as the native <select>
popup, so it is the same on Linux, macOS and Windows, works in a borderless or
transparent window, and follows the theme’s palette when there is one.
- A
data-oncontextmenuhandler still wins. If the pressed node or any ancestor carries a live one, it runs exactly as before and the built-in menu stays shut. Attach one to a field to give it a menu of your own. - Each item runs the code its chord runs —
Ctrl+X/Ctrl+C/Ctrl+V/Ctrl+A(Cmdon macOS), including the editor’s asynchronous, anchored paste. Today that is literally one function per operation on both paths, and a fixture pins item and chord to the same value, selection and clipboard; nothing in the code enforces it, so a check added to one path alone would drift the other silently. - Enabled states follow the field. Cut and Copy need a non-empty
selection; a
readonlyfield greys Cut and Paste; apasswordfield greys Cut and Copy; Select all needs content; adisabledfield takes no focus and gets no menu. Without theclipboardfeature Cut, Copy and Paste are all greyed — there is no clipboard for them to reach, and an enabled Cut would delete text it never copied. Paste is not decided by reading the clipboard — a read can block for up to four seconds on X11 — so it stays enabled over an empty clipboard, where a native menu would grey it out; pasting nothing does nothing. - The press places the caret like a native editor’s. Outside the current selection it moves the caret to the press point; inside it, the selection is kept, so Cut and Copy act on what you selected.
- The field keeps the keyboard while the menu is open and after an item
runs. Up / Down / Home / End move over the enabled rows (wrapping), Enter or
Space runs the highlighted one, and Escape closes through the dismiss
stack — the menu pushes its entry at open time, above an
enclosing
Modal’s, and releases it on close. Any press outside the menu closes it without acting and is swallowed; a wheel closes it too. - It goes with its target. The menu closes when the field’s claim moves (a click elsewhere, a programmatic focus), when the window loses focus, and when the field leaves the document — the last is checked on every event rather than waiting for a second event that might never arrive.
On rinch-web the browser’s own menu covers text fields and the runtime draws
none (issue #812). On Android a long press in a field arrives as the same
right press; which presentation the Android shell uses is that platform’s
half of #813 — the runtime exposes the state and the actions
(RinchApp::text_edit_state, perform_text_edit) for a native toolbar to
build on, with this menu as the fallback.
Where this does not apply
- The browser backend (
rinch-web). There is no arbiter there because the browser is one:register_focus_targetis a desktop / Android / embed API. On web, give the element a realtabindexand use the DOM’s ownfocus,blurandkeydownevents.data-trap-focusis the exception — it works on both backends, because it is a contract the backend reads rather than a registration against the arbiter. - The document-level keyboard hook.
set_keyboard_interceptoris a capture-phase hook for the whole document, dispatched before the arbiter and regardless of focus. It is for global shortcuts;on_keyis for a focused widget. They are different jobs and both still exist. It routes per document (issue #340): a hook registered while a document’s code is running — its event dispatch, its mount (issue #295), or an effect it owns, wherever that effect happens to be flushed — intercepts only that document’s keys, and one registered frommainis the thread-global fallback that intercepts for every document without its own — so two windows that each register at mount or from their own event handling no longer clobber each other. Registrations made outside any document (frommain, a timer orrun_on_main_threadclosure armed there, or on rinch-web, which marks no document) still share the single fallback slot, last-wins. A timer,run_on_main_threadclosure or http/ws completion armed inside a document runs as that document’s code (issue #963), so what it registers or clears is that document’s. A document is served whichever of its own hook and the fallback was registered later, so a single-window app keeps last-registration-wins wherever it registers from;clear_keyboard_interceptorfrom outside any document clears every entry, and from inside one leaves that document with none. The cost with several documents on one thread (two embedded contexts, an app and its DevTools): code outside any document —main, a timer armed there, a closure queued from a worker thread, a native menu or tray callback — cannot say which document it belongs to, so its registration overrides every document’s own hook and its clear wipes every document’s. Registrations made inside documents stay isolated from each other. Its lifetime does match the arbiter’s, though: registering it during a render releases it when that component unmounts, exactly as aFocusEntryis deregistered (issue #183). Registering it frommainkeeps app lifetime. For Escape, use the dismiss stack instead — one slot cannot nest. - IME on the browser backend.
on_imeis desktop / Android / embed only, like the rest of this API. On web, attachcompositionstart/compositionupdate/compositionendto your element yourself — the browser delivers composition to whatever it considers focused. - The Android soft keyboard. A registered target participates in desktop
IME, but does not yet raise Android’s on-screen keyboard: the shell still
watches for a focused
<input>or the rich-text editor. - Modality. Tab is contained by an open
ModalorDrawer— see Containing Tab inside an overlay — but a click still reaches controls behind the backdrop wherever the backdrop itself does not cover them, and a click or a scriptedfocus()outside an overlay moves focus out of it. A browser’sshowModal()refuses both, because it marks the rest of the pageinert; rinch has noinertand models noshowModal.DropdownMenuhas notrap_focusprop and contains nothing.
Components
Rinch provides a comprehensive component library with 60+ styled, themeable UI components inspired by Mantine. Enable with the components feature (which also enables theme):
[dependencies]
rinch = { workspace = true, features = ["desktop", "components", "theme"] }
Note: The workspace dependency uses
default-features = false, so features must be listed explicitly:
"desktop"— enablesrun(), window management, and the software renderer (tiny-skia)"gpu"— (optional) adds GPU rendering via Vello/wgpu beside the software renderer, chosen at run time"components"— enables the component library (Button, TextInput, Stack, etc.)"theme"— enables automatic theme CSS loading and CSS variables
Using Components
Components are used directly in RSX with a declarative syntax:
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
Stack { gap: "md",
Title { order: 1, "Welcome" }
Text { size: "lg", color: "dimmed", "Get started with Rinch components." }
Group { gap: "sm",
Button { variant: "filled", "Get Started" }
Button { variant: "outline", "Learn More" }
}
}
}
}
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("cyan".into()),
..Default::default()
};
App::new(app)
.title("My App")
.size(800, 600)
.theme(theme)
.run();
}
Component Categories
Layout Components
| Component | Description |
|---|---|
Stack | Vertical flex container with spacing |
Group | Horizontal flex container with spacing |
Container | Centered max-width container |
Center | Center content horizontally/vertically |
Space | Empty space component |
#![allow(unused)]
fn main() {
// Stack - vertical layout
Stack { gap: "md",
Text { "Item 1" }
Text { "Item 2" }
}
// Group - horizontal layout
Group { gap: "sm", justify: "space-between",
Button { "Left" }
Button { "Right" }
}
// Center content
Center {
Loader {}
}
// Add spacing
Space { h: "xl" } // height
Space { w: "md" } // width
}
Buttons
| Component | Description |
|---|---|
Button | Clickable button with variants |
ActionIcon | Icon-only button |
CloseButton | Dismiss/close button |
#![allow(unused)]
fn main() {
// Button variants
Button { variant: "filled", "Primary" }
Button { variant: "outline", "Secondary" }
Button { variant: "light", "Light" }
Button { variant: "subtle", "Subtle" }
// Button sizes
Button { size: "xs", "Extra Small" }
Button { size: "sm", "Small" }
Button { size: "md", "Medium" } // default
Button { size: "lg", "Large" }
Button { size: "xl", "Extra Large" }
// Button colors
Button { color: "red", "Delete" }
Button { color: "green", "Success" }
// Button states
Button { disabled: true, "Disabled" }
Button { loading: true, "Loading..." }
Button { full_width: true, "Full Width" }
// With click handler
Button { onclick: move || count.update(|n| *n += 1), "Click Me" }
// ActionIcon - for icon buttons
ActionIcon { variant: "filled", "+" }
ActionIcon { size: "lg", variant: "light", "?" }
// CloseButton
CloseButton { onclick: move || modal_open.set(false) }
}
Form Inputs
| Component | Description |
|---|---|
TextInput | Single-line text input |
Textarea | Multi-line text input |
PasswordInput | Password field with visibility toggle |
NumberInput | Numeric input with controls |
Checkbox | Checkbox input |
Switch | Toggle switch |
Select | Dropdown select |
Radio / RadioGroup | Radio buttons |
Slider | Range input slider |
Fieldset | Grouped form fields |
#![allow(unused)]
fn main() {
// TextInput with label and description
TextInput {
label: "Email",
placeholder: "you@example.com",
description: "We'll never share your email."
}
// TextInput with error
TextInput {
label: "Password",
input_type: "password",
error: "Password must be at least 8 characters"
}
// Controlled input (value_fn + oninput)
let input_text = Signal::new(String::new());
TextInput {
placeholder: "Type here...",
value_fn: move || input_text.get(),
oninput: move |value: String| input_text.set(value),
}
// TextInput with onsubmit (fires on Enter key)
let search = Signal::new(String::new());
TextInput {
placeholder: "Search...",
value_fn: move || search.get(),
oninput: move |value: String| search.set(value),
onsubmit: move || println!("Searching: {}", search.get()),
}
// PasswordInput with visibility toggle
PasswordInput {
label: "Password",
placeholder: "Enter password"
}
// NumberInput — uncontrolled: the component owns the number, so the
// +/- steppers step, clamp to [min, max] and format it themselves,
// reporting each written value through `oninput` (#501)
NumberInput {
label: "Quantity",
value: Some(1.0),
min: Some(0.0),
step: Some(1.0)
}
// NumberInput — controlled: pass `value_fn` and the signal becomes the
// field's single write path; the steppers stay callback-only, so write
// the signal in `onincrement`/`ondecrement` (#264)
let quantity = Signal::new(1.0);
NumberInput {
label: "Quantity",
value_fn: move || quantity.get().to_string(),
min: Some(0.0),
step: Some(1.0),
onincrement: move || quantity.update(|v| *v += 1.0),
ondecrement: move || quantity.update(|v| *v = (*v - 1.0).max(0.0))
}
// Textarea
Textarea {
label: "Description",
placeholder: "Enter your message...",
min_rows: 4 // visible rows; defaults to 2
}
// Checkbox
let checked = Signal::new(false);
Checkbox {
label: "Accept terms",
checked: checked.get(),
onchange: move || checked.update(|v| *v = !*v)
}
// Switch
Switch {
label: "Enable notifications",
checked: enabled.get(),
onchange: move || enabled.update(|v| *v = !*v)
}
// Select
Select {
label: "Country",
placeholder: "Select a country",
option { value: "us", "United States" }
option { value: "uk", "United Kingdom" }
option { value: "ca", "Canada" }
}
// RadioGroup
RadioGroup { label: "Plan",
Radio { value: "free", label: "Free" }
Radio { value: "pro", label: "Pro" }
Radio { value: "enterprise", label: "Enterprise" }
}
// Slider
Slider { color: "cyan" }
// Fieldset
Fieldset { legend: "Personal Info",
Stack { gap: "sm",
TextInput { placeholder: "First name" }
TextInput { placeholder: "Last name" }
}
}
}
Typography
| Component | Description |
|---|---|
Text | Text display with styling |
Title | Heading text (h1-h6) |
Code | Inline or block code |
Kbd | Keyboard key |
Anchor | Styled link |
Blockquote | Styled quotation |
Mark | Highlighted text |
Highlight | Search text highlighting |
// Text sizes and weights
Text { size: "lg", weight: "bold", "Large Bold Text" }
Text { size: "sm", color: "dimmed", "Small dimmed text" }
// Titles (h1-h6)
Title { order: 1, "Main Heading" }
Title { order: 2, "Subheading" }
Title { order: 3, "Section Title" }
// Code
Text { "Run " Code { "npm install" } " to install." }
Code { block: true, "fn main() {\n println!(\"Hello!\");\n}" }
// Keyboard keys
Group { gap: "xs",
Kbd { "Ctrl" }
Text { "+" }
Kbd { "C" }
}
// Anchor (link)
Anchor { href: "https://example.com", "Visit site" }
Anchor { href: "#", underline: true, "Always underlined" }
// Blockquote
Blockquote { cite: "— Albert Einstein",
"Imagination is more important than knowledge."
}
// Mark (highlight)
Text { "This has " Mark { "marked text" } " inline." }
// Highlight (search highlighting)
Highlight { highlight: "quick brown", "The quick brown fox jumps" }
Feedback
| Component | Description |
|---|---|
Alert | User feedback messages |
Loader | Loading spinner/indicator |
Progress | Progress bar |
Skeleton | Loading placeholder |
Tooltip | CSS-only hover tooltip |
#![allow(unused)]
fn main() {
// Alert variants
Alert { color: "blue", title: "Info", "This is informational." }
Alert { color: "green", title: "Success", "Operation completed!" }
Alert { color: "yellow", title: "Warning", "Please review." }
Alert { color: "red", title: "Error", "Something went wrong." }
Alert { variant: "filled", color: "blue", "Filled style" }
Alert { variant: "light", color: "blue", "Light style" }
Alert { variant: "outline", color: "blue", "Outline style" }
// Loader
Loader {}
Loader { size: "lg", color: "cyan" }
// Progress
Progress { color: "blue" }
Progress { color: "green", striped: true }
Progress { striped: true, animated: true }
// Skeleton (loading placeholders)
Skeleton { height: "12px", width: "80%" }
Skeleton { height: "40px", width: "40px", circle: true }
// Tooltip
Tooltip { label: "This is a tooltip!",
Button { "Hover me" }
}
Tooltip { label: "Top tooltip", position: "top",
Button { "Top" }
}
}
Data Display
| Component | Description |
|---|---|
Avatar | User avatar with image or initials |
Badge | Small status indicator |
Card / CardSection | Container with sections |
Paper | Card-like container with shadow |
Divider | Horizontal/vertical separator |
Image | Responsive image with fallback |
List / ListItem | Styled list |
#![allow(unused)]
fn main() {
// Avatar
Avatar { name: "John Doe" } // Shows "JD"
Avatar { size: "lg", color: "cyan", name: "Jane Smith" }
Avatar { src: "https://example.com/photo.jpg" }
// Badge
Badge { "New" }
Badge { variant: "light", color: "green", "Active" }
Badge { variant: "outline", color: "red", "Error" }
Badge { variant: "dot", "With dot" }
// Card
Card { shadow: "sm",
CardSection { with_border: true,
div { style: "padding: var(--rinch-spacing-sm);",
Text { weight: "bold", "Card Title" }
}
}
div { style: "padding: var(--rinch-spacing-md);",
Text { "Card content goes here." }
}
}
// Paper
Paper { shadow: "md", p: "lg",
"Simple paper container"
}
Paper { shadow: "sm", p: "md", with_border: true,
"Paper with border"
}
// Divider
Divider {}
Divider { label: "OR" }
// Image (local file or URL with image-network feature)
Image {
src: "photo.png",
width: "200",
height: "150",
fit: "cover",
radius: "md",
}
// Avatar with image
Avatar { src: "photo.png", size: "lg" }
// List
List {
ListItem { "First item" }
ListItem { "Second item" }
ListItem { "Third item" }
}
}
Navigation
| Component | Description |
|---|---|
Tabs / TabsList / Tab / TabsPanel | Tab navigation |
Accordion / AccordionItem / AccordionControl / AccordionPanel | Collapsible sections |
Breadcrumbs / BreadcrumbsItem | Navigation trail |
Pagination | Page navigation |
NavLink | Navigation link with active state |
Stepper / StepperStep / StepperCompleted | Step progress |
#![allow(unused)]
fn main() {
// Tabs
Tabs {
TabsList {
Tab { value: "gallery", "Gallery" }
Tab { value: "messages", "Messages" }
Tab { value: "settings", "Settings" }
}
TabsPanel { value: "gallery",
Text { "Gallery content" }
}
TabsPanel { value: "messages",
Text { "Messages content" }
}
}
// Tab variants
Tabs { variant: "outline", /* ... */ }
Tabs { variant: "pills", /* ... */ }
// Accordion
Accordion {
AccordionItem { value: "item-1",
AccordionControl { "What is Rinch?" }
AccordionPanel {
Text { "A reactive GUI framework for Rust." }
}
}
AccordionItem { value: "item-2",
AccordionControl { "How do I get started?" }
AccordionPanel {
Text { "Add rinch to your Cargo.toml!" }
}
}
}
// Breadcrumbs
Breadcrumbs {
BreadcrumbsItem { "Home" }
BreadcrumbsItem { "Products" }
BreadcrumbsItem { "Component" }
}
Breadcrumbs { separator: ">",
BreadcrumbsItem { "Docs" }
BreadcrumbsItem { "API" }
}
// Pagination
Pagination {}
Pagination { with_edges: true }
// NavLink
NavLink { label: "Dashboard", active: true }
NavLink { label: "Settings", description: "App configuration" }
NavLink { label: "Disabled", disabled: true }
NavLink { label: "With handler", onclick: move || navigate("/page") }
// Stepper. Each step's state follows its position against `active`, and
// `{|| …}` makes the whole stepper follow the signal.
let step = Signal::new(1u32);
Stepper { active: {|| step.get()},
StepperStep { label: "Account", description: "Create account" }
StepperStep { label: "Verify", description: "Verify email" }
StepperStep { label: "Complete", description: "Get started" }
}
}
Overlays
| Component | Description |
|---|---|
Modal | Dialog overlay |
Drawer | Slide-out panel |
Notification | Toast notification |
Popover / PopoverTarget / PopoverDropdown | Positioned popup |
DropdownMenu / DropdownMenuTarget / DropdownMenuDropdown / DropdownMenuItem | Dropdown menu |
HoverCard / HoverCardTarget / HoverCardDropdown | Card on hover |
LoadingOverlay | Loading overlay for containers |
Modal, Drawer, and the top-anchored Notification positions automatically
clear any window chrome rinch draws (the Linux in-app menu bar, the
BorderlessWindow titlebar) by offsetting with --rinch-window-top-inset. The
invisible click-catching backdrops behind DropdownMenu and Select deliberately
do not — they must catch clicks on the chrome too.
If you hand-roll a position: fixed overlay, offset it the same way
(top: var(--rinch-window-top-inset, 0px)); fixed resolves against the real
viewport, so a bare top: 0 renders underneath the chrome. See
Theming → Window Chrome Inset.
An overlay that animates must stay rendered. Hide it with visibility or
opacity and animate transform; do not toggle display. An element that was
not being rendered has no before-change style, so un-hiding it and retargeting a
transitioned property in the same style pass animates nothing — on desktop and
in the browser, which refuses the same shape (that is what @starting-style and
transition-behavior: allow-discrete exist for). Drawer and Popover are the
two worked examples: both keep their panel rendered while closed, Drawer slides
its transform and Popover fades its opacity. See the transitions section
of the rendering pipeline.
To animate the close as well, delay the hide. Put transition: visibility 0s linear <duration> on the hidden state only: the box stays visible through
the transition’s delay, then hides, and the shown state — which declares no
visibility transition — cancels a pending hide if the overlay reopens first.
Drawer and Popover both do this. visibility is transitionable on desktop
since issue #759, interpolated as CSS says (every step strictly inside a
transition with a visible end is visible), and unlike any other property
there its animated value reaches the descendants that inherit it, so the panel’s
content stays on screen with it.
A visibility: hidden subtree is excluded from paint, from hit testing and from
the Tab order, which is most of what display: none was doing — but not all of
it, and the difference is that the box is still there. Two consequences to
know before you reach for it are below.
On desktop, “excluded from paint” covers the box’s background, border and
shadow, and also its text, underline (wavy too) and line-through, text-shadow, inline
backgrounds, SVG shapes, scrollbar thumbs, RenderSurface frame, filter
overlay and read-only selection highlight (desktop drew the text until issue
#829). Three known exceptions remain: a hidden span inside a nowrap or
pre root that text-overflow: ellipsis cuts is still drawn (#853; a
wrapping root holding one is clipped without a “…” instead), a hidden data-viewport
still cuts its hole in its ancestors’ backgrounds (#854), and a ligature that
straddles a visibility boundary follows the wrong end of it (#852). As in CSS,
a descendant that declares visibility: visible is painted again.
- It still takes part in layout, so a hidden overlay that is an in-flow
child of an
overflow: autocontainer contributes its box to that container’s scrollable extent, and the container grows a scrollbar for content nobody can see — as it would in a browser. Aposition: fixedoverlay (Modal,Drawer) is exempt, since #765: a fixed box is no part of any scroll range below the viewport. - A descendant may declare
visibility: visible, and the three readers do not agree about what that means. Paint honours it (the node is drawn), the Tab order honours it (collect_focusable_nodes_fromtests each node on its own), and hit testing does not — it rejects the whole subtree at the hidden ancestor before it ever reaches the descendant. So such a node is painted and tabbable but not clickable (#843).
Neither is a reason to go back to display, which cannot animate at all. They
are the two places to look when a closed overlay misbehaves.
#![allow(unused)]
fn main() {
// Modal
let modal_open = Signal::new(false);
Button { onclick: move || modal_open.set(true), "Open Modal" }
Modal {
opened: modal_open.get(),
onclose: move || modal_open.set(false),
title: "Confirm Action",
size: "md",
Text { "Are you sure you want to proceed?" }
Space { h: "lg" }
Group { justify: "end", gap: "sm",
Button { variant: "outline", "Cancel" }
Button { "Confirm" }
}
}
// Drawer
Drawer {
opened: drawer_open.get(),
onclose: move || drawer_close.set(false),
title: "Settings",
position: "right", // left, right, top, bottom
size: "sm",
// Drawer content
}
// Notification
Notification {
opened: show_notification.get(),
title: "Success!",
color: "green",
position: "top-right",
onclose: move || close_notification.set(false),
"Your changes have been saved."
}
// Popover
Popover { position: "bottom",
PopoverTarget {
Button { "Click me" }
}
PopoverDropdown {
Text { "Popover content!" }
Button { size: "xs", "Action" }
}
}
// DropdownMenu
DropdownMenu {
DropdownMenuTarget {
Button { "Menu" }
}
DropdownMenuDropdown {
DropdownMenuLabel { "Application" }
DropdownMenuItem { "Settings" }
DropdownMenuItem { "Profile" }
DropdownMenuDivider {}
DropdownMenuItem { color: "red", "Delete" }
}
}
// HoverCard
HoverCard { position: "bottom",
HoverCardTarget {
Text { weight: "bold", "@username" }
}
HoverCardDropdown {
Group { gap: "sm",
Avatar { name: "User Name" }
Stack { gap: "xs",
Text { weight: "bold", "User Name" }
Text { size: "sm", color: "dimmed", "Bio here" }
}
}
}
}
// LoadingOverlay — the `position: relative` on the container is REQUIRED.
// Without it the overlay's containing block is the nearest positioned ancestor
// further up — or the viewport, and it covers the whole window (the CSS rule,
// and what rinch-web has always done).
div { style: "position: relative; height: 200px;",
// Your content
LoadingOverlay { visible: is_loading.get() }
}
}
Window Components
| Component | Description |
|---|---|
BorderlessWindow | Container for borderless/transparent windows with custom titlebar |
use rinch::prelude::*;
use std::rc::Rc;
#[component]
fn app() -> NodeHandle {
// For custom left section (e.g., menu button)
let menu_open = Signal::new(false);
let left_section: SectionRenderer = Rc::new(move |__scope| {
rsx! {
ActionIcon { onclick: move || menu_open.update(|v| *v = !*v),
"☰"
}
}
});
rsx! {
BorderlessWindow {
title: "My App",
radius: "md", // none, xs, sm, md, lg, xl
left_section: Some(left_section),
on_minimize: || minimize_current_window(),
on_maximize: || toggle_maximize_current_window(),
on_close: || close_current_window(),
// Your content here
Stack { gap: "md",
Title { order: 2, "Welcome!" }
Text { "This is a borderless window." }
}
}
}
}
fn main() {
let window_props = WindowProps {
title: "My App".into(),
borderless: true,
transparent: true,
resize_inset: Some(8.0),
..Default::default()
};
App::new(app).window_props(window_props).run();
}
BorderlessWindow Props:
| Prop | Type | Description |
|---|---|---|
title | Option<String> | Window title in titlebar |
radius | Option<String> | Corner radius: none, xs, sm, md, lg, xl |
show_minimize | bool | Show minimize button (default: true) |
show_maximize | bool | Show maximize button (default: true) |
show_close | bool | Show close button (default: true) |
left_section | Option<SectionRenderer> | Custom content for titlebar left |
right_section | Option<SectionRenderer> | Custom content before controls |
on_minimize | Option<Callback> | Minimize callback |
on_maximize | Option<Callback> | Maximize callback |
on_close | Option<Callback> | Close callback |
The component provides:
- Rounded corners with configurable radius
- Draggable titlebar (via
app-region: drag) - Window control buttons with hover effects
- Left/right custom sections for menu buttons or additional controls
- Proper theming via CSS variables
--rinch-window-top-insetpublished for overlays (36px for the titlebar, plus the menu bar height when one sits below it)
The titlebar is an in-flow element, so your content sits below it automatically.
A position: fixed overlay does not — it resolves against the real viewport and
would cover the titlebar and its close button. Offset such overlays by
var(--rinch-window-top-inset, 0px); see
Theming → Window Chrome Inset.
Building Custom Components
See the dedicated Writing Components guide for the recommended approach using
#[component]PascalCase functions. The manualComponenttrait approach below is only needed for advanced use cases.
Building Custom Component Crates (Advanced)
You can create your own component library crate that integrates with Rinch’s theme system.
1. Create the Crate
# Cargo.toml
[package]
name = "my-components"
version = "0.1.0"
edition = "2021"
[dependencies]
rinch-core = { path = "../rinch-core" }
2. Implement the Component Trait
Each component must implement the Component trait from rinch-core. Components receive a RenderScope for DOM construction and return a NodeHandle:
#![allow(unused)]
fn main() {
use rinch_core::{Component, NodeHandle, RenderScope};
/// A custom card component.
#[derive(Debug, Default)]
pub struct MyCard {
/// Card title.
pub title: Option<String>,
/// Card variant.
pub variant: Option<String>,
/// Whether to show shadow.
pub shadow: bool,
}
impl Component for MyCard {
fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
// Create the card element
let card = scope.create_element("div");
// Build CSS classes
let mut classes = vec!["my-card".to_string()];
if let Some(ref v) = self.variant {
match v.as_str() {
"elevated" => classes.push("my-card--elevated".to_string()),
"outlined" => classes.push("my-card--outlined".to_string()),
_ => {}
}
}
if self.shadow {
classes.push("my-card--shadow".to_string());
}
card.set_attribute("class", &classes.join(" "));
// Add title if provided
if let Some(ref title) = self.title {
let title_div = scope.create_element("div");
title_div.set_attribute("class", "my-card__title");
title_div.set_text(title);
card.append_child(&title_div);
}
// Append children
for child in children {
card.append_child(child);
}
card
}
}
}
3. Add Event Handlers
For interactive components, add event listeners directly to DOM nodes:
#![allow(unused)]
fn main() {
use rinch_core::Callback;
pub struct MyButton {
pub onclick: Option<Callback>,
}
impl Component for MyButton {
fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
let button = scope.create_element("button");
button.set_attribute("class", "my-button");
// Add click handler if provided
if let Some(ref cb) = self.onclick {
let cb = cb.clone();
button.add_event_listener("click", move |_| cb.invoke());
}
// Append children
for child in children {
button.append_child(child);
}
button
}
}
}
invoke runs the app’s handler untracked, so it is safe to call from inside one
of your component’s effects too: whatever signals the handler reads, your effect
is not subscribed to them. See Reactivity.
4. Create CSS Styles
Create a styles module that generates CSS for your components:
#![allow(unused)]
fn main() {
// src/styles/mod.rs
mod card;
mod button;
pub fn generate_all_styles() -> String {
let mut css = String::new();
css.push_str(&card::styles());
css.push_str(&button::styles());
css
}
// src/styles/card.rs
pub fn styles() -> String {
r#"
.my-card {
background: var(--rinch-color-body);
border-radius: var(--rinch-radius-default);
padding: var(--rinch-spacing-md);
}
.my-card--elevated {
box-shadow: var(--rinch-shadow-md);
}
.my-card--outlined {
border: 1px solid var(--rinch-color-border);
}
.my-card__title {
font-size: var(--rinch-font-size-lg);
font-weight: 600;
margin-bottom: var(--rinch-spacing-sm);
}
"#.to_string()
}
}
5. Export Your Components
#![allow(unused)]
fn main() {
// src/lib.rs
pub mod styles;
mod card;
mod button;
pub use card::MyCard;
pub use button::MyButton;
// Re-export Component trait for convenience
pub use rinch_core::Component;
/// Generate all component CSS.
pub fn generate_css() -> String {
styles::generate_all_styles()
}
}
6. Use Theme CSS Variables
Leverage Rinch’s theme variables for consistency:
| Variable | Description |
|---|---|
--rinch-primary-color | Primary theme color |
--rinch-color-body | Background color |
--rinch-color-text | Text color |
--rinch-color-dimmed | Dimmed/muted text |
--rinch-color-border | Border color |
--rinch-color-{name}-{0-9} | Color shades (e.g., --rinch-color-blue-5) |
--rinch-spacing-{xs,sm,md,lg,xl} | Spacing scale |
--rinch-radius-{xs,sm,md,lg,xl} | Border radius |
--rinch-radius-default | Default radius |
--rinch-shadow-{xs,sm,md,lg,xl} | Box shadows |
--rinch-font-size-{xs,sm,md,lg,xl} | Font sizes |
--rinch-font-family | Default font |
--rinch-font-family-monospace | Monospace font |
--rinch-window-top-inset | Height of the window chrome rinch draws above your content. Not theme-generated — published at runtime by the in-app menu bar / BorderlessWindow titlebar. A position: fixed overlay should offset by it (top: var(--rinch-window-top-inset, 0px)), since fixed resolves against the real viewport and would otherwise slide underneath the chrome. See Theming |
7. Use Your Component Crate
use rinch::prelude::*;
use my_components::{MyCard, MyButton};
#[component]
fn app() -> NodeHandle {
rsx! {
MyCard { title: "Hello", variant: "elevated", shadow: true,
Text { "Card content here" }
MyButton { onclick: move || println!("Clicked!"),
"Click Me"
}
}
}
}
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("blue".into()),
..Default::default()
};
App::new(app)
.title("Custom Components")
.size(800, 600)
.theme(theme)
.run();
}
Component CSS Injection
Component CSS is automatically injected when you use ThemeProvider. If you need the CSS separately:
#![allow(unused)]
fn main() {
use rinch::components::generate_component_css;
let css = generate_component_css();
}
For custom component crates, inject your CSS alongside the theme:
#![allow(unused)]
fn main() {
use my_components::generate_css;
// Add to your HTML head or inject via ThemeProvider
let custom_css = generate_css();
}
Custom Components with #[component]
You can create reusable custom components using the #[component] macro. Components written in PascalCase become usable components in RSX:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
pub fn MyCard(
title: String,
color: String,
children: &[NodeHandle],
) -> NodeHandle {
rsx! {
div { class: "my-card",
h2 { {title.clone()} }
div { class: "card-body", style: {move || format!("color: {}", color.clone())},
children
}
}
}
}
// Use in RSX:
#[component]
fn app() -> NodeHandle {
rsx! {
MyCard { title: "Hello", color: "blue",
Text { "Card content here" }
}
}
}
}
Key points:
- PascalCase function names become components in RSX
children: &[NodeHandle]is a special parameter that receives child content- Use
childrenin the body to render child elements #[component]auto-injects__scope: &mut RenderScopeas the first parameter- Props are regular Rust function parameters
Without children:
#![allow(unused)]
fn main() {
#[component]
pub fn StatusBadge(label: String, status: String) -> NodeHandle {
rsx! {
div { class: "badge",
style: {move || format!("background: {}",
if status == "success" { "green" } else { "red" })},
{label.clone()}
}
}
}
// Use:
StatusBadge { label: "Active", status: "success" }
}
With callback props:
Callback and InputCallback have built-in defaults (no-op), so they work as direct props without Option wrapping:
#![allow(unused)]
fn main() {
#[component]
pub fn TodoItem(
label: String,
completed: bool,
on_toggle: Callback,
on_delete: Callback,
) -> NodeHandle {
rsx! {
Group { gap: "sm", align: "center",
Checkbox {
checked: completed,
onchange: on_toggle, // Forward directly — no Option unwrapping needed
}
Text { {label.clone()} }
ActionIcon { variant: "subtle", color: "red",
onclick: on_delete,
"X"
}
}
}
}
// Use — closures are auto-wrapped into Callback:
TodoItem {
label: "Buy groceries",
completed: false,
on_toggle: move || toggle(id),
on_delete: move || delete(id),
}
}
Prop type defaults:
The #[component] macro generates a Default impl for the component struct. Known types get sensible defaults automatically:
| Type | Default |
|---|---|
String | String::new() (empty) |
bool | false |
Option<T> | None |
Vec<T> | Vec::new() |
| Numeric types | 0 / 0.0 |
Callback | No-op (does nothing) |
InputCallback | No-op (does nothing) |
Other types (e.g., custom structs) fall back to Default::default(), so they must implement Default.
Icon System
Rinch provides a type-safe icon system built on Tabler Icons. Instead of passing arbitrary HTML strings, components accept TablerIcon enum values for discoverability and consistency.
TablerIcon lives in the rinch-tabler-icons crate and is not re-exported through the rinch prelude, so add it alongside rinch:
rinch-tabler-icons = { workspace = true }
Older snippets may show a curated
Iconenum fromrinch-core. That enum no longer exists — every icon prop takesOption<TablerIcon>. A few names were spelled differently:Icon::CheckCircleis nowTablerIcon::CircleCheck, andIcon::XCircleisTablerIcon::CircleX.
Basic Usage
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_tabler_icons::TablerIcon;
#[component]
fn app() -> NodeHandle {
rsx! {
Alert { icon: TablerIcon::InfoCircle, color: "blue", title: "Information",
"This is an informational message."
}
Alert { icon: TablerIcon::CircleCheck, color: "green", title: "Success",
"Operation completed successfully."
}
Alert { icon: TablerIcon::AlertTriangle, color: "yellow", title: "Warning",
"Please review this carefully."
}
Alert { icon: TablerIcon::CircleX, color: "red", title: "Error",
"Something went wrong."
}
}
}
}
Available Icons
All 4,983 Tabler icons are available as enum variants — browse them at
tabler.io/icons, or iterate ALL_ICONS (with
ICON_COUNT) to build a picker. A sample of the common ones:
| Category | Icons |
|---|---|
| Navigation | Home, ChevronUp, ChevronDown, ArrowLeft, ArrowRight, Menu2 |
| Actions | Plus, Minus, X, Check, Search, Settings, Edit, Trash |
| Status/Alerts | InfoCircle, CircleCheck, AlertCircle, AlertTriangle, CircleX |
| Content | User, Mail, Phone, File, Folder, Photo, Copy |
| UI | Bell, Message, Send, Share, Download, Upload |
Every icon has an Outline form; roughly a thousand also have real Filled
artwork, and asking for TablerIconStyle::Filled on one that doesn’t falls back
to its outline paths. See Icon System in CLAUDE.md for rendering icons standalone with render_tabler_icon.
Components with Icon Support
Every prop below is Option<TablerIcon>; rsx! supplies the Some(...), so
write icon: TablerIcon::Check, not icon: Some(TablerIcon::Check).
| Component | Icon Props |
|---|---|
ActionIcon | icon |
Alert | icon |
Notification | icon |
AccordionControl | icon |
Blockquote | icon |
List | icon |
ListItem | icon |
Stepper | completed_icon, progress_icon |
StepperStep | icon, completed_icon, progress_icon |
NavLink | left_section, right_section |
DropdownMenuItem | left_section, right_section |
Tab | left_section, right_section |
Tree takes its icons through data instead: TreeNodeData::icon, set with the
with_icon(TablerIcon) builder.
List::icon and the two Stepper icons are container defaults for the items
that set none — the item’s own icon always wins — and were declared but wired to
nothing until #707. The container patches its items after they have rendered,
because a parent component renders after its children, and it patches whatever
lands beneath it afterwards too: an item a for reconcile appends, a
show_dom branch reveals, or a hand-rolled append_child adds takes the
default as it lands (issue #716). An item moved from one container into another
re-resolves against the one it now belongs to. Stepper goes one further and
re-derives when an item leaves as well (issue #745), because its answer is a
step’s position: dropping a step, or moving one out, renumbers and restates every
step behind it.
Stepper::progress_icon stands in for the progress_icon a step did not set, so
it outranks that step’s plain icon, exactly as the step’s own progress_icon
would have.
Examples
#![allow(unused)]
fn main() {
// NavLink with icons
NavLink {
label: "Dashboard",
left_section: TablerIcon::Settings,
active: true
}
// Tab with icon
Tabs {
TabsList {
Tab { value: "home", left_section: TablerIcon::User, "Profile" }
Tab { value: "settings", left_section: TablerIcon::Settings, "Settings" }
}
}
// Dropdown menu items with icons
DropdownMenu {
DropdownMenuTarget {
Button { "Actions" }
}
DropdownMenuDropdown {
DropdownMenuItem { left_section: TablerIcon::Edit, "Edit" }
DropdownMenuItem { left_section: TablerIcon::Trash, color: "red", "Delete" }
}
}
// Blockquote with quote icon
Blockquote { icon: TablerIcon::Quote, cite: "— Unknown",
"The best code is no code at all."
}
// Stepper with custom icons. Each step's state comes from its position
// against `active`, so "Account" is completed and draws the CircleCheck,
// "Verify" is in progress and draws `2`, and "Complete" draws `3`.
Stepper { active: 1, completed_icon: TablerIcon::CircleCheck,
StepperStep { label: "Account" }
StepperStep { label: "Verify" }
StepperStep { label: "Complete" }
}
}
Benefits of the Icon System
- Type Safety: Compiler catches typos and invalid icon names
- Discoverability: IDE autocomplete shows all available icons
- Consistency: All icons use the same SVG style and sizing
- Performance: Icons render as structured nodes, enabling differential updates instead of full re-renders
Reactive Component Props
Rinch supports two mechanisms for making component props reactive:
1. Reactive Closures on Any Prop (Re-renders Component)
Pass a closure {|| expr} to any component prop (like variant, color, size, disabled). When signals inside the closure change, the entire component re-renders with the new prop values:
#![allow(unused)]
fn main() {
let active = Signal::new(false);
rsx! {
Button {
variant: {|| if active.get() { "filled" } else { "light" }},
color: {|| if active.get() { "green" } else { "gray" }},
onclick: move || active.update(|v| *v = !*v),
"Toggle me"
}
}
}
This works on all component props — the macro detects closures and wraps the component in a reactive scope automatically.
2. _fn Props (Surgical DOM Updates)
For components that support _fn props, these provide more efficient updates that only touch the specific DOM node, without re-rendering the entire component. The rsx! macro auto-wraps _fn props — just pass a closure directly:
#![allow(unused)]
fn main() {
let is_checked = Signal::new(false);
let input_text = Signal::new(String::new());
rsx! {
// Auto-wrapped: just pass a closure, no Rc::new() needed
Checkbox {
label: "Enable feature",
checked_fn: move || is_checked.get(),
onchange: move || is_checked.update(|v| *v = !*v),
}
TextInput {
placeholder: "Type here...",
value_fn: move || input_text.get(),
oninput: move |value: String| input_text.set(value),
}
}
}
Reactive Props Summary
| Prop Type | Accepts {|| ...} | Update Strategy |
|—|—|—|
| HTML element attributes | Yes | Surgical DOM update |
| Component style:/class:, style shorthands, hyphenated attributes | Yes | Surgical DOM update (also on a component that re-renders for a struct prop) |
| Component _fn props | Yes (auto-wrapped) | Surgical DOM update |
| Component props (all others) | Yes | Full component re-render |
Components with _fn Props
| Component | Reactive Prop | Purpose |
|---|---|---|
Checkbox | checked_fn | Toggle checked class reactively |
Switch | checked_fn | Toggle checked class reactively |
Radio | checked_fn | Toggle checked class reactively |
TextInput | value_fn | Reactive value binding |
NavLink | active_fn | Toggle active class reactively |
Progress | value_fn | Update progress bar width reactively |
Modal | opened_fn | Show/hide modal reactively |
Drawer | opened_fn | Show/hide drawer reactively |
Notification | opened_fn | Show/hide notification reactively |
Controlled Input Pattern
For controlled inputs, use value_fn + oninput together. value_fn keeps the DOM in sync with your signal; oninput updates the signal from user input. Without value_fn, programmatic signal.set("") won’t clear the input visually:
#![allow(unused)]
fn main() {
let text = Signal::new(String::new());
rsx! {
TextInput {
value_fn: move || text.get(),
oninput: move |value: String| text.set(value),
onsubmit: move || {
println!("Submitted: {}", text.get());
text.set(String::new()); // Clears the input thanks to value_fn
},
}
}
}
The echo is not written back. In this pattern every keystroke runs
oninput → signal → value_fn, which returns the very text the field already
shows. TextInput, PasswordInput, Textarea and NumberInput compare
value_fn’s result with the field’s live text (NodeHandle::live_value,
issue #238) and write only when they differ — a normalising handler’s rewrite,
or a programmatic change. The comparison is against the live text, not the
value attribute, which on the web still holds the last programmatic write
while the user types.
Writing to the field the user is typing in. A value_fn write (or any
set_attribute("value")) that lands on the focused field is adopted by the
field on both backends (issue #238): it becomes the text the next keystroke
edits, the caret keeps its logical position through the rewrite (a kept
prefix or suffix keeps the caret with it, a same-length rewrite such as a case
change leaves it in place, a resized rewrite puts it after the new text), a
selection survives, and the write is deferred while an IME composition is in
flight (applied when the composition ends). A programmatic write never
commits onchange by itself — only a user edit in the gesture does. So a
normalizing or rejecting oninput is safe to write back on every keystroke:
#![allow(unused)]
fn main() {
let digits = Signal::new(String::new());
rsx! {
TextInput {
value_fn: move || digits.get(),
// Digits only: the rejected character never sticks, and the caret
// stays put — on desktop as well as on the web.
oninput: move |value: String| {
digits.set(value.chars().filter(char::is_ascii_digit).collect())
},
}
}
}
Text inputs (TextInput, Textarea, PasswordInput, NumberInput) also
take an onchange prop — the commit boundary (issue #226): it fires once with
the final value when the typed gesture ends (focus leaves the control after a
modification, or Enter — blur-only for Textarea), and only if the user
modified it during the gesture — a programmatic write to a still-untouched
field moves the reference point with it, matching the browser’s dirty flag. Keep oninput for the controlled-input signal
sync; use onchange for validate-on-commit and autosave-on-leave.
Customization
Components automatically respond to theme settings:
#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("violet".into()), // All primary-colored components use violet
default_radius: Some("lg".into()), // Larger border radius everywhere
dark_mode: true, // Dark theme colors
..Default::default()
};
// Components automatically adapt to the theme
App::new(app)
.title("My App")
.size(800, 600)
.theme(theme)
.run();
}
Component Props Reference
This page lists every prop for every component in rinch-components. All components use #[derive(Default)] unless noted, meaning Option<T> defaults to None and bool defaults to false.
Keeping this page honest: this file is hand-maintained (prose, worked examples, and “Custom Default” callouts don’t survive mechanical regeneration), so it will drift from the component structs again. Before trusting or editing a prop table, run python3 scripts/audit_component_props_doc.py from the repo root — it cross-references every row here against the real pub struct fields in crates/rinch-components/src/*.rs and prints every type mismatch, missing row, and stray row it finds (see the script’s own docstring for the handful of known non-issues it also reports, e.g. Callback vs a fully-qualified rinch_core::Callback spelling that source uses in a few files — same type, not a bug).
String props: All text/string component props are now String type (not Option<String>). Empty string "" means “not set/use default”. The RSX macro auto-converts string literals: variant: "filled" becomes String::from("filled").
Float literals: Float literals are auto-wrapped: value: 30.0 becomes Some(30.0) for Option<f32> fields.
Universal props: All components support style: and class: in RSX, which are applied to the component’s root DOM element. These support reactive closures {|| expr}. So does any hyphenated attribute — data-nofocus: "", aria-label: "…" — which is written on the root the way it would be on an HTML element; see Attributes on a component.
Both merge with what the component itself put on that root rather than replacing it: class: is added to the component’s own classes, and style: is laid over the component’s own inline declarations, last-wins per property. That matters because a component’s inline style is not decoration — it is how a component publishes a prop to its own stylesheet, and every overlay’s z_index is a custom property written there. style: used to write the whole attribute, which erased all of them (issue #647).
A reactive style: takes its own previous declarations back on each re-run, so it never accumulates: a property the closure has stopped naming is removed, or restored to the value it had been overriding. It overwrites a property it still names where that property stands rather than re-appending it, and it leaves alone one whose current value somebody else has since written. The one case it does not settle is a same-property collision with a style shorthand — see Application Order.
A reactive class: has the same memory: it takes off the words it last wrote before adding the new ones, so it never accumulates its own old values. It does not reach a class it has never named — but naming one is enough to own it. A word the closure emits is the closure’s to remove, so a re-run that stops naming it takes it off and nothing puts it back, and that holds for a class the component itself wrote, its base class included: class: {|| if p.get() { "rinch-checkbox marker" } else { "marker" }} strips rinch-checkbox off the element permanently. That is issue #647’s mechanism and the rule the reactive style: follows too — don’t name a class the component owns.
The merge only holds if the component keeps its side of it. A class: prop is applied to the handle Component::render returned, which is after the component has built its tree and registered its effects. A component effect that then rewrote the whole class attribute — from a base string captured during render — therefore discarded the caller’s class on its first run, and there was no way to get it back short of a re-render. Checkbox { class: "compact", checked_fn: … } styled correctly until the user clicked it once (issue #717). So a component effect adds and removes the one class it is responsible for (NodeHandle::add_class / remove_class) and never writes class wholesale. add_class is idempotent, as DOMTokenList.add is, so an effect that re-runs without changing its answer leaves the attribute alone.
Style shorthands: All elements and components support CSS shorthand props like w, h, m, p, maw, etc. These expand to set_style() calls and compose with component styles. Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}):
#![allow(unused)]
fn main() {
// Shorthands work on both HTML elements and components
div { p: "md", m: "lg", w: "200px", "Styled div" }
Stack { gap: "md", p: "xl", maw: "600px",
Text { "Content" }
}
}
See Style Shorthands for the full list.
Prop auto-wrapping: The rsx! macro automatically wraps prop values. See RSX Prop Transformation Rules — do NOT manually wrap in Some(...).
All props are reactive: Every component prop accepts a reactive closure {|| expr} in addition to a static value. When any prop uses a closure, the component automatically re-renders when the signals inside change:
#![allow(unused)]
fn main() {
let active = Signal::new(false);
// Static prop value
Button { variant: "filled", "Always filled" }
// Reactive prop value — re-renders when `active` changes
Button { variant: {|| if active.get() { "filled" } else { "outline" }}, "Toggle" }
}
For more efficient surgical updates (no full re-render), use _fn suffix props where available (e.g., value_fn, checked_fn, opened_fn).
A value_fn write to the text field that currently holds focus is adopted by the field (issue #238): the next keystroke edits the written text, the caret and selection keep their logical position through the rewrite, the write is deferred during an IME composition, and it never commits onchange by itself. See Controlled Input Pattern.
Layout
Stack
Vertical flex container.
| Prop | Type | Default | Description |
|---|---|---|---|
gap | String | "" | Spacing between children (xs, sm, md, lg, xl or CSS value) |
align | String | "" | CSS align-items (e.g., “center”, “flex-start”) |
justify | String | "" | CSS justify-content |
Group
Horizontal flex container.
| Prop | Type | Default | Description |
|---|---|---|---|
gap | String | "" | Spacing between children (xs, sm, md, lg, xl or CSS value) |
align | String | "" | CSS align-items |
justify | String | "" | CSS justify-content |
wrap | bool | false | Enable flex-wrap |
grow | bool | false | Children flex-grow: 1 |
SimpleGrid
Auto-layout grid.
| Prop | Type | Default | Description |
|---|---|---|---|
cols | Option<u32> | None | Number of columns (default 1) |
min_child_width | String | "" | Min column width for auto-fill; overrides cols |
spacing | String | "" | Gap between items (xs, sm, md, lg, xl or CSS value) |
vertical_spacing | String | "" | Vertical gap (xs, sm, md, lg, xl or CSS value); falls back to spacing |
Container
Centered max-width wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
size | String | "" | Max-width (xs, sm, md, lg, xl) |
fluid | bool | false | Full width (no max-width) |
Center
Centers content horizontally and vertically.
| Prop | Type | Default | Description |
|---|---|---|---|
inline | bool | false | Use inline-flex instead of flex |
Space
Empty spacing element.
| Prop | Type | Default | Description |
|---|---|---|---|
w | String | "" | Width (spacing scale or CSS value) |
h | String | "" | Height (spacing scale or CSS value) |
Buttons
Button
| Prop | Type | Default | Description |
|---|---|---|---|
variant | String | "" | “filled”, “outline”, “light”, “subtle”, “transparent”, “white”, “default”, “gradient” |
size | String | "" | xs, sm, md, lg, xl |
color | String | "" | Theme color name |
disabled | bool | false | |
loading | bool | false | |
full_width | bool | false | |
radius | String | "" | Border radius override |
onclick | Option<Callback> | None | Click handler |
ActionIcon
Icon-only button. For text-based action buttons, use Button with compact styling.
| Prop | Type | Default | Description |
|---|---|---|---|
icon | Option<TablerIcon> | None | Tabler icon to display |
variant | String | "" | Same variants as Button |
size | String | "" | xs, sm, md, lg, xl |
color | String | "" | Theme color name |
radius | String | "" | |
disabled | bool | false | |
loading | bool | false | |
onclick | Option<Callback> | None | Click handler |
CloseButton
Draws a stroked X, sized by icon_size or by size. Until issue #474 it drew
nothing at all: the glyph was an empty <span class="rinch-icon rinch-icon--close">
and no stylesheet in the workspace matches that class.
| Prop | Type | Default | Description |
|---|---|---|---|
size | String | "" | xs, sm, md, lg, xl |
radius | String | "" | |
disabled | bool | false | |
icon_size | Option<u32> | None | The X’s edge length in px. Unset, it follows size: 12 / 14 / 16 / 20 / 24 for xs…xl |
onclick | Option<Callback> | None | Click handler |
Form Inputs
TextInput
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
placeholder | String | "" | |
description | String | "" | Help text below input |
error | String | "" | Error message; shows error styling |
size | String | "" | xs, sm, md, lg, xl |
disabled | bool | false | |
required | bool | false | |
radius | String | "" | One of xs sm md lg xl, rounding the field. Empty keeps the theme’s default radius |
input_type | String | "" | HTML input type (“text”, “email”, etc.) |
value | String | "" | Static value |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
oninput | Option<InputCallback> | None | Receives String |
onchange | Option<InputCallback> | None | Commit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus |
onsubmit | Option<Callback> | None | Fires on Enter key |
Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
error | String | "" | |
placeholder | String | "" | |
size | String | "" | |
disabled | bool | false | |
required | bool | false | |
autosize | bool | false | Grows rows to the controlled value’s \n-line count, clamped to [min_rows, max_rows] (issue #715). Reactive only through value_fn |
min_rows | Option<u32> | None | Visible rows; sizes the control to that many lines. Defaults to 2 (HTML default) when unset. A larger CSS min-height wins |
max_rows | Option<u32> | None | Caps the control’s height at that many lines via max-height (issue #715, fixed: the control’s rows is a Taffy measure, not a min-height, so the cap can bind). The sheet’s 60–120px size-step min-height still wins when it is taller than the cap — ordinary CSS min over max, not a bug |
value | String | "" | |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
oninput | Option<InputCallback> | None | Receives String |
onchange | Option<InputCallback> | None | Commit boundary (#226): fires once with the final value when the gesture ends (blur after a modification); only if the value changed since focus |
PasswordInput
Custom Default: toggle_visibility defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
error | String | "" | |
placeholder | String | "" | |
value | String | "" | |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
visible | bool | false | Password visibility state |
visible_fn | Option<ReactiveBool> | None | Reactive visibility (auto-wrapped) |
disabled | bool | false | |
required | bool | false | |
autofocus | bool | false | |
size | String | "" | |
radius | String | "" | One of xs sm md lg xl, rounding the field. The visibility toggle inside keeps its own radius |
toggle_visibility | bool | true | Show/hide the eye toggle button |
ontoggle | Option<Callback> | None | Fires when visibility toggled |
oninput | Option<InputCallback> | None | Receives String |
onchange | Option<InputCallback> | None | Commit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus |
NumberInput
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
error | String | "" | |
placeholder | String | "" | |
value | Option<f64> | None | Initial value; uncontrolled, the steppers move the field from here |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped); when set, it is the field’s single write path and the steppers are callback-only |
default_value | Option<f64> | None | Initial value of an uncontrolled field when value is absent |
min | Option<f64> | None | |
max | Option<f64> | None | |
step | Option<f64> | None | |
decimal_scale | Option<u32> | None | Number of decimal places |
prefix | String | "" | e.g., “$” |
suffix | String | "" | e.g., “kg” |
disabled | bool | false | |
hide_controls | bool | false | Hide +/- buttons |
required | bool | false | |
size | String | "" | |
radius | String | "" | One of xs sm md lg xl. Rounds the field, a prefix/suffix and the steppers together |
onincrement | Option<Callback> | None | Notification; uncontrolled, the component steps the field itself and reports through oninput (#501) |
ondecrement | Option<Callback> | None | Notification (see onincrement) |
oninput | Option<InputCallback> | None | Receives String from direct text entry, and — uncontrolled — each stepper-written value |
onchange | Option<InputCallback> | None | Commit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus |
Checkbox
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | Secondary line under the label (#707). Puts both in a .rinch-checkbox__body column and aligns the box to the first line |
size | String | "" | |
disabled | bool | false | |
checked | bool | false | Static checked state |
checked_fn | Option<ReactiveBool> | None | Reactive checked binding (auto-wrapped) |
indeterminate | bool | false | |
onchange | Option<Callback> | None |
Switch
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | Secondary line under the label (#707). Puts both in a .rinch-switch__body column and aligns the track to the first line |
size | String | "" | |
disabled | bool | false | |
checked | bool | false | |
checked_fn | Option<ReactiveBool> | None | Reactive checked binding (auto-wrapped) |
label_position | String | "" | "start" puts the label before the track; anything else leaves it after |
onchange | Option<Callback> | None |
Select
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
error | String | "" | |
placeholder | String | "" | |
size | String | "" | |
disabled | bool | false | |
required | bool | false | |
value | String | "" | |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
onchange | Option<InputCallback> | None | Receives selected value as String |
data | Vec<SelectOption> | [] | The list of selectable options |
Keyboard (#251, #434). The trigger is focusable; Enter or Space opens the list, and while it is open ArrowUp/ArrowDown move the highlight (wrapping), Home/End jump, Enter commits (Space too, unless it is part of a type-ahead prefix), Escape closes without committing, Tab closes, and typing jumps to the first matching label. The trigger keeps the focus throughout, a pointer pick included; focus moving anywhere else closes the list.
Options are passed via data, not as children — the trigger’s _children param
is unused. Build a Vec<SelectOption> with SelectOption::new(value, label) (a
SelectOption is { value: String, label: String }; an empty label falls back
to displaying value):
#![allow(unused)]
fn main() {
Select {
data: vec![
SelectOption::new("us", "United States"),
SelectOption::new("ca", "Canada"),
],
value_fn: move || country.get(),
onchange: move |v: String| country.set(v),
}
}
The trigger is a Tab stop (tabindex="0", role="combobox",
aria-haspopup="listbox", and an aria-expanded that tracks the open state).
Enter and Space on it toggle the dropdown; the open list’s keys are above.
Radio / RadioGroup
Radio:
| Prop | Type | Default | Description |
|---|---|---|---|
name | String | "" | Radio group name |
value | String | "" | Radio value |
label | String | "" | |
description | String | "" | |
checked | bool | false | |
checked_fn | Option<ReactiveBool> | None | Reactive checked binding (auto-wrapped) |
disabled | bool | false | |
size | String | "" | |
color | String | "" | |
error | bool | false | |
onchange | Option<Callback> | None |
RadioGroup:
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
error | String | "" | |
size | String | "" | Default size for this group’s radios (#707); a Radio’s own size wins, and a radio records its ask as data-size so an explicit "md" is not mistaken for an unset one. A radio that arrives later, or moves in from another group, is sized as it lands (#716) |
orientation | String | "" | “horizontal” or “vertical” |
Slider
| Prop | Type | Default | Description |
|---|---|---|---|
min | Option<f64> | None | |
max | Option<f64> | None | |
value | Option<f64> | None | Static value |
value_signal | Option<Signal<f64>> | None | Direct signal binding |
step | Option<f64> | None | |
size | String | "" | |
color | String | "" | |
radius | String | "" | |
disabled | bool | false | |
label | String | "" | Tooltip label format |
show_label_on_hover | bool | false | |
label_always_on | bool | false | |
onchange | Option<ValueCallback<f64>> | None | Receives f64 |
Color
ColorSwatch
A colored square with checkerboard transparency indication.
| Prop | Type | Default | Description |
|---|---|---|---|
color | String | "" | CSS color value |
size | String | "28px" | Width and height |
radius | String | "sm" | Border radius (xs, sm, md, lg, xl or CSS value) |
with_shadow | bool | false | Show box shadow |
onclick | Option<Callback> | None | Click handler |
ColorPicker
Interactive color picker with saturation panel, hue/alpha sliders, hex input, and swatches.
| Prop | Type | Default | Description |
|---|---|---|---|
format | String | "hex" | Output format: hex, hexa, rgb, rgba, hsl, hsla |
value | String | "" | Initial color (any parseable CSS color) |
value_fn | Option<ReactiveString> | None | Reactive external value binding |
onchange | Option<InputCallback> | None | Fires formatted color string on change |
alpha | bool | false | Show alpha slider |
swatches | Vec<String> | [] | Preset swatch colors |
swatches_per_row | Option<usize> | 7 | Swatches per row |
size | String | "md" | Size: sm, md, lg, xl |
with_input | bool | false | Show hex text input |
onchange reports author edits only. A value arriving through value_fn — a
peer’s edit, a programmatic set — is adopted silently: the picker applies all
four of its internal components (hue, saturation, value, alpha) as one batched
transition and fires nothing, because the caller already has that value. Only a
drag, a typed hex or a swatch click reports — once per act, with the completed
colour, never a partially-applied mixture. This is what lets a consumer bind
value_fn to the same store onchange writes without the two chasing each
other.
The picker’s state survives its own round trip. An echoing binding — a store
that value_fn reads and onchange writes back into — no longer degrades the
picker’s hue, saturation, or alpha. Emitted colour strings quantize — to 8-bit
RGB under the hex and rgb formats, to whole degrees and percents under hsl — so
the echo of the picker’s own emission routinely parses to a slightly different
hue/saturation than the internal state it was formatted from (and to no hue
at grey, or no alpha under an alpha-less format like hex); the picker
recognises such an echo as its own state and leaves the internal signals
untouched, so dragging saturation down to grey and back resumes the original
hue, and the alpha slider works under the default hex format. A genuinely
foreign value (one that denotes a different colour, alpha included) still
applies — and even then, channels the value cannot carry are kept rather than
fabricated: a grey keeps the current hue (unless it states one, as any
hsl() value does — hsl(240, 0%, 50%), the sub-percent
hsl(205, 0.3%, 49%), and hsl(0, 0%, 50%) alike — a stated hue is adopted;
note that a store which re-spells an RGB grey as hsl() writes hue 0, and the
picker adopts it), a black keeps hue and saturation, judged by what the value
renders at 8-bit rather than by exact parse floats. One corner is deliberately
conceded: under an alpha-dropping display format, an inbound value that
restates the picker’s current RGB with an explicitly opaque alpha
(rgba(r, g, b, 1)) is indistinguishable from a normalizing store’s echo of
the emission and does not apply — bind an alpha-carrying format (hexa,
rgba, hsla) when external writes need to drive alpha. More generally, the
echo test judges identity at the resolution of the picker’s own emission in
the notation the inbound value is written in — 8-bit channels for a hex, named
or rgb() value, whole degrees and whole percents for an hsl() value, alpha
at two decimals under rgb()/hsl() — never finer than that notation’s
serializer writes, whatever finer channels the parser accepts. A difference the
emission could not have spelled folds as an echo, in both directions: on an
hsl-format wire a stated-hue move of a whole degree applies even at very low
chroma, where it does not move the rendered colour by an 8-bit step, while an
inbound hsl() value that shares a spelling with the picker’s current colour
folds even where 8-bit could tell them apart (a normalizing store re-spelling
the picker’s own hex emission as hsl() is indistinguishable from it). A
store that re-spells the emission in another notation may also round a tie
its own way — #797e81’s hue is exactly 202.5°, which rinch writes as 202 and
an exact converter as 203 — so an inbound value compared against what the
picker emits (not what it holds) is accepted at either rounding of the
emission’s exact value, and at nothing farther: #797e82’s hue is 206⅔°, so a
peer’s hsl(206, …) under a hex display still applies. The text field’s
write-back guard judges at the same resolution, so field, thumbs and store stay
in step.
Accepted colour notations (everywhere a colour string is read — value,
value_fn, typed text, swatches): hex in 3, 4, 6, or 8 digits (#rgb,
#rgba, #rrggbb, #rrggbbaa, with or without the #), rgb()/rgba()
and hsl()/hsla() in both the legacy comma syntax and the modern
space-separated syntax (rgb(51 51 102 / 0.5); alpha as a number or a
percentage), and the CSS named colours (red, rebeccapurple,
transparent). Named colours and function names are case-insensitive, as
in CSS. Out-of-range channels clamp to their CSS ranges; hue wraps (#243).
The text field is the author’s while they type. The hex field parses on
every keystroke, and a valid prefix of the colour being typed (#336 on the
way to #3366cc) is already a parseable colour — it updates the preview and
reports through onchange live, but the picker never rewrites the field under
the author’s caret while its text denotes the colour the picker already holds.
The field text is normalized to the output format when the colour actually
moves away from it (a drag, a swatch click, an external value_fn change) —
and, since issue #226, when the typed gesture commits (focus leaves the
field, or Enter): a committed shorthand like 336 normalizes to #333366,
and committed text that parses as no colour reverts to the colour the picker
holds, so an attribute-reading consumer never sees a stale shorthand outlive
the gesture that typed it. ColorInput’s text field follows the same
contract.
“The field’s text” here is the field’s live text, read through
NodeHandle::live_value (issue #238) — on the web the .value property the
user types into, never the value attribute, which there holds only what was
last written programmatically. It is the same read the value_fn binding of
TextInput, PasswordInput, Textarea and NumberInput makes before
writing, so the echo of a keystroke is not written back there either.
ColorInput
Text input with inline color preview and dropdown ColorPicker.
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | Input label |
description | String | "" | Description text below the input |
error | String | "" | Error message (shows error styling) |
placeholder | String | "" | Placeholder text |
size | String | "" | Field size: xs, sm, md, lg, xl. Unset or unrecognised means md. Scales the field’s height, padding and font size, and the preview swatch alongside them |
radius | String | "" | Field border radius: xs, sm, md, lg, xl. Unset or unrecognised leaves the default sm |
disabled | bool | false | Disable the input |
value | String | "" | Current color value |
value_fn | Option<ReactiveString> | None | Reactive value binding |
onchange | Option<InputCallback> | None | Fires the formatted color string for every change made in the dropdown picker — a swatch pick, and each frame of a panel or slider drag — and for a typed edit once, at its commit boundary: blur or Enter (#226). Typing previews live in the swatch and the picker but reports only on commit |
format | String | "hex" | Output format |
alpha | bool | false | Show alpha slider in picker |
swatches | Vec<String> | [] | Preset swatch colors |
swatches_per_row | Option<usize> | 7 | Swatches per row |
disallow_input | bool | false | Disallow typing (picker only) |
close_on_click_outside | bool | true | Dismiss the dropdown when a click lands outside it (#465). Off mounts no backdrop at all, so no click outside the field dismisses it — the pointer half of the pre-#465 behaviour. Escape closes the dropdown either way |
size and radius were declared but unread until #263. size always
resolves to a step, radius does not: an unrecognised size falls back to
md — matching TextInput, whose size parses with unwrap_or_default() —
and md reproduces the geometry the field was hard-coded to before, so nothing
resizes by upgrading. An unrecognised radius emits no class at all and leaves
the base --rinch-radius-sm standing, which is what DropdownMenu, Modal and
Card do.
The dropdown is dismissed three ways (#465): clicking the field again,
clicking anywhere outside it, and Escape. The outside click is the one that is
optional — close_on_click_outside is true by default, matching every other
popover in the library, and turning it off mounts no backdrop at all, so the
field is the only thing a click dismisses the dropdown with. Escape is not
gated by it, so false is the pointer half of the behaviour ColorInput had
until #465 rather than a return to it in full. (The prop was
declared and read by nothing before that; #263 removed it rather than wire it,
because unlike size and radius there was no behaviour behind it to connect,
and it came back here with the interaction it names.)
Two details follow from what the trigger is. The field stays clickable while
the picker is open — it is a text input, not a button, so a click into it
places a caret rather than being swallowed by the dismissal, and it still
toggles the dropdown shut. And Escape joins the dismiss stack when the
dropdown opens, not when the input mounts, so a ColorInput inside a Modal
answers Escape first while its picker is open and leaves the key to the modal
the moment it is closed.
The dropdown picker is bound to the input’s current colour (#237). Typed
text previews in it live (a parseable keystroke moves its panel and thumbs,
at the typed notation’s own grid — a typed hsl(205, 3%, 49%) lands the hue
thumb on 205° whatever the display format, and a typed #228be680 moves
the alpha thumb), an external value_fn change moves it, and a slider nudge
derives from the colour the input currently holds — never from the colour it
mounted with. An external arrival is silent all the way through: the picker
applies it without reporting, and the input’s onchange fires nothing the
caller did not author.
The field shows the colour in the format output spelling. A value or
value_fn written in another notation (red, hsl(200, 3%, 49%)) is
displayed re-spelled (#ff0000, #797e81 under hex); the field is
rewritten only when the colour moves away from its text, so the author’s
mid-typing text is left alone as before, and an unparseable commit reverts
it to the last committed colour in that same spelling. Under an
alpha-dropping format (hex, rgb, hsl) an alpha typed into the field
previews in the picker but is dropped at the commit boundary, and an alpha
arriving through value_fn is only half-honoured: the dropdown picker adopts
it (its alpha thumb moves when alpha is on) and the swatch renders it
translucent, but the field cannot spell it and every change the input
reports drops it. The corner ColorPicker concedes above applies here too: a
later value_fn value restating the colour opaque (#ff0000,
rgba(255, 0, 0, 1)) is indistinguishable from the input’s own echo and does
not apply, so the picker keeps the alpha — and the swatch snaps opaque on the
next reported change while the picker’s alpha thumb does not — until a value
carrying a different, non-opaque alpha arrives. Bind hexa/rgba/hsla
when alpha must be externally drivable.
Typography
Text
| Prop | Type | Default | Description |
|---|---|---|---|
size | String | "" | xs, sm, md, lg, xl |
weight | String | "" | CSS font-weight |
color | String | "" | Theme color or “dimmed” |
align | String | "" | CSS text-align |
inline | bool | false | Use <span> instead of <p> |
Title
| Prop | Type | Default | Description |
|---|---|---|---|
order | Option<u8> | None | Heading level 1-6 |
align | String | "" | CSS text-align |
size | String | "" | Override size independent of order |
Code
| Prop | Type | Default | Description |
|---|---|---|---|
block | bool | false | Block display (<pre>) vs inline (<code>) |
color | String | "" |
Kbd
| Prop | Type | Default | Description |
|---|---|---|---|
size | String | "" | xs, sm, md, lg, xl |
Anchor
| Prop | Type | Default | Description |
|---|---|---|---|
href | String | "" | |
target | String | "" | e.g., “_blank” |
size | String | "" | |
underline | bool | false |
Blockquote
| Prop | Type | Default | Description |
|---|---|---|---|
cite | String | "" | Citation source |
icon | Option<TablerIcon> | None | |
color | String | "" | |
radius | String | "" |
Mark
| Prop | Type | Default | Description |
|---|---|---|---|
color | String | "" | Highlight background color |
Highlight
Custom Default: ignore_case defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
text | String | "" | Full text to display |
highlight | String | "" | Substring(s) to highlight |
color | String | "" | Highlight color |
ignore_case | bool | true | Case-insensitive matching |
Data Display
Avatar
| Prop | Type | Default | Description |
|---|---|---|---|
src | String | "" | Image URL |
alt | String | "" | |
name | String | "" | For initials fallback |
size | String | "" | |
radius | String | "" | |
color | String | "" | |
variant | String | "" | “filled”, “light”, “outline” |
AvatarGroup: spacing: String — overlap spacing.
Badge
| Prop | Type | Default | Description |
|---|---|---|---|
variant | String | "" | “filled”, “light”, “outline”, “dot”, “transparent”, “white”, “default”, “gradient” |
size | String | "" | |
color | String | "" | |
radius | String | "" | One of xs sm md lg xl. A badge is a pill by default, so this squares it off |
full_width | bool | false |
Card
| Prop | Type | Default | Description |
|---|---|---|---|
shadow | String | "" | xs, sm, md, lg, xl |
padding | String | "" | |
radius | String | "" | |
with_border | bool | false |
CardSection: inherit_padding: bool, with_border: bool.
Paper
| Prop | Type | Default | Description |
|---|---|---|---|
shadow | String | "" | xs, sm, md, lg, xl |
p | String | "" | Padding (spacing scale) |
radius | String | "" | |
with_border | bool | false |
Divider
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | String | "" | “horizontal” or “vertical” |
size | String | "" | |
label | String | "" | Text label in the divider |
label_position | String | "" | “left”, “center”, “right” |
Fieldset
| Prop | Type | Default | Description |
|---|---|---|---|
legend | String | "" | |
variant | String | "" | “default”, “filled”, “unstyled” |
size | String | "" | |
disabled | bool | false |
Image
| Prop | Type | Default | Description |
|---|---|---|---|
src | String | "" | Image URL |
alt | String | "" | |
width | String | "" | CSS width |
height | String | "" | CSS height |
fit | String | "" | CSS object-fit |
radius | String | "" | |
fallback_src | String | "" | Fallback image URL |
caption | String | "" | Caption text below image |
List / ListItem
List:
| Prop | Type | Default | Description |
|---|---|---|---|
type | String | "" | “ordered” or “unordered” |
size | String | "" | |
spacing | String | "" | |
center | bool | false | Center items with icons |
icon | Option<TablerIcon> | None | Default icon for this list’s items that set none (#707); a ListItem’s own icon wins, and an item that arrives later, or moves in from another list, takes it as it lands (#716) |
with_padding | bool | false |
ListItem: icon: Option<TablerIcon> — per-item icon override, which beats
List::icon.
A List renders after its items, so its default cannot reach them as a prop:
it finds each item that built no icon layout of its own and rebuilds that item
into the same markup ListItem uses, moving the content it already held into
the content span.
That patch runs at the list’s own render and again on whatever lands beneath
the list afterwards — a for reconcile, a show_dom branch, a hand-rolled
append_child (issue #716). The second half rides
rinch_core::dom::on_child_inserted, a DOM-level hook the four NodeHandle
insertion verbs fire: the container registers against its own root and is told,
synchronously, each time a subtree arrives. Synchronously matters — the default
is in place before the frame that shows the new item is laid out, so nothing
flashes.
The same applies to every container default in the library: RadioGroup::size
and the four Stepper props, active included. An item moved from one
container into another re-resolves against the one it now belongs to, because a
container marks the values it supplied as its own (data-list-icon on a list
item) and will not touch a value the child asked for itself.
rinch_core::dom::on_child_removed is the other half, and a container takes it
only if its per-item answer depends on a sibling’s position (issue #745).
Stepper does — a step that goes moves every step behind it backwards, which
renumbers it and can restate it — so it registers both (and on either event
re-derives only the steps that moved: one it has already settled at the position
it still holds costs three attribute reads, issue #748); List and RadioGroup
register only the insertion half, since no row going away can change the icon or
the size the next one should have. It is fired by the four verbs that take a
node out of a tree (remove_child, remove, discard, and replace_with for
the node it displaces) and by the implicit detach an insertion verb performs when
handed a node that already has a parent, which is what tells the container a
child moved away. set_text on an element that has children and
set_inner_html replace the whole child list, and fire it as well (issue
#1440). A batched DomUpdate::SetText (UpdateBatch::apply) does not.
Because of that, a step’s state, step and disabled are read when the step
arrives or moves, not on every change to the list: change them through the
StepperStep props (which re-renders the step), not by writing data-state,
data-step or disabled on a mounted step by hand or through a reactive root
attribute.
The removal half is handed the node the subtree left — its former parent —
rather than the node that went, because a removed node is detached and after a
discard may have been retired by the backend. A reorder inside one parent
fires the insertion half only: the child set did not change.
The rule a container applies to a nested container is the same one its
render-time walk applies downwards, read upwards: every registered ancestor is
told about an insertion, and each declines a node whose chain up to it crosses
one of its own items. So a List inside another list’s item owns its own rows.
A removal is tested against the same chain, starting at the parent it left.
Stepper is the one that has to hand something back down. A step’s state
decides which icon it draws, and a step draws its icon before its stepper
exists — so a step with no state of its own renders the icons for the states
it might be moved into and leaves them in its icon box, hidden, for the stepper
to promote (issue #709). Promotion re-parents the glyph out of its wrapper
before the wrapper goes, since a discard retires the whole subtree (issue
#719).
A glyph the step’s props supplied is parked, not discarded, when the step
stops drawing it (issue #716). An insertion moves the steps after it, a
removal moves them the other way (issue #745), and a keyed for reorder
moves a step in either direction, so a step this stepper put into completed can
be displaced back out of it — and its icon is a
TablerIcon in props that no patch of the rendered tree could rebuild, so it
goes back into a hidden wrapper under the content key it serves. A built-in —
the tick, or the step number — is rebuildable from nothing and still leaves by
discard. Every parked alternate is kept for as long as the stepper owns the
step’s state; a step that named its own state is in that state wherever it
moves, so for it they are all dropped.
The markers on a step’s icon box are therefore records of the step’s props
rather than of what it last drew, which is what lets the pass re-run:
data-icon-has names the content keys the box holds a real glyph for,
data-icon-live names the key showing, and data-step-derived on the step says
this stepper wrote its data-step rather than the caller.
Feedback
Alert
| Prop | Type | Default | Description |
|---|---|---|---|
color | String | "" | |
variant | String | "" | “filled”, “light”, “outline”, “transparent”, “white”, “default” |
title | String | "" | |
radius | String | "" | |
with_close_button | bool | false | |
icon | Option<TablerIcon> | None | |
onclose | Option<Callback> | None |
Loader
| Prop | Type | Default | Description |
|---|---|---|---|
type | String | "" | “oval”, “bars”, “dots” |
size | String | "" | |
color | String | "" |
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
value | Option<f32> | None | Percentage 0-100 |
value_fn | Option<ReactiveF32> | None | Reactive value binding (auto-wrapped) |
color | String | "" | |
size | String | "" | |
radius | String | "" | |
striped | bool | false | |
animated | bool | false |
Skeleton
Custom Default: animate and visible default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
width | String | "" | |
height | String | "" | |
radius | String | "" | |
circle | bool | false | |
animate | bool | true | |
visible | bool | true |
Overlays
z_index on an overlay (issue #474). Modal, Drawer, Popover,
DropdownMenu and Notification each paint more than one box, and the boxes
have to stay ordered against each other: a modal’s panel above its overlay, a
menu’s backdrop below its panel. z_index therefore names the level of the
component’s base layer — the one whose stylesheet number the per-component
table below quotes — and every other layer keeps its existing offset from it.
So Modal { z_index: 500 } is overlay 500 / panel 501, and
DropdownMenu { z_index: 500 } is panel 500 / backdrop 499; one number moves
the component as a piece. Leaving it unset keeps the stylesheet’s own levels.
Every layer that carries a level is a positioned box — fixed for the
Modal/Drawer roots, the DropdownMenu backdrop and the Notification;
absolute or relative for the panels inside them — so the level orders it
within its nearest ancestor stacking context rather than against the whole
page. See the stacking-context notes in CLAUDE.md if a raised overlay still
sits under something.
One limit worth knowing: the level is published as an inherited custom property, so an overlay of the same type nested inside a raised one inherits its level instead of falling back to the default — Chromium does exactly the same with the same CSS, and before this prop existed both were equal anyway.
A component-level style: prop is safe to combine with it. It used to replace
the root’s whole inline style, which dropped the published level and silently
returned the overlay to its stylesheet default (issue #647, fixed); a style:
now merges into the root’s own declarations the way class: and the style
shorthands always have. See the Universal props note at the top of this page.
Dismissing an overlay (issue #474). close_on_escape, and
Popover::close_on_click_outside, and Notification::auto_close were declared
and read by nothing until the dismissal PR. All three now work, on desktop and
on the web, and all three are requests rather than actions: they invoke
onclose, and it is the app that closes the overlay by writing the signal
opened_fn reads. An overlay with no onclose has nowhere to send the request,
so it registers nothing and leaves the key or the click to the app.
Escape goes through the dismiss stack — a last-in-first-out list every
open overlay joins while it is mounted. The innermost open overlay answers the
key and the ones beneath it do not, so nesting works; an overlay that is mounted
but closed passes the key down rather than swallowing it; and a key nothing
takes reaches the app unchanged. Some things beat it, all pre-existing and all
correct: a document-level
set_keyboard_interceptor, which
is the app saying it means to have Escape, and an in-progress drag, which
Escape cancels — both on either backend — plus, on rinch-web only, a focused
RenderSurface, which swallows every key there. An open native <select>
popup takes it too, but by joining this same stack rather than by beating it.
A custom overlay of your own joins the same stack with
push_dismiss_handler — reach for that rather
than an interceptor, which is one slot per document and so cannot nest. When
to join it is a real choice and these three overlays make the opposite one from
ColorInput’s dropdown and the <select> popup; see
Register at mount, or at open.
Outside clicks are caught by an invisible full-viewport backdrop the component
renders while it is open, one stacking level under its own panel, so a click on
the overlay’s own content still reaches the content. Modal and Drawer use
their dimming overlay for this and always have; Popover gained one with the
same PR. Each such backdrop carries data-backdrop
(rinch_core::events::BACKDROP_ATTRIBUTE), so a press of any button outside
dismisses, not only a left one. A custom overlay’s own backdrop needs it too, or
a right or middle press outside it is swallowed by the backdrop and does nothing
(#1093): a data-rid without it runs for a left press only.
auto_close arms a timer when the notification opens and cancels it if
the notification closes — or unmounts — first, so a toast the user dismisses by
hand does not fire onclose a second time at its original deadline, and one
that is shown again gets a fresh delay. 0 is off.
trap_focus contains Tab inside the overlay while it is open: the
component stamps data-trap-focus on its root, and both backends cycle Tab and
Shift+Tab within that element instead of walking the page. A click outside is
untouched — the pointer is not trapped, as a non-modal dialog’s is not.
It also moves focus into the overlay when it opens and gives it back when it
closes, because it is the prop that says the overlay is modal. Modal and
Drawer focus their first control (or an autofocus one); Popover moves
focus only for an autofocus child, which is the HTML popover API’s rule rather
than the dialog’s. Closing restores the element that held the keyboard when the
overlay opened, provided that element can still take focus, and unmounting while
open does the same. A claim the user had already moved outside the overlay is
left alone. Details, the nesting rule and the backend timing are in the
focus guide.
lock_scroll holds the page still while the overlay is open, and this is the
one overlay behaviour that works differently on the two backends. Desktop
refuses the scroll gesture: a wheel (which is also what a touch scroll and the
MCP scroll tool arrive as) and a scrollbar-thumb press are rejected for any
container that is not inside a locking overlay, so the dialog’s own
overflow: auto body still scrolls while the page behind does not. Nothing is
restyled, so the page does not move and its scroll position is exactly where the
user left it. The web sets overflow: hidden on the real <html>, because
rinch cannot gate the browser’s own wheel — which also removes the page’s
scrollbar, so a web page with a classic (non-overlay) scrollbar shifts
sideways when an overlay opens, and a desktop one does not. That is the whole of
the divergence.
Two more things follow from “the gesture, not the style” on desktop, and neither has a web equivalent:
- The page’s scrollbar is still painted while it is locked. You can see it; it will not move. Nothing is drawn differently, only refused.
- Only input is gated. Programmatic scrolling —
NodeHandle::set_scroll_top, an app scrolling a list behind the dialog on purpose — still works. Desktop has no keyboard page-scrolling (PageDown and the arrows scroll nothing at the document level), so there is nothing to gate there. - A scrollbar drag already in flight when the lock arrives is ended, not left to keep scrolling the page while the button is held.
- The native
<select>popup is exempt: its option list is appended to<body>, so it is not inside any overlay’s root, and a long list inside a dialog would otherwise be unscrollable. A scroll container of your own that sits outside the overlay and must keep scrolling while it is open (a popup portalled to<body>, a panel that paints over the dialog) says so withdata-scroll-lock-exempt; the DOM menu bar’s dropdowns carry it. See Focus.
On the web the lock is the whole page even in island mode — there is one
<html> — so a rinch Modal inside an island freezes its host page too.
Locks are counted: two overlays open and the inner one closing leaves the
page locked, and an overlay that unmounts while still open releases its own.
A custom overlay of your own takes one with
node.set_scroll_locked(true) on its root — the node matters, it is the subtree
that stays scrollable — and must release it on close and on unmount.
Tooltip
The open state is the class rinch-tooltip--opened on the root, added and
removed by the hover effect. .rinch-tooltip__content is display: none and
that class is what reveals it, so styling the open tooltip — or overriding how
it appears — is a rule of your own against .rinch-tooltip--opened. Spell it
with a child combinator, .rinch-tooltip--opened > .rinch-tooltip__content,
as the shipped sheet does: the content is a direct child of the root, and a
descendant rule also reaches any tooltip nested inside this one’s target. Before
issue #760 the class was
emitted and matched by nothing, while the reveal was an inline style rewrite
on the content node.
disabled wins over opened: Tooltip { opened: true, disabled: true } shows
nothing. Before #760 it showed the content, because the inline reveal outranked
the sheet’s .rinch-tooltip--disabled rule.
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | Tooltip text |
position | String | "" | “top”, “bottom”, “left”, “right” |
color | String | "" | |
opened | bool | false | |
disabled | bool | false | |
with_arrow | bool | false | |
multiline | bool | false | |
width | String | "" |
Modal
Custom Default: with_overlay, close_on_click_outside, close_on_escape, with_close_button, lock_scroll, trap_focus all default to true.
Positioned with top: var(--rinch-window-top-inset, 0px), so it clears any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) and is flush with the top of a plain window. See Theming.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
title | String | "" | |
size | String | "" | |
radius | String | "" | |
with_overlay | bool | true | |
overlay_opacity | Option<f32> | None | Backdrop alpha, 0-1 (default 0.75). Applies to the dimming overlay, not the panel |
overlay_blur | String | "" | |
centered | bool | false | |
close_on_click_outside | bool | true | A click on the dimming overlay invokes onclose |
close_on_escape | bool | true | Escape invokes onclose while this is the innermost open overlay |
with_close_button | bool | true | |
padding | String | "" | |
z_index | Option<i32> | None | Stacking level of the whole modal: the full-viewport overlay sits here and the panel one above it (defaults 200 / 201) |
lock_scroll | bool | true | Holds the page still while open. Desktop refuses the gesture, web sets overflow: hidden on <html> — see Overlays |
trap_focus | bool | true | Tab cycles inside the overlay while it is open (#474) |
onclose | Option<Callback> | None |
Drawer
Custom Default: with_overlay, close_on_click_outside, close_on_escape, with_close_button, lock_scroll, trap_focus all default to true.
Positioned with top: var(--rinch-window-top-inset, 0px), so it clears any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) and is flush with the top of a plain window. See Theming.
Opening slides the panel in over 300ms, and closing slides it back out. The closed drawer is visibility: hidden, not display: none (issue #751), which is what lets the slide run: an element that was not being rendered has no before-change style, so a display flip and a transform retarget in one style pass animate nothing — on either backend, since a browser refuses the same shape. See The before-change style, and who has one. Hidden this way the drawer is still excluded from paint — its title and body text included, which desktop drew until issue #829 — from hit testing and from the Tab order. The slide-out keeps the root visible for the 300ms with transition: visibility 0s linear 300ms on the closed state (issues #413, #759), so for those 300ms the closing drawer paints, takes clicks and is reachable by Tab, as it is in a browser; its focus trap is released at the close itself. Reopening part way through cancels the pending hide. An app that wants the old instant close can declare .rinch-drawer__root--hidden { transition: none !important; }.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
title | String | "" | |
position | String | "" | “left”, “right”, “top”, “bottom” |
size | String | "" | |
with_overlay | bool | true | |
overlay_opacity | Option<f32> | None | Backdrop alpha, 0-1 (default 0.75). Applies to the dimming overlay, not the panel |
close_on_click_outside | bool | true | A click on the dimming overlay invokes onclose |
close_on_escape | bool | true | Escape invokes onclose while this is the innermost open overlay |
with_close_button | bool | true | |
padding | String | "" | |
z_index | Option<i32> | None | Stacking level of the whole drawer: the overlay sits here and the panel one above it (defaults 200 / 201) |
lock_scroll | bool | true | Holds the page still while open. Desktop refuses the gesture, web sets overflow: hidden on <html> — see Overlays |
trap_focus | bool | true | Tab cycles inside the overlay while it is open (#474) |
onclose | Option<Callback> | None |
Notification
Custom Default: with_close_button defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
title | String | "" | |
color | String | "" | |
position | String | "" | Toast position. The top-* variants offset by --rinch-window-top-inset so they clear window chrome |
radius | String | "" | |
with_close_button | bool | true | |
with_border | bool | false | |
icon | Option<TablerIcon> | None | |
auto_close | u32 | 0 | Invoke onclose this many ms after it opens (0 = disabled). Cancelled if it closes or unmounts first |
loading | bool | false | |
z_index | Option<i32> | None | Stacking level of the toast (default 300) |
onclose | Option<Callback> | None |
Popover
Custom Default: close_on_click_outside and close_on_escape default to true.
Popover had no close callback and no reactive open state until #474, so
neither dismissal prop had anywhere to send a request. Both onclose and
opened_fn were added then, matching DropdownMenu; a Popover without
onclose still renders, and simply cannot dismiss itself.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
position | String | "" | |
offset | Option<i32> | None | |
radius | String | "" | |
shadow | String | "" | |
with_arrow | bool | false | |
arrow_size | Option<f32> | None | |
arrow_offset | Option<f32> | None | |
close_on_click_outside | bool | true | Renders a dismiss backdrop while open; a click on it invokes onclose |
close_on_escape | bool | true | Escape invokes onclose while this is the innermost open overlay |
width | String | "" | |
z_index | Option<i32> | None | Stacking level of the dropdown; its click-catching backdrop stays one below (defaults 100 / 99) |
trap_focus | bool | false | Tab cycles inside the popover (target + dropdown) while it is open (#474) |
onclose | Option<Callback> | None | Invoked when the popover asks to close |
Sub-components: PopoverTarget (no props), PopoverDropdown (no props).
ContextMenu
A right-click context menu. No props on the wrapper — state is managed internally.
Sub-components: ContextMenuTarget (no props), ContextMenuDropdown (no props).
Use DropdownMenuItem and DropdownMenuDivider as children of ContextMenuDropdown.
DropdownMenu
Custom Default: close_on_click_outside and close_on_item_click default to true.
The open state is the class rinch-dropdown-menu--opened on the root, kept
in step with opened_fn by an effect. One class reveals both boxes —
.rinch-dropdown-menu__dropdown and the outside-click
.rinch-dropdown-menu__backdrop are display: none until it lands. Before
issue #760 the class matched
nothing and each box was revealed by an inline style rewrite. One consequence
of the move: “the dropdown” is now named by its class rather than by its
position among the children, so a child other than a DropdownMenuDropdown
passed after the target is not hidden with the menu. Use the documented pair.
The DropdownMenuDropdown does not have to be a direct child of the menu.
rsx! often wraps it on its own — a {Option<NodeHandle>} child, an else if
branch, a component with a reactive prop inside an if, a helper component
whose body is an if — and a div of your own around it is fine too; the menu
opens through any of them. Nesting is handled as well: a closed DropdownMenu
inside an open one’s panel stays closed (its panel and its backdrop, which would
otherwise take the clicks meant for the outer menu’s items), and an open one
inside an open one’s panel — a submenu — shows. The panel’s open rule is a
descendant rule that excludes any panel with a closed menu root between it and
an open ancestor:
.rinch-dropdown-menu--opened .rinch-dropdown-menu__dropdown:not(
.rinch-dropdown-menu--opened .rinch-dropdown-menu:not(.rinch-dropdown-menu--opened)
.rinch-dropdown-menu__dropdown)
Two consequences of that spelling:
- A nesting limit: an open menu C inside the target (trigger) of a closed menu B that is itself inside an open menu A keeps its panel hidden — B is closed and sits between A and C’s panel, though C’s own root is open. “Inside A” means either half of A, its panel or its target, and the same holds however many closed menus are stacked in triggers between them. (An open menu in a closed menu’s panel is hidden anyway, correctly.) This is a menu in the trigger of a menu in a menu; it opened before #760, and the limit is pinned by a test so it cannot change silently.
- Do not put a
displayon aDropdownMenuDropdown. The panel is hidden by a single-class rule, (0,1,0), and shown by the open rule, (0,6,0). Adisplayof your own that beats the hidden rule — a two-class rule such as.my-menu .rinch-dropdown-menu__dropdown { display: flex }, or an inlinestyle: "display: flex"— keeps the panel visible while the menu is closed, so the menu never hides (measured for the two-class rule on desktop and in Chrome, and for the inlinestyle:on desktop). While the menu is open the same declaration loses to the open rule unless it is more specific still. Put the layout (display: flex, agap) on a child of theDropdownMenuDropdowninstead, as withTabsPanel. Astyle:on the panel that sets anything other thandisplayis kept.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive opened getter — use this for fine-grained updates |
on_close | Option<Callback> | None | Fired when the user clicks outside the menu or clicks a menu item (with close_on_click_outside/close_on_item_click); the caller should set their opened_fn source to false from here |
position | String | "" | |
offset | Option<i32> | None | |
radius | String | "" | |
shadow | String | "" | |
close_on_click_outside | bool | true | |
close_on_item_click | bool | true | |
width | String | "" | |
z_index | Option<i32> | None | Stacking level of the menu panel; its click-catching backdrop stays one below (defaults 100 / 99) |
DropdownMenuTarget, DropdownMenuDropdown, DropdownMenuLabel, DropdownMenuDivider: No props.
DropdownMenuItem:
| Prop | Type | Default | Description |
|---|---|---|---|
left_section | Option<TablerIcon> | None | |
right_section | Option<TablerIcon> | None | |
color | String | "" | |
disabled | bool | false | |
onclick | Option<Callback> | None |
HoverCard
| Prop | Type | Default | Description |
|---|---|---|---|
position | String | "" | |
offset | Option<i32> | None | |
radius | String | "" | |
shadow | String | "" | |
width | String | "" | |
open_delay | Option<u32> | None | |
close_delay | Option<u32> | None | |
with_arrow | bool | false |
Sub-components: HoverCardTarget (no props), HoverCardDropdown (no props).
LoadingOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
visible | bool | false | |
overlay_opacity | Option<f32> | None | |
overlay_blur | String | "" | |
loader_type | String | "" | |
loader_size | String | "" | |
loader_color | String | "" | |
radius | String | "" | |
z_index | Option<i32> | None | |
transition_duration | Option<u32> | None |
Navigation
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
value | String | "" | Active tab value |
default_value | String | "" | |
variant | String | "" | “default”, “outline”, “pills” |
orientation | String | "" | “horizontal”, “vertical” |
position | String | "" | |
grow | bool | false | |
color | String | "" | |
radius | String | "" | One of xs sm md lg xl, rounding the tab buttons. outline tabs keep their bottom corners square; the default underline variant has no visible corners to round at rest, though its hover background does |
TabsList: grow: bool, justify: String.
Tab:
| Prop | Type | Default | Description |
|---|---|---|---|
value | String | "" | Tab identifier |
disabled | bool | false | |
left_section | Option<TablerIcon> | None | |
right_section | Option<TablerIcon> | None | |
onclick | Option<Callback> | None |
TabsPanel: value: String — matches the Tab value.
The active tab and the hidden panel are both stylable. The active tab
carries data-active="true" and the class rinch-tabs__tab--active
(inactive tabs carry data-active="false"), and the inactive panel carries the
HTML hidden attribute, which .rinch-tabs__panel[hidden] turns into
display: none. All three are what the shipped sheet styles against, and you
can style against them too, with two things to know:
- Do not put a
displayon aTabsPanel.hiddenhides a panel only through that one rule — desktop has no UA[hidden]rule, and a browser’s loses to any authordisplay— so adisplayof your own on the panel that wins against.rinch-tabs__panel[hidden](an inlinestyle: "display: flex"always does) keeps the inactive panel showing, on desktop and in the browser alike. Put the layout on a child of the panel instead. - Spell a variant rule from the root with child combinators —
.rinch-tabs--pills > .rinch-tabs__list > .rinch-tabs__tab--active, as the shipped sheet does for the activepillsandoutlinestyling and the vertical indicator — if yourTabscan nest. ATabsinside another’s panel is inside that root too, so a descendant rule reaches the nested tabs.
The default variant’s underline is a real element, .rinch-tabs__tab-indicator,
appended to each tab button; it carries transition: background-color 150ms ease, so switching tabs animates it. The tab button’s own transition: color
starts on a switch as well, but what it animates differs by backend: in a
browser the label text inherits the animated colour and fades, while on desktop
a transition on an inherited property does not reach descendants (visibility
is the one exception, since #759), so the label
text — which lives in a child span — snaps to its new colour.
None of these hooks was live before issue
#760: the component set neither
hook and wrote the active colour, the pills fill, the outline border and the
underline inline, so all four of the sheet’s [data-active]/--active rules
(eight selectors) were unreachable and the underline’s declared transition never
ran. One visible consequence of handing the indicator to the sheet: a
vertical default Tabs draws its indicator as a 2px bar down the tab’s
right edge, which is what the sheet always declared, where before #760 it drew
the horizontal underline along the tab’s bottom edge.
Accordion
| Prop | Type | Default | Description |
|---|---|---|---|
value | String | "" | Active item value |
default_value | String | "" | |
variant | String | "" | “default”, “contained”, “filled”, “separated” |
radius | String | "" | |
multiple | bool | false | Allow multiple open items |
chevron_position | String | "" | “left”, “right” |
disable_chevron_rotation | bool | false | Holds the chevron still while an item is open (#707), through data-disable-chevron-rotation on the root. The rotation is a class rather than an inline style so this can outrank it |
AccordionItem: value: String.
AccordionControl: disabled: bool, icon: Option<TablerIcon>, onclick: Option<Callback>.
AccordionPanel: No props.
Breadcrumbs
| Prop | Type | Default | Description |
|---|---|---|---|
separator | String | "" | Custom separator character |
separator_margin | String | "" | Spacing around separator |
BreadcrumbsItem: href: String.
Pagination
Custom Default: total, value, siblings, boundaries default to 1; with_controls defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
total | u32 | 1 | Total number of pages |
value | u32 | 1 | Current page |
siblings | u32 | 1 | Pages visible on each side |
boundaries | u32 | 1 | Pages at start/end |
size | String | "" | |
radius | String | "" | |
with_edges | bool | false | Show first/last page buttons |
with_controls | bool | true | Show prev/next buttons |
color | String | "" | |
disabled | bool | false | |
gap | String | "" | |
onchange | Option<ValueCallback<u32>> | None | Receives page number |
NavLink
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
href | String | "" | |
active | bool | false | |
active_fn | Option<ReactiveBool> | None | Reactive active binding (auto-wrapped) |
variant | String | "" | “light”, “filled”, “subtle” |
color | String | "" | |
left_section | Option<TablerIcon> | None | |
right_section | Option<TablerIcon> | None | |
disabled | bool | false | |
children_offset | String | "" | Indentation for nested NavLinks |
opened | bool | false | Nested section expanded |
default_opened | bool | false | |
no_wrap | bool | false | |
onclick | Option<Callback> | None |
Stepper
| Prop | Type | Default | Description |
|---|---|---|---|
active | u32 | 0 | Active step index. Every step takes its state from its position against this — before it completed, at it in progress, after it inactive (#709) — unless it named a state of its own. A step that arrives later is stated as it lands, and so are the steps it displaced (#716); a step removed, or moved out to another stepper, renumbers and restates the ones behind it too (#745). As a closure (active: {|| sig.get()}) it re-renders the stepper and re-derives |
size | String | "" | |
orientation | String | "" | “horizontal”, “vertical” |
color | String | "" | |
radius | String | "" | |
icon_size | String | "" | |
allow_next_steps_select | bool | false | Makes each step past active clickable (#707), a step that arrives later included (#716). Grants only: a step’s own allow_step_click / allow_step_select is never taken away. With no on_step_click set, this grants only the class — exactly as before #737 |
on_step_click | Option<ValueCallback<u32>> | None | Fired with a step’s 0-based position when it is clicked or activated (Enter/Space) on a step the stepper considers reachable (#737). Mantine’s default: a strictly completed step (position < active) is clickable without allow_next_steps_select; the active step itself and every step past it need that flag or its own allow_step_click/allow_step_select (Mantine’s active state is 'stepProgress', not 'stepCompleted', so it is not reachable by default either). Stepper does not move active itself — the caller does, from this callback, as Tabs moves its own selection. The index is read from the step’s current position at click time, not baked in when the handler was wired (#714’s pattern), since a later insertion or removal can renumber a step (#716, #745) — and the clickable class is removed the moment a shift makes a step unreachable, unless the step’s own ask keeps it. A step moved into another Stepper calls that stepper’s callback, with its position there (#1428); moved into one with no on_step_click it is no longer wired (no data-rid, tabindex or role), and keeps the clickable class only if it asked for it itself or that stepper’s allow_next_steps_select grants it. A wired step moved out of every stepper keeps its wiring (#1453) |
completed_icon | Option<TablerIcon> | None | Default completed icon for the steps that set none (#707); the step’s own wins, and a step that arrives later takes this one as it lands (#716) |
progress_icon | Option<TablerIcon> | None | Default in-progress icon for the steps that set none (#707). It stands in for the progress_icon the step did not set, so it outranks that step’s plain icon. A step that arrives later takes it as it lands (#716) |
StepperStep:
| Prop | Type | Default | Description |
|---|---|---|---|
label | String | "" | |
description | String | "" | |
icon | Option<TablerIcon> | None | Default icon |
completed_icon | Option<TablerIcon> | None | Per-step override |
progress_icon | Option<TablerIcon> | None | Per-step override |
allow_step_click | bool | false | Grants this step the clickable class regardless of its position against active. With Stepper::on_step_click set, also grants the wiring (handler, tabindex="0", data-rid) unless disabled is set (#737) |
allow_step_select | bool | false | The same grant as allow_step_click — Mantine keeps both names, so rinch does too |
loading | bool | false | |
disabled | bool | false | Never clickable, never focusable, whatever else grants it (#737) — the plain HTML disabled attribute, read tag-agnostically |
state | String | "" | "completed" or "progress"; anything else is inactive. Leave it unset and the parent Stepper derives it from this step’s position against active (#709). Set it to override that for this one step: what a step names, it keeps |
step | Option<u32> | None | Step index, 0-based. Set by the parent Stepper from this step’s position unless named here, in which case that is the index shown — and the number in the icon box, one higher. An index named here changes the label, not the position the state derives from |
StepperCompleted: No props.
StepperCompleted is not a position (issue #741): it is what the stepper
shows instead of its steps once they are all done. The parent’s step walk skips
it, so a StepperStep placed inside it keeps the index and state it rendered
itself with, and a whole Stepper nested inside it keeps its own.
It is a skip and not a full stop, so the count runs straight across the block
and a step placed after it is numbered like any other. Mantine writes
Stepper.Completed last by convention but does not require it: a stepper whose
completed block comes first still derives all of its steps.
Tree
Custom Default: level_offset defaults to "md", expand_on_click defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
data | Vec<TreeNodeData> | [] | Tree data |
tree | Option<UseTreeReturn> | None | State from UseTreeReturn::new() |
level_offset | String | "md" | Indentation per level |
expand_on_click | bool | true | Click expands/collapses |
select_on_click | bool | false | Click selects |
render_node | Option<RenderTreeNode> | None | Custom node renderer |
onselect | Option<ValueCallback<String>> | None | |
onexpand | Option<ValueCallback<String>> | None | |
oncollapse | Option<ValueCallback<String>> | None | |
data_source | Option<Rc<dyn Fn() -> Vec<TreeNodeData>>> | None | Reactive data source — when provided, root nodes update when this changes |
TreeNodeData: value: String, label: String, children: Vec<TreeNodeData>, disabled: bool, icon: Option<TablerIcon>, payload: Option<Rc<dyn Any>>.
Window
BorderlessWindow
Custom Default: show_minimize, show_maximize, show_close all default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
title | String | "" | Window title in titlebar |
radius | String | "" | Corner radius (none, xs, sm, md, lg, xl) |
show_minimize | bool | true | |
show_maximize | bool | true | |
show_close | bool | true | |
left_section | Option<SectionRenderer> | None | Custom titlebar left content |
right_section | Option<SectionRenderer> | None | Custom content before controls |
on_minimize | Option<Callback> | None | |
on_maximize | Option<Callback> | None | |
on_close | Option<Callback> | None |
FloatingPanel
A draggable, resizable floating panel for desktop app patterns — tool panels,
property inspectors, floating toolbars. Position and size are controlled via
Signal<f32> props (each defaults to an internal signal when not given —
x/y default to 50.0, width to 300.0, height to 200.0); the panel
writes back to them during drag/resize.
| Prop | Type | Default | Description |
|---|---|---|---|
title | String | "" | Displayed in the panel header; header renders no title span when empty |
x | Option<Signal<f32>> | None | X position (viewport px) |
y | Option<Signal<f32>> | None | Y position (viewport px) |
width | Option<Signal<f32>> | None | Width (viewport px) |
height | Option<Signal<f32>> | None | Height (viewport px) |
min_width | Option<f32> | None | Minimum width constraint (falls back to 200.0) |
min_height | Option<f32> | None | Minimum height constraint (falls back to 100.0) |
resizable | bool | true | Whether the panel can be resized |
on_close | Option<Callback> | None | Shows a close button in the header when set |
#![allow(unused)]
fn main() {
let x = Signal::new(100.0);
let y = Signal::new(100.0);
let w = Signal::new(300.0);
let h = Signal::new(200.0);
rsx! {
FloatingPanel {
title: "Properties",
x: x,
y: y,
width: w,
height: h,
on_close: move || { /* hide panel */ },
div { "Panel content here" }
}
}
}
Callback Types Reference
| Type | Signature | Used By |
|---|---|---|
Callback | Rc<dyn Fn()> | onclick, onclose, onsubmit, onchange (toggle) |
InputCallback | Rc<dyn Fn(String)> | oninput, onchange (value) |
ValueCallback<T> | Rc<dyn Fn(T)> | Slider (f64), Pagination (u32), Tree (String) |
ReactiveBool | Rc<dyn Fn() -> bool> | checked_fn, active_fn, opened_fn, visible_fn |
ReactiveString | Rc<dyn Fn() -> String> | value_fn |
ReactiveF32 | Rc<dyn Fn() -> f32> | Progress value_fn |
SectionRenderer | Rc<dyn Fn(&mut RenderScope) -> NodeHandle> | BorderlessWindow sections |
All callback/reactive props are auto-wrapped by the rsx! macro — just pass closures directly, do not wrap in Some(...) or Rc::new(...).
Theming
Rinch’s theme system is CSS-variable-based, Mantine-inspired, and designed to be extended rather than fought against. Enable it with the theme feature (auto-enabled by components):
rinch = { git = "...", features = ["desktop", "theme"] }
Quick Start
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("cyan".into()),
default_radius: Some("md".into()),
dark_mode: false,
..Default::default()
};
App::new(app)
.title("My App")
.size(800, 600)
.theme(theme)
.run();
}
That’s it. Every component picks up your colors, radius, and spacing through CSS variables. Toggle dark_mode: true and the whole UI flips. All semantic colors — body background, text, borders, placeholders — adjust automatically.
Omit .theme(...) to get the default theme (blue primary, light mode, small radius) — it is loaded either way, which is what makes components visible out of the box.
ThemeProviderProps
| Prop | Type | Default | Description |
|---|---|---|---|
primary_color | Option<String> | "blue" | Primary color name |
primary_shade | Option<u8> | 6 | Shade index (0-9) |
default_radius | Option<String> | "sm" | Border radius (xs, sm, md, lg, xl) |
font_family | Option<String> | System fonts | Primary font stack |
font_family_monospace | Option<String> | System mono | Monospace font stack |
dark_mode | bool | false | Dark mode |
CSS Variables
The theme generates CSS custom properties you can use anywhere in your styles.
One variable in this list — --rinch-window-top-inset —
is not generated by the theme. It is published at runtime by whatever window
chrome rinch is drawing, and is documented here because you consume it the same
way as the rest.
Colors
14 named palettes, each with 10 shades (0 = lightest, 9 = darkest):
var(--rinch-color-blue-0) /* Lightest blue */
var(--rinch-color-blue-5) /* Mid blue */
var(--rinch-color-blue-9) /* Darkest blue */
var(--rinch-primary-color) /* Primary at your chosen shade */
var(--rinch-primary-color-0) /* Lightest primary */
var(--rinch-primary-color-9) /* Darkest primary */
Available palettes: dark, gray, red, pink, grape, violet, indigo, blue, cyan, teal, green, lime, yellow, orange.
Semantic Colors
These flip automatically in dark mode:
var(--rinch-color-body) /* Background (#f8f9fa light, dark gray dark) */
var(--rinch-color-text) /* Primary text */
var(--rinch-color-dimmed) /* Secondary text */
var(--rinch-color-border) /* Borders */
var(--rinch-color-placeholder) /* Input placeholders */
Spacing
var(--rinch-spacing-xs) /* 10px */
var(--rinch-spacing-sm) /* 12px */
var(--rinch-spacing-md) /* 16px */
var(--rinch-spacing-lg) /* 20px */
var(--rinch-spacing-xl) /* 32px */
Border Radius
var(--rinch-radius-xs) /* 2px */
var(--rinch-radius-sm) /* 4px */
var(--rinch-radius-md) /* 8px */
var(--rinch-radius-lg) /* 16px */
var(--rinch-radius-xl) /* 32px */
var(--rinch-radius-default) /* Whatever you set in ThemeProviderProps */
Typography
var(--rinch-font-size-xs) /* 12px */
var(--rinch-font-size-sm) /* 14px */
var(--rinch-font-size-md) /* 16px */
var(--rinch-font-size-lg) /* 18px */
var(--rinch-font-size-xl) /* 20px */
var(--rinch-line-height-xs) /* 1.4 */
var(--rinch-line-height-md) /* 1.55 */
var(--rinch-font-family)
var(--rinch-font-family-monospace)
/* Heading sizes (h1-h6) */
var(--rinch-h1-font-size)
var(--rinch-h1-line-height)
var(--rinch-h1-font-weight)
These drive the Title component. A bare <h1>–<h6> does not read them:
it gets the browser’s own user-agent typography instead — 2em down to
0.67em, font-weight: bold, and em block margins, exactly the values the
HTML Standard’s rendering section gives (issue #627). <th> likewise gets
font-weight: bold and a centring. Those are UA-origin cascade rules, so any
declaration of your own beats them, and Title (which declares its own size,
weight and margin: 0) is unaffected. Reach for Title when you want the
theme’s scale and a plain heading tag when you want the browser’s.
The <th> centring is conditional, as it is in a browser: the spec’s rule
matches only a th whose parent’s computed text-align is still the initial
value, so a header cell inside a container you have aligned inherits that
alignment rather than being re-centred. If you want a header cell centred
regardless, say so on the cell — an author declaration always wins.
The rest of the browser’s defaults
The same audit (issue #674) finished the job for the elements around a heading, every value measured in Chrome 150 and every one a UA-origin cascade rule your own declaration beats:
| element | what it now gets |
|---|---|
p, blockquote, figure, ul, ol, menu, dir, pre | margin-block: 1em |
blockquote, figure | margin-inline: 40px |
dd | margin-inline-start: 40px |
menu, dir | the whole ul treatment — display: block and padding-left: 40px as well |
one of ul/ol/menu/dir inside another | margin-block: 0 — no compounding gap per level |
pre | white-space: pre, so it preserves runs of spaces and newlines |
code, kbd, samp, pre | font-family: monospace |
small, sub, sup | font-size: smaller |
hr | color: gray, a 1px inset border, margin-block: 0.5em, margin-inline: auto, height: 0, overflow: hidden |
Before this an <hr> rendered nothing at all — the UA sheet’s own
* { border-width: 0 } reset, which is there to undo Stylo’s medium initial,
applied to <hr> too and nothing put the border back.
Four things about that set are worth knowing before they surprise you.
- The
<hr>rule is coloured throughcolor, notborder-color. The border iscurrentcolor, so<hr style="color: #c00">gives you a red rule and there is no need to restate the border.border: noneremoves it outright, which is what theDividercomponent does. - A bare
<hr>in aStackcollapses to a dot.margin-inline: autoare auto cross-axis margins on a flex item, and those suppress the stretch that would otherwise give it the container’s width — so it shrinks to its content width, which for an<hr>is nothing, and centres. A browser does exactly the same. UseDivider, or give the rule awidth: 100%. - Whitespace in
<pre>is preserved now, which means a<pre>whose source was indented in yourrsx!will show that indentation. That is the element doing its job;white-space: normalon it opts out. - rinch does not copy Chrome’s monospace font-size quirk. Chrome renders a
medium-sized monospace element at 13px rather than 16px; rinch has no per-family default size, so<code>keeps the size it inherits. Only the family comes from the UA sheet.
Text controls
A raw <input> or <textarea> carries the browser’s own box and font (issue
#1194, every value measured in Chrome 153). The input components (TextInput,
Textarea, NumberInput, PasswordInput, ColorInput) declare their own
border, padding and font size, so this is about controls you write by hand:
| control | box | font and text |
|---|---|---|
text-state <input> (no type, text, search, email, password, number, …) | padding: 1px 2px, border: 2px inset grey | 13.3333px Arial, weight and style normal, line-height: normal |
date/time <input> | padding: 0 0 0 1px (inline start only), the same border | the same, in monospace |
checkbox, radio, range, file, image, hidden | none | the same 13.3333px font |
the button types, color | none in rinch — Chrome gives submit/reset/button 1px 6px and a 2px outset border, color 1px 2px and a 1px solid one (not modelled yet) | the same 13.3333px font |
<textarea> | padding: 2px, border: 1px solid grey | 13.3333px monospace, white-space: pre-wrap, overflow-wrap: break-word |
Both controls also reset letter-spacing, word-spacing, text-transform,
text-indent and text-align rather than inheriting them. The font is not
cosmetic: a control is size (or cols) average characters of its own
font wide.
Overflow follows Chrome too. An <input> other than a checkbox, radio or range
is overflow: clip, and stays clip whatever you declare (the UA rule is
!important). A <textarea>’s computed overflow is auto, and a visible you
declare on either axis computes auto (a hidden or clip is kept), so a raw
textarea shrinks as a flex item instead of holding its full width. rinch does
not yet lay out or scroll a textarea’s text, so it shows no scrollbar and takes
no wheel even when its value overflows.
Everything else is an ordinary UA rule your own declaration beats. The theme’s
button, input, select, textarea { font-family: inherit } is one such
declaration, so under the theme a raw control takes the page’s family and keeps
the 13.3333px size — as it does in a browser under the same sheet. The colours
(FieldText on Field) are not copied: the theme gives form controls
color: inherit on purpose, so they stay readable in dark mode.
vertical-align: sub/super on <sub>/<sup> is now part of this (issue
#724): ComputedStyle::vertical_align plus its ifc::vertical_align_shift_px
consumer shift the glyph post-layout, calibrated against Chrome 153 and the
bundled Inter at a 16px parent font-size (exact only there — see that doc for
the numbers). top/text-top/middle/bottom/text-bottom parse but still
lay out as baseline, and the shift does not grow the line box the way
Chrome’s does, both filed as #1357.
List markers on <li> (issue #725) are partly part of this. li computes
display: list-item, and the UA sheet gives lists Chrome’s own defaults: ul,
menu and dir are disc, ol is decimal, a list inside one other list is
circle and inside two is square — an ol counts as a level, so ol > li > ul is circle, measured in Chrome 153. A block-level list-item box lays out
as a block one does (its children stack, its height is theirs); display: inline list-item stays inline.
What desktop draws is a text marker followed by an en space: •, ◦ or ▪
for disc, circle or square, N. for decimal (<ol start> and
<li value> included), and the old bullet or number for any other keyword. It
is generated as a span at the start of an <li> whose parent is a <ul> or
<ol>, whose computed display is list-item, whose list-style-type is not
none and which has no ::before or ::after of its own (Chrome draws the
marker beside either; a menu/dir item gets none yet — #1370). So an item
restyled to display: flex — a List item with an icon — draws no marker.
The marker is inline content of the item, not an outside marker hanging in its
padding, and list-style-position is not read (#1356). The rich-text editor’s
stylesheet takes that span out of flow and hangs it against the item’s left
edge, as a browser’s outside marker hangs, and draws its task-list checkbox
itself as a ::before hung the same way (#1246).
Components are unaffected, and that is pinned rather than assumed —
Divider, List, Breadcrumbs, Tree, Image, Blockquote and Code all
declare margin: 0, so the author cascade beats every new rule and nothing you
render through the component library moves.
The rich-text editor has one exception: its <sub> and <sup> shrink. Its
stylesheet declares its own margin for every block tag above, its own
font-family for code/pre and white-space: pre-wrap on pre, so all of
that is unmoved — but it declares no font-size for sub/sup, which it
renders as literal elements. So editor subscripts and superscripts now take the
browser’s smaller, exactly as they always have in a browser and on
rinch-web. Declare a font-size on them if you want the old desktop size
back.
Letter and word spacing
letter-spacing and word-spacing apply to laid-out and painted text on
desktop (issue #698). Both inherit, both take a length, and both follow
css-text-3: the extra advance is added after every typographic character
unit, the last one included, so letter-spacing: 4px over five characters
widens the line by 20px rather than 16. word-spacing adds its length to every
space and no-break space. Measured against Chrome 150, rinch and the browser
agree on all three numbers.
A declaration on an inline element covers that element’s own characters only:
#![allow(unused)]
fn main() {
rsx! {
p { style: "letter-spacing: 0.08em",
"Spaced out, "
span { style: "letter-spacing: normal", "but not here." }
}
}
}
A percentage resolves against the element’s own font-size (issue #743),
on both properties: letter-spacing: 50% / word-spacing: 50% add 10px per
affected character at font-size: 20px, and calc(5px + 50%) adds 15px —
measured in Chrome 153 and matched here, including word-spacing, whose
percentage css-text-4 describes as relative to the space glyph’s own advance
rather than the font-size; Chrome’s actual behaviour (and rinch’s, now) is
the font-size basis, not that reading.
normal is zero, as in CSS, and it is a genuine reset: an inline element
declaring it inside a spaced ancestor gets no spacing on its own characters,
which is what the span above relies on.
Form controls are the exception. An <input>, <textarea> or <select>
shapes its own value through a separate text path that does not read either
property yet, so a spacing declared on one has no effect on the text inside it.
Tracked as issue #320, which unifies that path with the rest.
Scaling the whole UI with rem
rem lengths resolve against the computed font-size of the root (<html>)
element, exactly as in a browser. The root defaults to 16px, so 1rem is
16px until you say otherwise; html { font-size: 20px } (or a relative value
like 125%, resolved against the 16px initial) rescales every rem length
in the app — stylesheets and inline styles alike — which makes a root
font-size rule the one-line way to scale a rem-based design up or down.
Changing it at runtime recascades the document, and the basis survives
window resizes. em is unaffected: it keeps resolving against the element’s
own inherited font-size.
Resolution media queries follow the display
The shell feeds the window’s scale factor to Stylo as device_pixel_ratio,
so @media (min-resolution: 2dppx) (and the -webkit-device-pixel-ratio
forms), image-set() candidate selection, and border-width device-pixel
snapping all reflect the display the window is actually on — from the first
paint, and re-resolved when the window moves to a display with a different
scale. Fractional scales (1.25, 1.5) match fractionally, as in a browser.
This never changes layout geometry: 1 CSS px stays 1 layout unit, and paint
applies the scale. One visible consequence: border: 0.5px on a 2x display
renders as a real hairline instead of being snapped up to 1px.
Generic font families on Android
monospace, ui-monospace, ui-sans-serif and ui-serif resolve to a real
device face on Android. They do not out of the box: the font backend looks the
monospace slot up under the literal name "monospace", which /system/fonts
is not indexed under, so the slot ends up empty and every <code>/<pre> —
and the whole default font_family_monospace stack, whose every name is absent
on Android — renders proportional. rinch repairs the map when it builds a font
context (rinch_dom::fonts::new_font_context), appending the first candidate
the device actually has: Roboto Mono, Droid Sans Mono, Noto Sans Mono,
Cutive Mono for the monospace slots. Only slots the platform left empty are
touched, and no other platform is affected. ui-rounded and fangsong are
left empty — Android ships no face for either, on any platform they fall
through to the next entry in the stack.
Shadows
var(--rinch-shadow-xs)
var(--rinch-shadow-sm)
var(--rinch-shadow-md)
var(--rinch-shadow-lg)
var(--rinch-shadow-xl)
Window Chrome Inset
var(--rinch-window-top-inset) /* Height of the chrome rinch draws above your content */
Rinch sometimes draws window chrome itself: the DOM menu bar (28px — on Linux,
and on the web under rinch_web::mount_with_menu_bar) and the
BorderlessWindow titlebar (36px). That chrome reserves space with
in-document padding, so normal flow content clears it automatically.
A position: fixed element does not. Fixed elements resolve against the real
viewport — that is what browsers do, and what rinch-web does on the real DOM —
so a top: 0 overlay slides underneath the menu bar or titlebar.
The chrome therefore publishes its own height as --rinch-window-top-inset.
It’s an ordinary inherited custom property, so any fixed descendant can opt in:
#![allow(unused)]
fn main() {
rsx! {
div { style: "
position: fixed;
top: var(--rinch-window-top-inset, 0px);
left: 0; right: 0; bottom: 0;
",
"Full-height overlay that clears the menu bar"
}
}
}
The 0px fallback matters: on a plain window there is no chrome and the
variable is never set, so the overlay stays flush with the top.
Drawer, Modal, and the top-anchored Notification positions already do
this. DropdownMenu’s and Select’s click-catching backdrops are fixed and
deliberately do not — a dismiss region has to cover the chrome, or clicking
the title bar would leave the menu open.
Using Theme Variables in Your Styles
#![allow(unused)]
fn main() {
rsx! {
div { style: "
background: var(--rinch-color-body);
padding: var(--rinch-spacing-md);
border-radius: var(--rinch-radius-default);
box-shadow: var(--rinch-shadow-sm);
",
h1 { style: "color: var(--rinch-primary-color);", "Themed!" }
p { style: "color: var(--rinch-color-dimmed);", "Using theme variables." }
}
}
}
Or use CSS shorthand props, which resolve scale values automatically:
#![allow(unused)]
fn main() {
rsx! {
div { p: "md", m: "sm", // -> var(--rinch-spacing-md), var(--rinch-spacing-sm)
"Shorthand is nicer"
}
}
}
CSS Selector Support (desktop)
Desktop styling runs through Stylo, the same cascade engine Servo uses, so
specificity, inheritance, custom properties and @media behave as you expect.
The selector surface is narrower than a browser’s, and the gaps are silent:
an unsupported selector parses fine and then matches nothing, with no warning.
On rinch-web the browser does its own matching, so everything below works
there — which is exactly what makes a gap easy to miss.
Measured on the desktop engine:
| Selector | Desktop | Note |
|---|---|---|
tag, .class, #id, * | works | #id was silently dropped before #675 |
an UPPERCASE HTML tag name, in the markup (<DIV>, <Button>) | works | as of #739 — a tag is stored ASCII-lowercased in HTML content, matching Stylo’s own lowercased type-selector comparison for an HTML element. A tag that is one of SVG’s own camelCase spellings, however it was cased, is restored to that spelling rather than lowercased (attr_name::fold_tag_name), mirroring #688’s attribute-name fold one row down |
descendant / > / + / ~ | works | |
[attr], [attr=v], ~=, |=, ^=, $=, *= | works | |
an UPPERCASE attribute name, in the selector or the markup ([DATA-X], <div ID="up">) | works | as of #688 — attribute names are stored ASCII-lowercased in HTML content, and Stylo lowercases the selector’s name itself. viewBox and the other camelCase SVG attributes are kept verbatim |
[attr=v i] / [attr=v s] (case flags) | works | since the style-invalidation work: attribute matching goes through the selectors crate’s own evaluator, the same one Stylo’s invalidation snapshots use. This is the value; the name above is a different question |
:hover, :active, :focus, :focus-visible | works | |
:enabled, :disabled, :checked | works | form controls only, by design |
:link, :any-link | works | <a>/<area> with an href |
:visited | never matches | rinch tracks no history |
:first-child, :nth-child(), :not(), :is(), :where(), :root, :empty | works | handled by the selector engine itself |
:has() | never matches | stylo’s servo-feature SelectorParser answers parse_has() -> false (#680), so div:has(em) { … } never parses into a relative selector in the first place — an upstream-dependency limitation, not a rinch-dom matching bug, and fixing it needs a patched/forked stylo (the same shape as the vendored wgpu fork) |
:required, :optional, :read-only, :read-write, :placeholder-shown, :defined, :lang() | works (#681) | narrower than the full CSS definition — see Node::tag_supports_required / Node::tag_is_readonly_capable’s docs for the exact scope each leaves out (no <fieldset disabled> inheritance, no non-form-control :read-only; :lang()’s invalidation is a forced self+subtree+later-siblings restyle on a lang write, not Stylo’s state maps) |
:focus-within | never matches | attempted for #681 and reverted: an eager ancestor-propagated bit went stale under style_invalidation_twin_tests.rs’s random-mutation oracle (.a:focus-within) whenever a subtree was Moved while focus lived elsewhere — see stylo_impl.rs’s comment at the pseudo-class match’s catch-all |
:indeterminate, :valid, :invalid, :in-range, :out-of-range, :default, :target, :fullscreen, :modal, :popover-open, :autofill, :user-valid, :user-invalid | never match | they parse, then fall through to a catch-all false — rinch has no constraint validation, no document/fragment concept, and no “default” tracking distinct from current state |
::before, ::after | works | |
camelCase SVG type selectors (linearGradient, clipPath) | works (#683) | is_html_element_in_html_document answers false for SVG content tags (attr_name::is_svg_content_tag), so the selector keeps the author’s exact spelling instead of being lowercased as if every element were HTML |
width / height attributes on <img>, <video>, <iframe> | works | mapped to width / height below every author rule and above the UA sheet’s normal rules, plus the pair’s aspect ratio on an image or video (#684) |
Other presentational attributes (<td bgcolor>, <img hspace>, <hr width>, <table width>) | no effect | no hint is synthesized for them (#1419) |
Reach for a class where the table says a selector does not match — that is the one spelling with no gap on either backend.
Text decoration (desktop)
text-decoration-line, -color and -style all reach the desktop painter.
text-decoration-style: wavy draws a zigzag under the text — the spellcheck
squiggle — in the decoration colour, or the text colour when none is given.
The other styles (double, dotted, dashed) parse and paint as a solid
line. A decoration propagates to descendants as in CSS: a wavy word inside an
underlined link keeps the link’s line and gains the wave. The wave follows the
text on screen, right-to-left and mixed-direction runs included.
Extending the Theme
Scoped Overrides
CSS variables cascade. Override them on a container div and everything inside picks up the change:
#![allow(unused)]
fn main() {
rsx! {
div { style: "
--rinch-primary-color: #ff6b6b;
--rinch-radius-default: 0px;
",
// Red primary, sharp corners — only inside this div
Button { "I'm red and sharp" }
TextInput { placeholder: "Me too" }
}
}
}
This is how you create themed sections, cards with different accent colors, or “danger zone” areas with red buttons — without changing the global theme.
Replacing the Theme Entirely
The theme is just CSS. If you hate it, replace it:
#![allow(unused)]
fn main() {
use rinch::theme::{Theme, ColorName};
let theme = Theme::builder()
.primary_color(ColorName::Cyan)
.dark_mode(true)
.build();
// Generate the CSS string and do whatever you want with it
let css = rinch_theme::generate_theme_css(&theme);
}
Or ignore the theme system entirely and write your own CSS variables, your own classes, your own inline styles. Rinch components read from --rinch-* variables. If those variables exist, components use them. If they don’t, components fall back to hardcoded defaults. You’re never locked in.
Dark Mode
#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
dark_mode: true,
..Default::default()
};
}
In dark mode, semantic colors flip:
--rinch-color-bodybecomes dark gray--rinch-color-textbecomes light- Component backgrounds, borders, and hover states all adjust
For dynamic dark mode toggling (e.g., a switch in your app), use dark_mode_fn on WASM or re-create the theme and call update_theme() on desktop.
Scrollbars
A desktop scroll container paints an overlay thumb on each axis that is scrollable and overflowing. Pressing the thumb grabs it where it is, and dragging moves it with the pointer; pressing the empty track beside it jumps there, a position along the track mapping linearly onto the scroll range. Two custom properties style it, and both inherit — one declaration on a root node restyles every scroll region in the app.
:root {
--rinch-scrollbar-color: rgba(255, 255, 255, 0.35); /* thumb */
--rinch-scrollbar-color: rgb(120 120 130) rgba(0, 0, 0, 0.15); /* thumb, track */
--rinch-scrollbar-width: auto; /* auto | thin | none */
}
--rinch-scrollbar-color: <thumb> [<track>]— any CSS colour. With one colour only the thumb is painted, which is rinch’s default overlay look; with two, a track is painted behind it for the full length of the bar.auto(the initial value) keeps the built-in default described below.--rinch-scrollbar-width: auto | thin | none—autois a 6px thumb,thina 4px one.noneremoves the bar entirely: nothing is painted and nothing is hit-tested either, so an app drawing its own scrollbar can switch rinch’s off rather than covering it up.
What counts as overflowing
For the cases in this paragraph, rinch counts the same content a browser does.
A position: fixed child is anchored to the viewport, so it never makes an
ancestor scroll — a Modal, Drawer or Notification inside an
overflow: auto region adds nothing to that region’s scroll range, open or
closed. A position: absolute child counts only where its containing block is
the container or something inside it — the container is itself positioned (or
transformed), or an inline element between them is, such as a
position: relative <span> holding the absolute; an
absolute that resolves against something further up escapes that container,
exactly as it does on the web. visibility: hidden content still counts,
because it still occupies its box; display: none content does not, because it
has none. The children of a display: contents element count as the
container’s own, since the wrapper has no box — so a region whose rows come
from a for, an if branch or an embedded Vec<NodeHandle>, each of which
rsx! may put in such a wrapper, scrolls as it would on the web, and so does
one holding text — a reactive {|| text} included — whether it is the
container’s only content or sits beside block children, as a log panel’s
footer line sits after its rows.
Where desktop still differs from a browser:
- An
absolutechild of a padded scroll container can add up to the container’s right and bottom padding to its scroll range, which a browser does not. Aposition: absolute; inset: 0overlay — aLoadingOverlay, say — inside apadding: 20px; overflow: auto; position: relativepanel gives the panel 20px of scroll travel on each axis and two scrollbars, where a browser shows none. - A
position: relativechild with an offset counts only where the offset puts it, while a browser also counts where it would have been. Shifted toward the end of a padded container, it can add up to the container’s right or bottom padding; shifted toward the start, it can take away up to the offset, padded container or not — a 240px-wide child atleft: -40pxin a 200px-wideoverflow: autobox gets no scrollbar, where a browser gives 40px of travel. Arelativechild with no offset is measured like any other. - An absolute whose containing block is further up than its parent is
laid out in that containing block (#386) but counted by no scroll container
at all; a browser counts it in that ancestor (#770).
The exception is a parent that is a non-positioned inline element such as a
plain
<span>(at any depth of them) sitting directly in the containing block: the box then counts there, as a browser counts it. Under aposition: relativespan — which is then the box’s containing block — it counts when the scroll container is the block holding the span’s line, placed against the span’s text (#1049, #631); with another block between them it is counted nowhere (#770). - A child’s
transformand its end margins do not extend the scroll range; a browser’s scroll range includes both.
Why not scrollbar-color / scrollbar-width?
Those are the real CSS properties, and they are what these mirror — but they are
gecko-only in Stylo. That is a codegen-time filter rather than a #[cfg],
so the servo build rinch uses emits no parser entry for either and drops the
declaration. The --rinch- custom properties go through Stylo’s custom-property
cascade instead, which the servo build supports fully. If Stylo ever ships the
real properties for servo, they will be honoured alongside these.
Neither affects layout: rinch’s bar is an overlay drawn on top of content and
takes no space, so thin and none change what you see, not where anything
sits.
The default follows your palette
auto is not a fixed colour. The thumb is 40% black or 40% white, chosen by
the luminance of the container’s own computed color. A light theme’s text is
dark, so the thumb is the 40% black it has always been and nothing changes; a
dark theme’s text is light, so the thumb flips to white and is visible.
Only the polarity follows the palette — the thumb stays neutral grey — so a
container that happens to set color: red does not get a red thumb. color is
read rather than background-color because it is always resolved: backgrounds
are transparent by default, so a scroll region inheriting the page’s dark
background would have nothing to read.
Desktop only. On the web the browser draws its own scrollbars, and the real CSS properties work there.
Color Palette Reference
| Color | Shade 0 (lightest) | Shade 6 (primary) | Notes |
|---|---|---|---|
dark | #C1C2C5 | #1A1B1E | Dark grays |
gray | #f8f9fa | #868e96 | gray-0 = default body background |
red | #fff5f5 | #fa5252 | |
pink | #fff0f6 | #e64980 | |
grape | #f8f0fc | #be4bdb | |
violet | #f3f0ff | #7950f2 | |
indigo | #edf2ff | #4c6ef5 | |
blue | #e7f5ff | #228be6 | Default primary |
cyan | #e3fafc | #15aabf | |
teal | #e6fcf5 | #12b886 | |
green | #ebfbee | #40c057 | |
lime | #f4fce3 | #82c91e | |
yellow | #fff9db | #fab005 | |
orange | #fff4e6 | #fd7e14 |
Gotcha:
--rinch-color-gray-0is#f8f9fa, which matches the default body background. If you use it as a card background, it’ll be invisible. Usegray-1or higher for visible backgrounds.
CSS sizing keywords
For width, height, min-width/min-height, max-width/max-height and
flex-basis, rinch supports lengths, percentages, calc() mixing the two,
auto and none. The intrinsic sizing keywords are supported on width,
height and flex-basis (#691), and parse but lay out as auto on
min-*/max-* (#1275).
| Value | width / height / flex-basis | min-* / max-* |
|---|---|---|
<length>, <percentage>, calc() of the two | Supported | Supported |
auto, none | Supported | Supported |
max-content | Supported | Laid out as auto |
min-content | Supported | Laid out as auto |
fit-content | Supported | Laid out as auto |
stretch, -webkit-fill-available | Supported | Laid out as auto |
fit-content(<length-percentage>) | auto — Chrome 153 does not accept it on these properties either | Laid out as auto |
anchor-size(…) | Laid out as auto | Laid out as auto |
They are checked against Chrome 153 in block, flex (both axes, and
flex-basis), grid, inline-block/inline-flex, absolute and fixed
contexts (crates/rinch-dom/tests/intrinsic_sizing_twin_tests.rs). What still
differs, each with its issue:
fit-contentaround content that wraps comes out as wide as its widest wrapped line rather than the available width (#1276) — the same measurement that makes anauto-width absolute too narrow. It is right whenever the content fits on one line, or cannot wrap at all.height: stretchon aninline-blocklays out asauto, andwidth: stretchon a flex item whose content is wider than the container is not capped by it (#1277).- An
inline-block(orinline-flex,inline-grid) whose width isautois shrink-to-fit since #658, as in a browser: capped at its containing block, so wider content wraps there. That holds where the block’s own width comes from the box too (#1476): inside a flex item, a grid item in anautotrack, afit-contentormin-contentblock, a shrink-to-fit absolute box or anotherautoatomic inline, the container is sized from the box’s min-content and max-content widths (its longest word, its one-line width), and the box wraps in what that gives. Two things in this corner are not a browser’s yet. A box whose ownmin-widthormax-widthis a percentage, or whosewidthisfit-contentor a percentage, still contributes the size it currently has to such a container, as before, so it is not a browser’s there: awidth: fit-contentbox keeps its full width, and amax-width: 80%box comes out narrower than a browser’s and narrower again on each relayout (older than #1476). A box whosewidthis a percentage, in a flex item or anautogrid track, is the same cycle: its container is sized from the box and the box from its container, so the first layout is not where it settles — it drifts over the next few layouts, and since #1476 anautobox in a neighbouring item or track moves with it (up to seven layouts, measured). A box that fits within a pixel of its containing block is left unwrapped (a 380.9px line in a 380px block overflows by that pixel).
For min-*/max-* rinch prints one line on stderr per property and keyword
per process, so the substitution is visible rather than silent:
[rinch] `min-width: max-content` is not implemented; it lays out as `auto`,
which matches a browser only where `auto` already gives the same used size
(issues #626, #1275). Reported once per property and value per process.
Grid tracks take the keywords too: grid-template-columns: max-content
and min-content size a column the way a browser does, and
fit-content(<length-percentage>) is converted for tracks.
When auto is the right answer for min-*/max-*
On min-*/max-*, where every keyword is still laid out as auto, whether
that changes anything depends on the box. Measured against
Chrome 150, for a box whose content is 300px wide inside an 800px containing
block:
| The box | auto gives | max-content/min-content/fit-content | stretch |
|---|---|---|---|
min-width / max-width | no constraint | wrong — should constrain to 300 | correct |
min-height | none | correct | wrong — should fill the containing block |
max-height | none | correct | correct |
A browser shrink-wraps a float too. rinch does not, because rinch implements
no CSS float at all — measured, float: left on the same box gives 800, the
full containing block, where a browser gives 300. So a float is not a way to
shrink-wrap a box here; width: fit-content (or display: inline-block) is.
Where a min-*/max-* keyword is wrong, a declared length or percentage is
the workaround: min-height: 100% gives min-height: stretch’s used size as
long as the box has no margin, border or padding on that axis.
Fonts
An app that wants a particular typeface has two ways to get one: the platform’s font list, or its own bytes.
The platform list is not a promise anyone can keep. A phone has whatever its OEM shipped. A Linux desktop has whatever the user installed. So a build with a designed identity, or one whose content needs a monospaced face — a chord chart, a diff, a table of figures, anything where the columns lining up is the meaning and not decoration — has to carry the file.
Shipping a face
Bundled fonts are one method on the App builder, so they compose with every
other startup option:
use rinch::prelude::*;
const FONTS: &[AppFont] = &[
AppFont::serif(include_bytes!("../assets/fonts/Newsreader.ttf")),
AppFont::sans_serif(include_bytes!("../assets/fonts/Karla.ttf")),
AppFont::monospace(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")),
];
fn main() {
App::new(app)
.title("My App")
.size(900, 1200)
.fonts(FONTS)
.run();
}
and on Android, the same chain with a different terminal:
#[unsafe(no_mangle)]
fn android_main(android_app: AndroidApp) {
App::new(app).fonts(FONTS).run_android(android_app);
}
.fonts() is part of the startup configuration rather than something you
register beforehand because the faces have to be in place before the first
layout pass. A face that arrives after it cannot un-measure the frame that has
already been drawn: the first frame comes out in a fallback and then reflows.
Configuring them on the builder leaves no ordering to get wrong.
Calling .fonts() more than once registers all of them, in call order — unlike
.title() or .menu(), a second call adds rather than replaces.
AppFont::data is &'static [u8], which is what include_bytes! gives you.
The file is a TrueType (.ttf), OpenType (.otf) or collection (.ttc).
What a face is reachable as
This is the part that catches people out. Take a stack:
font-family: Newsreader, Georgia, serif;
Every name in it is looked up one of two ways.
| In the stack | How it resolves | What a bundled face needs |
|---|---|---|
Newsreader, Georgia | by family name — the name inside the font file’s own name table | nothing; registering the file is enough |
serif, sans-serif, monospace, system-ui, … | by generic — a slot the platform fills, which is not the name of anything | the face has to be declared for that slot |
So AppFont::new(bytes) is reachable, but only by an author who spells its
name. If the stack’s last entry is a generic — and essentially every real stack
ends in one — that generic is what has to resolve when the names ahead of it
miss, and it will not resolve to a bundled face by accident.
The constructors say which slot a face is claiming:
| claims | |
|---|---|
AppFont::new | nothing — reachable by family name only |
AppFont::serif | serif, ui-serif |
AppFont::sans_serif | sans-serif, system-ui, ui-sans-serif |
AppFont::monospace | monospace, ui-monospace |
Anything else is a struct literal, over the full set of CSS generics:
AppFont {
generics: &[GenericFamily::Cursive, GenericFamily::Fantasy],
..AppFont::new(bytes)
}
A bundled face goes ahead of the platform’s own entry for a slot —
including the face rinch’s Android repair filled an empty slot with (below).
An app that ships a face for monospace means that one. What was in the slot
stays behind the claim rather than being discarded, so a character the claimed
face lacks can still fall through to the platform’s entry.
Claim only what is true. A serif declared sans-serif will be picked for
sans-serif, which is precisely the problem.
Android and monospace
Worth knowing if you are shipping to a phone. The text stack’s Android backend
(fontique) maps the monospace generic by looking up a font family named
monospace, and its scan of /system/fonts indexes files under the family
names in their own name tables — where the device’s actual monospaced file
(/system/fonts/DroidSansMono.ttf) is Droid Sans Mono. Nothing is called
monospace, so upstream the slot is left empty, and font-family: monospace
would resolve to no face at all.
rinch repairs this at font-context construction (issue #322): a generic slot
the platform left empty is filled with the best platform face the device
actually has — Droid Sans Mono on stock Android for monospace — so
<code> and <pre> render monospaced out of the box, with no bundling.
What the repair cannot do is pick your face: it answers an empty slot with
whatever the OEM shipped, whichever that is. An app whose content depends on a
particular monospaced design bundles the file and declares it —
AppFont::monospace(...) — and the declared face is picked ahead of both the
repair’s choice and the platform’s own map.
That precedence is the reason a claim is a prepend rather than an append. The repair runs at context construction, before any app font can register, so an appended claim would sit behind the repair’s platform face and silently lose — on Android only, which is the platform nobody would test it on.
A stack that names nothing installed
CSS falls back to the browser’s default font when every family in a
font-family list is missing. rinch finishes such a stack with the first face
of sans-serif — so font-family: Helvetica on a Linux machine without it
draws its letters and digits in the default sans face, or in the face your
app registered for sans-serif (AppFont::sans_serif) if it did. (Chrome resolves
Helvetica, Times and Courier through fontconfig aliases, and an unknown
name in its serif default; rinch knows only the families actually installed or
registered.) A stack that finds any family, or names a generic that has one, is
used exactly as written — end your stacks in a generic to choose the fallback
yourself.
Emoji
An emoji-presentation character — 😀, ⬜, a flag, or an emoji character
followed by U+FE0F (❤️, ©️) — is drawn from the emoji generic unless a family
in your stack covers it first, where each generic in the stack counts as its
first face only; the generics’ other faces come after the emoji generic,
so a character the emoji face lacks still finds them. U+FE0F after a character
that is not an emoji (𝄞, Thai ก) changes nothing.
So under font-family: sans-serif (or the theme’s default stack) an emoji is in
colour even on a machine whose sans-serif list goes on to text faces that
also have it (DejaVu Sans, FreeSans), as in Chrome; a family you name that
covers the emoji still draws it, as in Chrome, and an emoji face you name after
a generic (…, sans-serif, 'Segoe UI Emoji') wins it. A character that is text
by default — ❤ without U+FE0F, ©, the digits — follows the stack as written.
Platforms with no fonts at all
Wasm has no system font source: an unregistered app renders zero glyphs, not
a fallback. That case wants a face in the last-resort fallback chain as well as
in a generic slot, which is what RinchApp::register_font_data and
RinchContextConfig::fonts do, and what AppFont::script_fallback turns on.
Do not turn it on where the platform does have fonts. The text stack resolves a script’s fallback from the app’s own list first and the platform’s second — as an alternative, not a chain — so an entry in the app’s list replaces that script’s platform fallback rather than extending it. One bundled Latin face becomes what CJK, Arabic and emoji fall back to.
The effect is measured, not hypothetical, and the tests pin it: with
script_fallback set on a Latin face, laying out 漢字 in a stack headed by
that face resolves to the bundled face, at glyph 0 — .notdef, the empty
box — where the same text without it resolves to the platform’s CJK face with
real glyphs. You will not always see it: if something else in the author’s
stack already covers the character, fallback is never consulted. That makes it
worse, not better — the breakage shows up only for the scripts and stacks
nobody tested.
Where the bytes come from
include_bytes! puts the file in the binary — in the .so, for an Android
build — and needs no packaging. On Android the more idiomatic alternative is to
put the files in the APK’s assets/ and read them through AndroidApp’s asset
manager, which keeps the shared object smaller; it is also more machinery, it
is Android-only, and the bytes it produces are owned rather than 'static. The
API here takes &'static [u8], so include_bytes! is the supported route
today.
Running on WASM
Rinch compiles to WebAssembly with a browser-native DOM backend. Instead of painting pixels to a canvas, the WASM build creates real <div>, <span>, and <button> elements. The browser handles layout, CSS, text rendering, and painting natively.
The result: ~3MB binary, no JavaScript framework, real DOM elements you can inspect in Chrome DevTools.
How It Works
The magic is in the DomDocument trait. On desktop, NodeHandle talks to RinchDocument (Stylo + Taffy + Parley + Vello/tiny-skia). On WASM, it talks to WebDocument (web_sys). Your components, signals, effects, and stores don’t know or care which one they’re using.
Desktop: Signal -> Effect -> NodeHandle -> RinchDocument -> tiny-skia pixels
WASM: Signal -> Effect -> NodeHandle -> WebDocument -> real browser DOM
Same rsx!. Same Signal::new(). Same Button { "Click me" }. Different backend.
Project Setup
The WASM entry point is its own crate (separate from the desktop workspace) so it can carry wasm-tuned build profiles and a Trunk setup. It depends on rinch-web — the browser-native DOM backend that implements DomDocument over web_sys. Structure your project like this:
my-app/ # Shared library: components, stores, logic
my-app-desktop/ # Desktop entry point
my-app-web/ # WASM entry point (separate workspace)
The Shared Library
# my-app/Cargo.toml
[package]
name = "my-app"
[dependencies]
rinch = { git = "...", default-features = false, features = ["components", "theme"] }
#![allow(unused)]
fn main() {
// my-app/src/lib.rs
use rinch::prelude::*;
#[component]
pub fn app() -> NodeHandle {
let count = Signal::new(0);
rsx! {
Stack { gap: "md", p: "xl",
Title { order: 1, "My App" }
Button { onclick: move || count.update(|n| *n += 1),
{|| format!("Clicked {} times", count.get())}
}
}
}
}
}
The Desktop Entry Point
# my-app-desktop/Cargo.toml
[dependencies]
rinch = { git = "...", features = ["desktop", "components", "theme"] }
my-app = { path = "../my-app" }
// my-app-desktop/src/main.rs
use rinch::prelude::*;
fn main() {
App::new(my_app::app).title("My App").size(800, 600).run();
}
The WASM Entry Point
# my-app-web/Cargo.toml
[package]
name = "my-app-web"
[dependencies]
rinch = { git = "...", default-features = false, features = ["components", "theme"] }
rinch-core = { git = "..." }
rinch-web = { git = "..." } # browser-native DOM backend: WebDocument + mount
my-app = { path = "../my-app" }
wasm-bindgen = "0.2"
console_error_panic_hook = "0.1"
// my-app-web/src/main.rs
use wasm_bindgen::prelude::*;
use rinch_core::element::ThemeProviderProps;
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
// `rinch_web::mount` creates the WebDocument, builds your component tree,
// wires browser event delegation, and installs the theme CSS.
let theme = ThemeProviderProps {
dark_mode: false,
..Default::default()
};
rinch_web::mount(theme, my_app::app);
}
fn main() {}
Islands (multiple roots per page)
mount boots a single whole-page app. For a static-first page (e.g.
server-rendered HTML) you can instead hydrate independent rinch widgets into
specific placeholder elements — the “islands” pattern. Each island is its own
reactive root; they coexist on one page and can be unmounted individually.
<article>… static server-rendered content …</article>
<div id="comments"></div>
<div id="search"></div>
#![allow(unused)]
fn main() {
#[wasm_bindgen(start)]
pub fn start() {
let theme = ThemeProviderProps::default();
// Mount into an element selected by CSS selector …
rinch_web::mount_selector("#comments", theme.clone(), comments_app);
// … or into a `web_sys::Element` you already hold:
let el = web_sys::window().unwrap().document().unwrap()
.get_element_by_id("search").unwrap();
let handle = rinch_web::mount_into(&el, theme, search_app);
// Later, tear that root down (removes its DOM + handlers; the host stays):
// handle.unmount();
}
}
| API | Purpose |
|---|---|
mount_into(&Element, theme, build) -> RootHandle | Mount a root into an existing element |
mount_selector("#id", theme, build) -> Option<RootHandle> | Same, by CSS selector (None if no match) |
RootHandle::unmount(self) | Remove that root’s DOM subtree and its event handlers |
Islands adopt their host element directly (no #rinch-root/#rinch-body
wrappers), share one page-global theme <style>, and install document event
listeners once — so any number can coexist without interfering. Dropping a
RootHandle keeps the root mounted; only unmount() tears it down. See
examples/islands-web for a runnable demo.
Mounting with a menu bar
Browsers have no window menu to attach a native menu bar to. Rinch renders one
out of DOM nodes instead — the same bar the Linux desktop uses, built from the
same Menu / MenuItem values you would hand to App::menu. So the menus are
declared once and both targets render them.
#![allow(unused)]
fn main() {
use rinch_web::{Menu, MenuItem};
#[wasm_bindgen(start)]
pub fn start() {
let file = Menu::new()
.item(MenuItem::new("New").shortcut("Ctrl+N").on_click(|| new_doc()))
.separator()
.submenu("Open Recent", Menu::new()
.item(MenuItem::new("notes.md").on_click(|| open("notes.md"))))
.item(MenuItem::new("Save").shortcut("Ctrl+S").enabled(false));
let view = Menu::new()
.item(MenuItem::new("Focus Search").shortcut("Ctrl+K").on_click(|| focus_search()));
rinch_web::mount_with_menu_bar(
ThemeProviderProps::default(),
vec![("File", file), ("View", view)],
my_app::app,
);
}
}
| API | Purpose |
|---|---|
mount_with_menu_bar(theme, menus, build) | Whole-page app under a menu bar |
mount_into_with_menu_bar(&Element, theme, menus, build) -> RootHandle | An island under its own menu bar |
mount_selector_with_menu_bar("#id", theme, menus, build) -> Option<RootHandle> | Same, by CSS selector |
Each (label, menu) pair becomes one top-level menu, in order. Clicking a label
opens it; moving the pointer across the bar switches to the next menu without a
second click; clicking outside or pressing Escape dismisses it. Separators and
submenus render as flyouts.
The bar is laid out inside whatever it is mounted into, above the content, and
the wrapper it builds is viewport-tall — height: 100vh, from the component
stylesheet. A whole-page app needs nothing for that; an island gets no say in it
through the API, so a host shorter than the viewport is overflowed by the bar and
giving that host a height changes nothing. Measured in Chrome 153: with no
height on html, body or the host, everything lands at the viewport’s 437px;
with the host at height: 120px, the wrapper is still 437px. Tracked
separately. An author stylesheet can still override it — the component sheet’s
rule is a bare class selector, so div.rinch-app-menu-bar-wrapper { height: 100% }
wins on specificity (measured: a 120px host then gets a 120px wrapper).
Shortcuts are armed against the page. Every item’s shortcut string is
matched on a capture-phase keydown on window, before the app sees the key,
and a chord the menus claim is consumed: preventDefault, so the browser does
not also act on it, and the event is stopped before it reaches anything else in
the app — the same thing the desktop does by returning before a matched chord
becomes an event. Ctrl and Cmd are interchangeable in the string, as on the
desktop, so "Ctrl+K" is Cmd+K on a Mac. A chord nothing is listening to falls
through to the page: an item with a shortcut but no on_click, or a disabled
one, arms nothing.
A few chords are the browser’s own and cannot be taken. The ones that open
and close windows and tabs — Ctrl+N, Ctrl+T, Ctrl+W and their Shift
variants in Chrome and Firefox — are handled by the browser’s chrome ahead of the
page, so preventDefault does not reach them and the page may not be sent the
keystroke at all. Exactly which chords those are depends on the browser and the
platform, and rinch’s Ctrl↔Cmd mapping shifts the question again on a Mac,
where "Ctrl+Q" means Cmd+Q and belongs to the OS. Declaring one is still
right when the same Menu drives a desktop build — Ctrl+N is what “New”
should be there — just do not rely on the chord in the browser, and check any
chord you care about in the browsers you support.
Unmounting gives the chords back. They are page-global, so an island mounted
into somebody else’s page arms them against the whole document;
RootHandle::unmount releases exactly the ones that island armed. Two islands
that each declare menus still share one registry — the later one to arm wins, and
the earlier one unmounting leaves the winner alone.
Nothing about this needs the desktop feature. rinch::menu’s declaration types
and the DOM renderer build with default-features = false; only the muda
builders behind them are desktop-gated. See examples/menu-bar-web for a
runnable demo.
Right-click menus
oncontextmenu and ContextMenu behave as they do on the desktop: a right-click
on an element with a live handler dispatches it, and the browser’s own menu does
not open. Everywhere else it does, which is what an island in somebody else’s
page must do. A whole-page app that draws its own menus can take the rest of the
page as well:
#![allow(unused)]
fn main() {
rinch_web::set_suppress_native_context_menu(true);
}
ContextMenu needs this to look native: its dropdown is portalled out of the
element carrying the handler, so without the flag a right-click inside the open
menu opens the browser’s on top of it. The flag is page-global and can be flipped
at any time.
It leaves text fields alone. With the flag on, a right-click on a
<textarea>, on an <input> of type text, search, url, tel, email,
password or number (or no type), or on contenteditable content still
opens the browser’s menu, readonly and disabled fields included. That menu is
the user’s cut, copy and paste, spelling suggestions and autofill, and a page
cannot rebuild it. A live handler still wins: to show your own menu in a field,
put oncontextmenu on the field or an ancestor of it. Outside contenteditable content, selected text, a checkbox, a <select> and a
label next to a field are still suppressed, and so is a contenteditable="false"
island. Inside editable content the browser reports a checkbox, a <select> or a
label as editable too, so they keep its menu there.
The rich-text Editor keeps the browser’s editing menu too, flag or no flag
(issue #814). Its surface is not contenteditable, so a right-click on it is not
an editing context by itself — but a right-button press parks the hidden
<textarea> the editor takes its input through under the pointer for the instant
the browser needs to hit-test the point, so the contextmenu the browser fires
targets that textarea, which the carve-out covers (measured in Chrome 153: a real
right press dispatches contextmenu at the textarea, not prevented). The
keyboard’s menu key and Shift+F10 go to the focused element, that same textarea;
the editor parks it at the caret on that key, so the contextmenu the browser
places at the focused element’s box lands at the caret rather than at the
textarea’s resting corner. Under an app’s data-oncontextmenu the key opens that
handler instead, with its click context at the caret, and the browser’s menu is
prevented. A contextmenu dispatched at the
surface itself with nothing parked — by script, or by a touch long-press (#821) —
is still suppressed with the flag on. So is a right-click on a link or an image in
the editor outside the selection: the editor parks nothing there, so that the
browser’s own link or image menu can open, and a link or an image is not an editing
context, so the flag takes that menu away as it does on any other link or image.
What the menu’s items do is described in the editor guide.
Building
With Trunk (Recommended)
Trunk handles WASM compilation, asset bundling, and dev server:
cargo install trunk
cd my-app-web
trunk serve --release --port 8080
Add an index.html:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body></body>
</html>
Trunk does the rest.
With wasm-pack
cd my-app-web
wasm-pack build --target web --release
Manual
cd my-app-web
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen target/wasm32-unknown-unknown/release/my_app_web.wasm --out-dir pkg --target web
Browsers, and running the browser tests
The browser backend is tested in Chrome and Firefox: CI runs
crates/rinch-web’s browser tests (and rinch-storage’s IndexedDB tests) in
both, headless, on every pull request (test-wasm in
.github/workflows/ci.yml). Safari is not run anywhere.
The tests are ordinary wasm-bindgen-test files under crates/rinch-web/tests/.
wasm-bindgen-test-runner loads each in a real browser through that browser’s
WebDriver, and which driver it finds decides the browser:
# Once: the runner, at the wasm-bindgen version Cargo.lock pins.
cargo install wasm-bindgen-cli --version "$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | grep -o '[0-9.]*')" --locked
export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner
# Chrome: a chromedriver of the same version as the installed Chrome.
CHROMEDRIVER=/path/to/chromedriver \
cargo test -p rinch-web --target wasm32-unknown-unknown
# Firefox: geckodriver (https://github.com/mozilla/geckodriver/releases), with
# `firefox` on PATH.
GECKODRIVER=/path/to/geckodriver \
cargo test -p rinch-web --target wasm32-unknown-unknown
# One file, and the tests behind the `collaboration` feature:
GECKODRIVER=/path/to/geckodriver \
cargo test -p rinch-web --target wasm32-unknown-unknown --features collaboration --test editor_read_only
When both variables are set, GECKODRIVER wins. To use a Firefox that is not
on PATH, put a webdriver.json in crates/rinch-web/ (do not commit it):
{ "moz:firefoxOptions": { "binary": "/path/to/firefox", "args": ["-headless"] } }
Two things to know when a test passes in one engine only:
- A synthetic
ClipboardEventdoes not carry itsclipboardDatain Firefox.new ClipboardEvent("paste", { clipboardData })hands the listeners an empty transfer there. Build clipboard events withtests/support/clipboard.rs. - The engines answer geometry questions differently, which is often why the
code under test exists: after a preserved newline Chrome gives a collapsed
range no rect and Firefox gives it the line above; a
<br>on an empty line is the glyph box in Chrome and the line box in Firefox. A positive control that pins the browser’s own answer states each engine’s (tests/support/engine.rs,is_gecko()), never one engine’s for both.
What Works
Everything that goes through NodeHandle works:
- Signals, Effects, Memos, derived state
- Stores and Context
- All 60+ components
- The theme system and CSS variables
- Event handling (onclick, oninput, etc.)
- Reactive control flow (if, for, match)
- CSS shorthand props
The abstraction is clean. If your component code doesn’t import anything from rinch-dom or winit directly, it’ll work on WASM without changes.
Browsers. The wasm suites run in Chrome only. A press, drag or hover in the
Editor (and the data-block-index text hit) finds its text position with
caretRangeFromPoint where the browser has it and the standard
caretPositionFromPoint otherwise, which is what Firefox has. That second path is
tested by hiding the first in Chrome, where the two give the same positions; it has
not been run in Firefox, and neither has the rest of the editor’s input there
(#1461).
What Doesn’t (Yet)
- Custom painting — Vello and tiny-skia don’t run in the browser. The browser paints for you instead.
- Game engine embedding —
RenderSurfaceandRinchContextare desktop-only. - Native menus — There is no OS menu bar to attach one to. Rinch renders its
own DOM menu bar instead, from the same
Menu/MenuItemdeclarations the desktop uses — see Mounting with a menu bar. - File dialogs — Use the browser’s
<input type="file">or the File System Access API. - System tray — Not a thing in browsers.
Theme in WASM
The theme system works by injecting a <style> tag into <head>. Dynamic theme changes (primary color, dark mode) work via reactive props:
#![allow(unused)]
fn main() {
let dark = Signal::new(false);
// ThemeProviderProps with reactive dark_mode
let theme = ThemeProviderProps {
dark_mode_fn: Some(Rc::new(move || dark.get())),
..Default::default()
};
}
Binary Size
A full UI Zoo demo (12 sections, 60+ components) compiles to ~3.3MB after wasm-opt. A minimal app is well under 500KB. The Rust compiler’s dead code elimination is doing real work here — unused components, icons, and CSS don’t make it into the binary.
Reference
See examples/ui-zoo-web in the repo for a complete, working WASM app with sidebar navigation, theme switching, and all components,
and examples/menu-bar-web for the menu bar.
Windows
Rinch supports multi-window applications with window configuration at the runtime level. Windows are created using WindowProps when launching the application.
Basic Window
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div {
h1 { "Window Content" }
}
}
}
fn main() {
// Window title, size, and root component
App::new(app).title("My Application").size(800, 600).run();
}
Window Properties
Configure windows using WindowProps:
use rinch::prelude::*;
use rinch_core::element::WindowProps;
#[component]
fn app() -> NodeHandle {
rsx! {
div { "Content" }
}
}
fn main() {
let props = WindowProps {
title: "My Application".into(),
width: 800,
height: 600,
x: Some(100), // Initial x position
y: Some(100), // Initial y position
borderless: false, // Show window decorations
resizable: true, // Allow resizing
transparent: false, // No transparency
always_on_top: false, // Normal z-order
visible: true, // Show on creation
resize_inset: None, // No custom resize handles
};
App::new(app).window_props(props).run();
}
| Property | Type | Default | Description |
|---|---|---|---|
title | String | “Rinch Window” | Window title bar text |
width | u32 | 800 | Initial window width in pixels |
height | u32 | 600 | Initial window height in pixels |
x | Option<i32> | None | Initial x position (centered if None) |
y | Option<i32> | None | Initial y position (centered if None) |
borderless | bool | false | Remove native window decorations |
resizable | bool | true | Allow window resizing |
transparent | bool | false | Enable window transparency |
always_on_top | bool | false | Keep window above others |
visible | bool | true | Whether the window is on screen at startup; false starts a tray-only app (see below) |
resize_inset | Option<f32> | None | Resize handle inset for borderless windows |
non_activating | bool | false | Never take keyboard focus or activation, even when clicked (see Overlay Windows) |
click_through | bool | false | Let pointer events pass through to the window underneath |
skip_taskbar | bool | false | No taskbar entry (and on Windows, none in Alt+Tab) |
level | WindowLevel | Normal | How high the window sits: Normal, Floating, StatusBar, ScreenSaver (see Level and workspaces) |
visible_on_all_workspaces | bool | false | Show the window on every workspace / Space, fullscreen ones included |
Starting with no window (tray-only apps)
visible: false starts the app with no window on screen (issue #1520): the
tray is the whole UI until something calls show_current_window(), for
example a “Settings…” tray item. The root component is mounted at startup all
the same, at the window’s size, so its effects, timers and stores run while
nothing is shown. Showing the window creates the OS window over that same
document, without mounting it again. hide_current_window() hides it again,
and so does an on_close_requested that calls hide_current_window() and
returns false (returning false alone only cancels the exit). Desktop only; no frame
is presented before the window is first shown (on X11 the window is created
unmapped and destroyed again, measured with xev). No renderer exists before
the first show either, so gpu_handle() is None while the root mounts.
#![allow(unused)]
fn main() {
let props = WindowProps {
title: "Settings".into(),
visible: false,
on_close_requested: Some(Arc::new(|| {
hide_current_window();
false
})),
..Default::default()
};
App::new(app).window_props(props).run();
}
examples/tray-only is a complete one. Before #1520, visible was accepted and
ignored: the window was always shown.
On macOS a hidden window still leaves the app’s icon in the Dock, because the
Dock icon belongs to the app, not the window. A tray-only app pairs
visible: false with the accessory activation policy (issue #1528), which
removes the Dock icon and the app’s menu bar while its window can still be
shown and typed into:
#![allow(unused)]
fn main() {
App::new(app)
.window_props(props)
.activation_policy(ActivationPolicy::Accessory)
.run();
}
visible: false does not imply it: an app that only starts hidden and is
otherwise an ordinary app keeps its Dock icon. Without activation_policy the
policy is winit’s default, unchanged: Regular for a bare executable, and the
bundle’s Info.plist (LSUIElement) for a .app; setting one overrides the
Info.plist. ActivationPolicy::Prohibited forbids windows altogether. On
Windows and Linux the call does nothing — an app has a taskbar entry there only
while a window is shown.
Frameless Windows (Custom Chrome)
Create frameless windows for custom title bars and window chrome using borderless: true:
use rinch::prelude::*;
use rinch_core::element::WindowProps;
#[component]
fn app() -> NodeHandle {
rsx! {
div { class: "custom-titlebar",
"My Custom Title Bar"
}
div { class: "content",
"Window content"
}
}
}
fn main() {
let props = WindowProps {
title: "Frameless".into(),
width: 800,
height: 600,
borderless: true,
..Default::default()
};
App::new(app).window_props(props).run();
}
Custom Title Bar with Window Controls
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::element::WindowProps;
#[component]
fn app() -> NodeHandle {
rsx! {
style {
"
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui;
background: #1e1e1e;
color: white;
}
.titlebar {
height: 32px;
background: #2d2d2d;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
-webkit-app-region: drag;
}
.titlebar-buttons {
display: flex;
gap: 8px;
-webkit-app-region: no-drag;
}
.titlebar-button {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
}
.close { background: #ff5f57; }
.minimize { background: #febc2e; }
.maximize { background: #28c840; }
.content {
padding: 16px;
height: calc(100vh - 32px);
overflow: auto;
}
"
}
div { class: "titlebar",
span { "My App" }
div { class: "titlebar-buttons",
button {
class: "titlebar-button minimize",
onclick: || minimize_current_window()
}
button {
class: "titlebar-button maximize",
onclick: || toggle_maximize_current_window()
}
button {
class: "titlebar-button close",
onclick: || close_current_window()
}
}
}
div { class: "content",
h1 { "Welcome" }
p { "This window has a custom title bar." }
}
}
}
}
Window Control Functions
For custom window chrome, use the window control functions from the prelude:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
// In event handlers:
button { onclick: || minimize_current_window(), "−" }
button { onclick: || toggle_maximize_current_window(), "□" }
button { onclick: || close_current_window(), "×" }
}
These functions work from onclick handlers and affect the window containing the element.
set_click_through_current_window(bool) belongs to the same family; see
Overlay Windows.
Hiding, showing, and where the window comes back
hide_current_window() takes the window off screen and
show_current_window() puts it back. The window comes back where it was
and at the size it had when it was hidden, including a position and size the
user gave it by dragging (issue #1546). On Wayland only the size comes back:
the compositor places windows, and a client cannot learn or choose its
position — except a layer-shell overlay,
which is placed by rinch and comes back where it was. A maximized window comes back maximized, and un-maximizing it
returns it to the last place and size it had before it was maximized. A
minimized window’s position is never recorded (Windows reports it as
(-32000, -32000)), and a position that lies on no connected monitor when the
window is shown (a monitor unplugged while it was hidden) is dropped, so the
system places the window.
The shell does this by recording the window’s outer (frame) position and inner
size at the hide and creating the new window with them, rather than by keeping
an invisible window: Wayland has no invisible toplevel, and on macOS winit’s
set_visible(true) makes the window key, which would activate a
non-activating panel. A non-activating window is still
shown without taking focus, and click-through and skip-taskbar are kept.
The first time a window is shown after a hidden start,
it appears at WindowProps::x/y, as a window created visible does.
Reading and setting the window’s geometry
| Function | Description |
|---|---|
current_window_position() -> Option<(f64, f64)> | Outer top-left in logical pixels; None before the window exists and on Wayland (a layer-shell overlay answers its position on its output) |
current_window_size() -> Option<(f64, f64)> | Inner (content) size in logical pixels; None before the window exists |
set_current_window_position(x, y) | Move the outer top-left (ignored on Wayland, except a layer-shell overlay) |
current_window_surface_kind() -> Option<WindowSurfaceKind> | Toplevel, or LayerShell for a Wayland layer-shell overlay |
set_current_window_output(Option<&str>) | The output a layer-shell overlay is on, by connector name |
start_window_drag() / start_window_resize(edge) | Move or resize the window with the pointer, from a press handler |
set_current_window_size(width, height) | Resize the content area |
They are in the prelude and callable from any thread. The getters keep
answering while the window is hidden, so an app can read them at exit and
persist them; the setters, called while it is hidden, decide where and how big
it comes back — except for a window hidden while maximized, which comes back
maximized, the set geometry becoming the one it returns to when
un-maximized. On Wayland the position setter does nothing and the position
getter always answers None, except for a layer-shell overlay. A setter takes effect on the event loop’s next turn, so a getter
called right after it still answers the old value. The window manager may
adjust a requested size or position.
#![allow(unused)]
fn main() {
// At exit (or in on_close_requested): remember the geometry.
if let (Some((x, y)), Some((w, h))) = (current_window_position(), current_window_size()) {
save_geometry(x, y, w, h);
}
// At startup: pass it back through WindowProps.
let props = WindowProps {
x: saved.map(|g| g.x as i32),
y: saved.map(|g| g.y as i32),
width: saved.map_or(800, |g| g.w as u32),
height: saved.map_or(600, |g| g.h as u32),
..Default::default()
};
}
The position is in logical pixels, like WindowProps::x/y. On a desktop
whose monitors have different scale factors a logical position is ambiguous;
the hide/show round trip itself does not go through it (it keeps desktop
pixels on X11 and Windows and points on macOS), so a window comes back to the
pixel.
Fixed Overlays and Custom Chrome
When you draw your own titlebar (or use BorderlessWindow, or the Linux in-app
menu bar), that chrome is an ordinary in-flow element — so normal content sits
below it automatically, but a position: fixed overlay does not. Fixed elements
resolve against the real viewport, exactly as in a browser, so a top: 0 drawer
or modal renders underneath your titlebar and covers its close button.
BorderlessWindow and the menu bar publish their height as
--rinch-window-top-inset for this. Offset full-height overlays by it:
#![allow(unused)]
fn main() {
div { style: "position: fixed; top: var(--rinch-window-top-inset, 0px); bottom: 0; left: 0; right: 0;" }
}
Give a fixed overlay an inset on both axes. An axis with neither inset
(left and right both auto, or top and bottom) keeps the box where it
stands in the flow on that axis — its static position, as in a browser — so
top: 0; bottom: 0 alone starts wherever the element’s parent put it
horizontally, not at the window’s left edge. (Before #633 such a box went to
the window’s origin on the desktop backend.)
If you hand-roll the titlebar shown above, publish the variable yourself on a wrapper around your content so overlays inside it inherit the value:
#![allow(unused)]
fn main() {
div { style: "--rinch-window-top-inset: 36px;",
// titlebar + content + any fixed overlays
}
}
Drawer, Modal, and top-anchored Notifications already consume it. See
Theming → Window Chrome Inset.
Transparent Windows
For windows with transparency (useful for rounded corners or non-rectangular shapes):
use rinch::prelude::*;
use rinch_core::element::WindowProps;
#[component]
fn app() -> NodeHandle {
rsx! {
style {
"
body { background: transparent; }
.window-content {
background: rgba(30, 30, 30, 0.95);
border-radius: 12px;
margin: 8px;
padding: 16px;
height: calc(100vh - 16px);
}
"
}
div { class: "window-content",
h1 { "Rounded Window" }
}
}
}
fn main() {
let props = WindowProps {
title: "Transparent".into(),
width: 400,
height: 300,
borderless: true,
transparent: true,
resize_inset: Some(8.0), // Enable resize handles
..Default::default()
};
App::new(app).window_props(props).run();
}
Custom Resize Handles
When creating borderless windows, native resize handles are not available. Rinch provides custom resize handle support through the resize_inset property.
The resize_inset value defines the distance (in logical pixels) from the window edges where resize handles are active. This is useful for transparent windows where the visible content is inset from the actual window edges for shadow effects.
How it works:
- Resize handles are active within
resize_insetfrom each edge — the inset is the whole grab zone, with nothing added to it - A corner is simply where two edge zones meet, a
resize_insetxresize_insetsquare; it is not enlarged, so raiseresize_insetif you want bigger diagonal handles - A visible scrollbar thumb takes an edge press (and the hover cursor) off the zone where the two overlap; the empty track past the thumb still resizes, and a corner never yields to the thumb
- The cursor automatically changes to indicate resize direction when hovering edges
- Only works when both
borderless: trueandresizable: trueare set (for such windows the shell defaultsresize_insettoSome(8.0)) - On Windows, transparent areas don’t receive mouse events, so the resize detection only works on visible content
Example with shadow:
#[component]
fn app() -> NodeHandle {
rsx! {
style {
"
body { background: transparent; }
.window {
margin: 12px; /* Same as resize_inset */
background: #1e1e1e;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
height: calc(100vh - 24px);
}
"
}
div { class: "window",
"Your content here"
}
}
}
fn main() {
let props = WindowProps {
borderless: true,
transparent: true,
resize_inset: Some(12.0), // Match CSS margin
..Default::default()
};
App::new(app).window_props(props).run();
}
Observing Window Resizes
There is no separate resize event. The supported way to observe viewport size
changes is a root element’s bounds_signal(), which the runtime refreshes after
every layout pass — including pure window resizes:
#![allow(unused)]
fn main() {
#[component]
fn app() -> NodeHandle {
let root = __scope.create_element("div");
root.set_attribute("style", "width: 100%; height: 100%");
let bounds = root.bounds_signal(); // Signal<ElementBounds>
// bounds.get().width / bounds.get().height track the viewport reactively.
root
}
}
Overlay Windows
An overlay — a status pill, a HUD, an indicator — sits on top of another
application while the user keeps typing into that application. It must not
take the keyboard: not when it is created, not when it is shown, and not when
it is clicked. Three WindowProps fields ask for that (issue #1519):
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::element::WindowProps;
App::new(pill)
.window_props(WindowProps {
width: 360,
height: 44,
borderless: true,
transparent: true,
always_on_top: true,
non_activating: true, // never takes focus or activation
skip_taskbar: true, // no taskbar / Alt+Tab entry
click_through: false, // the pill's buttons take clicks
..Default::default()
})
.prefer_x11(true) // see "Wayland" below
.run();
}
A non-activating window still takes pointer clicks in its DOM, so it can
have buttons; it does not take keystrokes, so a text field in it cannot be
typed into. show_current_window() shows it without activating it.
click_through: true lets pointer events pass to whatever is under the window.
Change it at run time with set_click_through_current_window(bool), from any
thread. A click-through window receives no pointer events at all, so nothing
inside it can turn click-through off again: do it from a hotkey, a tray item, a
timer or another thread. The setting is kept when the window is hidden and
shown again.
| Platform | non_activating | click_through | skip_taskbar |
|---|---|---|---|
| Windows | WS_EX_NOACTIVATE; WM_MOUSEACTIVATE answers MA_NOACTIVATE; first shown with SW_SHOWNOACTIVATE | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW (no taskbar button, no Alt+Tab entry) |
| macOS | a non-activating NSPanel with becomesKeyOnlyIfNeeded, kept on screen while its app is inactive, ordered front at creation without becoming key | ignoresMouseEvents | a window has no Dock entry of its own (the app’s Dock icon and Cmd-Tab entry are the activation policy’s business); the window is kept out of Cmd- cycling and Mission Control (ignoresCycle | stationary`) |
| X11 | WM_HINTS.input = False | an empty input shape (set by rinch over its own connection, XFixes version negotiated) | _NET_WM_STATE_SKIP_TASKBAR and _SKIP_PAGER |
| Wayland | a wlr-layer-shell surface with no keyboard (below); no effect on GNOME | an empty input region | a layer surface has no taskbar entry; alone, no effect |
On Windows and X11 such a window is created hidden, adjusted, and only then shown, so it is never on screen in a state that could take focus. On macOS it is shown at creation (winit orders a window created inactive to the front without making it key) and adjusted right after, before any event is handled: showing a hidden window later would make it key. A window without these fields takes exactly the path it always did.
Level and workspaces
Two more fields place an overlay among other windows and workspaces (issue #1549):
#![allow(unused)]
fn main() {
use rinch_core::element::{WindowLevel, WindowProps};
WindowProps {
level: WindowLevel::StatusBar, // above floating windows, the menu bar and the Dock
visible_on_all_workspaces: true, // every Space / desktop, fullscreen apps' included
non_activating: true,
skip_taskbar: true,
..Default::default()
}
}
level is Normal (the default), Floating, StatusBar or ScreenSaver.
always_on_top: true is Floating; it never lowers a higher level. The
names are macOS’s, the one platform with several levels; elsewhere every
raised level is the same “above” state.
| Platform | Floating | StatusBar | ScreenSaver | visible_on_all_workspaces |
|---|---|---|---|---|
| macOS | NSFloatingWindowLevel (3) | NSStatusWindowLevel (25) | NSScreenSaverWindowLevel (1000) | canJoinAllSpaces | fullScreenAuxiliary collection behavior |
| Windows | HWND_TOPMOST | HWND_TOPMOST | HWND_TOPMOST | no effect (no public API pins a window to every virtual desktop) |
| X11 | _NET_WM_STATE_ABOVE | _NET_WM_STATE_ABOVE | _NET_WM_STATE_ABOVE | _NET_WM_DESKTOP = 0xFFFFFFFF and _NET_WM_STATE_STICKY, set before the map |
| Wayland | a layer-shell window: top layer; a toplevel: no effect | layer-shell: overlay layer | layer-shell: overlay layer | no effect |
On macOS a window shows over another app’s fullscreen window only with
visible_on_all_workspaces (fullScreenAuxiliary lets it into that app’s
Space) and a raised level. Overlays that do this on macOS are usually
non-activating panels, which is what non_activating: true makes; an ordinary
window with the same settings has not been tried on a Mac. Keeping the app out of Cmd-Tab and the Dock is
the activation policy (App::activation_policy(ActivationPolicy::Accessory)),
not a window setting. On X11 a window with visible_on_all_workspaces is
created hidden and shown after the hints are set. All of these come from the
props whenever the window is created, so hiding and showing it again keeps
them.
Window identity on Linux (app_id and WM_CLASS)
On Wayland a window’s app_id is WindowProps::app_id, or the executable’s
file name when that is not set. On X11 an explicit WindowProps::app_id is the
window’s WM_CLASS (instance and class) since #1519; before, X11 never saw it.
Without one, WM_CLASS is unchanged: winit’s default, argv[0]’s file name,
with RESOURCE_NAME as the instance when it is set. The two defaults can
differ (a binary run through a symlink has argv[0] naming the link), so a
.desktop file whose StartupWMClass must match on both should set
WindowProps::app_id.
Wayland layer-shell overlays
A plain xdg-toplevel cannot refuse keyboard focus or a taskbar entry: the
compositor decides (KWin decides whether to activate a window when it is
mapped, and ignores a request to show it inactive). So on native Wayland a
non_activating window is not an xdg-toplevel: it is a wlr-layer-shell
surface (issue #1523) — the kind of surface panels, docks and on-screen
indicators are. It never takes the keyboard (keyboard interactivity none),
has no taskbar entry, and sits in the top layer, above ordinary windows —
or in the overlay layer, above panels and fullscreen windows, when its
level is StatusBar or ScreenSaver.
KWin, wlroots compositors (sway, labwc), Hyprland, niri and COSMIC support it.
WindowProps::wayland_layer_shell: Option<bool> decides: None (the default)
makes a window a layer surface when it is non_activating; Some(true) makes
any window one (a focusable overlay, whose keyboard is then on-demand: it
takes focus when clicked); Some(false) never does. skip_taskbar alone does
not: a decorated tool window that only wants no taskbar entry keeps its
decorations and compositor move. Under App::prefer_x11(true) the app runs on
XWayland and the X11 column applies instead.
This changed what a non_activating window is on KWin and wlroots: before
#1523 it was an ordinary window there (with a warning). A layer surface has no
server decorations, title bar, minimize, maximize or fullscreen, and the
compositor does not move or resize it. rinch does, so the app’s own controls
keep working:
- Position. The surface is anchored to its output’s top-left corner and
placed by its margins:
WindowProps::x/y,set_current_window_positionandcurrent_window_positionare logical pixels relative to its output, not the desktop. With no position it is not anchored and the compositor centres it;current_window_positionanswersNoneuntil it is moved. - Output.
WindowProps::outputnames the output by its connector name ("DP-1","eDP-1", aswlr-randrorkscreen-doctor -olist them);set_current_window_output(Some("HDMI-A-1"))moves it there (the surface is made again, at the same position and size). An unknown name, orNone, leaves the choice to the compositor. - Size.
set_current_window_sizeandWindowProps::width/heightset it. - Move and resize by the user. A
data-drag-windowregion andresize_insetedges work as on any borderless window: rinch follows the pointer until the button is released and moves or resizes the surface itself. From a handler of your own, callstart_window_drag()orstart_window_resize(edge)(withrinch::platform::ResizeDirection) on the press; on an ordinary window they ask the window manager for its interactive move or resize. Called with no button held (from a timer, or after the press already ended) they do nothing, on every window. - Hide and show keep the position and size, as for any window, and showing it takes no focus.
- Its output going away (unplugged, or turned off) makes the compositor
close a layer surface. rinch does not take that for a close: it makes the
window again on an output the compositor picks (the
outputname is dropped untilset_current_window_outputnames one again), at the same position and size, or, with no output left, as soon as one appears.
current_window_surface_kind() answers what the window got:
Some(WindowSurfaceKind::LayerShell), or Some(WindowSurfaceKind::Toplevel)
on a compositor without layer shell — GNOME/Mutter, where the window is
an ordinary xdg-toplevel as before and rinch logs one warning. There, two
ways to live with it:
App::prefer_x11(true)builds the event loop on X11 whenever an X server is reachable (DISPLAYis set), which under a Wayland session means XWayland, where the X11 column above applies. The backend is chosen once per process, so this is an application option, not a window one. It changes the whole app’s backend (fractional scaling and input go through XWayland).- Create the overlay once and keep it mapped. Since activation is decided at map time, create the overlay at startup, keep it on screen (fully transparent while idle) and click-through, and change its content rather than hiding and showing it per use. What remains is a taskbar entry.
The layer role comes from rinch’s winit fork
(WindowAttributesWayland::with_layer_shell); the renderers, input and
scaling are those of any window. examples/wayland-overlay is a bar to try it
with.
Programmatic Window Management
Open and close windows programmatically at runtime using the windows module.
Opening Windows
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::windows::{open_window, WindowHandle};
use rinch_core::element::WindowProps;
#[component]
fn app() -> NodeHandle {
let settings_handle = Signal::new(None::<WindowHandle>);
let handle_clone = settings_handle.clone();
rsx! {
button {
onclick: move || {
let handle = open_window(
WindowProps {
title: "Settings".into(),
width: 400,
height: 300,
..Default::default()
},
"<h1>Settings</h1><p>Configure your app here.</p>".into()
);
handle_clone.set(Some(handle));
},
"Open Settings"
}
}
}
}
Closing Windows
#![allow(unused)]
fn main() {
use rinch::windows::close_window;
// Close a window by its handle
if let Some(handle) = settings_handle.get() {
close_window(handle);
settings_handle.set(None);
}
}
Window Builder Pattern
For more ergonomic window creation, use WindowBuilder:
#![allow(unused)]
fn main() {
use rinch::windows::WindowBuilder;
let handle = WindowBuilder::new()
.title("Settings")
.size(400, 300)
.position(100, 100)
.resizable(false)
.content("<h1>Settings</h1>")
.open();
}
Builder Methods
| Method | Description |
|---|---|
title(impl Into<String>) | Set window title |
size(u32, u32) | Set width and height |
position(i32, i32) | Set initial position |
resizable(bool) | Enable/disable resizing |
borderless(bool) | Remove window decorations |
transparent(bool) | Enable transparency |
always_on_top(bool) | Keep window above others |
content(impl Into<String>) | Set HTML content |
open() | Create the window and return handle |
Complete Example
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::windows::{open_window, close_window, WindowBuilder, WindowHandle};
#[component]
fn app() -> NodeHandle {
let dialogs = Signal::new(Vec::<WindowHandle>::new());
let dialogs_open = dialogs.clone();
let dialogs_close = dialogs.clone();
let dialogs_display = dialogs.clone();
rsx! {
div {
button {
onclick: move || {
let handle = WindowBuilder::new()
.title(format!("Dialog {}", dialogs_open.get().len() + 1))
.size(300, 200)
.content("<p>A new dialog window!</p>")
.open();
dialogs_open.update(|v| v.push(handle));
},
"Open New Dialog"
}
button {
onclick: move || {
if let Some(handle) = dialogs_close.get().last().copied() {
close_window(handle);
dialogs_close.update(|v| { v.pop(); });
}
},
"Close Last Dialog"
}
p { "Open dialogs: " {|| dialogs_display.get().len().to_string()} }
}
}
}
}
Window State Persistence
For the app’s main window, use current_window_position / current_window_size.
Note: the
WindowHandle/WindowStateAPI below belongs to the programmatic window API (open_window,WindowBuilder), which the desktop shell does not process yet: no window is opened from it and no state is recorded for it, soget_window_stateanswersNone.
Getting Window State
#![allow(unused)]
fn main() {
use rinch::windows::{get_window_state, WindowHandle, WindowState};
fn save_window_positions(handle: WindowHandle) {
if let Some(state) = get_window_state(handle) {
// state contains: x, y, width, height, maximized, minimized
println!("Window at ({}, {}), size {}x{}",
state.x, state.y, state.width, state.height);
// Save to config file, database, etc.
save_to_config("window", state);
}
}
}
WindowState Fields
| Field | Type | Description |
|---|---|---|
x | i32 | X position (outer window position) |
y | i32 | Y position (outer window position) |
width | u32 | Content area width |
height | u32 | Content area height |
maximized | bool | Whether window is maximized |
minimized | bool | Whether window is minimized |
Getting All Window States
#![allow(unused)]
fn main() {
use rinch::windows::get_all_window_states;
fn save_all_windows() {
for (handle, state) in get_all_window_states() {
// Save each window's state
save_to_config(&format!("window_{}", handle.id()), state);
}
}
}
Restoring Window State
When creating a window, pass the saved position and size to WindowProps:
#![allow(unused)]
fn main() {
use rinch::windows::WindowBuilder;
fn restore_window(saved: WindowState) -> WindowHandle {
WindowBuilder::new()
.title("Restored Window")
.size(saved.width, saved.height)
.position(saved.x, saved.y)
.content("<p>Window restored!</p>")
.open()
}
}
Rendering Backends
Rinch has two rendering backends. Every desktop build carries the software one; the gpu feature adds the GPU one beside it, and a gpu build chooses between them at run time, when the window opens.
| Build | Renderers it carries |
|---|---|
features = ["desktop"] | Software only |
features = ["desktop", "gpu"] | GPU and software, chosen at run time |
GPU (features = ["gpu"])
Windows are rendered using Vello, a GPU-accelerated 2D graphics library via wgpu. This provides:
- Smooth animations
- High-quality text rendering
- Efficient GPU-accelerated repaints
- Cross-platform consistency (Vulkan, Metal, DX12, WebGPU)
Software (always available on desktop)
Windows are rendered using tiny-skia (CPU rasterizer) and presented via softbuffer. It is the only renderer of a build without the gpu feature, and the fallback of a build with it. This provides:
- No GPU required — works in headless, CI, containers, SSH sessions
- Full rendering fidelity (same visual output as the GPU renderer)
- Dirty region caching — only changed areas are repainted for fast incremental updates
- Subtree pruning — nodes outside the dirty region are skipped during paint
Choosing the renderer at run time
On a gpu build, App::renderer picks which renderer presents the window:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
App::new(app)
.renderer(Renderer::Auto) // the default
.run();
}
Renderer | What it does |
|---|---|
Auto (default) | Presents on the GPU. When the GPU will not start (no adapter, no surface, no device, or a validation error while setting them up), logs a warning and presents with the software renderer instead |
Gpu | Presents on the GPU, and panics when the GPU will not start |
Software | Presents with the software renderer without touching the GPU |
On a build without gpu there is only the software renderer, whatever you choose; asking for Renderer::Gpu there logs a warning.
RINCH_RENDERER overrides the app’s choice, so a user can force a renderer without a rebuild:
RINCH_RENDERER=cpu ./my-app # software
RINCH_RENDERER=gpu ./my-app # GPU, panic if it will not start
It accepts auto, gpu, software, and cpu (an alias of software), trimmed and in any case. An empty value is ignored, and any other value is logged as a warning and ignored, so the app’s own choice applies.
An app that configures the GPU device itself always presents on the GPU. App::gpu_config and App::external_gpu exist so the app can build its own pipelines on rinch’s device (see Game Engine Integration), and that app needs the device gpu_handle() hands it. So for such an app:
- there is no fallback: a GPU that will not start panics at startup, even under
Renderer::Auto; - a
Softwarechoice, whether fromApp::rendereror from a user’sRINCH_RENDERER=cpu, is logged as a warning and ignored.
Everywhere else, gpu_handle() returns None for the whole session while the software renderer presents, so code that reads it should handle None.
Menus
Rinch provides native menu support through the muda library. Menus use a unified builder API (Menu / MenuItem) shared between native window menus and system tray context menus.
The builder API itself needs no windowing at all. Two targets have no native menu bar to attach a menu to — Linux, where muda wants a GTK window, and the browser, where there is no window menu — so both render the same menus out of DOM nodes instead. On the desktop that is automatic; on the web it is rinch_web::mount_with_menu_bar, covered in Running on WASM.
Native Menus
Add a native menu bar with App::menu:
use rinch::prelude::*;
use rinch::menu::{Menu, MenuItem};
#[component]
fn app() -> NodeHandle {
rsx! {
div { "Application content" }
}
}
fn main() {
let file_menu = Menu::new()
.item(MenuItem::new("New").shortcut("Ctrl+N").on_click(|| println!("New!")))
.separator()
.item(MenuItem::new("Quit").on_click(|| std::process::exit(0)));
App::new(app)
.title("My App")
.size(800, 600)
.menu(vec![("File", file_menu)])
.run();
}
For apps without menus, just leave .menu(...) off. .menu() composes with
every other builder method, so a menu bar and custom window props (borderless,
transparent, an icon) go on the same chain — which no run_* signature could
express.
Menu Types
Menu
A container for menu items, separators, and submenus:
#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};
let menu = Menu::new()
.item(MenuItem::new("Open"))
.separator()
.submenu("Recent", Menu::new()
.item(MenuItem::new("file1.txt"))
.item(MenuItem::new("file2.txt"))
);
}
MenuItem
A clickable menu item with optional shortcut and callback:
#![allow(unused)]
fn main() {
use rinch::menu::MenuItem;
let item = MenuItem::new("Save")
.shortcut("Ctrl+S")
.enabled(true)
.on_click(|| println!("Saving..."));
}
| Method | Type | Description |
|---|---|---|
new(label) | impl Into<String> | Create a new item |
shortcut(s) | impl Into<String> | Keyboard shortcut |
enabled(e) | bool | Whether the item is clickable |
on_click(cb) | impl Fn() + 'static | Callback when activated |
checkbox(label, signal) | Signal<bool> | A checkbox item; see below |
radio(label, signal, value) | Signal<T>, T | One option of a radio group; see below |
checked() | Some(state) for a checkbox or radio item, None otherwise |
Checkbox and radio items
A checkbox or radio item is built over a Signal, and the signal is its
state in both directions (issue #1520):
#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};
let sounds = Signal::new(true);
let speed = Signal::new("fast");
let menu = Menu::new()
.item(MenuItem::checkbox("Play sounds", sounds))
.separator()
.item(MenuItem::radio("Fast", speed, "fast"))
.item(MenuItem::radio("Accurate", speed, "accurate"));
// Read it back: sounds.get(), speed.get()
// Set it: sounds.set(false) moves the check mark in every menu built
// from these items.
}
- Activating the item (a click, its shortcut, a tray menu) flips a
checkbox’s signal, or writes a radio option’s value to the group’s signal,
before its
on_clickcallback runs, so the callback reads the new state. Activating the option that is already selected leaves it selected. - Writing the signal from anywhere moves the mark: in the native window
menu bar and a tray menu on Windows and macOS (muda
CheckMenuItems), in a Linux tray menu (ksni), and in the DOM menu bar on Linux and in the browser (arinch-app-menu-entry__checkmark before the label, withrole="menuitemcheckbox"/"menuitemradio"andaria-checked). - Every item built over one signal is one radio group. Keep a group’s options next to each other: the Linux tray draws a run of adjacent options of one group as a single radio group.
- The item follows the same lifetime rule as a callback: built during a render, it belongs to that component, and once the component unmounts (freeing a signal it created) the item stops toggling and its mark stops following.
Menu Callbacks
Callbacks are impl Fn() + 'static — no Send/Sync required. They always run on the main thread, so you can safely capture Signals:
#![allow(unused)]
fn main() {
use rinch::menu::MenuItem;
let count = Signal::new(0);
let item = MenuItem::new("Reset Counter")
.on_click(move || {
count.set(0);
println!("Counter reset!");
});
}
Callbacks fire both when the user clicks the menu item and when the keyboard shortcut is pressed.
Callback lifetime
A callback belongs to the component that created it — the scope that was rendering when you called on_click, which is where the closure captured its Signals. When that component unmounts its signals are freed, so the item stops firing rather than reading freed state (reading a freed signal panics). The callback also runs inside that component, so a Signal it creates belongs there too.
Ownership is per item, not per menu: one Menu may collect items contributed by several components, and each item’s callback stops on its own component’s unmount. This holds however the item is activated — a native menu click, a tray click, the DOM menu bar on Linux or on the web, or the keyboard shortcut.
Build the menu outside any component — from main, before App::run, which is what all the examples do — and there is no owner to record, so the callback lives for the life of the app:
fn main() {
// Created in main(), so menu callbacks can reference them for the whole run.
let count = Signal::new(0);
let file_menu = Menu::new()
.item(MenuItem::new("Reset").on_click(move || count.set(0)));
App::new(app)
.title("My App")
.size(800, 600)
.menu(vec![("File", file_menu)])
.run();
}
A callback may rebuild the menu it was dispatched from — including registering new items and shortcuts — from inside its own handler.
Menu ids are also released when the menu that registered them goes away: building a new native menu bar releases the previous bar’s, and dropping a TrayIcon releases that tray’s after taking its icon off the panel. Keep the TrayIcon for as long as you want the tray. See System Tray for why it has to stay on the thread that built it.
A shortcut consumes the keystroke only when a callback actually runs. A chord belonging to a disabled item, to an item given a shortcut but no on_click, or to a component that has since unmounted falls through to the app instead of being swallowed — and never shadows a live duplicate of the same chord.
A shortcut with no Ctrl, Cmd or Alt leaves its key to a focused text field. While an <input> that takes text, a <textarea>, the rich-text Editor (read-only included) or a custom text target (a register_focus_target entry with on_ime) holds the keyboard, a chord such as "/", "N", "Shift+/", "Space" or "Delete" matches nothing: the field types or edits with the key, and the item fires once focus is elsewhere. The keys that yield are letters, digits, punctuation, Space, and Enter, Backspace, Delete, Home, End, PageUp, PageDown and the arrows. Escape, Tab and F1–F12 do not, so a bare "F5" item still fires from inside a field, and neither does any chord holding Ctrl, Cmd or Alt. A checkbox, radio, range or button <input> is not a text field. In the browser, “a text field” is the element the key event is dispatched at (composedPath()[0], so a field inside a shadow root counts), and an element with contenteditable counts. The rule covers rinch’s own chord matching only: on macOS and Windows the native menu also installs the item’s accelerator, which the operating system matches before rinch sees the key (issue #1284).
Submenus
Create nested menus using Menu::submenu:
#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};
let view_menu = Menu::new()
.item(MenuItem::new("Zoom In").shortcut("Ctrl+="))
.item(MenuItem::new("Zoom Out").shortcut("Ctrl+-"))
.separator()
.submenu("Appearance", Menu::new()
.item(MenuItem::new("Light Theme"))
.item(MenuItem::new("Dark Theme"))
);
}
Keyboard Shortcuts
Shortcuts are specified as strings combining modifiers and a key, separated by +.
Modifiers
| Spelling | macOS | Windows / Linux |
|---|---|---|
Ctrl, Cmd, Control, Meta, CmdOrCtrl, Command, Super, CommandOrControl (also CommandOrCtrl, CmdOrControl) | one modifier: the native menu shows ⌘; the chord fires with Command or Control held | Ctrl; the chord also fires with the Windows/Super key held |
Alt, Option | Option | Alt |
Shift | Shift | Shift |
rinch has no way to bind Control and Command separately on macOS: "Ctrl+K" and "Cmd+K" are the same shortcut.
Modifiers come first and the key last, and a shortcut has exactly one key: "Ctrl+Shift+C+A",
"Ctrl+N+Shift" and an unknown modifier such as "Hyper+N" are not shortcuts (they warn, below).
Supported Keys
Letters: A through Z
Numbers: 0 through 9
Function keys: F1 through F12
Special keys:
Enter,ReturnEscape,EscBackspaceTabSpaceDelete,Del
Navigation:
Home,EndPageUp,PageDownUp,Down,Left,Right(arrow keys)
Symbols:
=,Equal,Plus-,Minus/,Slash·,,Comma·.,Period·;,Semicolon·',Quote[,BracketLeft·],BracketRight·\,Backslash·`,Backquote
A shortcut names a key, not the character it types. A character that needs
Shift is spelled as its key plus Shift: "Ctrl+Shift+/", not "Ctrl+?".
(Chrome 153 reports that keystroke as key: "?", code: "Slash",
shiftKey: true.) The same holds for Plus, which is the = key without
Shift. Punctuation and digit chords are always matched by the physical key and
the modifiers held — the desktop by winit’s KeyCode, the browser by
KeyboardEvent.code — named by where it sits on a US layout; on another
layout such a chord follows the key position, not the printed character.
A letter chord follows the character, not the physical position (issue
#1170). "Ctrl+Z" fires from whichever key is labelled Z, wherever the
active keyboard layout put it — QWERTZ’s Y/Z swap, AZERTY’s A/Q swap — not
necessarily from the US physical KeyZ position. Desktop reads this from
winit’s key_without_modifiers (ignoring Shift and Caps Lock); the browser
reads KeyboardEvent.key, lowercased. When the active layout types no Latin
letter at all for the pressed key — Cyrillic, Thai, Armenian, … — the chord
falls back to the physical key instead, which is the only way such a layout
can reach Ctrl+C/Ctrl+V at all (the same fix GNOME/GTK have discussed for
their own non-Latin-layout bug). Digit and punctuation chords are never
affected by any of this.
An app (or a user) who wants shortcut positions to stay fixed regardless of
the active layout can turn the character matching off:
App::shortcut_matching(ShortcutMatching::Physical) on desktop, or
rinch_web::set_shortcut_matching(ShortcutMatching::Physical) before mounting
on the web — the same escape hatch VS Code’s keyboard.dispatch setting and
JetBrains’ “use national layout for shortcuts” toggle offer. It affects only
rinch’s own chord matching; see the note on macOS/Windows below.
On macOS and Windows, a window menu bar’s item stops going through rinch’s
own chord matching once its native accelerator has actually attached to the
window — the OS then resolves and matches the accelerator muda installs
for it (NSMenu on macOS, an accelerator table on Windows) ahead of rinch
ever seeing the key, so matching it a second time would risk firing the
callback twice. That “actually attached” is a recorded fact, not a guess
from which platform the build targets: a window whose handle was not a
Win32 one, whose accelerator table failed to install, or that never attaches
a native bar at all (a borderless window using the DOM bar instead) keeps
matching through rinch’s own registry, so its shortcuts are never silently
unreachable. ShortcutMatching::Physical has no effect on an item once its
accelerator has attached: the OS, not rinch, decides what counts as “the Z
key” there, following its own per-layout accelerator resolution (character-
based on both platforms, like rinch’s default). Linux has no such OS-level
menu-accelerator integration and never attaches one, so its native bar,
every system-tray item, and the DOM menu bar (the browser, and Linux’s own
in-app bar) all go through rinch’s chord matching — and the override —
exactly as described above. See issue #1284 for a related, unmeasured gap: a
modifier-less chord (chord_yields_to_text_input above) may still be
taken by a macOS/Windows native accelerator from a focused text field,
because that check runs inside rinch’s own matching, which an attached item
skips.
A shortcut string that names no key ("Ctrl+?", a typo) registers no chord and
logs one tracing warning per distinct string. The DOM menu bar (Linux, the
browser) still prints the string beside the item; the native menu on macOS and
Windows derives its accelerator from the same parse as the chord, so it shows
one exactly when a chord is armed.
Examples
#![allow(unused)]
fn main() {
MenuItem::new("New").shortcut("Ctrl+N")
MenuItem::new("Save As").shortcut("Ctrl+Shift+S")
MenuItem::new("Exit").shortcut("Alt+F4")
MenuItem::new("Zoom In").shortcut("Ctrl+=")
MenuItem::new("Find Next").shortcut("F3")
}
Shortcuts work across platforms - Cmd and Ctrl are automatically mapped to the platform-appropriate modifier.
In a browser a few of them are the browser’s own and cannot be claimed — see Running on WASM. It changes nothing about the declaration; the desktop build still gets the chord.
Platform Behavior
macOS
On macOS, the menu appears in the system menu bar at the top of the screen, following Apple’s Human Interface Guidelines.
Windows
On Windows, the menu appears attached to the window’s title bar.
Linux
On Linux there is no native menu bar (muda needs a GTK window; winit uses raw
X11/Wayland), so rinch renders the menu bar itself as ordinary DOM inside your
window, 28px tall, from the same Menu/MenuItem API.
Because it lives in the document, it reserves its space with padding-top on a
wrapper around your content — normal flow content clears it automatically.
A position: fixed element does not: fixed resolves against the real viewport,
so a top: 0 overlay slides underneath the bar. The bar publishes its height as
--rinch-window-top-inset, and full-height overlays should offset by it:
#![allow(unused)]
fn main() {
div { style: "position: fixed; top: var(--rinch-window-top-inset, 0px); bottom: 0; left: 0; right: 0;" }
}
Drawer, Modal, and top-anchored Notifications already handle this. See
Theming → Window Chrome Inset.
The bar behaves like a native one: click a top-level label to open its menu, then move across the bar to switch between menus without clicking again. Hover or click a submenu row to open its flyout, click an item to run it, and click anywhere else in the window — or press Escape — to dismiss.
Escape rides the same dismiss stack every other rinch overlay uses, so it is
LIFO against Modal, Drawer, Popover and an open <select>: the bar
registers at mount, which puts every later overlay above it, and its handler
declines the key outright while no menu is open.
That last one is a full-window overlay rinch renders under the open menu, at
z-index: 199 against the bar’s 201. It is position: fixed, so it covers
the window whichever of the three menu-bar layouts built it — including the
title bar, so clicking there dismisses too.
It was position: absolute between issues #527 and #324. Two z-indexes in
different stacking contexts are never compared; BorderlessWindow’s container
was a stacking context purely because of its overflow: hidden; and a fixed box
was hoisted clear out to the body, past everything between. So the overlay’s
199 and the menu’s 201 were never compared with each other, and the overlay
covered the very menu it sits beneath. Both halves are fixed: overflow no
longer creates a stacking context, and a fixed box now stops at its nearest
ancestor stacking context — so the 199 and the 201 meet in one sequence.
To put an overlay of your own above the menu bar, render it with a z-index
above 201. Since #324 that is the whole rule, in either window type: the
bar’s 201 and your overlay’s number are compared in one sequence whether or
not there is an overflow: hidden container between them. It used not to be —
a root-level overlay behind a borderless window’s container sat above the bar at
any z-index, because the two numbers were never compared at all. Code that
relied on that asymmetry needs a real z-index now.
Context Menus
Rendered Context Menu
Use the ContextMenu component for a styled, theme-aware context menu:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
ContextMenu {
ContextMenuTarget {
div { "Right-click me" }
}
ContextMenuDropdown {
DropdownMenuItem { onclick: || edit(), "Edit" }
DropdownMenuItem { onclick: || duplicate(), "Duplicate" }
DropdownMenuDivider {}
DropdownMenuItem { color: "red", onclick: || delete(), "Delete" }
}
}
}
The ContextMenu component automatically:
- Wires up the
oncontextmenuhandler on the target - Positions the dropdown at the click coordinates using
position: fixed - Shows an invisible overlay for click-outside-to-close
- Reuses
DropdownMenuItemandDropdownMenuDividerfor consistent styling
oncontextmenu Event
The oncontextmenu prop is available on all HTML elements. It fires on right-click and provides mouse coordinates via get_click_context():
#![allow(unused)]
fn main() {
div {
oncontextmenu: move || {
let ctx = get_click_context();
println!("Right-clicked at ({}, {})", ctx.mouse_x, ctx.mouse_y);
},
"Right-click target"
}
}
On Android there is no right button, so a long press stands in for it: a
finger held still for 500ms — ViewConfiguration.getLongPressTimeout(), the
same deadline the platform’s own widgets use — synthesises the same event
through the same dispatch. The press must stay within 8dp to count; moving
further makes it a scroll instead, and lifting before the deadline makes it a
tap. A long press that fires the menu does not also fire onclick, so a
target can safely carry both.
Complete Example
use rinch::prelude::*;
use rinch::menu::{Menu, MenuItem};
#[component]
fn app() -> NodeHandle {
let file_path = Signal::new(None::<String>);
let show_about = Signal::new(false);
rsx! {
div {
h1 { "Application with Menus" }
p {
"Current file: "
{|| file_path.get().unwrap_or_else(|| "Untitled".into())}
}
if show_about.get() {
div {
h2 { "About My App" }
p { "Built with Rinch" }
}
}
}
}
}
fn main() {
let file_path = Signal::new(None::<String>);
let show_about = Signal::new(false);
let file_menu = Menu::new()
.item(MenuItem::new("New").shortcut("Ctrl+N").on_click(move || {
file_path.set(None);
}))
.item(MenuItem::new("Open...").shortcut("Ctrl+O").on_click(move || {
file_path.set(Some("example.txt".into()));
}))
.separator()
.item(MenuItem::new("Save").shortcut("Ctrl+S").on_click(|| println!("Saving...")))
.item(MenuItem::new("Save As...").shortcut("Ctrl+Shift+S"))
.separator()
.item(MenuItem::new("Exit").shortcut("Alt+F4"));
let edit_menu = Menu::new()
.item(MenuItem::new("Undo").shortcut("Ctrl+Z"))
.item(MenuItem::new("Redo").shortcut("Ctrl+Shift+Z"))
.separator()
.item(MenuItem::new("Cut").shortcut("Ctrl+X"))
.item(MenuItem::new("Copy").shortcut("Ctrl+C"))
.item(MenuItem::new("Paste").shortcut("Ctrl+V"))
.separator()
.item(MenuItem::new("Select All").shortcut("Ctrl+A"));
let help_menu = Menu::new()
.item(MenuItem::new("Documentation"))
.item(MenuItem::new("About").on_click(move || {
show_about.update(|v| *v = !*v);
}));
App::new(app)
.title("My App")
.size(800, 600)
.menu(vec![
("File", file_menu),
("Edit", edit_menu),
("Help", help_menu),
])
.run();
}
Platform Features
Rinch provides optional platform integration features that can be enabled via Cargo features.
Image Loading
Images work out of the box for local files. Both <img> elements and background-image: url(...) CSS are supported. Images load asynchronously on background threads and render when decoded.
Local Files (built-in)
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div {
// img element with object-fit
Image { src: "photo.png", width: "200", height: "150", fit: "cover" }
// Avatar with image
Avatar { src: "avatar.png", size: "lg" }
// background-image via CSS
div { style: "width: 300px; height: 200px; background-image: url(photo.png); background-size: cover;" }
}
}
}
}
Supported formats: PNG, JPEG, GIF, WebP.
An <img> is sized as a browser sizes a replaced element: with no CSS size it
is its natural size — display: block included, so margin: 0 auto centres
it rather than it filling its container — and a lone width or height (or a
min-*/max-* clamp) gives the other dimension through the image’s aspect
ratio. A flex container still stretches an <img>, as in a browser; a grid
container also still stretches it, where a browser does not (#1280). An
image not yet loaded has no natural size: with no size from anywhere it is 0x0.
The width and height attributes reserve the box before the image
arrives, as in a browser (#684):
#![allow(unused)]
fn main() {
img { src: "photo.jpg", width: "640", height: "480" }
}
Each is a length in CSS pixels ("640") or a percentage ("50%"), and is a
presentational hint: it has less weight than any declaration in your own
CSS, so a stylesheet rule or an inline style for width/height wins (it
still outranks the built-in browser-default sheet). Together they
also give the image an aspect ratio until it loads, so the common responsive
reset img { width: 100%; height: auto } keeps the right height from the
first frame; once the image has loaded its own ratio is used. That ratio
cannot be switched off: desktop reads no aspect-ratio property (#1286), so
aspect-ratio: auto on such an image changes nothing, where a browser would
drop the mapped ratio. And an image that has not loaded or failed reserves
its attributes’ box even with alt text, where a browser lays the alt text
out instead. <video> reads
the same two attributes with the same ratio, and <iframe> reads them with
no ratio. The other legacy attributes (hspace, vspace, border, align,
<hr width>, a table’s width/bgcolor/…) still do nothing on desktop
(#1419).
Background layers
A background image — a url(...) or a linear-gradient/radial-gradient —
is sized, placed and tiled the way a browser does it, for the first layer
of the list (#468):
background-size: lengths, percentages,auto,cover,contain. An image’sautois its intrinsic size; a gradient’s is the positioning area.background-position(and-x/-y): lengths, percentages, keywords and edge offsets (right 10px top 0). A percentage is a share of the area less the tile, so100%puts the tile’s far edge on the area’s.background-repeat:repeat,no-repeat,repeat-x,repeat-y.spaceandroundare painted asrepeat.background-origin:padding-box(the default),border-box,content-box.background-clipis not read, so the painting area is always the border box.background-coloris painted under the image.
background-position is animatable, by transition and @keyframes alike;
it moves paint only, never layout. This is what makes the striped Progress
bar’s stripes move and the Skeleton pulse.
A single tile that covers the whole box (background-size: cover, or a
gradient at auto) is one draw. Any other layer that repeats (on either axis)
is painted as one repeating pattern, whatever its tile count: its tile is rasterised once, at the size it
covers on the device, and cached until the image, gradient or tile size
changes. A tile that is a fractional number of device pixels (10px at 125%
scaling) is rasterised at the next whole size and sampled back down smoothly;
an axis that does not repeat clips the pattern to its one-tile strip. A build
without the software painter (embed) has nothing to rasterise a tile with and
draws one fill per visible tile instead. No layer is ever dropped for having too
many tiles.
One known difference from browsers (tracked separately as #1151): rinch
places a tile on whole device pixels, rounding a fractional
background-position (and a fractional translate) to the nearest one. Chrome
keeps the fraction and blends the pixel on the tile’s edge. The result is within
one device pixel of Chrome’s, but a background-position animation — the
striped Progress bar — moves in one-device-pixel steps in rinch where Chrome’s
moves smoothly.
An image used to be stretched over the element’s border box whatever its
background-size; one that relied on that now tiles at its natural size.
background-size: 100% 100% keeps the stretch on a box without a border; on a
bordered box add background-origin: border-box, since 100% is otherwise the
padding box, and the tile repeats under the border.
Network Images (optional)
Enable with: features = ["image-network"]
This adds HTTP(S) URL support using ureq. Non-URL paths fall back to local file loading.
#![allow(unused)]
fn main() {
// With image-network feature enabled, URLs work in src:
Image { src: "https://example.com/photo.jpg", width: "200", height: "150" }
Avatar { src: "https://example.com/avatar.png", size: "lg" }
}
How It Works
- When an
<img>element orbackground-image: url(...)is encountered, the source is checked against an in-memory cache - If not cached, loading is dispatched to a background thread via the
ImageLoadertrait - The image bytes are decoded (using the
imagecrate) into RGBA8 pixel data - On the next layout pass, decoded images are picked up from a pending queue and inserted into the cache
- The element is marked dirty for re-layout/re-paint with the image’s intrinsic dimensions
Custom Image Loader
An app that keeps pictures somewhere only it can reach (embedded assets, a blob
store, an authenticated download) answers for a URL scheme of its own. Sources
with that scheme go to the app’s loader; every other source still goes to
rinch’s default loader (files, plus HTTP(S) with image-network), so there is
nothing to forward:
use rinch::image::ImageLoadResult;
use rinch::prelude::*;
fn main() {
App::new(app)
.image_scheme("asset", |src: &str| match load_from_assets(src) {
Ok(bytes) => ImageLoadResult::Loaded(bytes),
Err(e) => ImageLoadResult::Failed(e.to_string()),
})
.run();
}
// Anywhere in the UI:
// img { src: "asset:logo.png" }
rinch::image::register_image_scheme(scheme, loader) is the same registration
without the builder. A loader is any Fn(&str) -> ImageLoadResult + Send + Sync
closure, or a type that implements rinch::image::ImageLoader.
What a loader must know:
- It returns encoded bytes: the contents of a PNG, JPEG, GIF or WebP file. rinch decodes them. It never returns decoded pixels.
- It runs on a background thread spawned for that one load, never the UI thread, so it may block on I/O. Several loads run at once.
- Its answer is cached by source string, a failure included. A source that failed is not asked for again on its own.
- On desktop, a panic is a failed load. It is caught on the load’s thread and
cached as a failure carrying the panic’s message, so
reload_imagecan ask again. In the browser (and on a native build withpanic = "abort") a panicking loader or resolver aborts the whole app. - The
datascheme cannot be registered (register_image_scheme("data", ..)panics). No app loader is ever offered adata:URL; an<img>’s is decoded in place. - A registration lasts until
unregister_image_scheme. It is process-wide and is not undone when the component that made it unmounts.
A picture that arrives later
When the bytes may not exist yet (still downloading, not synced), answer
ImageLoadResult::Failed straight away rather than parking the thread, and tell
rinch when they are there:
#![allow(unused)]
fn main() {
// From any thread, once "asset:photo-42" can be loaded:
rinch::image::reload_image("asset:photo-42");
}
Every <img> and background-image naming that source, in every window, loads
it again and takes its size. The same call refreshes a picture whose bytes
changed under an unchanged source: the old picture stays on screen until the new
one has decoded.
- Pass the source exactly as the element spelled it. The source string is
the key, scheme case included:
reload_image("asset:photo-42")does not reach an element whosesrcisAsset:photo-42. - Reloads of one source are ordered and coalesced. A reload asked for while
a load of that source is still out (its first load or an earlier reload)
starts nothing at once: the answer on its way is dropped when it lands and the
source is loaded once more. So the picture shown is one the loader was asked
for after the last
reload_image, and ten reloads during one slow load cost one more load, not ten. - Do not call
reload_image(src)from inside the loader’s answer forsrc: each call discards the answer being given and asks again.
In the browser
The same two calls work in a rinch-web build. The browser loads an <img>
itself, so there the loader’s bytes are wrapped in an object URL (blob:…) and
that is what the element shows. The loader is called on the browser’s one
thread, so it must answer from what it already holds (Failed for “not yet”,
then reload_image).
The app is never asked from inside a DOM write, so a resolver or loader may
write signals. An element given a source with no remembered answer shows nothing
until a microtask asks for it, once per source, before the next paint; sources
given while a root mounts are asked for before mount_into returns.
reload_image asks at once.
An app that already has a URL to show (it made the object URL itself), or whose
state lives in Rcs a Send + Sync loader cannot capture, uses the
browser-only form instead:
#![allow(unused)]
fn main() {
rinch_web::register_image_url_scheme("asset", move |src| {
store.borrow().object_url(src) // Some(url), or None for "not yet"
});
// later:
rinch::image::reload_image("asset:photo-42");
}
Either way the element keeps the source it was given: it is in
data-rinch-src, NodeHandle::get_attribute("src") answers it, and an editor
document’s image node never holds the resolved URL. While the answer is “not
yet” the element has no src at all. A reload reaches every <img> rinch
was given that source, including one in a branch that is hidden right now, and
never an <img> the page made outside rinch. It asks nothing for a source no
element was given, and forgets its answer. To make rinch forget a URL you
revoked, call reload_image and answer None: pictures already shown stay, and
an element given the source later shows nothing until the next reload. “Not yet”
is remembered per source in the browser as on desktop: only reload_image asks
again, never an element that is given the source again. <img> markup set with
set_inner_html is resolved like any other. Only <img src> is resolved in the
browser; a background-image: url(…) there is the browser’s own.
File Dialogs
Enable with: features = ["file-dialogs"]
Native file dialogs for opening, saving, and folder selection.
Opening Files
#![allow(unused)]
fn main() {
use rinch::dialogs::{open_file, MessageLevel};
// Open a single file with filters
if let Some(path) = open_file()
.set_title("Select an image")
.add_filter("Images", &["png", "jpg", "gif"])
.add_filter("All Files", &["*"])
.set_directory("/home/user/pictures")
.pick_file()
{
println!("Selected: {}", path.display());
}
// Open multiple files
if let Some(paths) = open_file()
.add_filter("Rust Files", &["rs"])
.pick_files()
{
for path in paths {
println!("Selected: {}", path.display());
}
}
}
Saving Files
#![allow(unused)]
fn main() {
use rinch::dialogs::save_file;
if let Some(path) = save_file()
.set_title("Save document")
.set_file_name("untitled.txt")
.add_filter("Text Files", &["txt"])
.set_directory("/home/user/documents")
.save()
{
println!("Save to: {}", path.display());
}
}
Picking Folders
#![allow(unused)]
fn main() {
use rinch::dialogs::pick_folder;
if let Some(path) = pick_folder()
.set_title("Select output folder")
.pick()
{
println!("Folder: {}", path.display());
}
}
Message Dialogs
#![allow(unused)]
fn main() {
use rinch::dialogs::{message, MessageLevel};
// Simple alert
message("File saved successfully!")
.set_title("Success")
.show();
// Warning with OK/Cancel
let proceed = message("This will overwrite existing files.")
.set_title("Warning")
.set_level(MessageLevel::Warning)
.confirm();
if proceed {
// User clicked OK
}
// Yes/No question
let delete = message("Are you sure you want to delete this file?")
.set_title("Confirm Delete")
.set_level(MessageLevel::Warning)
.ask();
if delete {
// User clicked Yes
}
}
Clipboard
Enable with: features = ["clipboard"]
Cross-platform clipboard support for text and images.
Text Operations
#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_text, paste_text, has_text, clear};
// Copy text to clipboard
copy_text("Hello, clipboard!").unwrap();
// Check if clipboard has text
if has_text() {
// Paste text from clipboard
match paste_text() {
Ok(text) => println!("Clipboard: {}", text),
Err(e) => println!("Failed to paste: {}", e),
}
}
// Clear the clipboard
clear().unwrap();
}
Image Operations
#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_image, paste_image, has_image, ImageData};
// Copy an image (RGBA format)
let image = ImageData::new(
100, // width
100, // height
vec![255; 100 * 100 * 4], // RGBA data (white image)
);
copy_image(image).unwrap();
// Check and paste image
if has_image() {
let image = paste_image().unwrap();
println!("Image size: {}x{}", image.width, image.height);
println!("Bytes: {}", image.bytes.len());
}
}
Using with Hooks
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::clipboard::{copy_text, paste_text};
#[component]
fn app() -> NodeHandle {
let text = Signal::new(String::new());
let text_copy = text.clone();
let text_paste = text.clone();
rsx! {
div {
input {
value: {|| text.get()},
oninput: move |e| text.set(e.value())
}
button {
onclick: move || {
let _ = copy_text(text_copy.get());
},
"Copy"
}
button {
onclick: move || {
if let Ok(pasted) = paste_text() {
text_paste.set(pasted);
}
},
"Paste"
}
}
}
}
}
Reading without freezing the UI
A clipboard read is a request to whichever application owns the clipboard. On
X11 that application can be hung, and the read waits up to four seconds for
it; the browser cannot be read synchronously at all. So paste_text() called
straight from an event handler — as in the example above — can block the UI
thread: no repaint, no input, and on some window managers a “not responding”
badge (issue #149).
Every read therefore comes in three shapes, on every platform:
| Function | Blocks the caller? | Use for |
|---|---|---|
paste_text() | yes, indefinitely | scripts, background threads, existing code |
paste_text_timeout(Duration) | yes, bounded — Err(TimedOut) after that | an interactive path that can accept a bounded hiccup |
paste_text_async(callback) | no | an interactive path that must stay responsive |
paste_html / paste_image have the same three. paste_rich resolves
text/html → bitmap → text/plain in one read and answers with a
RichPaste, so a rich-paste consumer never stacks three worst-case waits.
paste_rich_with_text (and its _timeout / _async) answers the same plus the
text/plain offered beside an html answer, (RichPaste, Option<String>), still in
one read — it is what the built-in editor’s Ctrl+V uses, so the editor’s paste hook
(Plugin::handle_paste) can tell a pasted URL by its text even when the copy also
carried html.
On native, all of them are served by a single clipboard worker thread that owns the system clipboard. That is what makes the timeout worth having: giving up does not cancel the request, it only stops waiting for it, so an abandoned read finishes on the worker instead of wedging every later caller behind a lock.
Which thread does the callback run on? Not necessarily the UI thread — on
native it is the clipboard worker, which is why the callback must be Send.
rinch UI state is thread-local, so hop back before touching it:
#![allow(unused)]
fn main() {
use rinch::clipboard::{paste_text_async, ClipboardResult};
use rinch::prelude::*;
let text = Signal::new(String::new());
button {
onclick: move || {
// `Signal::send` marshals to the UI thread from anywhere.
paste_text_async(move |result| {
if let Ok(pasted) = result {
text.send(pasted);
}
});
},
"Paste"
}
}
For a !Send continuation (an EditorHandle, an Rc), park it on the UI thread
and send only its id across:
#![allow(unused)]
fn main() {
let id = rinch_core::park_main_callback::<ClipboardResult<String>>(move |result| {
// Runs on the UI thread; free to touch the DOM and any Rc-based handle.
});
paste_text_async(move |result| {
rinch_core::run_on_main_thread(move || rinch_core::resume_main_callback(id, result));
});
}
The built-in fields already work this way. Ctrl+V — and the context menu’s or
Android toolbar’s Paste — in the rich-text editor and in a plain <input> or
<textarea> returns at once, reads on the worker, and inserts when the read
answers (issue #149 for the editor, #328 for the plain fields). A plain field
takes the paste at its current selection if it still holds the keyboard
from the same focus gesture; if focus moved on while the read was in flight,
the paste is dropped rather than aimed at a field the user did not paste into.
A read-only field starts no read at all.
Web: reaching content copied outside the app
The browser has no synchronous system-clipboard read, so on wasm32
paste_text() answers from an internal buffer. rinch-web fills that buffer from
the document’s paste ClipboardEvent — the only synchronous channel to content
copied in another app or tab — so a web app can paste from outside itself
(issue #150). paste_text_async additionally tries
navigator.clipboard.readText(), which needs a secure context and usually a user
gesture, and falls back to the buffer.
Because the browser’s paste event arrives after the keydown that caused it,
app paste logic on the web should hang off the paste rather than off Ctrl+V:
#![allow(unused)]
fn main() {
use rinch_core::{set_paste_interceptor, PasteEventData};
set_paste_interceptor(|data: &PasteEventData| {
// The clipboard buffers are already filled when this runs, so
// `rinch::clipboard::paste_text()` works here too.
if let Some(text) = &data.text {
insert_into_my_editor(text);
return true; // handled: the browser should not also insert it
}
false
});
}
The interceptor is a single slot per document — plus a thread-global
fallback for registrations made outside any dispatch, which is where every
registration on rinch-web lands (like set_keyboard_interceptor; issues #340,
#478) — and is dispatched by rinch-web; desktop reads the clipboard directly
when Ctrl+V arrives and has no OS paste event to hang it off.
Registering it from inside a render ties it to that component: unmounting
releases the slot, so an interceptor that captured a Signal can never outlive
it (issue #183). Registering from main or startup code has no owner and keeps
app lifetime. See
Lifetimes.
Android: the text-selection toolbar and paste
A long press on an <input> or <textarea> shows Android’s own floating
text-selection toolbar — Cut / Copy / Paste / Select all, the platform’s
strings, the platform’s look — floating over the selected word, or over the
caret when nothing is selected: the rect the runtime’s TextEditState.anchor
reports (issue #813). It is an
ActionMode.TYPE_FLOATING started by RinchActivity on the window’s decor
view, and it is what the desktop’s built-in DOM context menu becomes on
Android: the shell sets TextContextMenuPresentation::Shell, so the runtime
prepares the caret and selection and hands the presentation to the platform.
A data-oncontextmenu handler on the field or an ancestor still wins, as on
desktop.
The long press follows the platform convention: it selects the word under the
finger, a press inside an existing selection keeps that selection, and an
empty field (or a press on whitespace) keeps its caret and offers only Paste.
Cut and Copy appear only over a selection, Paste is hidden on a readonly
field and while the clipboard is empty (hasPrimaryClip(), asked once per
long press, which reads no clip and raises no clipboard-access notice), and
Select all needs content.
The clipboard itself is read only when Paste is tapped. Each item runs
exactly the code its keyboard shortcut runs
(RinchApp::perform_text_edit), so a toolbar Paste, a hardware Ctrl+V and an
IME’s paste are one path.
The android feature implies clipboard. Paste is not optional on a
phone, and without the feature handle_paste has nothing to read: a
hardware Ctrl+V inserts nothing and an IME’s paste request is answered “done”
with nothing pasted (the toolbar hides its Paste instead). Every route
measured on an API 34 emulator with one Gboard build lands: the toolbar’s
Paste; the clipboard chip Gboard puts on its suggestion strip after a copy;
an item in Gboard’s clipboard panel; the Paste key of Gboard’s Text Editing
panel; and Ctrl+V from a hardware keyboard. That Gboard build delivers its
chip and clipboard-panel pastes as ordinary committed text
(InputConnection.commitText), and its Text Editing panel’s Paste as
performContextMenuAction(android.R.id.paste) — which BaseInputConnection
would otherwise drop, since rinch’s connection keeps no Editable, and which
reaches the same path through RinchInputConnection’s override.
The toolbar goes when the user taps or scrolls elsewhere, types, edits from
the keyboard, cuts, copies or pastes — from the toolbar or through the IME’s
own editing keys, as over a platform text view — or when the field loses
focus; the activity also finishes it on its own in onPause and on
window-focus loss. An item that finishes the toolbar (Cut, Copy, Paste) is
finished on the Java side before the item is reported, so a refresh the
shell decides after hearing of the item never re-prepares it; and a refresh
never starts a toolbar — it only moves and re-prepares one that is up — so a
refresh already on its way to the UI thread when the item finished the mode
finds nothing to update and does nothing, where it used to start a new
toolbar that nothing could take down. An IME’s Cut, Copy or Paste finishes
nothing on the Java side, and the shell asks for the finish itself.
(Gboard’s Text Editing panel enables its Cut and Copy only over a selection
the IME can see, and rinch’s input connection reports none, so from that
panel only Paste and Select all reach a rinch field — measured.) The
rich-text Editor is not part of an Android build today (it is
desktop-only), so the toolbar covers <input> and <textarea>.
System Tray
Enable with: features = ["system-tray"]
System tray icon with menu support. Uses the same unified Menu/MenuItem types as native window menus.
Basic Tray Icon
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
// Create a tray menu using the unified Menu API
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Settings"))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
// Create the tray icon
let tray = TrayIconBuilder::new()
.with_tooltip("My Application")
.with_menu(menu)
.build()
.unwrap();
}
Tray Icon with Image
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
// From PNG data (e.g., include_bytes!)
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.build()?;
// From file path
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_path("assets/icon.png")?
.build()?;
// From RGBA data (32x32 icon)
let rgba = vec![255u8; 32 * 32 * 4]; // White icon
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_rgba(rgba, 32, 32)?
.build()?;
}
Menu Callbacks
Callbacks are impl Fn() + 'static — no Send/Sync required. They run on the main thread via push-based event delivery (no polling):
#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use rinch::prelude::*;
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
let tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.with_menu(menu)
.build()?;
}
Changing the Tray at Runtime
Every part of a built tray can change while it runs (issue #1520). Each setter
takes &self, so a tray kept in an Rc or a thread_local! can be changed
from a menu callback, a timer or an effect:
#![allow(unused)]
fn main() {
use rinch::tray::{TrayIconBuilder, TrayIconImage};
// Decode each image once; swapping is then cheap.
let idle = TrayIconImage::from_png(include_bytes!("idle.png"))?;
let busy = TrayIconImage::from_png(include_bytes!("busy.png"))?;
let tray = TrayIconBuilder::new().with_icon(idle.clone()).build()?;
tray.set_icon(&busy)?; // also set_icon_png / set_icon_rgba
tray.set_tooltip("Working… 43%")?;
tray.set_menu(Some(new_menu)); // or None to remove the menu
}
None of them blocks the calling thread. On Windows and macOS tray-icon
applies a change on the spot. On Linux each change goes to a thread of rinch’s
own, which applies it to the ksni service: a tray host that has stopped
answering holds that thread, never yours. Changes made while it is held are
coalesced — the newest icon, tooltip and menu win, and only the latest state of
each check mark is kept — so an app swapping icons several times a second
queues neither work nor memory behind a hung host. (Measured: 200 icon and
tooltip swaps on a KDE tray return in about 1 ms, and the item’s tooltip then
reads the last one.)
On Linux the item’s Title, which Plasma sorts the tray by, is a stable name:
TrayIconBuilder::with_title(..), else the tray’s id. set_tooltip changes
only the ToolTip, so the icon does not move when the text does. Windows and
macOS have no title property and ignore with_title.
set_menu releases the previous menu’s callbacks at once, like a rebuilt menu
bar; it is safe to call from one of the old menu’s own callbacks (“Start”
replacing itself with “Stop”). To move a check mark, write the item’s signal
instead: checkbox and radio items (Menus)
follow their signals in a tray menu too.
Item Id
On Linux the tray item reports an id to the panel (the StatusNotifierItem
Id), which panels use to remember per-item settings such as whether the icon
is shown and where. It is the app’s own (issue #1529; it was "rinch-app" for
every rinch app): the executable’s file name, or the WindowProps::app_id the
app declared if the tray is built after App::run has started. Set it
explicitly with with_id, which an app that declares an app_id and builds its
tray in main should do with the same string. Rebuilding the tray after
App::run has started can change its id (the declared app_id is seen from
then on), so pass with_id to keep it fixed. Upgrading to this changes an
existing app’s id, so a panel forgets any visibility or position the user had
set for its icon, once.
#![allow(unused)]
fn main() {
let tray = TrayIconBuilder::new()
.with_id("com.example.notes")
.with_icon(icon)
.build()?;
}
Windows and macOS have no such id (they identify the icon by the app), so
with_id is not used there.
Left Click (Activate)
By default a primary click on the icon shows the window. on_activate replaces
that:
#![allow(unused)]
fn main() {
let listening = Signal::new(false);
let tray = TrayIconBuilder::new()
.with_icon(idle)
.on_activate(move || listening.update(|l| *l = !*l))
.build()?;
}
On Linux this is the StatusNotifierItem Activate request (which click sends
it, and whether a panel opens the menu instead, is the panel’s choice); on
Windows and macOS it is a left-button release on the icon. The callback runs on
the main thread and follows a menu callback’s lifetime rule. Pass || {} for a
click that does nothing.
Tray-Only Apps
An app whose whole UI is the tray starts with WindowProps { visible: false, .. }
and shows its one window (“Settings…”) with show_current_window(); on macOS
it also sets App::activation_policy(ActivationPolicy::Accessory) so it has no
Dock icon. See
Windows and
examples/tray-only, which swaps icons on a timer, changes its tooltip, has a
checkbox and a radio group, and opens a settings window from the menu.
Nested Submenus
#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};
let submenu = Menu::new()
.item(MenuItem::new("Option 1"))
.item(MenuItem::new("Option 2"))
.item(MenuItem::new("Option 3"));
let menu = Menu::new()
.item(MenuItem::new("Main Action"))
.submenu("More Options", submenu)
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
}
Lifetime and Threads
On Linux, build() waits for the tray host to register the icon, which on a healthy desktop takes a few milliseconds. If the host holds its D-Bus name but never answers (a frozen kded or plasmashell), build() gives up after five seconds and returns TrayError::CreateFailed rather than hanging your app’s startup; treat that error as “no tray this session”. Do not retry in a tight loop: the timed-out registration cannot be cancelled, so each failed attempt under a still-hung host leaves a background thread and a D-Bus connection waiting on it until the host answers or goes away.
The tray lives exactly as long as the TrayIcon that build() returned. Dropping it removes the icon from the panel and then releases its menu callbacks, so an icon that can still be on screen keeps working items. On Linux the drop shuts ksni’s D-Bus service down and waits up to one second for it to close its connection, which on a healthy desktop takes a few milliseconds. If the tray host has stopped answering, the drop returns after that second anyway. The callbacks then stay registered until the service has closed, and are released by the next tray build or drop on the same thread. Keep the handle for as long as you want the tray, for example as a local in main that lives until the event loop exits: let _tray = … keeps it, while a bare let _ = … drops it at once.
A TrayIcon is neither Send nor Sync, on every platform. Keep it on the thread that built it. A static OnceLock<TrayIcon>, an Arc<Mutex<TrayIcon>>, or moving it into std::thread::spawn does not compile; a thread_local! works. This is deliberate. Menu callbacks capture Signals (which are !Send) and always run on the main thread, so they live in a thread-local registry, and dropping the tray releases them from the registry of the thread it is dropped on. Dropped on another thread, it would release nothing. On Linux this has been the case since the tray started releasing its callbacks on drop (issue #183); before that the Linux handle was Send.
A disabled item (MenuItem::new("…").enabled(false)) is shown greyed out and registers no callback. Its on_click never runs.
Minimize-to-Tray Pattern
Combine system tray with on_close_requested to hide instead of quit:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use std::sync::Arc;
// Set up tray icon
let menu = Menu::new()
.item(MenuItem::new("Show Window").on_click(show_current_window))
.separator()
.item(MenuItem::new("Quit").on_click(close_current_window));
let _tray = TrayIconBuilder::new()
.with_tooltip("My App")
.with_icon_png(include_bytes!("../assets/icon.png"))?
.with_menu(menu)
.build()?;
// Hide to tray on close instead of quitting
let window_props = WindowProps {
on_close_requested: Some(Arc::new(|| {
hide_current_window();
false // Don't exit
})),
..Default::default()
};
}
Notifications
Enable with: features = ["notifications"] (off by default; it needs no windowing feature).
rinch::notification shows an operating-system notification: a freedesktop notification on Linux (KDE, GNOME, dunst, mako), a toast on Windows, a UserNotifications banner on macOS.
#![allow(unused)]
fn main() {
use rinch::notification::{Notification, NotificationError};
use rinch::prelude::*;
let shown = Notification::new("Hotkey unavailable")
.body("Ctrl+Alt+Space is taken by another app.")
.icon_png(include_bytes!("icon.png")) // optional
.on_click(|| show_current_window()) // optional
.on_dismiss(|why| tracing::info!("gone: {why:?}"))
.on_error(|e: &NotificationError| tracing::warn!("{e}"))
.show(); // Result<NotificationHandle, NotificationError>
}
examples/tray-only’s “Test notification” item raises one; clicking it opens the settings window.
Threads and callbacks
show() makes no IPC call on the calling thread. The D-Bus calls (Linux) and the WinRT calls (Windows) run on a worker thread started by the first show(), and macOS’s calls answer through completion handlers. The only work done in place is writing an icon_png image to a file, once per distinct image, in a directory only the user can write ($XDG_RUNTIME_DIR on Linux, else the user’s cache directory; the per-user temporary directory on macOS and Windows). Call show() on the main thread: the callbacks are !Send and stay there.
on_click, on_dismiss and on_error run on the main thread, each in one batch, like a menu callback. They follow the same lifetime rule: a callback set while a component renders belongs to that component, and does not run once it has unmounted; one set outside any render (from main, a timer) keeps app lifetime.
A click ends the notification: on_dismiss does not run after on_click. on_dismiss gets a DismissReason: Expired, Dismissed (by the user), Closed (by NotificationHandle::close) or Unknown.
Errors
show() returns Err for what is known at once: Unsupported on a platform with no backend (Android; the feature does not build for the web, see below) and on an unbundled macOS executable. What only the platform can answer arrives later through on_error, on the main thread:
NoService: no D-Bus session bus (or it went away), or noorg.freedesktop.Notificationsserver on it.PermissionDenied: the user refused the app notifications (macOS).Platform: anything else (Windows would not show the toast, D-Bus failed).
A notification with no on_error logs the error with tracing::warn!.
App identity
The desktop files notifications under the app that sent them. rinch uses the identity the window has: WindowProps::app_id if the window sets one, else the executable’s file name (without .exe on Windows), the same derivation as the Wayland app_id. rinch::notification::set_app_id overrides it and set_app_name sets the name shown as the source (default: the app id).
- Linux: the id goes in the
desktop-entryhint, so the server can take the app’s name and icon from<app id>.desktopif it is installed. rinch writes no file for notifications. The image is passed as theimage-pathhint. A click needs a server with actions (KDE and GNOME have them). If the notification server restarts (plasmashell, dunst), the notifications it was showing are gone: each getson_dismiss(DismissReason::Unknown), and a signal from the new server that reuses an old id never reaches them. - Windows: the id is the toast’s AppUserModelID (AUMID). Windows shows toasts from an unpackaged app only for an AUMID it knows, either from a Start-menu shortcut that carries it or from a registry key. On the first
show()rinch writesHKCU\Software\Classes\AppUserModelId\<AUMID>with aDisplayName(theset_app_namename), unless that key already exists. No shortcut is needed or created. Callset_register_app(false)if your installer registers the AUMID itself. An AUMID may not contain spaces, so an executable namedMy App.exeneedsset_app_id("com.example.MyApp"); an explicit id also keeps two unpackaged apps with the same file name apart. A click is reported while the toast is up; once it has timed out into the Action Center (on_dismiss(Expired)has run), a click there is not reported. A toast clicked in the Action Center after the app has exited does nothing: relaunching on click needs a COM activator, which rinch does not register. - macOS: the identity is the bundle identifier. The UserNotifications framework works only inside an
.appbundle; run unbundled (cargo run),show()returnsUnsupported. The firstshow()asks the user for permission. macOS does not report a dismissal, soon_dismissruns there only forNotificationHandle::close. The image is attached to the notification; the app icon is the bundle’s. rinch installs itsUNUserNotificationCenterdelegate on the firstshow(): a click that launches the app is not reported, and an app or crate that sets its own delegate replaces rinch’s (one delegate per process).
Not covered
- The web: the feature brings in tokio’s multi-threaded runtime (the workspace’s tokio), which does not build for
wasm32. A web app can use the browser’sNotificationAPI throughweb-sys. - Android.
- Buttons other than the click itself, replies, sounds, urgency and timeouts.
Global Hotkeys
Enable with features = ["global-hotkeys"] (off by default). rinch::hotkey registers system-wide hotkeys: they fire while another application has the keyboard focus, and they report press and release, so a hold-to-talk binding works.
#![allow(unused)]
fn main() {
use rinch::hotkey::{self, HotkeySpec, HotkeyState};
let listening = Signal::new(false);
let hk = hotkey::register(
HotkeySpec::new("Ctrl+Alt+Space").id("listen").description("Start listening"),
move |ev| listening.set(ev.state == HotkeyState::Pressed),
)?;
hk.rebind("RightCtrl")?; // hold Right Ctrl alone
drop(hk); // unregisters
}
- Callbacks run on the main thread, each in its own
batch, so they can set signals directly. Register on the main thread; aHotkeyis neitherSendnorSync. - Lifetime. A hotkey registered while a component renders belongs to that component: it is unregistered when the component unmounts and its callback does not run after that. One registered outside any render (in
main) lives until itsHotkeyis dropped. Dropping the handle always unregisters. This is the menu-callback rule. - Conflicts are errors.
registerandrebindreturnHotkeyError::Conflictwhen another application holds a plain chord (on the platforms that can tell, see below) or when this app already registered it. A failedrebindkeeps the old binding. Rebinding while the hotkey is held delivers itsReleasedfirst. - Auto-repeat does not press a hotkey again: a held hotkey is one
Pressedand oneReleased, on every backend. - A report the backend made for a binding that has since been rebound is dropped, so a rebind never leaves the hotkey pressed.
Shortcut strings
A shortcut names physical keys, as a menu shortcut does: "Ctrl+/" is the key labelled / on a US layout, and a shifted character such as ? is an error that tells you to write Shift+/. Every key a menu shortcut accepts works here. On top of those:
- Sided modifiers:
LeftCtrl,RightCtrl(alsoRCtrl,ControlRight),RightAlt(AltGr),LeftShift,RightSuper. A plainCtrlmatches either key. - Modifier-only bindings: a shortcut whose last key is a modifier.
"RightCtrl"fires when Right Ctrl is pressed with no other key held and releases when it comes up."Ctrl+Shift"fires when Shift goes down while Ctrl is held, and releases when either comes up. Pressing Right Ctrl and then C (copy) does fire"RightCtrl": the press is reported the moment Right Ctrl goes down, because a hold-to-talk app cannot wait to find out whether a chord follows. ItsReleasedthen carrieschorded: true(another key went down during the hold). Only a key that was already held when the modifier went down stops the press. Caps Lock, Num Lock and Scroll Lock never count as another key.
A toggle app (press once to start, once to stop) should act on the release and ignore a chorded one, so that Right Ctrl+C copies text instead of toggling:
#![allow(unused)]
fn main() {
hotkey::register("RightCtrl", move |ev| {
if ev.state == HotkeyState::Released && !ev.chorded {
listening.update(|l| *l = !*l);
}
})?;
}
A hold-to-talk app keeps acting on Pressed and Released, and can drop what it recorded when the release is chorded. chorded is always false on the Wayland portal and for macOS plain chords, which do not see the other keys.
Super(alsoWin,Cmd,Meta) is its own modifier, separate from Ctrl.CmdOrCtrlis Cmd on macOS and Ctrl elsewhere.- Any W3C key
codename:F13…F24,Insert,Pause,PrintScreen,Numpad1.
Matching is exact on modifiers: Ctrl+Space does not fire for Ctrl+Shift+Space. Shortcut::parse checks a string without registering it, and its Display is the canonical spelling.
Letting the user choose: HotkeyCapture
HotkeyCapture turns the keys pressed in your own window into a shortcut. Feed it every key event while a settings field is waiting, for example from a keyboard interceptor installed for the duration:
#![allow(unused)]
fn main() {
use rinch::core::{set_keyboard_interceptor, clear_keyboard_interceptor};
use rinch::hotkey::{CaptureStep, HotkeyCapture};
let capture = RefCell::new(HotkeyCapture::new());
set_keyboard_interceptor(move |k| {
match capture.borrow_mut().feed(k) {
CaptureStep::Pending(held) => preview.set(held), // "Ctrl+Alt+"
CaptureStep::Done(sc) => { let _ = hk.rebind(&sc.to_string()); clear_keyboard_interceptor(); }
CaptureStep::Cancelled => clear_keyboard_interceptor(), // Escape alone
}
true
});
}
A non-modifier key completes a chord, captured with unsided modifiers (Ctrl+Alt+Space). Modifiers pressed and released with nothing else are captured as a modifier-only binding, with sides (RightCtrl). The capture reads only your app’s own key events, so it needs no permission. On the desktop it sees only the keys rinch’s desktop shell names (letters, digits, F1–F12, the editing and arrow keys, =, -, Ctrl/Shift/Alt). Other keys, Super included, are ignored there; write those shortcuts by hand.
Platforms
hotkey::backend() says which mechanism the process uses.
| Platform | Plain chord (Ctrl+Alt+Space) | Modifier-only or sided (RightCtrl, LeftShift+Space) |
|---|---|---|
| Windows | RegisterHotKey reserves it: a conflict is an error, and Windows swallows the chord (the focused app does not get it). A WH_KEYBOARD_LL hook reports press and release. | The hook alone. Not swallowed (Right Ctrl still reaches the focused app), and no conflict check. |
| macOS | Carbon RegisterEventHotKey: press and release, conflicts reported, swallowed, no permission needed. | A listen-only CGEventTap, which needs the Input Monitoring permission. Not swallowed, no conflict check. |
| Linux, X11 | XGrabKey reserves it: a conflict is an error, swallowed. XInput2 raw key events report press and release. | XInput2 raw key events alone. Not swallowed, no conflict check. |
| Linux, Wayland | The org.freedesktop.portal.GlobalShortcuts portal (KDE Plasma, recent GNOME). | The same portal. |
Windows. A low-level hook sees no input while an elevated (administrator) window has the focus. If a release is missed that way, a 100 ms check of the keys the hook believes are down releases the binding, so a hold never sticks.
macOS. The event tap starts only when the first modifier-only or sided binding is registered, so an app that uses only chords never asks for a permission. Without Input Monitoring that registration returns HotkeyError::PermissionMissing, and hotkey::permission() answers Permission::Missing. hotkey::request_permission() shows the system prompt. After the user grants it, register the binding again; some macOS versions apply the grant only after the app restarts.
Wayland. A Wayland compositor gives no application another application’s keyboard, so the portal is the only route, and the user decides the binding. The shortcut you pass is the preferred trigger; the desktop shows its own dialog, and remembers the user’s choice per application and id. That is why register answers Binding::Pending at once instead of waiting for the dialog, and why Hotkey::binding() later becomes Binding::SystemChosen { description }, with the desktop’s own text for the trigger, or Binding::Declined if the user dismissed the dialog. Hear the outcome with Hotkey::on_binding_change. Give every hotkey a stable id, or the desktop treats a rebound one as a new shortcut. On the portal, changing the binding is the desktop’s job: the desktop keeps the user’s choice for an id, so rebind’s new shortcut is only a preference, and it is used only for an id the desktop has no choice for yet. After a rebind, a portal of version 2 or later opens its own dialog (ConfigureShortcuts) so the user can change it there. All hotkeys share one portal session; registrations made in one turn are bound together, with one dialog. A modifier-only shortcut has no portal spelling, so it is registered with no preferred trigger and the user picks one. HotkeySpec::system_chosen(id) asks for a binding with no preference at all.
The portal files shortcuts under an app id, and it accepts only an app id that has an installed desktop entry (KDE Plasma refuses an app without one). rinch names the app with the id its window carries (WindowProps::app_id, else the executable’s name; an id without dots is accepted, checked against KDE’s registry); hotkey::set_app_id overrides that, for example when hotkeys are registered before the window exists. When no entry is installed, rinch writes one: a minimal <app id>.desktop with NoDisplay=true (so it shows in no menu) in $XDG_DATA_HOME/applications (~/.local/share/applications). It does so only when the portal refused the id for want of an entry, never when a system directory or the user’s directory already has one, and it never replaces a file. An app that installs its own entry, or must not write to the user’s home directory, calls hotkey::set_write_desktop_entry(false) before registering; without an entry, registration then fails with HotkeyError::Unsupported, and the message names the file the desktop needs.
In an X11 session (no WAYLAND_DISPLAY) rinch uses X11. RINCH_HOTKEY_BACKEND=x11 or =portal overrides the choice; X11 in a Wayland session sees only keys typed into XWayland windows.
The feature adds no crate to Cargo.lock. On Windows and macOS it adds tokio to the build (two crates; only the Linux portal uses it). On Linux it builds the D-Bus stack (zbus) and tokio, which the portal thread runs in; with file-dialogs already on, that is two more crates (tokio and its macros).
Enabling Features
Add features to your Cargo.toml:
[dependencies]
rinch = { version = "0.1", features = ["file-dialogs", "clipboard", "system-tray", "notifications"] }
Platform Support
| Feature | Windows | macOS | Linux | Android |
|---|---|---|---|---|
| File Dialogs | ✓ | ✓ | ✓ | SAF (rinch_android::file_picker) |
| Clipboard (Text) | ✓ | ✓ | ✓ | ✓ |
| Clipboard (Image) | ✓ | ✓ | ✓* | – |
| Text-selection toolbar | DOM menu | DOM menu | DOM menu | native (ActionMode) |
| System Tray | ✓ | ✓ | ✓** | – |
| Notifications | toast | ✓ (in an .app bundle) | ✓ (freedesktop server) | – |
| Global hotkeys | ✓ | ✓ | ✓ (X11, Wayland portal) | – |
* Linux image clipboard requires X11 or Wayland clipboard support.
** Linux system tray requires a system tray implementation (e.g., libappindicator).
Game Engine Integration
Rinch supports two complementary integration patterns for game engines and custom renderers:
- Embed API — Your game owns the window and GPU. Rinch provides UI as a Vello scene you composite on top.
- RenderSurface — Rinch owns the window. Your game/renderer submits frames (CPU pixels or GPU textures) into a DOM component.
RenderSurface (Recommended)
RenderSurface is a component that embeds external pixel content into rinch’s layout. Your renderer submits frames via a thread-safe SurfaceWriter (CPU pixels) or GpuTextureRegistrar (GPU textures), and rinch composites them during paint. Input events (mouse, keyboard) are routed back to your event handler.
This is the simpler pattern — rinch handles windowing, layout, and event dispatch. You just provide pixels and handle surface-local events.
Quick Start
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
let surface = create_render_surface();
// Handle input events from the surface
surface.set_event_handler(|event| {
match event {
SurfaceEvent::MouseDown { x, y, button } => { /* handle click */ },
SurfaceEvent::MouseMove { x, y } => { /* handle hover */ },
SurfaceEvent::MouseWheel { delta_y, .. } => { /* handle zoom */ },
SurfaceEvent::KeyDown(key) => { /* handle keyboard */ },
_ => {}
}
});
// Submit frames from a worker thread
let writer = surface.writer();
std::thread::spawn(move || {
loop {
let pixels = render_frame(); // your renderer
writer.submit_frame(&pixels, width, height);
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
rsx! {
div { style: "display: flex; height: 100%;",
div { style: "width: 200px;",
// Sidebar with rinch UI controls
Button { onclick: || do_something(), "Tool" }
}
RenderSurface { surface: Some(surface), style: "flex: 1;" }
}
}
}
fn main() {
App::new(app).title("My App").size(1280, 720).run();
}
CPU Pixel Submission
SurfaceWriter is Send + Sync + Clone — safe to use from any thread:
#![allow(unused)]
fn main() {
let surface = create_render_surface();
let writer = surface.writer();
// From any thread:
let pixels: Vec<u8> = render_rgba(width, height);
writer.submit_frame(&pixels, width, height);
}
Pixels are RGBA8, row-major. The surface redraws automatically after submit_frame().
GPU Texture Compositing
For zero-copy compositing, use GpuTextureRegistrar to provide a wgpu::TextureView directly:
#![allow(unused)]
fn main() {
let surface = create_render_surface();
let registrar = surface.gpu_registrar();
// Get the shared wgpu Device via gpu_handle(). It is None while the window
// presents with the software renderer (RINCH_RENDERER=cpu, or the fallback
// when the GPU would not start), so handle that case.
let Some(gpu) = gpu_handle() else {
// No GPU device: submit CPU pixels through surface.writer() instead.
return;
};
let device = &gpu.device;
let queue = &gpu.queue;
// Create your texture and render to it
let texture = device.create_texture(&wgpu::TextureDescriptor { /* ... */ });
// ... render into texture ...
// Register the texture for compositing — pass BOTH the texture and its view.
// rinch needs the texture for the GPU→CPU readback path (inline-painted
// RenderSurface) and the view for compositing it directly.
let view = texture.create_view(&Default::default());
registrar.set_texture_source(texture, view, width, height);
registrar.notify_frame_ready();
}
GpuTextureRegistrar is also Send + Sync + Clone. The texture must be created on the same wgpu::Device (available via gpu_handle()).
gpu_handle() returns None until the window’s GPU renderer has started, and for the whole session when the window presents with the software renderer: Renderer::Software was chosen, a user set RINCH_RENDERER=cpu, or the GPU would not start and Renderer::Auto fell back (see Rendering Backends). An app that configures the device with App::gpu_config or App::external_gpu (below) is exempt: it always presents on the GPU, or panics at startup if the GPU will not start, so once its window is up the handle is there.
Use
rinch::wgpu. rinch pins a patchedwgpufork. Construct everywgputype you hand back to rinch (TextureView, config features/limits, …) fromrinch::wgpu::…, not a separately-pinnedwgpudependency, or the types won’t match.
Sharing a High-Capability GPU Device
Zero-copy compositing requires your texture to live on the same device rinch composites with. By default rinch requests that device with Features::default() / Limits::default(). If your renderer needs more (extra features, larger storage buffers, more bind groups, …), raise the device’s requirements at startup so the shared device — the one published via gpu_handle() — can host your pipelines too.
Let rinch own the device (recommended). rinch still creates the instance, picks a surface-compatible adapter, and creates the device, so presentation is always correct — you only add the capabilities you need:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::wgpu;
let gpu = RinchGpuConfig {
required_features: wgpu::Features::FLOAT32_FILTERABLE,
required_limits: wgpu::Limits {
max_storage_buffers_per_shader_stage: 32, // wgpu default is 8
..Default::default()
},
};
App::new(app).gpu_config(gpu).run();
}
After startup, gpu_handle() returns the shared device, queue, and adapter (use adapter.limits() to clamp, e.g. max_buffer_size = min(adapter, 2 GiB)). The requested features/limits are passed through verbatim — if the adapter can’t satisfy them, device creation fails loudly rather than silently dropping a capability.
Bring your own device. If you must keep your renderer’s exact DeviceDescriptor, build the whole GPU stack and hand it to rinch. rinch creates only the window surface (from your instance), validates that your adapter can present to it, and composites directly onto your device — no request_device, no CPU readback:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rinch::prelude::*;
let gpu = ExternalGpu {
instance, // rinch creates the window surface from this
adapter: Arc::new(adapter), // must be able to present to the window
device: Arc::new(device),
queue: Arc::new(queue),
};
App::new(app).external_gpu(gpu).run();
}
The provided device is published through gpu_handle(). On a single-GPU machine a headless adapter (compatible_surface: None) usually presents fine; on multi-GPU systems create the adapter from a surface-compatible one, or prefer App::gpu_config (which guarantees compatibility). See the gpu-device-config example for both modes.
Layout Size
Query the surface’s current layout dimensions (set by CSS/Taffy) to match your render resolution:
#![allow(unused)]
fn main() {
let (w, h) = surface.layout_size();
// or from the registrar:
let (w, h) = registrar.layout_size();
}
Surface Events
Events are dispatched to the handler set via set_event_handler(). Coordinates are in logical pixels relative to the surface’s top-left corner.
| Event | Fields | Description |
|---|---|---|
MouseDown | x, y, button | Mouse button pressed |
MouseUp | x, y, button | Mouse button released |
MouseMove | x, y | Mouse moved over surface |
MouseWheel | x, y, delta_x, delta_y | Scroll wheel |
MouseEnter | x, y | Cursor entered surface |
MouseLeave | — | Cursor left surface |
KeyDown | SurfaceKeyData | Key pressed (when focused) |
KeyUp | SurfaceKeyData | Key released (when focused) |
TextInput | String | Text input (when focused) |
FocusGained | — | Surface received keyboard focus |
FocusLost | — | Surface lost keyboard focus |
A press keeps the pointer. From a press on the surface until the release of the button
that started it, every move and release of that pointer go to the surface wherever it is
(coordinates then fall outside the surface’s size), and it hears no MouseLeave until
then; a MouseLeave follows a release off the surface. A press or release of another button
meanwhile is a chord: the surface hears it and keeps the pointer. The browser backend does
this with setPointerCapture. Desktop routes the surface’s events the same way, but
not the document’s: DOM hover (:hover, onmouseenter / onmouseleave) and the cursor
still follow the element under the pointer during the press, where a browser routes them
to the capturing canvas.
A press whose release never arrives is ended without one, and the surface hears it ended —
PointerCancel with pointer events, else a MouseUp where the cursor is: on desktop when
the window loses focus, when the same pointer presses the left button again (another
finger’s press proves nothing), on a platform PointerCancel (Android’s touch-scroll
takeover), and silently when the surface unmounts; in the browser on pointercancel.
Pen, touch and pinch: pointer events (opt-in). A drawing surface wants to know which
device pressed and how hard. surface.set_pointer_events(true) replaces MouseDown /
MouseMove / MouseUp with these (every other surface hears exactly what it did before):
| Event | Fields | Description |
|---|---|---|
PointerDown | x, y, button, pointer | A button, pen tip or finger went down |
PointerMove | x, y, pointer | It moved (or its pressure changed) |
PointerUp | x, y, button, pointer | The press ended |
PointerCancel | pointer | The platform took the pointer away mid-press; no PointerUp follows |
Pinch | x, y, scale | Two touches spreading or closing, a trackpad pinch, or Ctrl+wheel; scale multiplies the zoom (above 1 is in), relative to the last Pinch |
SurfacePointer is { id, kind, pressure, primary }: kind is Mouse, Pen, Eraser,
Touch or Unknown; pressure is 0.0..=1.0, and a device that cannot measure it
reports 0.5 while down and 0 otherwise (the Pointer Events rule); id tells pointers
that are down at once apart (two fingers) and stays the same from a press to its release.
Compare ids for equality only: their values are the platform’s. On desktop the mouse is
1, a tablet tool 2 and each finger an id of its own above both; in the browser id is
the event’s pointerId, whose value for a mouse differs between engines. What each host
reports:
- Browser: Pointer Events (
pointerType,pressure,pointerId,isPrimary, the eraser button); two touches pinch; Ctrl+wheel (a trackpad pinch) pinches. - Desktop: winit’s pointer source (a tablet tool’s force, a touch’s force where
measured); two touches pinch; a trackpad pinch (macOS, iOS, Wayland) and Ctrl+wheel
pinch. One move per pointer per frame, as the browser does without
getCoalescedEvents. - Android: every finger is folded into one mouse pointer (id
1, kindMouse) by the touch translation, so there is noTouchkind and no two-fingerPinch. - Embed (
RinchContext): the host feeds plain mouse events, so every pointer is the mouse (Mouse, id1); Ctrl+wheel still pinches.
SurfaceKeyData contains key, code, ctrl, shift, alt, meta. key is spelled like the browser’s KeyboardEvent.key on both backends, so the space bar is key == " " and code == "Space".
A focused surface only swallows the keys it claims (issue #482). set_event_handler
keeps receiving every KeyDown/KeyUp exactly as above — that delivery is unaffected.
set_key_handler(handler: impl Fn(&SurfaceKeyData) -> bool) is a separate, additive
registration asked after: should this key stop at the surface? Leave it unset — the
default — and the surface claims nothing, so a host’s own window-level keybindings (and,
on the web, the browser’s own reload/find/… shortcuts) keep working the moment the canvas
is focused, and on desktop so do DevTools (F12), inspect mode (Alt+I) and Tab:
#![allow(unused)]
fn main() {
surface.set_key_handler(|key| matches!(key.code.as_str(), "KeyW" | "KeyA" | "KeyS" | "KeyD" | "Space"));
}
The document-level keyboard interceptor (set_keyboard_interceptor) is unaffected either
way — it already sees every key before a focused surface does, on both backends.
On the web, an unclaimed key also keeps the browser’s default action — no longer
preventDefault()ed just because the surface is focused, matching an unfocused <canvas>.
Space, PageUp/Down and the arrow keys can scroll the page, and Tab leaves the canvas. A web
game steering with any of those must claim them via set_key_handler, or the page scrolls
under it.
Web (canvas viewport)
The same RenderSurface + create_render_surface() API works on rinch-web, but the model is inverted. On the web the browser composites, so rinch only creates and manages a real <canvas> “viewport hole” sized by layout; the app owns the GPU context (rinch links no wgpu on web). This mirrors desktop symmetrically: desktop = rinch owns the window and you submit frames; web = you own the canvas surface.
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::render_surface::{create_render_surface, RenderSurface};
let surface = create_render_surface();
// After mount, grab the <canvas> and create your own wgpu WebGPU surface on it.
// (canvas_element() returns None until the component has mounted.)
if let Some(canvas) = surface.canvas_element() {
let wgpu_surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
// request adapter/device (async on web), configure, and render each frame.
}
// Resize notification (ResizeObserver-driven). Size is PHYSICAL px
// (CSS px × devicePixelRatio), matching desktop — HiDPI-correct out of the box.
surface.set_resize_callback(move |w, h| reconfigure_wgpu_surface(w, h));
// Per-frame tick (drives requestAnimationFrame on web).
surface.set_render_callback(move |_writer, w, h| render_triangle(w, h));
rsx! { div { style: "flex:1;", RenderSurface { surface: Some(surface) } } }
}
Notes:
- HiDPI out of the box.
layout_size()reports physical pixels (CSS px × devicePixelRatio), rinch sizes the canvas backing store to match, andset_resize_callbackpushes the new physical size on every resize so you can reconfigure your wgpu/WebGL surface. - Input (pointer / wheel / keyboard incl.
KeyUp/ focus) over the canvas is delivered toset_event_handler— rinch does not swallow it. Click the canvas to focus it for keyboard events. - CPU path still works & is portable.
SurfaceWriter::submit_frameblits via a lazily-created 2D context; it self-disables once you claim a WebGPU/WebGL context. So the same app can run on desktop and web. - Clean teardown. The ResizeObserver and canvas listeners are removed when the component unmounts — no leaks.
- See
examples/webgpu-surface-web(a WebGPU triangle in a rinch DOM UI) — the web counterpart ofexamples/game-embed. Build withtrunk serveoverlocalhost(sonavigator.gpuis available; thewebglcargo feature is a fallback).
API Reference: RenderSurface
| Function / Type | Description |
|---|---|
create_render_surface() | Create a new surface handle |
RenderSurfaceHandle | Main handle — set event handler, get writer/registrar |
SurfaceWriter | Thread-safe CPU pixel submission (Send + Sync + Clone) |
GpuTextureRegistrar | Thread-safe GPU texture registration (Send + Sync + Clone) |
RenderSurface | Component — use in RSX with surface: Some(handle) |
SurfaceEvent | Input event enum dispatched to handler |
RenderSurfaceHandle methods:
| Method | Description |
|---|---|
writer() | Get a SurfaceWriter for CPU pixel submission |
gpu_registrar() | Get a GpuTextureRegistrar for GPU texture compositing |
set_event_handler(handler) | Set input event callback (main thread closure) |
set_key_handler(handler) | Fn(&SurfaceKeyData) -> bool; whether a key already delivered to set_event_handler should stop here instead of continuing to a window-level host keybinding (web) or DevTools/inspect/Tab (desktop). Unset: claims nothing (issue #482) |
set_render_callback(cb) | Per-frame FnMut(&SurfaceWriter, w, h) — drives requestAnimationFrame on web |
set_resize_callback(cb) | FnMut(w, h) fired on backing-size change (physical px) — reconfigure a GPU surface |
layout_size() | Get current physical (width, height) (web: CSS px × devicePixelRatio) |
canvas_element() | (web only) the <canvas> after mount — create a WebGPU/WebGL context on it |
set_texture_source(texture, view, w, h) | (desktop, gpu) Set GPU texture directly (main thread only) |
has_texture_source() | Check if a GPU texture is registered |
id() | Surface ID |
viewport_name() | Internal viewport name |
Embed API
The embed API is for when your game owns the window and wgpu device. Rinch runs headless — you feed it platform events, it produces a Vello scene, and you render/composite it yourself.
Enable with: features = ["desktop"]
Quick Start
use rinch::prelude::*;
use rinch::embed::{RinchContext, RinchContextConfig, RinchOverlayRenderer};
#[component]
fn game_hud() -> NodeHandle {
let health = Signal::new(100);
rsx! {
div { style: "position: absolute; top: 10px; left: 10px;",
Text { size: "lg", color: "white", {|| format!("HP: {}", health.get())} }
}
}
}
fn main() {
// Your game creates the window and wgpu device
let (device, queue, surface, window) = my_engine::init();
// Create rinch UI context
let mut ctx = RinchContext::new(
RinchContextConfig {
width: 1280,
height: 720,
scale_factor: window.scale_factor(),
theme: None,
},
game_hud,
);
// Create overlay renderer from your device
let mut overlay = RinchOverlayRenderer::new(
&device, 1280, 720, wgpu::TextureFormat::Rgba8Unorm,
);
// Game loop
loop {
let events = collect_platform_events(&window);
let actions = ctx.update(&events);
for action in &actions {
match action {
AppAction::SetCursor(cursor) => { /* set cursor */ },
AppAction::Exit => return,
_ => {}
}
}
game.render(&device, &queue);
let ui_texture = overlay.render(&device, &queue, ctx.scene());
composite(&device, &queue, &surface, game_texture, ui_texture);
}
}
RinchContext
RinchContext is the main handle. Create it during initialization, call
update() each frame.
Multiple contexts. Several RinchContexts can coexist on one thread — a
screen HUD plus N render-to-texture panels, say. Each context tracks signal
changes through its own subscription (so creating or dropping one never
silences another) and keeps its own document-scoped element-bounds signals,
editor registrations, and focus requests. Stores/contexts are namespaced
per context (#136): a create_store inside a context’s component lands in
that context’s own namespace, its effects and event handlers resolve that
namespace first, and lookups fall back to the thread-global namespace for
stores created outside any context (e.g. before mounting). Two contexts can
therefore create the same store type without overwriting each other, and
dropping a context clears its namespace. One caveat remains: each context
still processes the input events you feed it — route each window’s events
only to its own context.
Background threads. The documented cross-thread pattern works in embed:
Signal::send() / Signal::update_send() from a worker, and
run_on_main_thread() for anything else, marshal the write onto the thread that
created the context and apply it at the top of the next update() — before
the events of that frame, so a queued write and an event handler are absorbed by
the same layout pass. Everything that rides that transport comes with it:
set_timeout, rinch-http completions, rinch-ws events. Signal::set() and
Signal::update() still panic off the main thread — only the _send variants
marshal.
The queue is process-global, so a RinchContext you drive drains work queued
against any context on the thread. That is fine: the payload writes its own
signals, signals are thread-local rather than per-document, and the document it
touches is still repainted by its own update().
Before issue #172 an embedded context registered the main thread but no dispatcher, and a background-thread
send()panicked on the calling thread. If you pinned an older rinch and wrapped your worker writes to avoid it, you can drop the workaround.
Timed work: the caret blink. A focused rich-text Editor’s caret toggles
every 530 ms, and update() ticks that clock just as the desktop runtime does
after each event-loop turn (issue #331). A host that calls update() every
frame needs nothing more. An event-driven host — one that renders only when
input arrives — asks ctx.next_wake() after each update(): it is the instant
the caret next toggles (what desktop arms its ControlFlow::WaitUntil with), or
None when nothing is blinking. Schedule a wake for it, or the caret toggles
only when some unrelated event happens to arrive. needs_update() answers
true once that instant has passed. A blurred host window
(PlatformEvent::WindowFocus(false)) stops the blink with the caret solid and
arms no wake. Each context has its own blink clock (issue #1149): two contexts
on one thread that each have a focused editor both blink, each on its own
phase, and blurring or typing in one leaves the other’s blink alone.
RenderSurface in an embedded context. A RenderSurface works inside a
RinchContext the way it does on desktop: scene() tells each surface in
that context’s document its physical layout size, runs its render callback, and
paints its latest frame inline at the surface’s box (issue #331). Call scene()
once per frame — every call runs the render callbacks again. A frame submitted
from another thread makes needs_update() true and the next update() answer
RequestRedraw. Several contexts on one thread each drive only their own
surfaces, and a thread with no RenderSurface at all pays nothing for this in
scene().
Three things are not composited by rinch in an embedded context, because
the host owns compositing there: a surface registered under a data-viewport
name (create_render_surface_with_name), a <video> / VideoViewport (whose
frame sink is installed only by the desktop runtime), and a
GpuTextureRegistrar texture on a RenderSurface (read back only through the
desktop runtime’s own device, so in an embedded context it draws nothing, and
its notify_frame_ready asks for no redraw). Treat them like a GameViewport
hole: render into your own target and composite it under rinch’s overlay.
Input Routing
For HUD overlays, use wants_mouse and wants_keyboard to decide whether input goes to the UI or the game:
#![allow(unused)]
fn main() {
if ctx.wants_mouse(mouse_x, mouse_y) {
ctx.update(&[mouse_event]); // UI element under cursor
} else {
game.handle_mouse(mouse_x, mouse_y); // game content
}
if ctx.wants_keyboard() {
ctx.update(&[key_event]); // text input focused
} else {
game.handle_key(key); // game shortcuts
}
}
A mouse-clicked plain button does not claim
wants_keyboard()— a Tab-focused one still does (issue #548). A<button>/<a href>is focusable with notabindexneeded, so a single click on one (no Tab involved) used to makewants_keyboard()answertrueuntil the next click landed somewhere else — a host following the pattern above then stopped seeing its own Esc/hotkey presses the moment the user clicked any button in a HUD. The fix follows the browser’s own:focus-visiblesplit rather than “is a node focused” or “did it register anything”:
- A mouse click on a plain button/link only ever consumes Enter/Space through rinch’s own activation path, so it does not need the host’s keyboard —
wants_keyboard()isfalse.- Tab (or a programmatic
NodeHandle::focus()) onto that same plain button sets its keyboard-focus ring (is_focus_visible), andwants_keyboard()istrue— so Tab navigation and Enter/Space activation of ordinary controls keep reaching rinch under the pattern above, exactly as before #548’s fix.- A custom widget that registered its own
FocusEntry::on_key(register_focus_target— arrow-key navigation, a shortcut of its own) istruewhichever way it was focused: the runtime can’t know which keys it wants without seeing them.- An open native
<select>popup is alwaystruetoo — its own arrow/Enter/Escape handling needs every key (a separate, pre-existing gap folded into the same fix).
A viewport hole must stay hittable — that is what routes the mouse.
wants_mouseonly reports “the game wants this” when the hit-tested node has adata-viewportancestor. A hole that is not hittable inverts the routing: the hit falls through tobodybehind it, the walk up frombodyfinds nodata-viewport, andwants_mouseanswerstrue— everywhere. The game silently receives no mouse input at all, with nothing in the layout or the render looking wrong.Since #207
GameViewportneeds no defending: everydata-viewportnode getspointer-events: auto; background: transparent;from the UA stylesheet, so bothGameViewport { name: "main" }andGameViewport { name: "main", style: "flex: 1;" }route correctly. Because those defaults are a UA rule rather than an inline style, restyling the hole cannot strip them — worth knowing, since a component’s universalstyle:prop replaces the component’s inline style rather than merging with it. Being a UA declaration, it also survivespointer-events: noneinherited from a HUD root, and still loses to anypointer-eventsyou declare on the viewport yourself (which is how you deliberately hand a region back to the UI).The inheritance trap is still real for everything you build yourself.
pointer-eventsis inherited, sopointer-events: noneon a HUD root — the natural way to let clicks fall through to the game — reaches every descendant. Your own HUD controls, and any hand-rolled hole that isn’t adata-viewportnode, need an explicit opt-in:#![allow(unused)] fn main() { div { style: "pointer-events: none;", // HUD root: clicks fall through GameViewport { name: "main", style: "flex: 1;" } // hittable by default div { style: "pointer-events: auto;", /* real HUD controls */ } } }That inheritance is spec-correct, not a bug — but the overlay idiom that triggers it is the obvious one to reach for, so it is worth designing around up front. (
visibility: hiddenis stronger still on desktop: it makes an entire subtree unhittable, and a descendant that setsvisibility: visibleis painted but still not hittable there — issue #843. A browser, and sorinch-web, lets that descendant take pointer events.)
Split Layout (Viewport Hole)
Use GameViewport to mark a transparent region where the game renders:
#![allow(unused)]
fn main() {
use rinch::embed::GameViewport;
#[component]
fn editor_ui() -> NodeHandle {
rsx! {
div { style: "display: flex; flex-direction: column; height: 100%;",
div { class: "toolbar",
Button { onclick: || save(), "Save" }
}
div { style: "display: flex; flex: 1;",
div { style: "width: 200px;", /* side panel */ }
GameViewport { name: "main", style: "flex: 1;" }
}
}
}
}
}
Query the viewport rect to set your game’s render region:
#![allow(unused)]
fn main() {
if let Some(rect) = ctx.viewport_rect("main") {
game.set_viewport(rect.x, rect.y, rect.width, rect.height);
}
}
A hole with nothing behind it:
data-viewport-ready. Cutting the hole removes the ancestors’ background —<body>’s included, since the UA sheet makes itoverflow-y: auto— so on a transparent window a hole nothing fills is see-through to the desktop, not to your app (#186). A viewport whose content can be absent opts out by stampingdata-viewport-ready="false"on the hole; paint then leaves the backgrounds alone and the node paints its ownbackground, which is the natural seat for a poster or an error affordance. Set it back to"true"once there is something to show.The attribute is an opt-out, and absence means ready: a
data-viewportnode that never stamps it punches unconditionally, exactly as before.GameViewportis therefore unaffected — it stamps nothing, keeps its hole from the first frame, and needs no change.VideoViewport(rinch-video) is the one built-in that opts in: it stays"false"until the decoder has handed a real frame to the compositor, and flips back to"false"on aPlaybackState::Error. A node that does carry the attribute must say exactly"true"to punch, so a mis-stamped value fails to the safe side — an opaque placeholder, never a see-through window.Not ready also means no frame, on both backends. A surface keeps the last buffer it was given, so a video that errors mid-stream still has one; while its node says
"false"that frame is not drawn — the software backend paints the node as an ordinary element, and the GPU backend hands the compositor no layer for it — and the node’s ownbackgroundis what shows. Say"true"again and the frame the surface holds is back, with no new frame needed.Since #358 software cuts no hole for a video at all (see below), so there the gate decides only whether the frame or the node’s
#000placeholder is drawn.
A hole bigger than what fills it: black bars (GPU). The complementary case (#354). The GPU compositor aspect-fits a frame inside its viewport, so a source whose aspect ratio differs from the box leaves letterbox or pillarbox bars — inside the punched hole, but covered by no layer. That was see-through to the desktop for a video playing perfectly. The compositor now pushes an opaque black layer over the whole viewport box under the frame’s own layer, so the bars come out black exactly as a browser paints them for
<video>, and the hole is always fully covered.The black is painted by the compositor, not by the viewport element. That distinction is the whole design: the compositor draws under the Vello UI, so giving the element an opaque
backgroundwould hide the video entirely.GameViewportis unaffected — its surface fills its box, so the fit is exact and the backdrop is entirely overdrawn.On the software backend the same bars are black for a different reason — see the next note.
Software paints every viewport inline. The two backends route viewport frames differently, and the split is worth knowing if you are reading the compositing code (#358, #361).
RenderSurfacevideo GameViewportsoftware inline inline inline GPU inline compositor + black backdrop compositor Software used to blit its compositor frames onto the finished pixel buffer, after the whole UI had been painted, clipped only by the viewport’s overflow-clipping ancestors. That write has no notion of occlusion, so every overlay above a playing video or a game — the nav drawer,
Modal,DropdownMenu,Select’s popup, tooltips, a HUD, DevTools — was overwritten. GPU has no such problem: its layers go down first and the Vello UI alpha-blends on top, so an opaque drawer already covers the layer there.So on software a frame goes through paint instead, on the same inline path a
RenderSurfacecomponent has always used: thedata-viewportnode draws the frameobject-fit: containinside its box, at its own z-order. Overlays occlude it by ordinary paint order, with no occlusion tracking anywhere, and a new frame’s damage is the viewport’s box — not a full repaint. A paint with no new frame (a hover elsewhere over a paused game) does not damage the viewport; it is repainted only where something else’s damage reaches it — a box moving across it, an overlay above it closing — and then from the frame it already holds.It is not slower than the blit was.
submit_framechecks, on the thread that submits, whether every pixel is opaque; an opaque frame is never premultiplied, and one that lands on whole pixels — at its natural size, or scaled to a box whose edges are whole pixels — is copied into the surface row by row, byte-identical to the sampled draw. Measured in release on one host (game_viewport_inline_bench), per frame on the UI thread, inline vs the old blit: a 1920×1080 frame in a 1920×1080 viewport 1.5 vs 4.8 ms, the same frame in a 960×540 viewport 2.3 vs 2.3 ms, a 320×180 frame in 640×360 0.7 vs 0.8 ms. A frame with any translucent pixel is premultiplied and sampled per draw, as any image is.What still tells video and
GameViewportapart is the hole:
- Video punches none: it is no compositor frame, so its name is absent from the active-viewport set and #186’s filter reads “absent ⇒ do not punch”. The node fills opaque black under the frame, and the
containfit plus that fill are #354’s letterbox bars on this backend.GameViewportkeeps its hole while it has a frame, as it always had: its ancestors’ backgrounds are cut away under it and no black is drawn, so where the frame does not reach — a letterbox, or pixels the game left transparent — is the hole, see-through on a transparent window.The two are told apart by an explicit flag set at video’s registration site, not by the viewport’s name — they share
create_render_surface_with_name, so a naming rule would treat the game viewport as video. A surface carrying a GPU texture source never takes the inline path: it has no CPU pixels to paint, and is the GPU compositor’s.
Overlays: which ancestor sizes them. A
position: absoluteoverlay is sized and placed against its containing block, as in a browser and underrinch-web: the padding box of its nearest positioned (or transformed) ancestor, however many unpositioned elements sit between them (#386), or the window when it has none (#204). Soposition: absolute; inset: 0insideposition: relativechrome fills the chrome even when the element directly enclosing it has auto height, and dropped into unpositioned containers it fills the window.Two things to know. An overlay meant to cover one panel needs that panel to be
position: relative, or it covers the next positioned ancestor up. And aposition: relativeinline element (a<span>) is a containing block too (#631): an overlay inside one resolves against the span’s text, not the block that holds the line. On one line that is the text’s box. For a span that wraps, the block runs from the first line’s start of the text to the last line’s end of it, soinset: 0is usually zero wide there (the last line ends left of where the first began), as in a browser. Make the wrapperdisplay: inline-block(or a block) when the overlay should cover a box.
Resize and Scale Factor
#![allow(unused)]
fn main() {
ctx.resize(new_width, new_height);
overlay.resize(&device, new_width, new_height);
ctx.set_scale_factor(window.scale_factor());
}
set_scale_factor also feeds Stylo’s device_pixel_ratio (issue #211): CSS
resolution media queries (@media (min-resolution: 2dppx)), image-set()
candidate selection, and border-width device-pixel snapping all follow the
value you pass. A changed scale restyles the document on the next update(),
which also re-lays it out at the new logical size. The initial value comes
from RinchContextConfig::scale_factor and is live from the first layout.
Hit testing is transform-aware, so the usual position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) centering idiom is fully clickable —
a transformed subtree is hit where it is painted.
Caveat at HiDPI on the embed path. The embed path resolves layout at logical size, so
set_scale_factorgenuinely matters here — and paint currently applies a CSStranslatewithout multiplying it by that scale (#202). At any scale other than1.0, a translated element therefore paints slightly off from where it hit-tests. Onlytranslateis affected (rotate/scale/skeware unit-invariant), and only on the embed path. Centering with flexbox instead of atranslatesidesteps it entirely until that fix lands.
Platform Events
Translate your engine’s events to rinch_platform::PlatformEvent:
#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, KeyRepeat, Modifiers};
PlatformEvent::MouseMove { x: 100.0, y: 200.0 }
PlatformEvent::MouseDown { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseUp { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseWheel { x: 100.0, y: 200.0, delta_x: 0.0, delta_y: -30.0 }
PlatformEvent::KeyDown {
key: KeyCode::KeyA,
// The layout-produced `KeyboardEvent.key` value, case-accurate ("A" under
// Shift). `None` = unknown; the app then falls back to the physical `key`.
logical_key: Some("a".into()),
text: Some("a".into()),
modifiers: Modifiers::default(),
// Is this a fresh physical press, or the OS repeating a key still held
// (issue #463)? Say `KeyRepeat::Repeat` for a re-sent held key, and
// `KeyRepeat::Unknown` if your engine cannot tell — the runtime then
// infers it from the press/release pair, as it always did. Answering
// where you can is what keeps a swallowed release from stranding the
// Enter/Space activation of a focused node.
repeat: KeyRepeat::Fresh,
}
PlatformEvent::KeyUp {
key: KeyCode::KeyA,
// Spell it like the press, or a consumer pairing press with release by
// key string ("is A still held") never matches.
logical_key: Some("a".into()),
modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}
Pointer coordinates are logical (CSS) pixels, not physical ones (issue #299).
MouseMove/MouseDown/MouseUp/MouseWheel,MouseWheel’sdelta_x/delta_y, and theFile*positions are all in the space the document is laid out in andhit_testprobes. A windowing system that reports physical pixels — winit does — must divide first, andrinch_platform::to_logical_point(point, scale)is the shared conversion:#![allow(unused)] fn main() { let (lx, ly) = rinch_platform::to_logical_point((position.x, position.y), window.scale_factor()); events.push(PlatformEvent::MouseMove { x: lx as f32, y: ly as f32 }); }Forwarding the physical position instead displaces every click, hover, drag and scroll by the scale factor times its distance from the window origin — it is invisible at scale 1.0 and wrong everywhere else. Note this is the opposite of
resize(w, h)andRinchContextConfig’swidth/height, which stay physical;scale_factoris how rinch reconciles the two.wants_mouse(x, y)takes the same logical pair, so ask it with exactly what you put on the event.examples/game-embeddoes this.
API Reference: Embed
RinchContext:
| Method | Description |
|---|---|
new(config, component) | Create and mount a rinch UI |
update(&events) -> Vec<AppAction> | Process events, update layout, return actions |
scene() -> &Scene | Get the Vello scene (lazy rebuild) |
resize(w, h) | Notify of window resize (physical pixels) |
set_scale_factor(scale) | Update DPI scale factor |
set_theme(&props) | Replace this context’s theme (restyles on the next update()) |
viewport_rect(name) -> Option<LayoutRect> | Query a GameViewport’s computed rect |
wants_mouse(x, y) -> bool | True if point hits UI (not viewport hole) |
wants_keyboard() -> bool | True for a text input, the editor, an open <select> popup, a generic node that registered on_key, or a keyboard-focused (Tab/NodeHandle::focus()) generic node — not a plain button/link a mouse click merely focused (issue #548; the click-vs-Tab split follows :focus-visible) |
needs_update() -> bool | True if UI needs repaint (including a due next_wake or a new RenderSurface frame) |
next_wake() -> Option<Instant> | When to call update() again with no input (the caret blink), or None |
register_font(data) | Register font data for text rendering |
app() / app_mut() | Access the underlying RinchApp |
RinchOverlayRenderer:
| Method | Description |
|---|---|
new(device, w, h, format) | Create from game’s wgpu device |
render(device, queue, scene) -> TextureView | Render scene to texture |
resize(device, w, h) | Resize render target |
texture() | Get the underlying wgpu Texture |
RinchContextConfig:
| Field | Type | Description |
|---|---|---|
width | u32 | Initial viewport width (physical pixels) |
height | u32 | Initial viewport height (physical pixels) |
scale_factor | f64 | Display scale factor |
theme | Option<ThemeProviderProps> | Theme configuration |
Theming is per-context. Each RinchContext owns the theme CSS generated
from its config.theme (or the default theme when None) — creating a second
context, or a shell app changing the thread-global theme, never restyles an
existing context (issue #138). To change an embedded context’s theme at
runtime, call ctx.set_theme(&props); the document restyles on its next
update().
Caveat: an embedded
ThemeProvidercomponent with a reactivedark_mode_fn/primary_color_fnwrites the thread-global theme slot, which embed contexts deliberately ignore —RinchContext::set_themeis the supported path for runtime theme changes in the embed API.
LayoutRect:
| Field | Type | Description |
|---|---|---|
x | f32 | Absolute X position (logical pixels) |
y | f32 | Absolute Y position (logical pixels) |
width | f32 | Width (logical pixels) |
height | f32 | Height (logical pixels) |
Which Pattern to Use?
| Scenario | Use |
|---|---|
| Adding UI overlay to an existing game engine | Embed API — game keeps its window/GPU ownership |
| Building a tool with embedded viewports (e.g., level editor, paint app) | RenderSurface — rinch handles the window, you embed content |
| Terminal emulator, video player, or custom canvas inside a rinch app | RenderSurface — component-level integration |
| WASM game with HTML-based UI | Neither — use the browser-native DOM backend |
Examples
examples/game-embed/— Spinning cube with rinch HUD overlay (embed API)examples/render-surface-demo/— Painting app with canvas and navigator (RenderSurface)examples/webgpu-surface-web/— (web) WebGPU triangle in a rinch DOM UI — the app owns a wgpu<canvas>viewport (RenderSurface onrinch-web)
Rich-text editing
Rinch ships a built-in rich-text editor — a ProseMirror-style, model-first editor
with schema-enforced structure, inline marks, lists, tables, and exact undo/redo.
You add it with the Editor {} component and drive it through an EditorHandle.
This page is the practical “how do I add an editor” guide. For the deeper model — the document tree, steps and transactions, the schema, commands, and the view seam — see the Rich-Text Editor guide.
Desktop and web. On desktop the editor arrives with the
desktopcargo feature; in the browserrinch-webre-exports it (see On the web below) — the app code is identical, because both share the renderer-agnostic view inrinch-editor-view. There is nocontenteditableHTML attribute and no DOM-level editing API to wire up — theEditorcomponent is the whole surface.
Quick start
Create a handle with create_editor(), mount it with Editor {}, and dispatch
named commands from toolbar buttons. Everything you need is in the prelude:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn my_editor() -> NodeHandle {
let editor = create_editor();
// One cheap handle clone per closure that needs it (EditorHandle is an Rc).
let ed_bold = editor.clone();
let ed_italic = editor.clone();
let ed_h1 = editor.clone();
rsx! {
div {
// Toolbar — each button runs a named command.
div {
button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
button { onclick: move || { ed_italic.command("toggleItalic"); }, "Italic" }
button { onclick: move || { ed_h1.command("setHeading1"); }, "H1" }
}
// The editing surface — mounts the handle and renders its content.
Editor {
editor: editor.clone(),
content: "<h1>Hello</h1><p>Type here…</p>",
}
}
}
}
}
The Editor component renders the document, the caret, and the selection straight
from the editor’s state. It ships its own default light/dark stylesheet, so the
content looks right out of the box — you don’t hand-roll editor CSS.
The Editor component
| Prop | Type | Purpose |
|---|---|---|
editor | Option<EditorHandle> | A handle from create_editor(). Omit it and the component creates its own self-contained editor. |
content | String | Initial content as schema-whitelisted HTML, parsed into the document once on mount. |
read_only | bool | Mount the editor read-only. false (the default) leaves the handle’s switch as it is, so a handle locked before mount stays locked. Change it afterwards with handle.set_read_only(..). |
Like every rinch component, Editor {} also accepts the universal style: and
class: props, applied to its host element.
#![allow(unused)]
fn main() {
// Self-contained: no handle needed if you don't drive it programmatically.
rsx! { Editor { content: "<p>Just some editable text.</p>" } }
}
The EditorHandle
create_editor() returns an EditorHandle — a cheap, cloneable (Rc) handle to a
single editor. Clone one per closure that captures it; all clones share the same
editor. It works before the editor is mounted or focused: state edits mutate the
owned state and render when the view attaches.
Driving the editor: command
handle.command(name) dispatches a built-in command by name and returns whether it
applied. This is the single entry point for toolbar buttons and menu items:
#![allow(unused)]
fn main() {
let ed = editor.clone();
button { onclick: move || { ed.command("toggleBold"); }, "Bold" }
}
Command names are case-sensitive. The full catalogue:
| Category | Commands |
|---|---|
| Inline marks | toggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleCode, toggleHighlight, toggleSubscript, toggleSuperscript |
| Block types | setParagraph, setHeading1…setHeading6, setCodeBlock |
| Alignment | setTextAlignLeft, setTextAlignCenter, setTextAlignRight, setTextAlignJustify |
| Containers | toggleBulletList, toggleOrderedList, toggleTaskList, wrapInBlockquote |
| Lists | sinkListItem (indent), liftListItem (outdent) |
| Inserts | insertHorizontalRule, insertHardBreak, insertTable |
| Tables | addRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell |
| Links | removeLink |
| Deleting | deleteSelection, deleteCharBackward, deleteCharForward, deleteWordBackward, deleteWordForward |
| History | undo, redo |
Links need a destination, so applying a link is a builder rather than a bare named command. Use
handle.toggle_link(href)to add (or, over an existing link, remove) a link,handle.command("removeLink")to clear one unconditionally, andhandle.active_link_href()to read the current link’s target for an edit dialog. Text typed right after a link is not part of it (linkis a non-inclusive mark), so a link does not grow as the user keeps typing past it. The one exception is a link that runs straight into a different link: text typed at that seam continues the first one (see inherited marks).
The word deletes remove from the caret to the boundary the word motion (Ctrl+Left / Ctrl+Right) lands on, taking the whitespace between: in
hello wor|ldbackward leaveshello |ld. A mark boundary is not a word boundary. Where there is no word to take they are Backspace / Delete: a non-empty selection is deleted as it stands, and at a textblock’s start / end they join (or lift), as Backspace and Delete do there. An inline atom — an image, a hard break — is a boundary of its own: one right beside the caret goes alone, and a word beyond one is not taken with it. There are no line delete commands. On the web the browser’sdeleteSoftLine*(a soft keyboard’s line delete, Cmd+Backspace / Cmd+Delete) deletes to the edge of the visual line the caret is drawn on — on a wrapped paragraph, normally only the current line’s prefix or rest — anddeleteHardLine*to the textblock’s edge (issue #301). Home / End go to the visual line’s edges on both backends. The web finds the edge from caret geometry: the first and last positions whose caret is drawn on the caret’s own line, which are the line’s logical edges — a right-to-left line starts at its right edge. Desktop reads the line from the textblock’s text layout — the line holding the caret, the upper one for a caret drawn at the end of a soft-wrapped line — and takes its logical start and end: after a hanging space at a soft wrap, before a hard break (Shift+Enter).Caret affinity at a soft wrap. The end of one visual line and the start of the next are one model position, so a caret there could be drawn in either place. The editor keeps a hint beside the selection, as CodeMirror and a browser do: End (landing on the wrap point itself, after any hanging space), a press past a wrapped line’s end, and an Up / Down move whose column lies past the target line’s end draw it at the end of the upper line; Home and anything else draw it at the start of the lower one. So Home twice stays on its line, Home then End reaches that line’s end, End on a word broken by
overflow-wraplands after the line’s last letter (typing appends to the upper line), and a soft-line delete backward right after Home deletes one character, as Chrome does. The hint is view state, not part of theSelection: it applies only while the selection is the one it came with, so typing, an edit before the caret in its own paragraph, undo, a load or a plainset_selectionreturns the caret to the lower line’s start. An edit that leaves the caret’s paragraph unchanged up to the caret — in an earlier paragraph, a collaborator typing above or after the caret — carries the hint along, so the caret stays at the end of its line. A caret right before a hard break (Shift+Enter) is always drawn at the end of its own line. A paragraph that ends in a hard break shows the empty line after it, where the caret after the break sits: a browser lays<p>a<br></p>out as one line (a line after the last forced break needs something after it), and so does rinch-dom, so the view renders a second<br data-pm-trailing-break>after the break, as ProseMirror does — and after a code block’s text that ends in a newline, for the same reason. It is not part of the document and never reachesdoc(), the HTML or the markdown. Set it yourself withEditorHandle::set_selection_with_affinity; read it withcaret_affinity(), andcaret_rectat the head draws with it.Neither backend hit-tests points on the line (the web uses collapsed
Rangerects, desktop the text layout), so a caret line scrolled out of the editor’s scroller or off the page behaves as a visible one (#1026, #1107), and a hard break (Shift+Enter) always ends the line: Home after it lands after it, End before it stops before it.Where this still differs from a browser field:
- Desktop, Home and End beside an image: Home on a line that starts with an image lands after it, and End on a line that ends with one lands before it (#1116). The caret beside an image is drawn on its side, and a drag or Up/Down onto an image lands on the half it reaches (#1104); a hard break is resolved on both sides on both platforms (#1099, #1025), and the web resolves both sides of an image too — a click, Home, a soft-line delete, and an IME commit or autocorrect right after one.
- On a line that mixes directions the answer is the line’s logical edge, which can differ from a browser field’s: at the end of a line whose last run is right-to-left, Chrome’s End stops before the trailing space where rinch’s lands after it, on the wrap point.
Alignment applies to the textblocks (
paragraph/heading) overlapping the selection, including ones nested in lists, blockquotes, and table cells. Re-applying the alignment a block already has is a no-op, so a toolbar button bound to the current alignment stays inert.setParagraph(“reset to normal text”) clears alignment back toleftalong with the rest of the formatting.
Querying state
Toolbar “active” states and enablement read the editor state, never the DOM:
#![allow(unused)]
fn main() {
handle.is_mark_active("bold") // -> bool: is bold active at the selection?
handle.current_block_type() // -> Option<String>: e.g. Some("heading")
handle.in_node_type("bullet_list") // -> bool: is the cursor inside a bullet list?
handle.can_run("liftListItem") // -> bool: would this command apply right now?
}
A reactive toolbar button can read these inside a {|| ... } closure so it
re-renders when the selection moves:
#![allow(unused)]
fn main() {
let ed = editor.clone();
Button {
variant: {|| if ed.is_mark_active("bold") { "filled" } else { "subtle" }},
onclick: move || { ed.command("toggleBold"); },
"B"
}
}
Setting content
Pass HTML through the content: prop, or load it imperatively:
#![allow(unused)]
fn main() {
let editor = create_editor();
// Before or after mount — both work; the view renders the result either way.
editor.load_html("<h1>Loaded</h1><p>Programmatically set content.</p>");
rsx! { Editor { editor: editor.clone() } }
}
| Method | Purpose |
|---|---|
load_html(&str) -> bool | Parse schema-whitelisted HTML and replace the document. No markup is refused, however broken: its text is read, into valid content (see What the HTML reader keeps). Returns false only when the load itself is refused (a read-only collaborating editor). |
doc() -> Node | The current document (the save shape; serialize it under the serde feature). |
insert_image(src, alt) | Insert an image node (e.g. a data: URL), replacing the selection. |
on_image_input(cb) / insert_image_at(&anchor, src, alt) | Be offered pasted and dropped pictures and answer with the src to insert, at once or later. See Pasted and dropped pictures. |
toggle_link(href) -> bool | Add a link mark with href across the selection, or remove it if the selection is already linked. No-op (returns false) for a collapsed cursor. |
active_link_href() -> Option<String> | The href of the link the selection is on, for pre-filling an “edit link” dialog: for a range, the first link in it; for a caret, the link text typed there would carry. A link is not inclusive, so a caret inside it answers its href and a caret at its start or right after its last character answers None — except where it runs straight into a different link, where the caret is in the first. |
paste(&PasteContent) -> bool | Paste text/plain and/or text/html over the selection the way the user’s paste does: your plugins first, then the default. See Seeing and rewriting a paste. |
replace_selection_with_html(&str) | Replace the selection with parsed HTML (the default rich paste; no plugin sees it), fitted to where it lands. See What a paste does. |
selection_clipboard() | The current selection serialized as (html, plain_text) for the clipboard. A selection inside a list is copied with its list, one across table cells with its table. |
anchor_selection() -> SelectionAnchor | Capture the selection for a later insertion, kept pointing at the same content as the user keeps editing. See Pasting is asynchronous. |
HTML is schema-whitelisted on load: known block tags become nodes and known
inline tags become marks; an element the schema has nothing for is read through for
its content, and unknown attributes, <script>, inline event handlers and the like
are dropped at parse time. The document can only ever hold structure the schema
allows, and it holds all of the text: see
What the HTML reader keeps.
Your own plugins and inline decorations
add_plugin(Rc<dyn Plugin>) -> bool installs a plugin of your own — a
spellchecker, a search highlighter. It rebuilds the editor’s state over the
current document and selection, which discards the undo history, so call it
on a freshly created handle, before content is typed. It is not an edit: a
read-only editor (collaborating or not) accepts it, nothing is sent to peers and
on_change does not fire. A plugin whose key is already installed is refused
(false), and a refused call changes nothing.
A plugin marks ranges of text through its decorations(), without touching the
document:
#![allow(unused)]
fn main() {
use rinch_editor_core::decoration::{Decoration, DecorationSet};
impl Plugin for Spellcheck {
fn key(&self) -> PluginKey { PluginKey("my-app.spellcheck") }
fn decorations(&self, state: &EditorState) -> DecorationSet {
DecorationSet::new(
self.misspelled(&state.doc)
.map(|(from, to)| {
Decoration::inline(from, to, Attrs::new().with("class", "pm-spell-error"))
})
.collect(),
)
}
}
}
The view wraps each decorated stretch in a <span data-pm-deco class="…">.
The built-in stylesheet draws pm-spell-error (red) and pm-grammar-error
(green) as wavy underlines, on desktop and on the web; any other class is yours
to style. Decorations are recomputed from every new state, and the editor does
not move a range for you when text is inserted before it — a plugin that
caches ranges maps them through tr.mapping() in its apply.
Plugin code runs untracked (issue #943). decorations, apply,
init_state, handle_paste and a plugin’s commands run synchronously inside
whatever app effect called the handle (a set_selection, a command, an
insert_text), and a signal they read does not become that effect’s
dependency. The flip side: a plugin is not re-asked when a signal it reads
changes. A spellchecker with an on/off switch re-runs its decorations by
dispatching something when the switch moves, from an effect of its own:
#![allow(unused)]
fn main() {
let ed = editor.clone();
Effect::new(move || {
let _ = spellcheck_on.get();
// Any transaction re-asks every plugin's decorations; this one changes nothing.
ed.update(|state| Some(state.tr()));
});
}
The build closure you pass to update is the exception: it is your own code,
and what it reads tracks as usual — including what plugin code it calls reads
(update(|s| { s.apply(..); … }) runs the plugins’ apply as your code).
A right press over the editor places the caret before an app’s
data-oncontextmenu handler runs, on both backends, so a handler that draws its
own suggestions menu reads the pressed word from selection().
Seeing and rewriting a paste
A plugin can claim a paste before the editor inserts it, through
Plugin::handle_paste. It is handed the editor’s state at the paste and the
clipboard’s text/plain and text/html flavours (PasteContent), and answers
the transaction to apply instead, or None to leave the paste alone. Every
paste the platform reports as a paste event reaches it: Ctrl+V / Cmd+V,
Ctrl+Shift+V and the context menu’s Paste on desktop, and the browser’s paste
event on the web, all go through EditorHandle::paste, which asks the plugins
first. A mobile keyboard’s clipboard chip is the exception: it inserts text
directly, as if typed, and does not reach the hook.
A URL pasted over selected words that links them instead of replacing them:
#![allow(unused)]
fn main() {
use rinch_editor_core::{
AttrValue, Attrs, EditorState, Mark, PasteContent, Plugin, PluginKey, Transaction,
};
struct LinkOnPaste;
impl Plugin for LinkOnPaste {
fn key(&self) -> PluginKey { PluginKey("my-app.link-on-paste") }
fn handle_paste(&self, state: &EditorState, paste: &PasteContent) -> Option<Transaction> {
let url = paste.text.as_deref()?.trim();
if !url.starts_with("https://") || url.contains(char::is_whitespace) {
return None; // not ours: the default paste
}
let (from, to) = (state.selection.from().0, state.selection.to().0);
if from == to {
return None; // nothing selected: paste the URL as text
}
let link = state.schema().mark_type("link")?.clone();
let mut tr = state.tr();
tr.add_mark(from, to, Mark::new(link, Attrs::from_iter([("href", AttrValue::from(url))])))
.ok()?;
Some(tr)
}
}
editor.add_plugin(Rc::new(LinkOnPaste));
}
- Order. Plugins are asked in the order they were installed (the built-ins
first, then
add_plugin’s, which claim no paste); the firstSomewins and later plugins are not asked. With no claim the default runs:text/htmlparsed into structure, elsetext/plainone paragraph per line. - Flavours. Either may be
None. A browser’s copy usually carries both, so read the text when you want to know whether the paste is a URL: a copied link’s html is an<a>whose text may be its title. Ctrl+Shift+V offers the text alone. An image-only paste (a screenshot) never reaches the hook. On desktop the text beside the html comes from the same clipboard read (paste_rich_with_text_async). - Where.
state.selectionis where the paste lands; on desktop that is where Ctrl+V was pressed, mapped through anything typed while the clipboard was read (see Pasting is asynchronous). - What a claim is. One transaction: one undo step,
on_changeif the document changed, the caret scrolled into view, and on a collaborating editor recorded and broadcast like any local edit.Some(state.tr())swallows the paste. A read-only editor refuses the claim as it refuses every edit (and on desktop starts no clipboard read at all). - Borrowing.
handle_pasteruns while the editor is borrowed, like a command, so it must not call theEditorHandle. Anything it needs (a note’s title for a link, say) has to be at hand already.
EditorHandle::paste(&PasteContent) is the same entry point for an app of its
own: a “Paste link” menu item that read the clipboard itself goes through the
plugins exactly as Ctrl+V does.
Pasted and dropped pictures
By default a bitmap pasted into an editor becomes an image whose src is a
data: URL, and image files dropped on a desktop editor go to the app’s own
onfiledrop handler. An app that keeps pictures somewhere of its own (a blob
store, an upload) asks to be offered them instead:
#![allow(unused)]
fn main() {
let editor = handle.clone();
handle.on_image_input(move |input: ImageInput| {
// input.bytes: the encoded file (a pasted bitmap arrives as PNG)
// input.mime: "image/png", "image/jpeg", "image/gif" or "image/webp"
// input.name: the dropped file's name; None for a paste
let src = store_blob(&input.bytes)?; // answer at once...
Some((src, String::new())) // ...with (src, alt)
});
}
The editor inserts an image with that src where the picture was aimed: the
selection at the paste, or the caret at the drop point. When storing takes a
round trip, keep the input, return None, and finish later:
#![allow(unused)]
fn main() {
handle.on_image_input(move |input| {
let editor = editor.clone();
store_blob_async(input.bytes, move |src| {
editor.insert_image_at(&input.anchor, &src, "");
});
None
});
}
input.anchor is the place the picture was aimed at, kept pointed at the same
content while the person, or a collaborating peer, types on. When the answer
arrives the picture goes there and the person’s caret stays theirs: if they
have moved on, their selection is kept (shifted by the picture when it lands
before it), nothing scrolls, and the picture is an undo step of its own. If
they have not moved, the caret ends up after the picture, as after any insert.
insert_image_at answers false, changing nothing, when the place is gone
(the document was replaced, or a peer removed or restructured the block it was
in), when the editor has become read-only, or when the place no longer takes an
image. Delete what you stored for it then: nothing references it. Returning
None and never inserting is a refusal: nothing is inserted, and no data:
URL is made.
ImageInput and ImageInputSource are #[non_exhaustive]: read the fields
you need (or destructure with ..), and give a match on the source a
wildcard arm.
Pictures drawn over, and controls over a picture
The <img> of an image node asks the image loader for its node’s src. An app
that shows something else for some pictures (a picture with marks drawn over it)
chooses the source from the node’s attributes, once, for every editor on the
thread:
#![allow(unused)]
fn main() {
rinch::editor::set_image_source(|attrs| {
let id = attrs.get_str("data-annotation-id").filter(|i| !i.is_empty())?;
Some(format!("{}#annotation={id}", attrs.get_str("src")?))
});
}
None keeps the src. The document is untouched (copy, export and collaboration
still see the src); only the picture shown changes. The answer is what the
loader is asked for and what rinch::image::reload_image names, so two images with
one src and different attributes are two pictures, each loaded and reloaded on
its own: reload_image("…#annotation=a1") asks again for that one alone. It is asked
whenever an <img> is built or its node’s attributes change, with the editor
borrowed, so it must be a function of attrs alone. clear_image_source() takes
it away. In the browser it is rinch_web::set_image_source.
Installed while a component renders, the source function is removed when that
component unmounts (a later install is never clobbered by an earlier unmount);
installed from main, it keeps app lifetime. It is per thread, not per document:
two documents on one thread (two embedded contexts) share it, the last install
winning for both. Pictures already shown keep the source they were given until
their <img> is rebuilt or their attributes change; handle.load_doc(handle.doc())
makes an editor’s pictures ask again.
To float controls over a picture (a button in its corner), an app hears where the pointer is:
#![allow(unused)]
fn main() {
let eye: Signal<Option<(Pos, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_image_hover(move |hover| eye.set(hover.map(|h| (h.pos, h.rect))));
}
| Method | Purpose |
|---|---|
on_image_hover(impl Fn(Option<&ImageHover>)) | The pointer came onto an image (Some), moved straight onto another (Some), or left this editor’s images (None). Called only when that answer changes; an image that was edited, or that a scroll or relayout moved, is reported again on the next move. |
image_at_host(host_id) -> Option<(Pos, Attrs)> | The image whose <img> is that host element (what the runtimes ask). |
ImageHover carries pos (before the image: where a SetNodeAttrStep for it
goes), attrs, and rect, the box the <img> is painted in, in the same frames
as LinkHover::rect. The controls are the app’s own elements outside the editor,
so moving onto them leaves the image and reports None; keep them shown while the
pointer is over them with their own onmouseenter / onmouseleave. Not reported
during a drag-select or a drag-and-drop; free while no editor has the callback.
Hover is measured on pointer moves only: a wheel scroll moves the picture under a
resting pointer and reports nothing until the pointer moves, so hide the controls
when the content scrolls (an onscroll on the scroller). On desktop the pointer
leaving the window reports nothing either (the browser reports None). Like
on_change, the callback belongs to the component that registered it: once that
component unmounts it is not called again, and one registered outside any render
keeps app lifetime.
With the callback registered:
- A paste whose clipboard holds a bitmap is offered. That includes a browser’s
“Copy image”, which also puts an
<img>on the clipboard as html: when the html is pictures and nothing else, the bitmap is what the app gets. - Image files dropped on the editor are offered one by one, in order, each as
soon as it is read, and the editor takes the keyboard. A file is a picture
when its extension says so (
png,jpg,jpeg,gif,webp), its first bytes agree, and it is at most 64 MiB (the file is read whole into memory and handed over as oneVec). The app’sonfiledrophandler gets every other file of the drop: the PDF dropped with a photo, a text file that is only named.png, a picture over the limit. A drop with no picture in it, or on a read-only editor, is the handler’s whole, as before. - A pasted bitmap has no size limit of its own: it is whatever the clipboard held, encoded as PNG on the UI thread (about 50 to 90 ms for a 4K screenshot).
- Where an image cannot be inserted (the caret is in a code block), nothing is offered: the app is not asked to store a picture that would be refused.
- An html paste that holds text as well as pictures is an ordinary paste: its
<img>elements keep thesrcthey came with.Plugin::handle_pasteis where an app rewrites or strips those.
The list above is the desktop’s. The browser build offers pictures through
the same callback, with these differences. There a picture is a File: a
pasted image file is offered as its own bytes with the browser’s name for it,
and image files dragged onto the editor from outside the page are offered at
the drop point, the editor taking the drop (and the keyboard) so the browser
does not open the file in place of the page. A file counts as a picture when
the browser calls it image/* (so an SVG or an AVIF is offered, typed as the
browser typed it), there is no size limit, and the other files of a mixed
drop are ignored: the browser build does not dispatch onfiledrop.
On a read-only editor with the callback, a drop is prevented and nothing
else happens: nothing is offered, the caret and the focus stay. It has to be
prevented, or the browser would navigate the tab to the dropped file and the
person would lose the app. With no callback a pasted image is a data: URL,
as before, and a file drop is the browser’s (which opens the file; that is
#1458).
Dark mode
The editor’s built-in stylesheet has light and dark color schemes; toggle between
them with set_dark_mode:
#![allow(unused)]
fn main() {
let dark = Signal::new(false);
let ed = editor.clone();
button {
onclick: move || {
dark.update(|d| *d = !*d);
ed.set_dark_mode(dark.get());
},
{move || if dark.get() { "Light mode" } else { "Dark mode" }}
}
}
Read-only
set_read_only(true) turns the editor into a document people can read, select and
copy, and cannot change — what readonly is to an <input>. It is a runtime switch:
flip it whenever access changes, mounted or not, and ask with is_read_only().
#![allow(unused)]
fn main() {
let editor = create_editor();
editor.load_html(¬e_html);
editor.set_read_only(!can_edit); // at open…
// …and again whenever the answer changes (a role was granted or revoked):
editor.set_read_only(!now_can_edit);
rsx! { Editor { editor: editor.clone() } } // or: Editor { read_only: true, content: … }
}
Refused (each answers false; document, undo history and on_change untouched) | Still works |
|---|---|
| Typing, IME commit, Backspace/Delete, Enter | Placing and moving the caret, pointer and keyboard selection, selectAll |
| Paste, cut, pasted images | Copy (selection_clipboard, Ctrl+C, the context menu’s Copy) |
Every document-changing command(..) — marks, block types, lists, indent/outdent, tables — and the keys bound to them | Every query: doc(), is_mark_active, current_block_type, … |
undo / redo | load_html / load_doc — the app showing a document is not the user editing one |
toggle_link, insert_image, a task checkbox click, any update(..) transaction that changes the document or sets stored marks | Remote collaboration updates (collab_receive), exactly as in an editable editor |
It makes no difference who asks: a toolbar button calling command("toggleBold") and
a keystroke go through the same gate, which sits at the one place every local change
lands rather than in each input handler. can_run(name) answers false for a refused
command, so a toolbar that greys its buttons from it goes inert with the switch; a
toolbar of your own should also read is_read_only(). The built-in context menu
offers Copy and Select all only, the OS input method stays off (no IME candidate
window), and on the web the hidden capture field is readonly, so no soft keyboard
is raised and the browser’s own menu drops Paste and Cut.
The caret stays visible — a reader selects and copies with it. The mounted container
carries data-pm-readonly="true" while the switch is on, for your own styling; the
built-in stylesheet uses it to hide the empty-editor placeholder.
Switching it on drops pending typing state (a clicked “Bold” waiting for text, an IME preedit). Switching it off gives everything back, undo history included.
Keeping the caret in view
A focused editor scrolls its caret into view after the user moves it or edits
at it: typing, Enter, Backspace/Delete, every command and the keys bound to them,
arrow keys and clicks, set_selection, paste, IME commit, insert_image and
toggle_link. For a node selection the selected
node’s outline is what is revealed, and for a cell selection the cell under its
moving (head) corner.
The scroll is the minimal one (nearest): a caret already in view moves nothing,
and one out of view is brought just inside the edge it left by, instantly rather
than smoothly. On the web that is the browser’s scrollIntoView, which scrolls
every scrollable ancestor, the page included. On desktop only the caret’s
nearest scroll container scrolls (issue #842), so an editor whose scroller is itself
off screen stays off screen.
It deliberately does not scroll when the caret’s position on screen changes without the user moving it — a user who scrolled away to read something is left where they are:
- a collaborator’s edit arriving (
collab_receive), even one above the caret; load_html/load_doc, andadd_plugin(neither is an edit);- a window resize that reflows the text, and the user’s own scrolling (including a long, virtualized editor measuring the blocks it scrolls past);
- an app transaction through
update(..)that edits the document and lets the selection be mapped rather than setting it. A transaction that callstr.set_selection(..)— astr.insert_textdoes — scrolls like typing; to reveal the caret after an edit of your own, set the selection explicitly, even to where it already is (let sel = tr.selection(); tr.set_selection(sel);); - an edit a read-only editor refuses. A read-only editor’s caret moves still scroll: they are real selection changes;
- focus on its own. A click that places the caret scrolls (it moved the
selection); a click that focuses the editor without moving the caret — a task
checkbox, a right-click on an image — does not jump to where the caret was, and
neither does
focus()when nothing is owed; - an editor that is not focused: its caret is not drawn, so a programmatic
set_selectionon it scrolls when it is next focused, not before.
Not covered: the moving end of a text range (Shift+arrow) is not revealed, and
moving the caret into a block of a virtualized editor that has never been laid out
(Ctrl+End from the top of a very long document) does not scroll to it until the
block is on screen (issue #845). An app that wants either on screen asks for it with
scroll_into_view, below.
Setting the selection from code
set_selection, command("selectAll") and any other change to the selection made
by app code — from a toolbar button, a menu, a timer or an effect — draws the caret
and the selection highlight without an input event in the editor. On desktop
(and in an embedded RinchContext) the change wakes the runtime for one layout and
overlay pass, like any other change to the page; on the web the overlays are
redrawn in a microtask, once the code that made the change has returned. Only a
focused editor draws a caret or a highlight, on both backends: set on an editor
that is not focused, the selection is shown when it is focused.
Focus and scrolling from code
Opening a note at a link — select the words the link quotes, bring them on screen, put the keyboard there — takes three calls, and no click:
#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view(from, to);
handle.focus();
}
scroll_into_view(from, to) scrolls so that the range is on screen: its start,
and as much of the rest as fits, 16px inside the scroller’s edge (where the
document extends that far). from == to reveals a caret position. A range already
in view moves nothing. It needs no focus and touches neither the selection nor
focus, and it is the only way to reveal a range: set_selection brings a caret
into view on its own, but never a range. The ends may be given in either order; a
position between blocks reveals the nearest text.
It uses the scroll the caret uses, on two hidden boxes the view places over the
range’s end and then its start, so the rules above apply: on desktop it happens
after the next layout (the call wakes the runtime for one, from an effect or a timer
as well as from an event handler) and moves the nearest scroll container; on the
web it happens during the call and scrollIntoView moves every scrollable
ancestor, the page included. A virtualized desktop editor lays out the blocks holding
from and to for it. The request waits on the handle until there is geometry for
its start (a block edited since the last layout has none on desktop); if the end has
none by then, the start alone is revealed. A later call replaces it, local edits
carry it along (text typed exactly at either edge stays outside the range), and
load_html / load_doc drop it.
A request that cannot be fulfilled does not wait for good. In an editor that is not
rendered (display: none, an inactive tab) the boxes are laid out at zero size on
both backends, so the request is used up at once and scrolls nothing; one whose start
has no geometry at all for REVEAL_PATIENCE (8) overlay passes in a row is dropped.
Either way nothing scrolls when the editor is shown later: ask again then.
scroll_into_view_aligned(from, to, align) chooses where the range lands.
ScrollAlign::Nearest is scroll_into_view itself. ScrollAlign::Fraction(f) puts
the start’s line f of the way down the scroll container’s visible height — its
padding box, inside the border, as a browser measures it (0.0 the top edge, 1.0 / 3.0 a third down; clamped to 0.0..=1.0), never nearer than
16px to either edge, and it scrolls even when the range is already in view. The
scroll is clamped to what the content allows, so a range near the top of the
document stays near the top of the view and one near the end sits lower. A range
taller than the space below f still puts its start at f. To open a note at a link
with some context above the words:
#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view_aligned(from, to, ScrollAlign::Fraction(1.0 / 3.0));
handle.focus();
}
It waits, carries and drops exactly as scroll_into_view does, and scrolls the same
containers on desktop (the nearest scroll container, vertically). On the
web it sets the nearest scroll container’s scrollTop (the page’s, when no
ancestor scrolls) and then brings the start into the view of the scrollers further
out, the page included, the “nearest” way. One limit: in a virtualized desktop
editor the blocks the scroll brings into the window were laid out at their
estimated heights when the scroll was computed and settle to their measured ones
after it, so the start can land a fraction of a line off f. Both are built on
NodeHandle::scroll_to_fraction(fraction, margin)
(DomDocument::request_scroll_to_fraction), which any element can use.
focus() gives the editor the keyboard as a press in it would — the previous
owner loses it the same way (an <input> commits its change, another editor hides
its caret) — without moving the selection. It never asks for a scroll of its own,
so giving the keyboard back after a dialog closes does not jump to a caret the user
scrolled away from. It does perform a caret scroll already owed: a
set_selection of a caret (or an edit through update that sets the selection) on
an editor that is not focused asks for its caret to be shown, and only a focused
editor draws a caret, so that scroll waits and happens when focus() lands. That is
what makes set_selection(Selection::cursor(pos)); focus() show the caret. A
range selection owes nothing (a range is never revealed on its own), which is why
the deep-link example above asks with scroll_into_view. The caret or selection
highlight is drawn where the selection already is. On desktop it posts the same
focus request NodeHandle::focus posts (which now focuses an editor container too),
and the runtime applies it through the focus arbiter after the current event or
effect; on the web it focuses the editor’s hidden capture textarea during the
call (preventScroll), as a mousedown does.
blur() is the other half: if this editor holds the keyboard, it lets go and
nothing holds it — what a press on page content that takes no focus does. Keys stop
reaching it, and its caret and selection highlight are hidden as on any blur; the
selection is kept, so a later focus() shows it again. If the editor does not hold
the keyboard, nothing happens: another editor or a field that has it keeps it. An app
with several editors calls it when a pane stops holding a document:
#![allow(unused)]
fn main() {
// The person focused an empty pane: no editor takes keys now.
for pane_editor in &editors {
pane_editor.blur();
}
}
On desktop it posts a release that the runtime applies at the same point as
focus()’s request, and the two are applied in the order they were called:
focus(); blur() in one handler or effect leaves nothing focused and
blur(); focus() leaves the editor focused. A release never displaces a focus
request posted by anything else (an overlay opening still moves focus into itself),
and several releases in one turn are all applied. Both are applied right after a
click or Enter/Space handler, otherwise before the next key press, pointer press or
IME event is routed — a key already queued behind the Escape whose on_key called
blur() does not reach the editor — and at the latest on the next turn of the event
loop. On the web it blurs the hidden
capture textarea during the call if this editor is the focused one; focus goes to
the page body, as a browser’s own blur() does.
Effects queued by a signal write earlier in the same handler run before the blur, as
they do before any NodeHandle call, so open.set(false); editor.blur() leaves
nothing focused even when the closing overlay hands focus back to the editor. A
composition in progress ends with the blur and leaves no preedit behind: the web
commits it (the browser’s compositionend reaches the editor before it lets go),
desktop drops it.
All three are no-ops before the editor is mounted, and nothing is remembered for the
mount: call them after the Editor {} has rendered.
Links: clicking and hovering
The editor never follows a link by itself: what a link means — a URL to open in a browser, another document in your app — is the app’s business. Two callbacks tell the app what the pointer does with links:
#![allow(unused)]
fn main() {
let ed = editor.clone();
editor.on_link_click(move |click| {
if !click.primary {
return false; // a plain click puts the caret in the link, as always
}
open_link(&click.link.href);
true // claimed: the caret does not move
});
let tooltip: Signal<Option<(String, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_link_hover(move |hover| {
tooltip.set(hover.map(|h| (h.link.href.clone(), h.rect)));
});
}
| Method | Purpose |
|---|---|
on_link_click(impl Fn(&LinkClick) -> bool) | A single primary-button press on a linked character, before the caret is placed. Return true to claim it: no caret move, no drag-select, no selection change (the editor still takes focus). false and the press is an ordinary one. Called in a read-only editor too. |
on_link_hover(impl Fn(Option<&LinkHover>)) | The pointer came onto a link (Some), moved straight onto a different link (Some), or left this editor’s links (None). Called only when that answer changes, never once per move. |
link_at(pos) -> Option<LinkSpan> | The link carrying the character that starts at pos, as the whole run. |
A LinkSpan is the whole link as the reader sees it: href, title (None when
empty) and its from..to range, taken across other formatting, so a link with a
bold word in the middle is one span. LinkClick carries the span and the modifiers
(ctrl, meta, shift, alt) plus primary, the platform accelerator — Cmd on
macOS, Ctrl elsewhere — which is the usual “open the link” chord. LinkHover
carries the span and rect, the box around the link’s painted run (a wrapped link
is the union of its lines), measured when the pointer came onto it: logical window
pixels on desktop, the frame bounds_signal() and root-level absolutely positioned
popups use; viewport client pixels (getBoundingClientRect) in the browser.
Both are decided by the character under the pointer, not by the nearest caret
position. The two differ at a link’s edges: a caret at either edge of a link is not
in it (a link is non-inclusive, so active_link_href() answers None there and
typing there is plain), but the pointer over the right half of the link’s last
letter, or over its first letter, is on it — and the pointer over the space after
the link is not.
Not reported: a double or triple press (they select a word or a block), the
secondary button (the context menu keeps its own link handling), a press on an image
inside a link (it selects the image), and hover during a drag-select or a
drag-and-drop. A callback runs with no internal borrow held, so it may re-enter the
handle — load another document, read the selection. It also runs untracked: a
signal it reads never becomes a dependency of an effect that happened to call
dispatch_link_click (issue #931). Like on_change, each belongs to the component
rendering when it was registered: once that component unmounts the callback is not
called any more (a press is not claimed, and the editor counts as having no click
callback), and one registered outside any render keeps app lifetime. A pointer move pays nothing for
hover while no editor on the thread has an on_link_hover callback; with one it
reuses the move’s own hit test on desktop.
The callbacks are not run inside a batch() (like on_change): a callback that
writes several signals flushes effects after each write. Wrap its body in
batch(|| …) if that matters.
In the browser an editor link is a real <a href>, and the click that follows
a press would navigate even though the editor cancels the press. Whether the
browser follows it is decided per editor:
| Editor | A click, middle click or Ctrl/Cmd+click on a link |
|---|---|
| Editable | Never navigates. A click in an editable editor is an edit gesture (it placed the caret), so following the link is always wrong. |
Read-only, with an on_link_click callback | Never navigates. The callback is where the app says what a link does, even when it declines the press. |
Read-only, no on_link_click callback | The browser’s own behaviour: a click follows the link, and a middle click or Ctrl/Cmd+click opens it in a new tab. |
The same rule covers an <a href> inside the editor that is not a link mark, but
such an anchor is never offered to on_link_click. The right-click menu is never
touched: it is the browser’s own link menu, whose “Open link” still works (see
On the web).
Keyboard activation. The editor container is not contenteditable, so an
editor link is an ordinary Tab stop, and the browser turns Enter on a focused link
into a click with no press before it. That click is offered to on_link_click with
the whole link and the modifiers held; claiming it prevents the navigation. With no
claim, the table above decides.
Migration note (web). Before link events, a click on a link in an editable web editor followed the link as it placed the caret, which was a bug; it no longer does. A read-only editor used as a document viewer keeps native link behaviour as long as it registers no
on_link_click. Registering one makes the app responsible for every link in that editor, middle click and Ctrl/Cmd+click included. To open links in an editable editor, registeron_link_clickand openclick.link.hrefyourself (for example onclick.primary, the Ctrl/Cmd+click convention). Desktop never navigates: there is no browser to follow the link.
Autocomplete popups: keys, selection and caret geometry
Four handle methods are what an app needs to run a popup off the editor, like a
[[ link picker or an @ mention list. They are the same on desktop and on the
web.
| Method | What it does |
|---|---|
on_key(|key: &EditorKey| -> bool) | Offered every key press before the editor moves the caret, runs a binding or types. true consumes it: the editor does nothing (on the web the keydown is preventDefaulted and stopped). false leaves it to the editor. |
on_selection_change(|sel: &Selection|) | Called after every change that leaves the selection different: typing, deleting, clicks, arrows, commands, app transactions, a load that resets it, a peer’s edit that maps it. Not called when the selection is unchanged. |
on_caret_moved(||) | Called once the runtime has placed the focused editor’s caret somewhere new, after layout. The moment to position the popup. |
caret_rect(pos) -> Option<ElementBounds> | Where a caret at pos is on screen (zero width), whether or not the caret is there: logical window pixels on desktop, client pixels on the web. The frame of a position: fixed popup. |
EditorKey speaks the browser’s KeyboardEvent: key is "ArrowDown",
"Enter", "Escape", "Tab" for named keys and the typed text for printable
ones ("[", "A" with Shift, " " for the space bar); primary is Cmd on macOS
and Ctrl elsewhere, beside ctrl, meta, shift, alt and repeat.
#![allow(unused)]
fn main() {
use rinch_editor_core::Pos;
let picker: Signal<Option<Pos>> = Signal::new(None); // where `[[` began
let popup_at: Signal<(f32, f32)> = Signal::new((0.0, 0.0));
// Open and close from the text before the caret.
editor.on_selection_change({
let editor = editor.clone();
move |sel| picker.set(link_trigger_before(&editor.doc(), sel.head()))
});
// Position once the text is laid out.
editor.on_caret_moved({
let editor = editor.clone();
move || {
if let Some(anchor) = picker.get()
&& let Some(r) = editor.caret_rect(anchor)
{
popup_at.set((r.x, r.y + r.height));
}
}
});
// Drive the list while it is open; every other key stays the editor's.
editor.on_key(move |key| {
if picker.get().is_none() {
return false;
}
match key.key {
"ArrowDown" => list.next(),
"ArrowUp" => list.previous(),
"Enter" | "Tab" => list.accept(), // e.g. replace `[[query` with a link via `update`
"Escape" => picker.set(None),
_ => return false, // typing narrows the list
}
true
});
}
Every callback runs with no internal borrow held, so each may call back into the
handle: read the document, run update(..) or toggle_link(..), move the
selection. A selection move made from on_selection_change calls it again, from
inside itself. Every callback also runs untracked (issue #931): an app effect
that calls set_selection, runs a command or offers a key calls these
synchronously, and the popup’s own signals a callback reads do not become that
effect’s dependencies. Each is one callback per handle (a second registration replaces the
first), like on_change, and costs nothing while none is registered.
Why on_caret_moved and not on_selection_change for geometry. On the web the
browser lays out on demand, so caret_rect is always current. The desktop runtime
lays out once per input event, after the editor has handled it, so from
on_selection_change or on_key a position in the block just edited answers
None (it has no layout yet). on_caret_moved comes from the caret pass after the
layout. The desktop may run a caret pass before an event’s layout as well, so a
call can come with None for the edited block; the one after the layout follows
in the same event. It is not called for a scroll: the caret moves with the editor
then, and a position: fixed popup does not.
What on_key is not offered. A key while an IME composition is in progress (the
input method owns it; the composed text arrives as a commit, which is typing); on
the web, a soft keyboard’s input, which arrives as beforeinput rather than keys
("Unidentified" and "Process" keys are not offered); key releases. A modifier
pressed alone is offered ("Shift", "Control"); ignore it.
With an input method on (on Linux, IBus or Wayland text-input, which the editor
switches on while it has focus), plain printable characters can arrive as input-method
commits rather than as keys, so on_key may never see the [ of a [[ trigger.
Named keys (arrows, Enter, Tab, Escape) still arrive as keys. Detect a typed trigger
from on_selection_change and the document around the caret, which sees the result
however the text came in.
What runs before on_key. Only a menu shortcut (a chord registered by an app menu
or the DOM menu bar). on_key comes before the document-wide
set_keyboard_interceptor and before the dismiss stack — Modal, Drawer and
Popover’s close_on_escape, the DOM menu bar, a <select> popup — on desktop and
in the browser alike. So a popup that claims Escape closes itself and leaves a Modal
around the editor open; an Escape it does not claim goes on and closes the modal.
Callbacks follow their component. on_key, on_selection_change, on_caret_moved
and on_change remember the component that was rendering when they were registered,
and stop being called once it unmounts, even if the EditorHandle lives on (an
app-level handle handed to a view that comes and goes). A live callback runs inside
that component, so a signal it creates belongs to it. A callback registered outside
any render (from main, a timer) keeps app lifetime.
Keyboard shortcuts
The editor handles its own keyboard input. Every shortcut below comes from the
editor-core keymap (BaseCommandsPlugin/HistoryPlugin), which both the desktop
and web views consult through one path — add a binding there and it works on every
platform. Mod = Ctrl on Windows/Linux, Cmd on macOS.
| Shortcut | Action |
|---|---|
| Mod+B / Mod+I / Mod+U | Toggle bold / italic / underline |
| Mod+E | Toggle inline code |
| Mod+Shift+S | Toggle |
| Mod+A | Select all |
| Mod+Alt+1…6 | Heading 1–6 |
| Mod+Shift+0 | Paragraph |
| Mod+Shift+7 / 8 / 9 | Task / bullet / ordered list |
| Mod+Shift+B | Blockquote |
| Mod+Shift+L / E / R / J | Align left / center / right / justify |
| Mod+Z / Mod+Shift+Z / Mod+Y | Undo / redo |
| Enter | Split block / new list item |
| Shift+Enter | Insert a hard break (line break within the block) |
| Tab / Shift+Tab | Move between table cells, else indent / outdent a list item |
| Backspace / Delete | Delete backward / forward |
| Ctrl+Backspace / Ctrl+Delete (Alt on macOS) | Delete the word backward / forward |
The four alignment chords are the Google Docs ones, and some of them are also
chords of the browser or the platform, which may take the key before the editor
sees it. Chrome reserves Ctrl+Shift+J (and Ctrl+Shift+I) for its developer
tools; Ctrl+Shift+R is the browser’s hard reload; some Linux input methods
(IBus’s emoji picker) bind Ctrl+Shift+E. The commands themselves
(setTextAlignLeft / Center / Right / Justify) always work from a toolbar.
The word-delete chord is the one binding whose modifier differs by platform: on macOS Cmd+Backspace
is the line delete, so the word deletes are Alt(Option)+Backspace / Alt+Delete
there. The desktop binding is chosen at compile time. On the web the keymap binds
no word-delete chord: the browser already turns the platform’s chord into a
beforeinput deleteWordBackward / deleteWordForward, which the web view runs
as the commands of the same name, and leaves Cmd+Backspace to its own line delete.
(Copy/cut/paste — Mod+C/X/V, and Mod+Shift+V for paste-as-plain — are handled by the platform clipboard, not the keymap.) On desktop the same four operations — cut, copy, paste and select all — are also on the built-in right-click menu of the editor (issue #813), and the menu items run exactly the code the chords run; see Right-clicking a text field. On the web the browser’s own menu covers the editor surface. Undo/redo is a single, exact history: each undo reverses one logical edit (typing is merged into a group), because every edit is an invertible step.
What a paste does
A paste is the clipboard’s text/html parsed against the schema (unknown tags and
attributes dropped), else its text/plain, one paragraph per line. The content is
then fitted to the caret, as ProseMirror and TipTap do it:
- Text and inline content go in at the caret. Several paragraphs: the first continues the line, the last takes the text after the caret.
- A list pasted on an empty line is that list — bullet, ordered (with its
start) or task (with its checkboxes). Pasted inside a line of text, its first item’s text continues the line, the other items are a list below it, and the text after the caret joins the last item. Pasted in a list item, its items become sibling items of the list the caret is in; on an empty item they take its place. That is between bullet and ordered lists, whose items are the same node (the target list keeps its kind). A task list and a plain list do not mix: one pasted in an item of the other is a list nested in that item. - A heading, a quote or a code block pasted on an empty line is that block; inside text a heading is its text, and a code block is a paragraph for each of its lines (the first continues the line; one copied line of code is just text in the line), never one paragraph holding line ends — wherever the caret is in a line that has content, its start included. A line selected whole counts as empty: the code block takes its place. A selection that runs into another line does not: the code is a line each, and what is left of the last selected line joins the last.
- A rule or a table splits the line: the text before the caret, the block, the text after. At the start or end of a line nothing is split and no empty line is left.
- In a table cell all of this happens inside the cell.
- Over selected table cells the selected cells are emptied and the content goes into the top-left one, as typing over them does. The table’s rows and columns are never changed by a paste.
- In a code block the paste is the clipboard’s plain text, exactly, line breaks included.
- Over a selection the selected content is removed first; a selection of a whole line counts as an empty line.
Copying from inside a list copies the list around the selected items, so the same kind of list arrives wherever it is pasted, in rinch or elsewhere. A selection inside one line copies as its text and marks only.
Lists copied from a browser, GitHub or Google Docs paste as lists, nested ones
included: Docs writes a nested list beside the item it belongs to
(<ul><li>a</li><ul><li>b</li></ul></ul>), and it is read as that item’s. Nothing
in a list is dropped for not being an <li>. A link or other mark around whole
blocks (a card that is one <a> around a heading and a paragraph) is kept on the
text of those blocks. A <b style="font-weight:normal">, which Docs wraps its
copies in, is not bold. All of this is the HTML reader, so load_html and
content: read the same way. (Before #1382 HTML
that began with a <meta> tag, as what Chrome and Firefox copy does, was read as
empty, and the paste fell back to its plain text.)
A paste the document has no valid place for falls back to the plain text; if that has none either, nothing happens. One paste is one undo step.
What the HTML reader keeps
load_html, content: and a paste read HTML the same way, and the reader is
total about text: no markup is refused, the text of every element is in the
document — the elements listed under “Hidden text” below aside — and the result is
always valid content. Structure it cannot keep is approximated; text is not dropped.
It is not a browser: it reads no CSS, so text hidden by display: none, hidden
or a closed <details> is read, and whitespace is kept as written.
-
Lines are where a browser puts them. A block-level element ends the line before it even when it is empty (
a<div></div>bis two paragraphs), and is a line itself when it holds something: a<div>holding only spaces is none, one that holds a non-breaking space is a blank line (and so, unlike in a browser, is one whose space is inside an inline element:<div><span> </span></div>). An inline element that holds a block ends no line by itself:x<b>y<div>z</div>v</b>wisxy,z,vw. -
An element the reader does not know — Word’s
<o:p>and<st1:place>, a custom element,<button>,<label>— is read through. It is inline unless it holds a block, soa <my-chip>b</my-chip> cis one line and<my-card><h2>…</h2><p>…</p></my-card>is its heading and paragraph. (Before #1397 the reader stopped at the first<o:p>, and a paste from Word kept its first paragraph only.) -
Hidden text is not read: comments, conditional comments (
<!--[if gte mso 9]>…<![endif]-->),<style>,<script>,<head>,<title>,<template>. Not read either, though a browser may show something for them: an<input>’s value, the options of a<select>, the fallback content of<object>/<video>/<canvas>, and<svg>and<math>(on a clipboard those are icons and the hidden copy of a rendered formula). An<svg>or<math>that is never closed ends at the first HTML element after it (<p>,<div>,<span>, …), as in a browser, so it does not take the rest of the paste with it; an unclosed<select>,<object>or<video>does, where Chrome shows what follows. An<img>with an emptysrcis no image. -
Broken markup is read as a browser reads it: a
<p>ends at the next block, an<li>at the next<li>, a cell at the next cell; an end tag with nothing to close is skipped, except a</p>, which ends the line as it does in a browser (<p>a<ul>…</ul>c</p>dreadscanddon two lines); a<that starts no tag (a < b) is text. -
A bold, italic, link or code end tag written across a block (
<b>a<div>b</b>c</div>d) ends the formatting where it is written, and what the block already held keeps it:aandbare bold,canddare not (#1410; the bold used to run to the end of the document). As in a browser, such an end tag is ignored when it would reach out of a table cell or across more than seven open blocks. One browser rule is not followed: formatting that a block’s end cut short is not carried on after it, so<p><b>a</p>bhas a boldaand a plainbwhere a browser bolds both (#1445). -
Character references are decoded as a browser decodes them (#1415): numeric ones (
é, Word’s’), every name of the HTML standard (é,+,✓,&), and the older names with no;(© 2024is© 2024,¬it;is¬it;). In a link’s address a name with no;is left alone before=, a letter or a digit, so?a=1©=2stays a query string. Anything else after an&is text (R&D,&nosuch;). -
Table parts with no
<table>are a table: a run of<td>is one row, a run of<tr>the rows of one table (#1392; they used to load as an invalid document and paste as plain text). What has no place in a table — text between its rows, a<caption>— becomes paragraphs in front of the table. That is where a browser shows it for a written<table>; for parts with no<table>a browser has no table to put it in front of, and the order can differ from both the source and the browser (<td>w1<tbody>w2readsw2, thenw1). No text is lost. Parts that do not make full rows (<td>A</td><td>B</td><tr><td>C</td></tr>) are padded with empty cells to a rectangle, as a browser renders them — up to eight empty cells for each cell written (sixteen at least). Parts whose spans ask for more (<td colspan=1000>over a few short rows) are left as written, short rows and all. A<table>is always read as it is written, short rows included. -
A heading around blocks (
<h2><div>…</div><ul>…</ul></h2>) keeps the blocks; the text between them is headings. -
A mark around blocks marks their text and images, not their line breaks, as it does around a line (#1401). Of two links or two colours, one inside the other, the text has the inner one.
-
Nesting is kept 192 elements deep (a list level costs two or three, a quote one). Deeper elements are flattened into the 192nd: every block stays a line of its own with its text, in order; what is lost is the nesting, the marks of inline elements that deep, and the attributes of a block’s later parts when it is split around the blocks inside it (the first part keeps them). Nothing overflows, and reading stays linear in the size of the input. A document nested deeper than that does not read back as itself from its own HTML — the same lines, less nesting.
What the applications people paste from write, and how it reads:
| From | Reads as |
|---|---|
| Word | Headings, paragraphs, tables, links, bold. A Word list is paragraphs that start with their bullet or number (·, 1.): that is how Word writes it, and it is kept that way rather than rebuilt into a list. |
| Excel, Google Sheets | A table (Sheets’ <google-sheets-html-origin> wrapper and Excel’s <col> are read through); one Sheets cell is a line of text. |
| Google Docs | Headings, paragraphs, links, nested lists. Bold, italic and underline are not read: Docs says them with CSS on a <span> (#1407). Text keeps the colour Docs gives it. |
| Notion, GitHub, Slack, Apple Notes and Mail, Gmail | Their headings, lists, quotes, code blocks, tables and links; a <div> per line is a paragraph per line. |
| VS Code | A code block: VS Code copies a <div style="white-space: pre"> holding one <div> per line, and a code block is what keeps the lines and their indentation. The token colours are not kept. Pasted inside a line of text, one copied line is its text, and several lines are a line of text each: the first continues the line, the text after the caret joins the last. |
Whitespace is kept as written, not collapsed as a browser collapses it, so a source that wraps its lines inside a paragraph (Word does) reads with those line ends in the text (#1406).
The rules are Transaction::replace_selection’s; see
Fitting a slice.
Pasting is asynchronous
Reading the clipboard is a request to another application, and that application can be slow or hung — on X11 the read waits up to four seconds. Ctrl+V therefore does not block: the key is consumed immediately, the clipboard is read on a worker thread, and the content is inserted when it arrives (issue #149). The editor stays live throughout, which means the user can keep typing while a slow paste is in flight.
Where does the content land, then? At the position the paste was asked for,
carried across whatever was typed in the meantime. Ctrl+V captures a
SelectionAnchor, every intervening transaction maps that anchor forward through
its steps, and the insertion happens there. Type ahead of it and the paste still
splits the text where you originally pointed; type after it and the paste is
unaffected; move the caret about and nothing happens to it at all — a
selection-only change is not a document change.
If the document is replaced while the read is in flight (load_doc /
load_html) the anchor reports None and the paste is dropped: the content it
was aimed at no longer exists, and reusing the raw offset would drop it into
unrelated text.
A collaborating peer’s edit is not a replacement. The anchor is carried across
it as across a local edit: text a peer types elsewhere shifts it, and text a
peer types in the anchor’s own paragraph leaves it at the same place in the
words. It reports None only when the peer removed, split, joined or changed
the kind of the block it sat in (a paragraph deleted, Enter pressed inside it,
a paragraph made a list item): there the place can no longer be named.
The same anchor is available to your own asynchronous insertions — an uploaded image, a completion from a model:
#![allow(unused)]
fn main() {
let anchor = editor.anchor_selection();
let editor = editor.clone();
fetch_something(move |content| {
// ... back on the UI thread ...
if let Some(sel) = anchor.selection() {
editor.set_selection(sel);
editor.replace_selection_with_text(&content);
}
});
}
Markdown shortcuts
As you type, the editor rewrites markdown shortcuts in place (the default
MarkdownInputRulesPlugin, on by default). Block shortcuts fire on a space at the
start of a line; inline mark shortcuts fire when you type the closing delimiter:
| Type | Becomes |
|---|---|
# … ###### | Heading 1–6 |
``` | Code block |
--- / *** | Horizontal rule (fires on the third character, no space) |
> | Blockquote |
- / * / + | Bullet list |
1. | Ordered list |
[ ] / [x] | Task list (unchecked / checked) |
**bold** / __bold__ | bold |
*italic* / _italic_ | italic |
~~strike~~ | |
==highlight== | highlighted |
`code` | inline code |
The horizontal rule fires only when the marker is the paragraph’s whole
content: --- typed in front of existing text, or in a heading, stays text. The
caret lands in the block after the rule, or in a fresh empty paragraph appended
after it (inside the same blockquote or list item) when nothing follows.
Inside a task list, Enter adds a new (unchecked) item, and Enter on an empty
item exits the list — just like bullet/ordered lists. Click a task’s checkbox to
toggle it done (works on desktop and web). To add your own shortcut, append
a mark_input_rule / wrapping_input_rule / textblock_type_input_rule to
markdown_input_rules() (or contribute an input_rules() set from your own plugin).
A complete toolbar
A full example pairing a command toolbar with the editor lives at
examples/markdown-editor/src/main.rs, and examples/ui-zoo/src/sections/editor.rs
shows the same pattern inside the component showcase. The shape is always:
create_editor() once, clone the handle into each button’s onclick, and place a
single Editor {} for the surface.
Put
data-nofocuson the toolbar. A focusable toolbar button takes the keyboard away from the editor when it is pressed, so Bold would apply to a selection that no longer has focus.data-nofocuson the toolbar container makes every control inside it take its click without taking the keyboard — thepreventDefault()-on-mousedown answer browsers converged on, and it works on both backends. See Taking the click without the keyboard.#![allow(unused)] fn main() { div { data-nofocus: "", class: "toolbar", button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" } } }
On the web (rinch-web)
The editor runs in the browser too — the same Editor {} / EditorHandle /
create_editor(), with identical app code. The renderer-agnostic view lives in
rinch-editor-view and projects onto rinch-web’s web_sys DOM (the model is the
single source of truth; the container is deliberately not contenteditable).
rinch-web re-exports the editor, so a web app just imports it:
#![allow(unused)]
fn main() {
use rinch_web::{Editor, create_editor};
#[component]
fn app() -> NodeHandle {
let editor = create_editor();
let ed_bold = editor.clone();
rsx! {
div {
button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
Editor { editor: editor.clone(), content: "<p>Edit me in the browser.</p>" }
}
}
}
#[wasm_bindgen(start)]
pub fn start() {
rinch_web::mount(ThemeProviderProps::default(), app);
}
}
A runnable demo is examples/editor-web (built with trunk serve). The browser
build links no rinch-dom/Parley/CRDT engine — the browser handles layout, text,
and painting.
Supported today: typing, the full command/toolbar surface, keyboard shortcuts,
caret + selection rendering (pixel-accurate overlays), click / double-click (word) /
triple-click (block) / shift-click / drag selection, arrow / word / Home-End /
vertical navigation, clipboard (copy / cut / paste — rich text/html, image, or
plain text), and IME composition (the preedit overlay matches the composing
block’s font).
Clipboard and IME ride a focused, off-screen hidden <textarea> capture target
(created on first editor focus): a plain non-contenteditable <div> receives no
paste/cut/compositionstart events, so the editor focuses the textarea to make
the browser route those native events to it — which also makes focus browser-native
so keys can’t reach the wrong control. Typed characters are still consumed by the
editor’s key handler (and never reach the textarea); only IME composition flows
through it. This mirrors the CodeMirror / ProseMirror hidden-input technique.
The editor owns a key only while that textarea holds focus (issue #271). Tab to a
button, press a focusable control (a <button>, a link, a tabindex element, a text
field), or press blank page, and the editor lets go: the next key — Enter and Space
included — belongs to whatever has focus now, and the editor stops painting its
caret, as a blurred editor does on desktop. Press inside the editor to give it the
keyboard back. A switch to another window and back is meant to leave it in place: the
textarea stays the page’s focused element, and the release waits for focus to move
within the page.
A press on a non-focusable clickable — a DOM menu-bar item, a div { onclick }
toolbar button — leaves the keyboard with the editor, as desktop does for such a
press. A focusable one takes it, DropdownMenu’s <button> items included, as on
desktop. A toolbar of real <button>s
that should leave the keyboard with the editor carries data-nofocus (see the
toolbar note above); the editor-web example’s does. A command run while the editor
keeps the keyboard with no pointer event at all — assistive technology or
element.click() on a toolbar button — still moves the caret with its edit.
Right-click gets the browser’s own editing menu (issue #814) — Paste, Cut, Copy,
Select All, and whatever else the browser puts there (emoji, extensions; the hidden
field has spellcheck off, so there are no spelling suggestions). The editor draws no
context menu of its own on the web: a page’s paste needs
navigator.clipboard.readText(), which prompts. Instead, the same hidden textarea
is what the browser’s menu is built for. The editor’s surface is not editable as
far as the browser is concerned, so a right-click there used to open the menu for a
plain element, with no Paste; now a right-button press parks the capture textarea
under the pointer — invisible, but hittable — for the instant the browser needs to
fire contextmenu and hit-test the point for its menu (CodeMirror 5’s technique),
then sends it back off-screen 100 ms later, whether or not a menu came, so a click
on the page after that reaches the page. (A click inside the textarea’s 30 px box
within those 100 ms goes to the textarea instead.) The menu’s items fire the ordinary paste /
cut / copy events on the focused textarea and are answered from the editor’s
model: a paste lands at the editor’s selection, Cut and Copy act on the editor’s
selection even when it spans blocks, and Select All — which the browser can only
apply to the textarea — is detected and becomes the editor’s selectAll, however
long the menu stayed open before it was chosen.
Where the right press lands decides the rest:
- Inside a non-empty selection — on its text, a link or an image in it — the selection is kept and the menu is the editing one, so Cut, Copy and Paste act on the selection.
- On a link or an image outside the selection (or with only a caret) nothing is
parked, so the browser’s own link or image menu opens — Open link, Copy link
address, Save image, Copy image — and there is no Paste at that spot. The caret
moves onto the link, and over an image the selection stays where it was: that is
what a native
contenteditabledoes in Chrome. - On a horizontal rule outside the selection, the rule is selected, and the menu’s Copy and Cut act on it.
- Anywhere else outside the selection, the caret moves to the press point first.
Undo and Redo in the menu act on the editor, but Chrome enables them only when its own page-wide undo stack has a step — after an IME commit in the editor, or after typing into another field on the page — so they are usually greyed out, and Redo stays greyed after a menu Undo. Use Ctrl+Z and Ctrl+Y. (Chrome aims the menu’s Undo at whichever field owns that step; the editor takes it back while it has focus, so the other field is not undone.)
The keyboard’s menu key and Shift+F10 open the same menu at the caret: the browser
sends their contextmenu to the focused element, which is the textarea, and the
editor parks it with its left edge at the caret first — at a selected horizontal
rule, which has no caret, it parks at the rule, and with neither a caret nor a
selected node — Select All in a document that ends with a rule — at the editor’s
own box. On macOS a Control-click opens the menu like a right press, and the word a
Mac right-click selects under the pointer is not mistaken for Select All; neither is
verified on a Mac. An element up the chain carrying a live data-oncontextmenu
still wins, as it does over any other element it wraps: over the whole editor,
links and images included, and for the menu key and Shift+F10, which dispatch that
handler with its click context at the caret and open no browser menu. What was measured, in Chrome 153: a real right press makes Chrome’s own
contextmenu target the parked textarea, unprevented, where before it targeted the
paragraph (outside a selection it still targets the <a> or <img>) — Chrome
builds its menu for the element its hit test finds there, so the editing menu
follows from that. Firefox and Safari are unverified.
Collaboration (optional, collaboration feature)
Two editors can share one live document. Enable the collaboration feature and the
editor projects every local edit onto a yrs (Yjs)
CRDT, broadcasts the resulting delta, and rebuilds the model from a peer’s delta when
one arrives — so concurrent edits converge. The CRDT adapter
(rinch-editor-collab) is the only thing in a
rinch app that links a CRDT engine; default builds link none of it.
rinch = { workspace = true, features = ["desktop", "collaboration"] }
One peer hosts (it owns the starting document); the others join from a
snapshot of it. Each side supplies an outbound closure — where to send a delta a
local edit produced — and feeds a peer’s delta back in with collab_receive:
#![allow(unused)]
fn main() {
// Host: project the current document onto a fresh CRDT and hand peers a snapshot.
let snapshot = host.start_collaboration_host(move |delta| transport.send(delta))?;
// Guest: adopt the host's document and start collaborating.
guest.start_collaboration_guest(&snapshot, move |delta| transport.send(delta))?;
// When a delta arrives from the network, apply it on the main thread:
guest.collab_receive(&delta_bytes);
}
On desktop, network callbacks usually arrive on a background socket/data-channel
thread — use the Send-safe entry point, which marshals the delta onto the main
thread for you:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
post_remote_delta(editor_container_id, delta_bytes); // any thread → main
}
The transport owns relaying. outbound fires only for an editor’s own local
edits — a delta that arrives through collab_receive is never re-broadcast (it’s
already in the shared CRDT; echoing it back would loop). So the transport must be a
full mesh (every peer’s outbound reaches every other peer directly) or a hub
that fans each delta it receives out to the other peers, forwarding the raw bytes
unchanged. A chain — A wired to B, B wired to C, with nothing joining A and C —
silently partitions: C never sees A’s edits, and nothing errors.
If a peer might have missed deltas — offline, reconnecting, or polling over HTTP with no persistent connection at all — reconcile with a state vector + diff exchange instead of relying on delta delivery being perfect:
#![allow(unused)]
fn main() {
// The peer that might be behind sends its state vector...
let my_sv = editor.collab_state_vector().unwrap();
transport.send(my_sv);
// ...the other side answers with what it's missing, plus its own state vector...
let diff = peer.collab_sync_diff(&my_sv).unwrap();
let peer_sv = peer.collab_state_vector().unwrap();
transport.send((diff, peer_sv));
// ...and the first peer applies it (and can answer the second peer's state vector
// the same way, to reconcile in the other direction too):
editor.collab_receive(&diff);
}
collab_receive is the same entry point for a broadcast delta and a reconciliation
diff — they’re the same wire format, so there’s no separate sync protocol and no
per-peer state to keep on either side; a stateless server can answer each state
vector it’s handed with nothing held between requests.
Never use state-vector equality as a convergence check. A state vector counts insertions only, so a deletion — or a mark removal (yrs un-formats a mark by deleting its format marker) — leaves it unchanged, and two editors can hold different documents behind equal state vectors. Always request and apply a diff instead; the diff reply always carries the full delete set, which is what actually converges the peer.
is_collaborating(), stop_collaboration(), collab_snapshot() (a fresh snapshot
for a late-joining peer to start_collaboration_guest from), and
collab_take_error() round out the API. The first milestone covers flat
text-blocks + marks (paragraphs, headings, code blocks, bold/italic/link/…),
list containers (bullet/ordered lists and list items) and block quotes, nested
into each other to any depth, tables (merged cells included, anything above inside a
cell), horizontal rules, and the inline atoms inside a line — images and hard breaks
(Shift+Enter). Task lists, and a pasted table whose rows are ragged, are still outside
it: an edit
outside that scope fails loud rather than silently diverging —
collab_take_error() surfaces it, and the CRDT is left untouched (the local edit is
not projected). Horizontal rules, images, hard breaks, block quotes and tables all
joined that scope without a new format tag, so every peer on a document must be upgraded
together: an older build accepts a rule, an image, a hard break, a quote or a table from a newer
peer and then cannot read it, which poisons its session (see below) in both
directions for as long as that content remains in the document — for a rule, an
image or a hard break it heals when the last one is deleted. A quote may never
leave: one emptied by two people deleting its contents at the same time is no
longer shown, but it stays in the shared document for good (deleting it could race
a third person typing into it), so once that has happened an older build stays
locked out of the document for its whole life. A peer joining from a snapshot that
already holds one fails the join instead.
Wrapping a paragraph in a quote or a list, or lifting it out of one, replaces that paragraph in the shared document, so if someone else is typing in it at that moment their typing is lost; both editors still end up with the same document.
Tables keep a full grid however their structure is edited at once: a row and a column added by two people meet at an empty cell, two columns added at once are both there in every row, and a row deleted while someone types in it is gone with that typing. What can still be lost is typing in a row, column or cell someone else deletes or merges away, and, when two people type at once into the same empty cell that such a concurrent row-and-column insert created, one of the two. Rows, columns and paragraphs that look alike are told apart by which one was actually edited, not by their content: deleting the first of three empty rows while someone types in the last keeps their typing.
A table read from someone else that would be absurdly large for what the shared
document actually holds (rows and columns cost a few bytes each to send, the empty
cells between them nothing) is not built: it shows up as a single empty cell instead,
and while the document holds one, the editor is frozen: it refuses every edit, as
a read-only editor does (typing, commands, paste, undo and load_doc/load_html do
nothing and answer false; the caret, selection and copying still work), while other
people’s edits keep arriving. collab_outbound_stall() reports
CollabError::OversizedTable naming the table; show it — “a collaborator added a
table too large to load; delete it to keep editing”. No edit is allowed near the
table — not even deleting it with the editor — because the empty cell is not the
table, and a change to it (or a document reloaded from an export, which cannot tell
it from an ordinary empty table) could delete the real one for everybody; and none is
kept to send later, because the next change from anyone else would wipe it. Two
things end it: collab_oversized_tables() lists the tables and
collab_delete_oversized_table(id) deletes one from the shared document for every
peer (it works while frozen); or someone else deletes or shrinks it. Nothing typed is
lost: nothing was accepted. One person’s edits never make such a table;
two people adding hundreds of rows and hundreds of columns at the same moment could.
A table you make yourself whose merged cells span far more rows and columns than it has cells (thousands of them) is refused before it is shared, and the session reports it as not syncing until you remove it.
An image’s attributes merge one by one: two people changing different attributes of
one image at the same moment (one its alt, the other its title or an app’s
data-* attribute) both keep their change, and two changing the same attribute end up with one
of the two values. An image is known by its type and its src: changing its src
makes it a new image, and a change someone else makes to the old one at that moment is
dropped. A change to an image’s attributes is kept while someone else types beside it,
presses Enter or Backspace anywhere in its line, drags it, inserts or deletes another
image next to it (an identical one included), copies it elsewhere, makes the line bold
or turns it into a heading; if they delete the image, it is deleted. Such a change
never shows on another image; pasting a picture with the same src over it counts
as the same image, so the change shows on the pasted one. Some cases lose it, and both
editors still end up with the same document when they do: changing the image’s
src, or pasting a picture with another src over it, at that moment (the
editor cannot tell the two apart, and a change dropped is better than a change shown
on the wrong picture); moving the image into or out of a table cell; cutting the
image and pasting it back, or deleting it and undoing the delete (the image comes
back as a new one: only a move made in one step, such as a drag, keeps it);
dragging an image after copying it within the editor, until either copy is changed
(the two are one image to the editor, so which one moved cannot be told); and
loading a document over it. Two people moving one image at the same moment (both
pressing Enter before it) end up with two copies of it.
Every person sharing a document must run a version with per-attribute image merging before any of them changes an image’s attributes or moves an image: an older version refuses such an image and stops syncing until it rejoins on a newer one.
Typing right after a link while someone else changes that link at the same moment
keeps their change — a new href, removing the link, or extending it over the text
after it — but the character you typed may end up inside their link rather than
plain, because it sits exactly where their change starts or ends. The same is true when they link the text right after yours to
something else. Nothing is lost and both editors converge; the typed character is
just formatted where you did not mean it (#923). A link is never brought back over text
someone unlinked, and never spreads over text nobody linked.
A runnable two-pane loopback (both editors in one window, no network)
lives at examples/collab-editor-demo/src/main.rs.
Outbound stalls: an out-of-scope edit, and how it un-sticks. The local edit that was refused is still in your document — only the CRDT declined it — so from that moment your editor holds content collaboration cannot express. Outbound is stalled: that edit and every one after it stays local, while inbound keeps working normally and the shared document stays healthy.
collab_outbound_stall() reports it. Unlike collab_take_error(), which is a
one-shot event you take and clear, this is the state — Some(err) for as long as
the condition holds — so it is what to drive a persistent indicator from:
#![allow(unused)]
fn main() {
if let Some(err) = editor.collab_outbound_stall() {
// e.g. "Not syncing — remove the task list to resume." The error names the
// content: "collab does not support this content yet: node `task_list` …".
show_banner(&err.to_string());
}
}
The cure is to remove the offending content — undo the paste, remove the task list. You do not have to re-send anything: the next edit that projects re-bases on the CRDT and broadcasts everything that accumulated during the stall, in one delta. Nothing typed while stalled is lost (issue #220).
This is not SessionPoisoned and needs no rejoin — see the next paragraph for that.
Errors: transient vs poisoned. Most collaboration errors are transient: an
undecodable blob from the transport, a local edit outside the staged scope, or a
rebuild still waiting on an out-of-order delta’s missing dependency — the session
keeps collaborating, and collab_take_error() tells you what was refused. One
class is not: inbound bytes that, once integrated, leave the shared CRDT document
unprojectable with nothing pending that could cure it (e.g. bytes from a
foreign, non-rinch yrs document, or a peer delta carrying content this build
cannot read back). yrs has no rollback, so such a session cannot receive — and,
before issue #196, it would keep broadcasting while receiving nothing, silently
partitioning the peers. The session now poisons itself instead: sticky
CollabError::SessionPoisoned on every affected call, in both directions
(local edits stop being projected/broadcast, and receives keep failing — though
they are still attempted, so an inbound update that makes the document
rebuildable again clears the poison on its own). A heal re-syncs the editor to
the converged shared document, discarding any local edits made during the
poison window — each was already refused loudly when it happened — the same
semantics as stopping and rejoining.
is_collaboration_poisoned() queries the state; the recovery in practice is
stop_collaboration() followed by rejoining from a healthy peer’s snapshot
(collab_snapshot() → start_collaboration_guest).
A read-only collaborator. set_read_only(true) on a collaborating
editor makes it a live view of a document other people are writing: collab_receive
(and post_remote_delta, and a reconciliation diff) integrates and re-projects as
ever — remote integration does not pass through the gate local edits pass through, so
there is nothing to switch off and on around it — while no local change is recorded
onto the CRDT and outbound never fires. Set it before or after joining, and flip it
mid-session when a role changes. Two consequences worth knowing:
load_html/load_docon a collaborating read-only editor are refused (load_htmlanswersfalse): with a session attached a load is a write to the shared document.stop_collaboration()first, or join the next document withstart_collaboration_guest, which adopts it without writing anything.collab_sync_diffis a pure read and still answers. A read-only client that was editable earlier in the session can hold edits its server lacks; whether to send that diff is the app’s call (a server that enforces the role will refuse it).
Sticky positions (deep links)
A Pos is a number into this editor’s document now: the next edit before it, yours
or a peer’s, makes it point somewhere else. To remember a place in a shared document (a
link to a paragraph, a bookmark, a comment anchor), ask for a sticky index instead.
It follows its character through every later edit, on every replica:
#![allow(unused)]
fn main() {
// Where the caret is, as an address that survives edits.
let anchor: Vec<u8> = editor.collab_sticky_index(editor.state().selection.head()).unwrap();
// Later, on this editor or on any peer's:
if let Some(pos) = editor.collab_resolve_sticky(&anchor) {
editor.set_selection(Selection::cursor(pos));
}
}
What it survives and what it does not:
-
Any edit elsewhere, local or merged from a peer: it moves with its character.
-
Its own character deleted: it resolves to where that character was.
-
Its block deleted:
collab_resolve_stickyanswersNone. Joining a block into the one before it counts (Backspace at a block’s start): the projection writes the joined text as a new insert, not a move. Likewise, splitting a block (Enter) before the position moves the tail into a new block, and an index on the tail then resolves to the split point. -
An edit next to its paragraph, at the top level or inside a list, a quote or a table cell. The projection finds the blocks an edit left alone by node identity, and a split or a join rebuilds both nodes it touches, so the paragraph beside it is rewritten even though its text did not change (tracked in #917). In practice, when the paragraph has a sibling at its level:
- Enter at the start of a paragraph (inserting an empty paragraph above it):
every index into that paragraph now resolves into the new empty paragraph.
That is a wrong position, not
None. - Backspace that deletes an empty line above a paragraph: every index into that
paragraph resolves to
None. - Toggling a bullet list on a paragraph replaces the block with one of another
kind:
None.
A paragraph that is alone at its level (the document’s only block, a list item’s only paragraph) fares better: an edit there keeps nothing of the level, which is then compared by content, so Enter at its start keeps its indexes.
- Enter at the start of a paragraph (inserting an empty paragraph above it):
every index into that paragraph now resolves into the new empty paragraph.
That is a wrong position, not
collab_sticky_index answers None when not collaborating, for a position that is
not inside a textblock (between two blocks), for the empty starter paragraph of a
shared document that has no blocks, and while the session is stalled or poisoned.
The bytes are plain yrs. They are a yrs StickyIndex in its v1 encoding
(StickyIndex::encode_v1), created on the text of the textblock holding the position,
with Assoc::After (or Assoc::Before at the very end of a text; an empty text names
the text itself). Nothing wraps them. So an app that keeps the shared document outside
the editor (a server, an index) can resolve one with yrs alone:
StickyIndex::decode_v1, then get_offset on a transaction of the document, then
check that offset.branch is the text of a block still under the content root.
offset.index counts UTF-16 code units, like every index of this document, but only
if you built your yrs::Doc for it. The offset unit belongs to each Doc and is not
carried in the bytes, and Doc::new() defaults to OffsetKind::Bytes, under which
get_offset gives a number in neither unit once the text has been edited (7 where the
UTF-16 answer is 4, after typing 😀ö before the position). Build the replica the way
the adapter builds its own:
#![allow(unused)]
fn main() {
use yrs::{Doc, OffsetKind, Options};
let doc = Doc::with_options(Options {
offset_kind: OffsetKind::Utf16,
..Default::default()
});
}
The
CollabDoc::sticky_index docs say the same at
the source. The adapter itself exposes the pair as CollabSession::sticky_index(doc, pos) / resolve_sticky(doc, bytes), taking the model document the session projects.
On the web
The same adapter runs in the browser — yrs compiled to wasm. Enable the
collaboration feature on rinch-web:
rinch-web = { path = "...", features = ["collaboration"] }
The EditorHandle collab API is identical to desktop, with two web specifics:
- Inbound is a direct call. Wasm is single-threaded, so a transport callback
(e.g. a
WebSocketonmessage) already runs on the main thread — callhandle.collab_receive(&bytes)(orcollab_receive_for(container_id, &bytes)) directly. There is nopost_remote_deltaon web (that is the desktop runtime’s off-thread marshaller). - No randomness shim needed. yrs carries its own
fastrand/jssource and builds forwasm32-unknown-unknownwith no extra features — unlike Automerge, which needed the app to adduuid = { version = "1", features = ["js"] }just to make the wasm build compile.rinch-web’scollaborationfeature now needs nothing else configured by the app.
A runnable two-pane web loopback is examples/collab-editor-web (built with
trunk serve), the browser counterpart of collab-editor-demo.
Where to go next
- Rich-Text Editor — the document model, schema, transactions, commands, history, and the view seam in depth.
examples/markdown-editor— a standalone editor app (great for MCP-driven iteration; built with thedebugfeature).examples/collab-editor-demo— two editors sharing one CRDT, live.
Rich-Text Editor
Rinch’s rich-text editor is a ProseMirror-faithful architecture in idiomatic
Rust: a pure, renderer-agnostic core (rinch-editor-core) plus an equally
renderer-agnostic view (rinch-editor-view) that projects it onto whichever host tree
the platform provides — rinch-dom on desktop, the browser DOM on web. This page is the
conceptual guide to that core — the document model, schema, steps and transactions,
state, commands, history, and the view seam.
Just adding an editor to a screen? Start with the Rich-text editing guide —
create_editor(), theEditor {}component, and the command API. This page is the layer underneath it.
The one big idea
There is exactly one source of truth and exactly one way to change it:
EditorStateis a value —{ doc, selection, stored_marks, plugin_state }. No DOM node is ever authoritative.- Every edit is a
Transactionof invertibleSteps.state.apply(tr)returns a newEditorState; it is pure and side-effect-free. - The view is a pure function of state. The view diffs the old and new document
and patches the host tree; it renders the caret and selection from
state.selection. The host tree is never read back for content. - Input produces transactions, not DOM edits. Keys, IME, paste, and pointer selection are translated into commands/transactions.
- The schema is authoritative and enforced. Transactions are validated; invalid steps are rejected, never silently written. Serialization is total and attr-aware — no mark or node type can be dropped.
Because the host is derived from the model on every transaction, “the DOM and the model disagree” is structurally impossible.
The document model
The model is a persistent (structurally-shared) immutable tree. Cloning a Node
is a cheap Rc bump; every edit produces a new tree that shares unchanged subtrees
with the old one. That is what makes states cheap to keep in history and cheap to
diff in the view.
Node, Mark, Fragment, Slice
Node— a value in the tree. It has a schema node type, typed attrs, aFragmentof children (empty for leaves), marks (on inline/text leaves), and optional text (for thetextnode).hard_break,horizontal_rule, andimageare first-class nodes, not strings.Mark— an inline annotation (bold,italic,link{href}, …) carried on text and inline leaves, with typed attrs.Fragment— an ordered, sized,Rc-shared child list with the cut / append / replace primitives the transform engine needs.Slice—{ content: Fragment, open_start, open_end }. The open depths let a copied or pasted range merge into the surrounding structure; it is the unit thatreplaceand paste operate on.replacewants the open depths to line up with the range;replace_rangefits a slice that does not (see Fitting a slice).
#![allow(unused)]
fn main() {
use rinch::prelude::*; // Node, Mark, Slice, Schema, Selection, Pos, …
let doc = editor.doc(); // the current document Node (the save shape)
assert!(doc.child_count() >= 1);
}
One position space
The editor uses ProseMirror’s single depth-aware integer position space, Pos:
- Each node contributes 1 for its opening boundary and 1 for its closing boundary.
- Text contributes one position per Unicode scalar value (char), not per byte — byte offsets exist only transiently at platform seams (Parley layout, IME).
- The document root starts at
0;doc.content_size()is the last valid position.
Pos resolves to a ResolvedPos that answers parent(), node(depth),
before(depth)/after(depth), index(depth), and marks() — replacing flat
block/inline/offset math with one consistent model.
Schema and ContentMatch
The schema defines which nodes and marks exist and what content each node may contain. The starter-kit catalogue:
- Nodes:
doc,paragraph,heading{level},blockquote,code_block,bullet_list,ordered_list,list_item,horizontal_rule,hard_break,text,image{src, alt, title, width, data-*}, plus the table nodes. - Marks:
bold,italic,underline,strike,code,link{href},highlight{color?},text_color{color},subscript,superscript. Every mark is inclusive exceptlink(see inherited marks).
An image keeps an app’s own data-* attributes — any valid HTML custom data
attribute name (data- and at least one character, no uppercase letter, no :, at
most 64 bytes), with a string value — beside src, alt, title and width. That
is what HTML’s data attributes are for: an annotation id, a reference into the app’s
own store, whatever the app needs to find the picture again. The editor keeps them
through edits, copy and paste (each written to HTML and to the host <img> as
itself, an empty one included) and collaboration, and does nothing else with them.
- Reserved names. A name rinch’s own code reads or writes on elements is never
kept, written, stamped or read from HTML:
data-pm-*,data-rinch-*, the shell’s state familiesdata-text-sel*,data-cursor-*,data-selection-*,data-tcm-*, and the whole names inrinch_editor_core::schema::RESERVED_DATA_ATTRS—data-rid, eachdata-on…event namersx!writes (exactly those:data-oneordata-onlineare yours),data-nofocus,data-block-index,data-type,data-checked, ….is_app_data_attr(name)is the one test; a ratchet test fails when rinch’s sources start using a name that is neither reserved nor known to be safe. - Bounds. A node keeps at most 32 app data attributes (
MAX_DATA_ATTRS), each value at most 1024 bytes (MAX_DATA_ATTR_VALUE). Past either bound an attribute is dropped whole, never truncated; the first ones are kept (in document order from HTML, in name order otherwise), and of two with one name, the first. The bound is the model’s: every node of an opted-in type is built through it (create_node, aSetNodeAttrStep, a node collaboration builds from a peer’s update, which a peer that does not bound them can send), so the document holds exactly what HTML,DocNodeand the view keep. A reserved, invalid or non-stringdata-*on such a node is dropped the same way. - Paste. The default paste keeps an image’s data attributes only when rinch’s
own copy-out wrote them:
selection_clipboardmarks each element that carries them withdata-rinch-clip(serialize::CLIPBOARD_MARK, reserved, so it never reaches a document), and the paste (replace_selection_with_html,serialize::slice_from_pasted_html) keeps them only beside it. So a copy between two rinch editors keeps them with no plugin, and another application’s (Slack’sdata-stringify-type, Vue’sdata-v-…, React’sdata-reactid, a lazy loader’sdata-src) are dropped. APlugin::handle_pasteseesPasteContent::htmlwhole and can keep what it wants. A load (load_html,slice_from_html) keeps every one, bounded. An app inserting its own markup withreplace_selection_with_htmlkeeps its data attributes by writingdata-rinch-clip=""on the element that carries them (or builds the node and inserts it withupdate). The mark is a filter for other applications’ noise, not proof of where markup came from: a page can write it too, and what it then keeps still passes the reserved list and the bounds.
Set or remove one like any attribute:
#![allow(unused)]
fn main() {
handle.update(|state| {
let mut tr = state.tr();
tr.step(Box::new(SetNodeAttrStep::new(pos, "data-annotation-id", AttrValue::from("a1"))))
.ok()?;
Some(tr)
});
}
The opt-in is the node spec’s: NodeSpec::data_attrs (NodeSpecBuilder::data_attrs(true)).
The starter kit’s image sets it and nothing else does. Only a leaf node type
(an atom: <img>, <hr>, <br>) may set it — SchemaBuilder::build panics for one
with content — because HTML carries the attributes on a void element only. An
attribute a node’s spec neither declares nor keeps is dropped by validation
(to_doc, node_from_doc), and the HTML writer does not write it.
width (whole CSS pixels, absent or not positive for the natural width; read from
HTML by HTML’s dimension rules, so 320px is 320, rounded and at most 65535) is the
<img>’s width hint and HTML’s width. A GFM image  has neither
width nor data attributes, so it is written without them; an image in a table
written as HTML keeps both. Like every attr of an inline atom they merge per
attribute when collaborating: two peers changing different attrs of one image at
once both keep their change, two changing the same one converge on one value, and a
peer’s attribute follows its picture through Enter, Backspace, drags and pictures
inserted beside it, and never shows on another picture (one pasted over it with the
same src counts as the same picture: identity is type and src). A change of an
image’s src (or a picture with another src pasted over it, which the editor cannot
tell from one) makes a new picture, and a concurrent change of the old one is dropped;
so does cutting and pasting a picture, undoing its delete, or dragging it after an
in-editor copy of it. Chosen by Joe (2026-10-09): a wrong attribution is worse than a
lost one — app data keyed to a picture would show on the wrong picture
(rinch-editor-collab/tests/image_attrs.rs, atom_attr_merge.rs,
atom_identity_differential.rs).
Each node spec carries a content expression (e.g. blockquote > block+,
list_item > block+, bullet_list > list_item+). These compile to a ContentMatch
NFA that the transform engine consults to decide whether a step’s result is valid —
matchType, matchFragment, and fillBefore drive both validation and the
automatic insertion of required nodes. Required attrs (link.href, image.src,
heading.level) are applied/enforced at the step boundary, so attr-aware round-trip
is structural, not best-effort.
Node and mark types are interned per Schema instance, and compared by pointer.
NodeType/MarkType equality is Rc::ptr_eq, so two Schema::starter_kit() values
mint two bold handles that are never equal — and a Mark carries its MarkType, so
marks from different schemas never match either. One document, one Schema: build the
document, the transform, and every mark you hand it from the same Rc<Schema>.
Getting this wrong used to be silent. Transform::remove_mark matched nothing and
returned Ok, so the removal quietly did nothing; add_mark added the foreign mark
beside the document’s real one. Both now fail loud with a StepError naming the cause
(issue #217) — but only for a genuine type-identity mismatch. Removing a mark the range
simply does not carry, or a link[href=a] where the text has link[href=b], is still an
ordinary no-op. The safest way to name “whatever this document calls bold” is to read the
handle off the document (or off state.schema(), which is the same instance).
The same pointer identity is why comparing Nodes across two editor handles fails even
for structurally identical documents — compare their serialized HTML instead.
This used to reach EditorHandle too, and no longer does (#440). create_editor
mints a new Rc<Schema> per handle, so b.load_doc(a.doc()) hands b a document built
by a’s schema — but load_doc (and therefore load_html, and the collaboration guest
join) now re-homes the incoming document onto the receiving schema first
(Node::rebind), matching every node and mark type by name against b’s own interned
handles before installing it. b.is_mark_active("bold") then answers true over the
adopted bold text, and toggleBold removes the mark rather than returning false (the
pre-#440 guard’s refusal) or — on an unfixed build older than #217’s guard — duplicating
it. b.load_doc(a.doc()) is the documented way to move a document between handles; no
manual serialization round-trip is needed for it any more. Node::rebind costs one
pointer comparison per node/mark when the document is already on the target schema (the
guest-join case: projected_doc already builds through the guest’s own schema), and a
rebuild only for a genuinely foreign type.
Serialization
The durable save/load shape is a recursive, schema-derived structure (under the
serde feature). Serialize walks the schema, so every node type and mark type has a
name and there is no string-tag fallthrough; deserialize consults the schema and
rejects unknown types rather than dropping them silently. HTML serialization
derives tags from the schema’s parse_html_tags, so copy-out and paste-in share one
table — the same one Editor’s content: prop and load_html use.
The HTML import is total about text (#1397): slice_from_html does not fail on
markup, the text of every element it reads is in the slice it returns (it reads no
CSS, and drops a fixed set of elements whole: scripts, styles, embedded content,
<select>, <svg>, <math>), and the slice’s content is valid. It is two passes. The tree builder
(serialize/html_tree.rs) follows the HTML parsing rules as far as they decide what
is text and what holds what: a tag name runs to the next whitespace, / or > (so
<o:p> is an element — the old tokenizer stopped reading at the colon); comments,
<![if …]>, <style>, <script>, <title> and <head> give nothing; a <p>,
<li>, <td> or <tr> ends where a browser ends it; an end tag closes the nearest
open element of its name unless a table cell (or, for an inline element, a block) is
in between; a formatting element’s end tag (</b>, </i>, </a>, …) across an open
block ends the element there and leaves what the block already holds formatted
(#1410: the adoption agency algorithm, done by turning the element’s stack entry
into a placeholder and copying the element around what each element open above it
holds, so the tree gets no deeper; skipped with more than 7 blocks or 32 elements
open in between); a </p> with no <p> to close ends the line; an unclosed <svg>
or <math> ends at the first HTML element. Character references are the HTML
standard’s 2,231 (#1415; html_entities_table.rs is generated by
tools/gen_html_entities.py from the standard’s entities.json). The tree
is at most 192 elements deep (html_tree::MAX_DEPTH), so the reader’s recursion is
bounded (10,000 nested <div>s overflowed the stack): an element opened deeper is
still open, so its end tag is its own, but it is flattened into the element at the
limit — a block as a child of it, split around the blocks inside it, an inline
element as its content alone — so past the limit no text is lost and every block
is still a line of its own. Chrome’s limit is 512 and it too keeps every element; 192 is what an
unoptimized build reads on a 2 MB stack (about 6.4 KB a level). A tag scans the
innermost 192 open elements for the one it closes and past those asks an index of
where each name is open (open_at), so it closes what an unbounded scan would at a
fixed cost: reading stays linear with thousands of elements open, and a block’s end
tag ends it however many unclosed elements are inside. The reader (serialize/html.rs) then maps the tree onto the schema:
- An element with no node or mark is read through. It is inline unless it holds a
block or is a block-level HTML element (
div,section, …). - A block-level element that holds no block ends the line before it (#1413) and is
a line itself when the text it holds directly is more than ASCII whitespace (a
non-breaking space); empty, or holding only spaces and line ends directly, it is
none. (A space inside an inline element in it still makes a paragraph:
<div><span> </span></div>.) An inline element that holds a block ends none: its children are read where it stands, so the inline content beside the block stays on the line of what is beside the element. - A run of table parts with no
<table>is a table; what has no place in a table (text in a row, a<caption>) is read as blocks in front of it, as a browser’s foster parenting puts it. - A textblock element (a heading) that holds blocks is not flattened: its blocks stay blocks and the inline content between them takes the element’s type.
- A
<div style="white-space: pre">is a code block, one line per<div>or<br>(what VS Code copies); so is<pre>, where a<br>is a line end too. - A mark element around blocks marks what the same element marks around inline content: text and images, not hard breaks (#1401). Mark elements nested directly in one another mark the blocks once for each mark type; with a block-level element between them it is once a level, as before. Neighbouring text with the same marks is one text node, and a mark of a type replaces an outer mark of that type.
tests/html_reader_total_1397.rs is the property test: a tag-soup generator that
knows what a browser shows, checked for text conservation, validity and reading back
the same, plus a step-count pin that reading is linear;
tests/html_reader_clipboard_samples.rs holds what Word, Excel, Sheets, Docs, Notion,
GitHub, VS Code, Apple Notes and Mail, Slack and Gmail write.
The HTML import reads its three integer attributes as Chrome 153 does (#1164):
<ol start> by HTML’s rules for parsing integers (" 3", "3abc" and "2.5"
are 3, 3 and 2; a value past i32 is the default 1), and colspan / rowspan
clamped to 1..=1000 and 0..=65534, a value too large to parse being the maximum,
and a negative or unparsable value 1.
The model has no “to the end” span, so rowspan="0" is imported as the number of
rows left in the cell’s row group (<thead>, <tbody>, <tfoot>, or a run of
bare <tr>s), counting its own — rows the import keeps: a <tr> with no cells is
dropped and not counted, where Chrome counts it. Any other rowspan is cut at the
same place, the end of its row group, which is where Chrome 153 cuts it when it
lays the table out (#1176).
A pasted row’s spans also reach at most 1000 grid columns
(tables::MAX_IMPORTED_ROW_WIDTH), counting the columns that rowspans from the rows
above carry into it: a colspan is cut to what is left, never below 1, and a cell
that starts in a row already 1000 columns wide spans that row alone, so it carries
nothing down (#1176). Each such cell adds one column, so a row is at most 1000 columns
plus one per cell that found it full. That limit is rinch’s, not Chrome’s (Chrome lays
out a row of 3001 columns); without it a row’s width grew with every rowspan above it,
and a 57 KB paste made a 1,000,000-column table.
A loaded document keeps no colspan past 1000 either (#1214, Chrome’s limit):
tables::cap_colspans cuts every larger one to tables::MAX_COLSPAN, and
Schema::node_from_doc, EditorHandle::new and EditorHandle::load_doc call it, as
the HTML import (load_html, paste, Editor’s content:) already reads colspan.
One colspan = 3,000,000 cell made a two-row table 2^21 columns wide, and
addRowAfter built a cell per column: 2,097,152 cells, 15.6 s and 5 GB through a
mounted view, 3.8 ms after the cap. Edits are not capped — an app’s own transaction,
a peer’s change (and the shared document a collaboration guest adopts on joining), or
a command (addColumnAfter across a 1000-wide cell makes it 1001) — so node_from_doc is not a lossless inverse of to_doc for a cell that went
past 1000 that way. rowspan is not capped: a grid is never taller than its rows. The
row-width limit above is the HTML import’s alone: a loaded row of 2,100
colspan = 1000 cells is still 2,100,000 columns wide.
Whatever route a table takes into the model — paste, load_doc, an app’s own
transaction, the table commands — the grid TableMap builds over it is bounded:
tables::column_count caps width × rows at tables::grid_slot_budget, twice the
table’s cells and never less than 2^22 slots (32 MB of map). A rectangular table with
no spans fills exactly width × rows slots with as many cells, so it is never cut;
what can be cut is a grid of more than 2^22 slots and more than twice its cells, as
spans or ragged rows make one. Past the cap a cell is cut at the grid’s right edge,
and one that starts past it is in no slot, so the table commands do nothing there.
A slot no cell covers — the tail of a row shorter than the grid, or what the cap cut —
is a hole, and the table commands treat it as no cell (#1184): deleteRow and
deleteColumn pass over it, addColumnBefore/addColumnAfter give a row with a
hole at that column a new cell at its end, a row added by addRowBefore/addRowAfter
gets a cell in a hole’s column, and mergeCells grows the top-left cell over the holes
in its rectangle. Every span a command writes is the cell’s extent in the grid ± 1,
never the attribute’s own value ± 1: a colspan of i64::MAX in a two-row table
(which an app’s own transaction can still write; a load caps it at 1000),
which the grid cuts to 2^21 columns, is 2^21 + 1 after addColumnAfter across it.
The view lays a table out as that same grid (#1182): <table> is a CSS grid of
tables::column_count columns and each cell is placed by its rectangle in the map
(tables::cell_rects), not by its raw attributes — so a rowspan of i64::MAX in a
two-row table spans two grid rows. The rectangle is written as definite grid lines,
grid-column: <left + 1> / <right + 1> and grid-row: <top + 1> / <bottom + 1>
(#1209): CSS auto-placement knows no rows and packed a cell of a row that a rowspan
leaves short into the row before it, where the map has it below. A cell the map has
no slot for is laid out as a band across the whole grid (grid-column: 1 / -1) on a
row of its own (the first the grid leaves empty, normally after its last),
so its text stays visible and adds no column.
Written raw, a few cells of huge spans stacked past the 32767 grid lines Taffy 0.12
numbered a grid with, and desktop layout panicked; Taffy 0.14 (#1236) clamps a grid
axis at 10000 tracks instead and overlaps whatever lands past them. Two limits remain, both from the desktop’s
Stylo clamping a grid line, a template and every span to 10000: a table wider than
that is drawn 10000 columns wide; and an axis past 9999 tracks, whose lines would be
clamped onto one, is placed by span <n> and auto-placement instead. A table past
9999 rows keeps its column lines, so a cell is still placed below the one before it
in its column. On the desktop such a table still meets Taffy’s own clamp:
a table of more than 10000 rows overlaps its rows from the 10000th on (they lay out
in the last grid track), where Taffy 0.12 laid out up to 32767 lines correctly. A table past 9999 columns loses its row lines too, because cells
locked to their row with auto-placed columns can grow the grid past what Taffy
numbers (on Taffy 0.12 a row of four colspan = 20000 cells panicked; 0.14 clamps
the axis at 10000 tracks); there a short row’s cells are still lifted into the row
before it, on the web as well as the desktop.
Markdown
Under the markdown feature, serialize::doc_to_markdown(&doc) writes a document
as Markdown and doc_from_markdown(&schema, md) / doc_from_markdown_strict(&schema, md) read it back. Markdown is for people and language models to read and write; the
durable format is still the DocNode shape above.
The dialect is CommonMark with GFM strikethrough and pipe tables (read by pulldown-cmark), plus a small, exact set of inline HTML for what Markdown has no syntax for:
| model | written as | also read |
|---|---|---|
bold, italic, strike | **…**, *…*, ~~…~~; <strong>, <em>, <s> where a delimiter would not flank | <b>, <i>, <del> |
underline | <u>…</u> | |
highlight | <mark>…</mark>, <mark style="background-color:C">…</mark> | |
text_color | <span style="color:C">…</span> | |
subscript, superscript | <sub>…</sub>, <sup>…</sup> | |
link, image, code, hard_break | Markdown syntax; a hard break is <br> at a textblock’s end and in a heading | <br>, <br/> |
table | a GFM pipe table when it has one header row, no merged cells and one inline paragraph per cell; otherwise an HTML <table> block | |
task_list > task_item | GFM’s - [ ] … / - [x] …; in a table written as HTML, <ul data-type="taskList"> > <li data-type="taskItem" data-checked="true"> | [X] |
C is a colour is_safe_css_color accepts (the HTML paste path’s check). The writer
writes a colour only when it passes: a highlight with any other colour is written as
a bare <mark>, and a text_color with one is not written at all, so a colour that
arrived unchecked (a DocNode, a collaboration peer) never reaches the output. HTML
copy-out (node_to_html) applies the same check, so it no longer writes a colour like
var(--x) or oklch(…). Tag
names are case-insensitive; the style attribute must be lowercase and quoted (either
quote) and hold that one declaration.
Task lists (#1365). An item’s marker starts its first paragraph, and the item’s
other blocks are indented under it like any list item’s. An item that starts with
another block (a heading, a quote, a list, …) is written with the marker alone on its
line, - [ ] with a trailing space, and the block on the next line: written after the
marker, pulldown-cmark misreads a quote’s or a nested list’s later lines. A rule in that
position is written ***, because --- under the marker’s line is a setext heading to
GitHub. An empty item is the marker and its trailing space; GFM itself has no empty task
item, and GitHub shows one as the text [ ]. A bullet or task list that directly
follows another is written with * instead of - (and back), because CommonMark
continues a list across blank lines when the bullet is the same, which would turn a
bullet list and the task list after it into one list.
On reading, a bullet list whose items all start with a marker is a task_list. The
reader finds a marker in the item’s source rather than trusting pulldown-cmark’s event
for it, which arrives inside whatever paragraph comes first or, before a heading (`- [ ]
Heading), not at all. A marker with nothing after it on its line (- [ ]`, the
writer’s empty item once an editor or an LLM strips trailing whitespace) is text to
pulldown-cmark; both readers take it as an empty item’s marker when it is the item’s
whole first paragraph. A bullet item whose text is [ ] is written - \[ \], so it
stays a bullet. A marker on an item of an ordered list, or of a list where some
items have none, has no place in the model: the strict reader refuses it
(Construct::TaskList) and the lenient one gives it back as text at the start of the
item ([ ] todo; before another block, a paragraph of its own).
HTML copy-out (node_to_html, slice_to_html) writes a task list with the same
data-type / data-checked markup (TipTap’s), and slice_from_html parses it, a run
of bare <li data-type="taskItem"> (a selection inside one list) included, as a
task_list. A pasted task list is fitted like any list
(Fitting a slice): on an empty line or an empty item
it keeps every checkbox; inside text its first item’s text continues the line (that
item’s checkbox goes with the item) and the rest stays a task list. In a collaborating
editor a pasted task list stalls outbound like any other (A22).
What round-trips. A document of the starter kit’s marks and nodes written with
doc_to_markdown reads back with doc_from_markdown_strict as the same document, and
writing it again changes nothing — except for the losses below.
tests/markdown_round_trip_fuzz.rs holds the writer to that over seeded random
documents. Text is escaped so it reads back as text: Markdown punctuation, a block
marker at a line’s start, < (a tag or an autolink), an entity-shaped &, a trailing
# run in a heading, and a line break inside text (written ).
Strict reading. doc_from_markdown is lenient: what it cannot represent (other raw
HTML, unsafe URLs, footnotes, a task-list marker in an ordered or mixed list) it drops
or keeps as text. doc_from_markdown_strict parses the same way but fails with
MarkdownError::Unsupported { construct, line, source } on the first construct the
lenient read would drop or degrade, naming it with a Construct (InlineHtml,
HtmlBlock, UnmatchedTag, Footnote, TaskList, UnsafeLink, UnsafeImage,
UnsupportedMark, Other; the enum is #[non_exhaustive]) and its 1-based line.
MarkdownError::Invalid carries a schema validation error. Strict accepts everything
the writer writes (strict_reads_everything_the_writer_writes in the fuzz holds it to
that, losses included): a textblock of only hard breaks, written <br> alone on a line
— which CommonMark reads as an HTML block — reads back as one. Inside an HTML <table>
block strict refuses another tag, stray text, an unsafe href or src (UnsafeLink /
UnsafeImage), and any attribute the import does not keep — a style other than a safe
colour or text-align, a colspan above 1000, a class, an event handler, a
data-type other than taskList on a <ul>, a data-checked other than
true/false or outside a task list (HtmlBlock). What strict accepts, it parses
exactly as the lenient reader does.
Known losses.
- Whitespace at the start or end of a textblock or line is stripped (CommonMark; not in a table written as HTML, where it round-trips), and whitespace at the edge of a bold, italic, strike or link run is written outside the run: the text round-trips and that whitespace leaves the mark.
- A code block’s language is lost in a table written as HTML; two adjacent ordered
lists, or two adjacent blockquotes, merge; empty paragraphs are dropped; a link
hrefcontaining\before punctuation or an entity is decoded on read (#1366). - A line break inside inline code becomes a space (a code span is literal), and one
inside a link
hrefreads back as text. - An ordered list numbered past nine digits is not a list to CommonMark.
- A
colspanabove 1000 or arowspanabove 65534 is written clamped to those limits, which the HTML import applies anyway. - A paragraph’s or heading’s
indentis not written, and a link’stargetand a textblock’stext_alignonly in a table cell.
The transform engine
Every editing operation is a Transaction carrying one or more Steps. Steps are
invertible (for undo) and mappable (for redo, collaboration rebase, and
decoration tracking).
A deliberately minimal Step set
There is not a step per gesture. A small primitive set expresses everything; the gestures map onto it:
| Step | Purpose |
|---|---|
ReplaceStep { from, to, slice } | Replace a range with a Slice. The workhorse — text insert, delete, split, join, and paste all reduce to this. |
ReplaceAroundStep { … gap …, slice } | Replace around a preserved gap — wrapping (blockquote/list), lifting, and re-parenting block-type changes. |
AddMarkStep { from, to, mark } | Add a mark across an inline range. |
RemoveMarkStep { from, to, mark } | Remove a mark across an inline range. |
SetNodeAttrStep { pos, attr, value } | Change one node attr (heading level, image alt, list start). |
SetDocAttrStep { attr, value } | A document-level attr. |
BatchStep (Transaction::batch(Vec<BatchEdit>)) | Many disjoint replaces and node-attr changes, stated in the coordinates of the document it applies to, as one step. |
BatchStep is the one step kind that exists for cost rather than expressiveness. It
means its edits applied one at a time from the last position to the first (two
changes of one node’s attribute: the last given wins): it builds the document they
build, and its step map maps every position as those ReplaceSteps did. It differs
in three places. Each rebuilt node’s content is checked once, on the result, so it
accepts a batch whose sequence would pass through an invalid intermediate state.
Mapped over another change (Step::map, a rebase), each range maps as one
ReplaceStep of it would — and inserts at one point, and deletions that meet, are one
range from construction, so a concurrent insert between two such deletions is deleted
with them where two separate deletions would keep it. Two kept ranges the change brings
to meet end to end become one replace of both — the same document — unless one
carries an open slice: then the later edit is dropped and its content is lost,
where separate steps would apply it. An attribute change the change puts inside a kept
range is dropped as well. No table command builds an open slice. And a selection a command
does not set is mapped once through the step and resolved in its result, where a
step per row mapped it after every step: in about 0.6% of the commands the table
differential compares, the selection lands somewhere else (the old one was placed in an intermediate document). It rebuilds
each node on the way to an edit once and keeps one document in the transaction
rather than one per edit. Every table command is one BatchStep
(#1200): as a step per row, a column into 16,000 rows rebuilt and kept the row list
16,000 times (6.4 s, 2.1 GB; 29 ms as one step). The one exception is deleteRow /
deleteColumn on a table whose cells overlap, which still removes one row or
column per step on a recomputed map. Collaboration adds no step kinds.
A few gesture → step mappings:
| Gesture | Step(s) |
|---|---|
| Type a character | ReplaceStep(from, to, Slice::text(ch, stored_marks)) |
| Backspace mid-text | ReplaceStep(pos-1, pos, empty) |
| Enter / split block | a ReplaceStep that splits the textblock (open slice + new block) |
| Toggle bold over a range | AddMarkStep / RemoveMarkStep over from..to |
| Toggle bold at a cursor | no step — set stored_marks (applied to the next typed text) |
| Wrap in blockquote | wrap(range, [(blockquote, {})]) → ReplaceAroundStep |
| Paste | parse to a Slice, then replace_range(from, to, slice): one ReplaceStep, or a ReplaceAroundStep when the text after the caret moves into the pasted content |
Fitting a slice: replace_range
ReplaceStep is strict: the slice’s open depths must line up with the range, and
every node it rebuilds must be valid. Content that comes from somewhere else rarely
lines up — a list has no place inside the paragraph the caret is in — so paste goes
through Transform::replace_range(from, to, slice) (Transaction::replace_range, and
Transaction::replace_selection(slice), which also places the caret). It is
ProseMirror’s replaceRange and Fitter (transform/fit.rs), and it adds one
step or fails with nothing changed.
The rules, with | the caret and a pasted <ul><li>A</li><li>B</li></ul>:
| Where | Result | Why |
|---|---|---|
| `
a | bc` | <p>aA</p><ul><li>Bbc</li></ul> |
| ` | ` (an empty line) | the list |
`
| y` | <ul><li>xA</li><li>By</li></ul> |
| an empty item | the pasted items, in its place | as the empty line |
| `
a | bc, pasting
` or a table | <p>a</p><hr><p>bc</p> |
| a table cell | as above, inside the cell | A fit never leaves the isolating node the range starts in |
`
| y`, pasting a task list | <ul><li>xA<ul data-type="taskList"><li>By</li></ul></li></ul> |
NodeSpec::defining (ProseMirror’s) marks the nodes that are kept on an empty line:
list_item, task_item, heading, blockquote and code_block in the starter kit.
A pasted <h2> on an empty line is therefore a heading (it was a paragraph with the
heading’s text before #1382), and in the middle of a paragraph it is its text.
slice_from_html decides the open depths, since markup has none: each edge is open
down to the textblock there, and closed when it reaches none (a rule, a table). So a
list is open through its first and last items, as a selection inside it is.
What it does not do, where ProseMirror does:
- It never creates a node to make content valid (
fillBefore) and never wraps content in a node it was not in (findWrapping): the content matcher cannot be asked for either. A fit that would need one fails, and a paste then falls back to its plain text. - A range whose two ends are in different isolating nodes (two table cells) is not
fitted: only the plain
replaceis tried, as before. - A cell selection is not a range: its
from()..to()are the positions before its two corner cells.Transaction::replace_selectionrefuses one. The paste (EditorHandle) clears the selected cells (commands::table_ops::clear_cells) and fits the content in the top-left cell, in one transaction — what ProseMirror’sCellSelection.replacedoes with content that is not cells.replace_rangeitself takes positions and will fit whatever range it is given. - A slice holding an invalid node (the nodes open at its edges aside) is refused.
The HTML reader never makes one: a
<td>or<tr>with no table around it is read as a table (#1392).
The fitter keeps, per open node, how far its children have got through the content
expression (ContentMatch::start / advance / accepts_end) and steps it once per
node placed, so a paste costs in proportion to its size
(a_fit_is_linear_in_what_it_places counts the steps at n and 2n).
tests/replace_range_fuzz.rs pastes random slices over random ranges of random
documents: no panic, no invalid document, every step undoes exactly, and the content
the HTML reader makes of the fuzz’s markup (every starter-kit block, nested) always
fits at a caret or a selection inside one textblock.
A delete is fitted the same way. Transform::delete(from, to) is the plain
replace when the range’s ends are at the same depth. When they are not (from a list
item into the item nested under it, from a paragraph into a list), it fits the empty
slice: what is left of the textblock the range ends in joins the one it starts in, as
ProseMirror’s Transform.delete does, in one step. deleteSelection, Backspace and
Delete over a selection, Enter, insertHorizontalRule, the word deletes and typing
over a selection (EditorHandle::insert_text, which deletes the selection and then
inserts; Transaction::insert_text alone is the plain replace) all go through it, and leave a caret where the range began. Still
refused: a range that leaves or enters a table cell (an isolating node), a range that
holds only the boundary between two textblocks that cannot join (the end of a code
block and the start of marked text), and insertHardBreak or an image insert over a
selection whose ends are at different depths (#1460). A selected list item is emptied,
not removed (#1460). tests/review_1422.rs checks every range of random nested
documents against those rules.
Step::apply is where schema enforcement lives: a ReplaceStep whose slice
would violate the parent’s ContentMatch returns an error and the whole transaction is
rejected. Invert is mechanical; map rebases positions through a Mapping; merge
coalesces consecutive single-char replaces so typing groups naturally.
Transactions
A Transaction accumulates steps (each applied into a running doc), maps the
selection forward as steps are added, and carries stored_marks and plugin meta. You
rarely build one by hand — commands do — but the shape is:
#![allow(unused)]
fn main() {
// Inside a command, or via EditorHandle::update:
let mut tr = state.tr();
tr.insert_text("hello").ok()?; // a ReplaceStep
tr.set_selection(Selection::cursor(Pos(/* … */)));
Some(tr) // dispatched and applied by state.apply
}
State, selection, stored marks
EditorState { doc, selection, stored_marks, schema, plugins, plugin_state }
state.apply(tr) runs the transaction’s steps, then folds each plugin’s state
forward (history pushes inverted steps). Decorations are not stored and
remapped by the core: state.decorations() asks every plugin afresh for the
state being rendered, so a plugin that caches decoration ranges maps them through
tr.mapping() in its own apply.
It returns a brand-new state; nothing is mutated in place, no DOM is touched.
Selection is part of state and is mapped forward by every transaction. It is one of:
Text { anchor, head }— a text caret/range (anchor fixed, head moving).Node { pos }— a whole atom selected (an image, a horizontal rule).Cell { anchor_cell, head_cell }— a table-cell rectangle.
The caret is rendered from the selection by the view — there is no second cursor model.
Stored marks carry a tri-state for the “click Bold then type” case:
None→ inherit marks from the position context on the next insert.Some(vec![])→ explicitly no marks.Some([bold])→ the next inserted text gets bold.
Toggling a mark at a collapsed cursor sets stored_marks; toggling over a range
emits an AddMark/RemoveMark step.
Inherited marks and inclusive. With no stored marks, typed text takes the marks
of its position (ResolvedPos::marks, ProseMirror’s $pos.marks()):
- inside a text run: that run’s marks;
- at a boundary between two runs: the marks of the run before it;
- at the start of a textblock: the marks of the run after it.
A mark spec’s inclusive flag (MarkSpec::inclusive, default true, builder
.inclusive(false)) decides what happens at a mark’s end. An inclusive mark
(bold) carries on: type right after a bold word and the new text is bold. A
non-inclusive mark does not: at a boundary it is dropped unless the run after the
position carries a mark of the same type too, and at a textblock’s start it is
dropped. The starter kit’s link is the one non-inclusive mark, as in ProseMirror’s
example schema and tiptap, so for a link:
| Caret | Typed text | active_link_href() |
|---|---|---|
| inside the link | linked | the link’s href |
| at the link’s start | not linked (the text before decides) | None |
| right after its last character | not linked | None |
| right after its last character, with a different link right after | in the first link | the first link’s href |
That last row departs from ProseMirror, where the typed text is plain. With collaboration on, plain text between two links cannot be written to the CRDT without a formatting marker that brings a link back onto text nobody linked when a peer removes the second link at the same moment; text that continues the first link needs no marker at all.
is_mark_active, marks_at and Transaction::add_stored_mark read the same rule.
With collaboration on, the typed character is written to the CRDT outside the link
as well, in a way that leaves the link’s own formatting untouched, so a peer’s
concurrent change to the link — a new href, removing it, extending it over the
text after it — survives. What such a concurrent change can do is take the typed
character with it: when the peer re-writes the link or links the text after it at
the same moment, the character may end up inside the peer’s link (#923). Both editors
still end up with the same document.
Commands, keymap, input rules
A command queries the state and, if it applies, builds a transaction and
dispatches it — returning true. Called for applicability only, it reports whether
it would apply (driving toolbar enabled/disabled state). Toolbar queries read
state, never the DOM:
#![allow(unused)]
fn main() {
editor.command("toggleBold"); // dispatch by name
editor.can_run("liftListItem"); // would it apply? (enablement)
editor.is_mark_active("bold"); // toolbar "on" state
editor.current_block_type(); // e.g. Some("heading")
editor.in_node_type("bullet_list"); // ancestor-aware (lists, blockquote)
}
The built-in command catalogue is listed in the
Rich-text editing guide:
mark toggles, block-type setters, list/blockquote wrapping, indent/outdent, inserts,
the full table command set, and undo/redo.
The keymap is the single source of truth for command keys. Each platform view
translates its native event into a platform-agnostic KeyBinding and routes it through
one entry point, EditorHandle::dispatch_key, which looks up the aggregated Keymap and
runs the bound command (so Mod-b → toggleBold everywhere). Letters resolve by the
logical key (winit’s layout-mapped logical_key on desktop, event.key() on web), so
Mod-b follows the keycap on Dvorak/AZERTY; digits and symbols resolve by the
physical key (KeyCode / event.code()), so Mod-Shift-8 matches the 8 key
regardless of the shifted glyph. Only keys that can’t be pure editor-core commands stay
view-owned: cursor movement (needs laid-out geometry), clipboard (needs the platform
clipboard), and plain text insertion. Input rules are regex-driven
transforms — block shortcuts like ## → heading, - → bullet list, [ ] → task
list, and inline mark shortcuts like **bold** / ==highlight== / `code` —
each returning an optional transaction. The view runs apply_input_rules inside
EditorHandle::insert_text (before the plain insert) on every text-entry path, so a
just-typed character can complete a shortcut and rewrite the text instead of being
inserted verbatim (ProseMirror’s inputRules plugin). They only fire at a collapsed
cursor; paste and IME preedit don’t reach this path (an IME commit does).
History
There is one history, implemented as a plugin. It stores inverted steps (not
byte positions), groups them by transaction boundary, and merges consecutive typing
transactions within a time/affinity window, so a burst of typing undoes as one
step. On undo it pops a group, rebases its inverted steps over any intervening
mappings, applies them as a transaction that doesn’t re-enter history, and restores
the recorded selection. undo / redo are the only history entry points.
Plugins
History, tables, links, input rules, and (later) collaboration and accessibility are
all plugins — none is special-cased in the core. A plugin can contribute schema
nodes/marks, commands, keymap bindings, input rules, per-document state, decorations
(a widget such as the placeholder, or an inline class over a document range — a
spellcheck squiggle, a search highlight), node-views, and a claim on a paste
(handle_paste: the transaction to apply instead of the default paste, asked in
plugin order through EditorState::handle_paste). An app adds its own with
EditorHandle::add_plugin. This is how
features compose without bloating the core.
The view seam
The core defines an EditorView trait and a small request/event vocabulary; it knows
nothing about any renderer. The view that implements it — RinchDomEditorView — lives
in rinch-editor-view and is itself renderer-agnostic: it projects onto any
rinch-core DomDocument, so the desktop (rinch-dom) and browser (web_sys) editors
run the same view code. Only the thin input glue and the layout-coupled extras are
per-platform (rinch on desktop, rinch-web in the browser).
The view, on each transaction:
- Diffs the new document against the descriptor tree it retained from the previous
one. Because the model is persistent,
Node::same_ref(anRc::ptr_eq) makes the diff cheap — unchanged subtrees are skipped entirely. Siblings unchanged at the start and the end of a child list are matched first, so a new or removed block leaves the blocks after it on their own host nodes; the stretch between is diffed positionally, and a node whose tag or mark set changed is rebuilt rather than patched. - Patches the host for the changed regions via the standard
DomDocumentprimitives (create_element/create_text/append_child/insert_before/remove/set_text/set_attribute/set_style), choosing tags from the schema-driven serializer. Decorations (placeholder, IME preedit) diff separately, so a decoration-only transaction still produces a visible update. - Renders the caret and selection from
state.selection(after layout, in the second phase), converting the model’s charPosto a byte offset for the platform’s text layout only at this render edge.
Around that, the platform crates own block virtualization (skipping layout for
off-screen blocks while keeping their real height), IME positioning, and — under the
opt-in a11y feature — pushing an accessibility tree derived from the state.
End-to-end edit flow
key / IME / paste / pointer event
→ the view translates it to a command call or a transaction
→ command(state, dispatch): queries state, builds tr, dispatches it
→ new_state = state.apply(tr) // schema-validated; reject ⇒ no-op
• steps applied, selection mapped forward
• plugins fold state (history pushes inverse, decorations remap)
→ view.update(old_state, new_state) // diff doc → minimal host patches
• caret/selection rendered from new_state.selection
No step in this pipeline reads the host tree for content. That is the whole design.
Persisting content
handle.doc() returns the current document Node — the canonical save shape. Enable
the serde feature (rinch-editor-core/serde, or serde on the rinch facade) to
serialize it to the recursive, schema-derived wire shape and load it back; unknown
types are rejected on load, never silently dropped.
Where to go next
- Rich-text editing — the practical component + command API.
examples/markdown-editorandexamples/ui-zoo/src/sections/editor.rs— working editors driving the command API.- Editor Architecture — the crate boundaries, data flow, and the invariants each boundary protects.
Measuring Performance
Rinch counts the work each frame does. Every document carries a fixed set of per-frame counters: elements cascaded, Parley layouts shaped, whole-document IFC setup passes, Taffy computes, full repaints and why, pixels repainted, hit tests, effect runs, and a few phase timings. The counters are always compiled in. An increment is one store into memory the document already owns, so reading them costs nothing you would notice.
Prefer the counters to timings when you want to prove that a change made something cheaper. Timings move with the machine’s load. A counter moves only when the code does different work: “a colour-only hover now runs 0 Taffy computes instead of 1” means the same thing on every host.
There are three ways to read them.
RINCH_PERF
Set RINCH_PERF (any value) and every completed frame prints one line to
stderr: the phase times, then every counter that is not zero.
$ RINCH_PERF=1 cargo run --release -p ui-zoo-desktop
[PERF] frame 212: style 0.08ms layout 0.02ms paint 1.41ms present 0.35ms | style_resolves=1 elements_cascaded=3 ... repaint_partial=1 repainted_px=41236 surface_px=1382400 ...
The variable is read once, the first time a frame ends.
On the desktop, one redraw is one frame. On Android, one pass of the loop that
tries to present a frame is one frame. On an embedded RinchContext, one
update() plus the scene() call that follows it is one frame.
The perf_stats MCP tool
With the debug feature enabled, the perf_stats MCP tool (debug-protocol
command perf_stats) returns this JSON:
{
"frames": 212,
"last_frame": { "elements_cascaded": 3, "taffy_root_computes": 0, ... },
"current_frame": { ... },
"total": { ... }
}
Each object holds every counter, keyed by the counter’s name. Pass
reset: true to zero the counters after they are read. That gives you a way to
measure a single interaction:
perf_stats(reset: true) # start from zero
click(x: 120, y: 340) # the interaction
wait_frame()
perf_stats() # `total` now covers only the click and its frames
This is the tool to use for measurements. The DevTools panel is a poor substitute, because while it is open it rebuilds its own tree view on every dirty frame of the app, and that work shows up in what you measure.
From code
-
RinchDocument(rinch_dom):doc.tree.perfis arinch_dom::perf::PerfCounters.frame()returns the frame in progress.last_frame()returns the last frame that ended.total()returns everything counted since the last reset.end_frame()closes the current frame.reset()zeroes everything.
A document that nothing drives never ends a frame, so
frame()then covers everything since the lastreset(). -
RinchApp:last_frame_perf(),total_perf(),reset_perf()andend_perf_frame(). -
RinchContext(embed):perf_last_frame(),perf_total()andreset_perf().
rinch::perf re-exports the module. Each counter is a Counter variant with a
stable name(). FrameStats::to_json() and FrameStats::summary_line()
produce the two output formats shown above.
#![allow(unused)]
fn main() {
use rinch_dom::perf::Counter;
doc.tree.perf.reset();
doc.set_attribute(row, "class", "row selected");
doc.resolve_layout(800.0, 600.0);
let frame = doc.tree.perf.end_frame();
assert_eq!(frame.get(Counter::TaffyRootComputes), 0, "a colour change must not lay out");
}
The counters
| Group | Counter | What it counts |
|---|---|---|
| Style | style_resolves | resolve_styles calls, including the synchronous one each DOM insertion makes |
elements_cascaded | Elements that went through a full selector match and cascade | |
style_nodes_visited | Nodes the style walk visited, including cached ones | |
pseudo_element_passes | ::before/::after resolutions: one per cascaded element for each of the two that some stylesheet has a rule for, so none in a document with no such rule | |
full_restyles, full_restyle_{viewport,theme,stylesheet,dpr,root_font_size} | Requests for a whole-document restyle, in total and by reason. Two requests can share one walk (a theme change that also changes the root font-size counts twice). A resize counts under viewport only when it flips a media query’s answer | |
style_invalidations | Elements whose attribute or state change Stylo’s invalidator examined — one per changed element per resolve. What it restyled shows in elements_cascaded | |
viewport_unit_restyles | Elements a resize that flipped no media query restyled because their last cascade resolved a vw/vh/vmin/vmax (each with its subtree). A resize restyles nothing else | |
full_style_walks | resolve_styles walked from <html>, because a whole-document restyle asked it to or no layout has completed yet | |
taffy_style_syncs / taffy_style_changes | Nodes synced to Taffy, and how many of those actually changed their Taffy style | |
| Text | shape_measure_ifc / shape_measure_text | Parley layouts built inside the Taffy measure function |
shape_ifc_build | Parley layouts built by build_ifc_layouts (the layouts paint uses) | |
shape_atomic_inline | Parley layouts built while sizing an inline-block/-flex/-grid box | |
ellipsis_builds | text-overflow: ellipsis rebuilds, one per root with an overflowing line | |
ellipsis_shapes | layouts those rebuilds shaped: 2 for a per-line cut (the “…” and the result, however many lines; one more for a hard-break fallback or a step back from a kerning overshoot), plus one per prefix a whole-text cut’s binary search tried | |
shape_paint | Parley layouts built by paint itself: input values, <select> labels, the fallback for uncached text | |
shape_form_control_metrics | Parley layouts built to find an <input> / <textarea>’s primary font, whose average character width times size / cols is the control’s intrinsic width (#1177): one per control when first sized and when its font family, weight or style changes | |
shape_select_label | Parley layouts built to size an auto-width <select> from its widest option label: one per label, only when its labels or font changed since it was last sized; and, uncached, one per label each time the desktop native popup opens (to size the panel to its widest row) | |
ifc_measure_cache_hits | IFC measures answered from the per-root measure cache without shaping | |
ifc_measure_invalidations | Roots whose cached measures a restyle or content change dropped (O(1) each) | |
ifc_signature_changes | Roots a structural pass found new or changed, and so re-measures; every other root keeps its cached measures and paint layout | |
ifc_hang_passes / ifc_hang_lines | Parley layouts broken a second time to hang preserved (pre-wrap, editable) trailing spaces at a soft wrap, or to move a line break parley put after a no-break space it hung (#1218) — at most one per layout, however many lines it fixes — and the lines those passes re-broke | |
ifc_unglue_rebreaks | Breaks of one line made to move a line break parley put after a no-break space it hung (#1218): a handful per such line, logarithmic in the length of an NBSP-glued chain (a galloping search), never one per glued word | |
ifc_phantom_rebreaks | Parley layouts broken once more to drop the empty line parley commits after an inline box too wide for its line (#1050), or after a forced break — a <br>, a preserved newline — that ends the text (#1172; CSS makes a line after the last forced break only when something follows it). A min-content measure of a paragraph that ends in an atomic inline pays one, since at width 0 every box is too wide, and so does every layout of a paragraph or text leaf that ends in a forced break | |
| Layout | layout_resolves, layout_skipped_paint_only, layout_skipped_text_only | resolve_layout calls, and how many of them took each early return |
ifc_setup_passes | IFC structural setup passes, scoped or whole-document | |
ifc_scoped_passes, ifc_scope_containers, ifc_scope_nodes | Passes scoped to the formatting containers a structural change reached, and how many containers and nodes they set up again. Everything else keeps its splices, boxes, marks and Taffy cache | |
ifc_full_passes, ifc_full_{initial,theme,unattributed} | Passes over the whole document, and why: the first layout, a whole-document restyle, or NodeTree::ifc_dirty written directly with no reason | |
taffy_root_computes | Root Taffy computes (the same count as NodeTree::taffy_computes) | |
taffy_measure_calls | Calls to the measure function during root computes | |
inline_block_computes | Standalone Taffy computes that size an atomic inline | |
calc_fixpoint_passes | Extra computes run by the calc(%, px) fixpoint | |
abs_containing_block_passes | Extra computes run because an absolutely positioned box’s containing block — a positioned ancestor that is not its layout parent — came out of a compute at a size the box was not sized for: the document’s first layout, and a resize of that block. Only a box whose size depends on the block (inset: 0, width: 50%) can cause one | |
abs_boxes_visited | Absolutely positioned boxes resolved against a containing block the layout engine does not know — a positioned ancestor that is not the layout parent, or the initial containing block — one per box per pass that looks at it: the size check after each compute (ancestor boxes only), the placement as the layout is read back, a second placement when something between a box and its containing block moved late (an anonymous block box read back, an inline-block on its line — on a text-only pass too, as after a text-align change — a clamped scroll offset), and the placement a scroll runs. A box whose containing block is a positioned inline span is counted once more per layout by each of the two passes that follow the lines being built (its size check, and the second placement, which then covers every placed box). An absolute child of its own positioned parent is never counted; a scroll counts only when the scrolled box lies between such a box and its containing block | |
abs_inline_measures | Inline formatting contexts whose lines were measured for the positioned inline spans absolute boxes hang from: once per context per set of lines, however many spans it holds. 0 in a layout that rebuilt no such line | |
abs_static_lookup_steps | Looks at an inline formatting context’s notes to find an out-of-flow box’s static position in its lines (#632): one per box per placement, however many boxes the lines hold | |
abs_inline_measure_steps | What those measurements looked at — the context’s entries and their ancestors up to its root, its line items and clusters, each span’s own clusters on its first and last line. Linear in the context’s size for any number of sibling spans: twice the badges in one paragraph is twice the steps (spans nested in one another each walk their own clusters) | |
| Paint | paint_frames / paint_cached_frames | Frames actually painted, and redraws that reused the cached frame |
repaint_partial, repaint_full | Software frames limited to their damage, and full repaints | |
repaint_none | Software frames whose scene was marked dirty but whose damage named nothing on screen, so the pixels on screen were kept and nothing was painted (an alt-tab, a focus move onto a box no rule styles) | |
damage_rects | Rects the partial repaints cleared and painted. The damage is a short list (at most 8) of disjoint rects, so two small changes far apart cost their own areas and not the span between them | |
repaint_full_{first_frame,resize,unattributed,region_too_large,restyle,theme,invalidated,gpu} | The reason for each full repaint. region_too_large: the damage’s rects add up to half the surface or more. restyle: the whole document was restyled (a stylesheet, the viewport, the device pixel ratio or the root font-size changed). unattributed: something called RinchApp::mark_scene_dirty, which asks for a frame without saying what changed, and nothing else named any damage. Everything the framework itself changes names its damage — a software GameViewport frame included, since #361 paints it inline — so on a running app this counts only an embedder’s own calls, and a window re-created by the shell | |
repainted_px / surface_px | Pixels repainted, and pixels in the surface (their ratio is the fraction of the surface repainted) | |
paint_nodes_visited | Nodes paint_node visited. A box that would draw nothing — outside the window, outside the damage, or outside every clip the painter has open (a scroller’s rows past its viewport) — is dismissed by its parent’s loop without a visit, so a scroller costs its visible rows, not its list (#910) | |
removal_damage_steps | Work a removal (or a move to another parent) did to record the old pixels of what it took out: one per node, plus one per ancestor its clip-chain walk stepped through (#909). Linear in what was removed, never in what else is pending | |
stacking_order_builds | Stacking sequences built, by paint and by hit testing | |
inset_shadow_mask_px | Pixels of blurred inset box-shadow computed by paint, cropped to the render target, the open clips and a partial repaint’s damage. Rebuilt on every paint of the box, so a caret-sized repaint inside a large shadowed panel costs about its own area here (#974) | |
| Software painter | glyph_cache_hits / glyph_cache_misses | Glyphs drawn from the rasterised-glyph cache, and glyphs rasterised (then cached). A steady frame has no misses |
clip_masks, clip_mask_px | Clip masks pushed, and the mask pixels they were filled and intersected over (each clip’s own bounds, not the surface; none for a clip that fully covers the clip enclosing it, which a scroller around a partial repaint does, and no intersection for one lying wholly inside the enclosing clip’s fully covered area) | |
paint_layers, layer_px | Opacity layers opened, and the layer pixels composited back (the part of each layer anything was drawn into) | |
paint_surface_allocs | Surface-sized masks and layer pixmaps allocated rather than reused from the painter’s pool. Zero in a steady state | |
paint_surface_trims | Pooled masks and layer pixmaps released at the end of a frame because none of the last 8 frames needed that many at once | |
image_premultiplies | Images premultiplied at draw time. A cached <img> or background-image is premultiplied once, on its first software paint; a live frame source (RenderSurface, video, GameViewport) on every draw — unless every pixel of the submitted frame was opaque, which submit_frame checks on the submitting thread | |
background_tile_rasters | Background layers painted as one repeating pattern whose tile was rasterised this frame (#468): a new image, gradient or tile size. A repaint that leaves the layer alone is a cache hit, not a raster | |
background_tile_cache_hits | Background patterns whose rasterised tile came from the per-thread cache | |
opaque_image_copies | Opaque frames copied straight into the surface instead of sampled: no rotation or skew, a positive scale, destination edges on whole pixels, and no clip partial where the frame lands. Byte-identical to the sampled draw (opaque_image_copy_tests) | |
| Input | hit_tests, hit_test_nodes_visited | Hit tests run, and the nodes they visited |
hit_extents_computed | Subtree extents the hit tester computed so it can skip subtrees nowhere near the pointer. They are kept until the document or its layout changes, so a run of moves over a still document computes each one once. A scroll keeps them (a scroll container’s extent is its own box), so a wheel notch after the first computes none, not one per row | |
| Reactive | effect_runs, signal_notifies | Effect bodies run, and signal writes. A memo’s internal marker (which passes a wake on to the memo’s dependents) is not counted, nor is a recompute, nor is a dependent the equality cut-off skipped: effect_runs is the effect bodies that actually ran |
rerender_events_queued | ReRender events queued to the desktop event loop. At most one is queued at a time — writes between two event-loop turns coalesce into it — so this counts turns that had something to resolve, not writes | |
| Time (ns) | time_style_ns, time_layout_ns (time_ifc_setup_ns, time_taffy_compute_ns, time_build_ifc_ns), time_paint_ns, time_present_ns | Wall-clock time per phase, summed over the frame. Each phase call is timed, never each node; a phase can run several times a frame (every DOM insertion runs the two style phases), so this is a few clock reads per call |
Some limits on what these numbers mean:
- The reactive counters are per thread. Two documents on one thread share
them. A desktop window and its DevTools panel are two such documents, as are
two embedded contexts. While DevTools is visible, every frame’s
effect_runsandsignal_notifiesinclude its own work: the shell writes its frame-time and FPS signals on every redraw, and its panels re-run. Close DevTools before reading the reactive counters. - A frame repaints what its damage names, and nothing else. The damage is
the paint-dirty nodes (where each is now and where it was last painted),
the rects of removed nodes, and the drag ghost’s and inspect highlight’s
old and new rects. A box whose place in the paint order changed — it
started or stopped being a stacking context (
opacitycrossing 1, a transform or filter starting or ending,position: fixed/sticky) or a positioned layer, or itsz-indexmoved, by a restyle or by a transition or animation frame — names everything its subtree paints, since az-indexdescendant that overflows it changes order against the boxes beside it. Such a frame repaints the whole window when the subtree cannot be measured: it holds more than 512 nodes, aposition: fixedorstickybox, or an absolute box that escapes anoverflowbox at or under the one that flipped. A fade or a transform on a long list is therefore two full repaints (its first and last frame), where the frames between repaint the list’s own rect. A change that reaches pixels without aDomDocumentwrite must name its node (NodeTree::mark_paint_dirty) or callRinchApp::mark_scene_dirty, which is counted asrepaint_full_unattributed. - The GPU backend always repaints in full. Every painted frame on that
backend counts as
repaint_full_gpu. - The software-painter counters are the desktop and Android shells’ only.
TinySkiaPaintercounts into its ownSkiaPainterStats(stats(),take_stats()), which the shell folds into the frame after each software paint. A test painting throughpaint_documentdirectly reads the painter, not the document. - What the software painter keeps between frames. Its glyph cache (cleared past about 16 MB of accounted bytes or 512 font/size runs); a premultiplied copy of each translucent cached image; and a pool of up to 8 clip masks (1 byte per surface pixel each) and 8 layer pixmaps (4 bytes per pixel each — 33 MB at 4K). The pool is trimmed after every software frame to the most any of the last 8 frames had open at once, so an app that idles right after a deeply nested frame keeps that frame’s buffers until it paints again.
- A screenshot is a frame. The debug
screenshotcommand paints, and that paint shows up in the counters like any other frame. The capture after it adds nothing: on the software backend it reads the frame just presented back, as the GPU backend always has, rather than painting a second time (#364).
The committed baselines
crates/rinch-dom/tests/perf_counter_baselines.rs runs the paths that matter
for responsiveness on a 40-row list and asserts today’s counter values:
- idle
- a colour-only hover, with and without a
display: contentswrapper - a colour-only class toggle
- an attribute no selector reads (nothing is cascaded)
- a container class a descendant rule depends on
- appending a row
- removing a row
- setting one text node’s content
- a 1px resize, with and without
vwrules - a
transformanimation tick
crates/rinch/src/app/perf_stats_tests.rs covers the counters only the shell
fills: the full-repaint reasons, the dirty region, hit testing, and the
reactive deltas. It also pins the pointer-move path: a warm move over a
500-row scroller runs one hit test that visits the four boxes under the pointer
and builds no stacking sequence, a move with no onmousemove handler anywhere
runs one hit test, and five drag moves queued before a frame lay out once.
A press shares its hit test the same way: a click on an editor toolbar button
runs one hit test for the press and one for the release
(perf_regression_editor_tests::a_toolbar_bold_over_a_word, #908).
Each scenario in perf_counter_baselines.rs asserts the whole frame:
every counter’s exact value, with any counter the baseline does not list
expected to be zero. perf_stats_tests.rs is exact on the counters each test
names and does not check the rest of the frame. That catches a path
that got more expensive and, just as important, an increment that stopped
counting, which an upper bound alone would read as a saving. A test in the same
file checks that every counter rinch-dom owns fires on some path.
A performance fix is expected to change these numbers. Updating the baseline to
the new values is the proof that the fix worked; say in the PR which counters
moved and why. PERF_BASELINE_PRINT=1 prints each scenario’s frame in the
form the baseline is written in.
The file’s #[ignore]d perf_scenario_timings prints wall-clock times for the
same scenarios, and structural_timings_at_scale times an append, a removal and
a toggle on 500 and 2000 rows, with and without a display: contents wrapper
per row. Run them in release:
cargo test --release -p rinch-dom --test perf_counter_baselines -- --ignored --nocapture
The regression scenarios
Three more files hold scenarios under the same exact-count contract. They are new files rather than new rows in the two above so that parallel performance PRs do not collide in one file.
crates/rinch-dom/tests/perf_regression_scenarios.rshas one scenario per increment site of every counter that has more than one site.shape_painthas three (a<select>label, an<input>value, and text with no cached layout — a constructed state since #904, when aninline-flexlabel stopped reaching it),shape_atomic_inlinetwo andpseudo_element_passestwo.ellipsis_buildsis down to one site, an IFC root’s: the text-leaf rebuild was deleted in #982 once #998 had closed the last route to it, and the file keeps a scenario pinning that a flex container’s own text builds none (Chrome draws no “…” there). A per-counter check cannot see a deleted increment while a sibling site still fires; one document per site can.crates/rinch/src/app/perf_regression_tests.rsdrives a realRinchAppthe way the desktop loop does (AboutToWait, then the paint the loop asked for):- idle: a plain page, a focused
<input>, a paused animation, a runningLoader, and aLoaderin a closedDrawer(which idles: the drawer’s closed rule pauses it, #912); - keyed
forover 200 rows: move one row, insert one, remove one, and replace them all; - a wheel scroll over 500 rows;
- ten queued
Drag::absolutemoves; - a theme toggle and a scale-factor change;
- one frame per full-repaint reason:
first_frame,resize,unattributed,region_too_large,restyle,theme,invalidatedandgpu, plusrepaint_none.
- idle: a plain page, a focused
crates/rinch/src/app/perf_regression_editor_tests.rscovers a 30-paragraph editor: idle while focused, one caret blink, typing a character, ArrowRight, Enter, Backspace across a block boundary, and the toolbar’s Bold.
Each scenario also asserts a positive control, a check that it did what its name says (a repaint happened, a row moved, the document grew by one character). Without it, an idle scenario whose app never mounted would pass as a perfect idle.
Some of these scenarios do work they should not, and they pin it as it is. A scenario like that says so in its doc comment and names the counter. A fix then shows up as a baseline that falls.
The baselines are the contract
Four files assert whole frames: perf_counter_baselines.rs and the three
regression-scenario files. Each checks every non-timing counter exactly, and
any counter the baseline does not list must be 0. perf_stats_tests.rs is
exact only on the counters it names. Two counters are left out of the whole
frame: the time_* counters, which are wall-clock, and rerender_events_queued
in the shell scenarios, which is folded in from a process-wide atomic.
queued_rerender_events_are_folded_into_the_frame pins that counter on its
own, under a lock it shares with the one other test that writes the atomic.
The numbers must not depend on the host’s fonts. A declared line-height
pins only the vertical axis. A width, a caret step or a repaint rect still
comes from the glyph advances of whatever font answered, so a scenario whose
numbers include horizontal text geometry sets its text in the bundled Inter:
- the shell scenarios build every app with
perf_expect::new_app, which makes Inter thesans-serif, and set all text insans-serif; - the rinch-dom scenarios register Inter under their own family name.
Before this rule, the editor’s ArrowRight repainted 608 px on one machine and
640 px on CI (Noto Sans against DejaVu Sans). Check a new scenario under a
second font set before you push: point FONTCONFIG_FILE at a config that maps
sans-serif to DejaVu Sans, and run it both ways. The two runs must print
identical frames.
A pinned finding names its issue. Some scenarios pin work that should not happen (issues #904–#914). Each one’s doc comment names its issue and says that a fix must lower the number. A fall there is the fix proving itself, not an increment that stopped counting.
This makes the baselines the performance contract for every change:
- A PR that changes a baseline must explain it in its body. For each scenario that moved, list the counters that moved, old and new, and why. The pull request template has a Performance section for this.
- A counter that rose is a path that got more expensive. Either fix it, or accept the cost and say why.
- A counter that fell is either a fix proving itself or an increment that stopped counting. The PR says which.
- Never loosen an assertion to make it pass. Do not turn an exact value
into an upper bound, and do not remove a counter from a baseline. A value
that honestly varies from run to run (the rotated
Loader’s repainted area depends on the angle the clock lands on) gets a range with its derivation written next to it. Every other counter in that frame stays exact.
Updating a baseline
When an assertion fails, it prints the frame it saw in the form the baseline is written in, ready to paste:
editor: type one character: the frame differs from its recorded baseline.
taffy_root_computes: 1 (baseline 2)
...
Actual frame:
(StyleResolves, 2),
(ElementsCascaded, 2),
...
PERF_BASELINE_PRINT=1 prints every scenario’s frame, passing or not. Use
--test-threads=1 so the frames do not interleave:
PERF_BASELINE_PRINT=1 cargo test -p rinch-dom --test perf_regression_scenarios -- --nocapture --test-threads=1
PERF_BASELINE_PRINT=1 cargo test -p rinch --lib perf_regression -- --nocapture --test-threads=1
PERF_BASELINE_PRINT=1 cargo test -p rinch-dom --test perf_counter_baselines -- --nocapture
Run the shell scenarios with CI’s feature set as well. The theme-toggle and
gpu scenarios compile only with theme and with gpu or embed:
cargo test -p rinch --features desktop,embed,theme,clipboard,debug --lib perf_regression
Reading a frame
The names are the ones RINCH_PERF prints and perf_stats returns. To find
out why a number moved, run the same interaction in a live app:
- Call
perf_stats(reset: true). - Do the interaction.
- Call
wait_frame(), thenperf_stats().
Or set RINCH_PERF=1 and read the one line each frame prints. Close DevTools
before you read the reactive counters (see the limits above).
What a blurred text-shadow costs
A text-shadow with a blur radius is rasterised into a coverage mask, blurred
and filled once per shadowed block of text (#980). The masks are kept between
paints, so a repaint of shadowed text that has not changed, and is not cut by
the edge of the window, a clip or the damage, pays only the fill — a scroll
included, once each whole-pixel position a scroll can put each glyph at has
been drawn (a sub-pixel scroll changes a line’s mask only where it moves a
glyph to another pixel). A shadow the window, a clip or the damage cuts is
rasterised again whenever that cut moves across it. The
text_shadow_masks_rasterised counter says how many a frame rasterised. The first
paint of a page of blurred shadows costs about twice what the same page with an
unblurred shadow does, and more the larger the blur and the display scale: the
mask is the text grown by one and a half blur radii on every side, in physical
pixels. On a build with no software rasteriser (embed without
software-renderer) a blurred shadow is drawn as up to 13 copies — fewer, or
none, for a shadow of more than 153 glyphs, decided by that shadow alone.
CI regression job
Counters say what a frame did; they do not say how much it cost. The Perf
workflow (.github/workflows/perf.yml) covers cost. On every pull request it
counts the instructions each hot path executes. It counts them on the merge
commit GitHub builds for the PR, and on that commit’s first parent, the tip of
main it was merged onto. It fails when one count grew by more than a
threshold. Every run re-bases on main as it is at that moment, so the Δ says
what merging the PR would do now. Changes other PRs landed on main are not
counted against it.
It counts instructions and not time because a hosted runner’s timings move with
whatever else shares the machine, while an instruction count moves only when
the code does. The counts come from Valgrind’s Callgrind, driven by
Gungraun (the renamed iai-callgrind).
The benchmarks live in crates/rinch-bench:
| Benchmark | What one run measures |
|---|---|
dom::hover.list_500 | :hover on one row of a 500-row list (display: contents wrappers, an inline-block chip per row), then layout |
dom::class_toggle.list_500 | A colour-only class change on one row, then layout |
dom::append_row.list_500 / remove_row | Append or remove one row, then layout |
dom::resize_1px.list_500 | Layout at a viewport 1px wider |
dom::set_text_content.list_500 | set_text_content on one row’s wrapped text, then layout |
dom::flex_label_hover.list_500 | A colour-only :hover on one of 500 display: flex rows whose text is a direct child (a flex item’s text leaf), then layout and paint. Paint colours a leaf itself, so no compute runs (#904) |
dom::drawer_toggle.list_500_closed | Open, then close, a closed Drawer holding 500 rows, with the theme’s and the component library’s stylesheets loaded; each re-cascades the whole subtree. The one benchmark that sees what a component-library selector costs (#935: a pseudo-element rule is matched without a bloom filter) |
dom::inset_move.list_500 | One drag step of an absolute panel beside the list: set_styles of left/top (the inset fast path), then layout |
dom::abs_badge_relayout.direct_500 / icb_500 | One leaf restyled, then layout, in a scroller of 500 rows that each hold an absolutely positioned badge: a child of its own position: relative row (direct), or a box with no positioned ancestor (icb). Neither needs a size check after the compute, and the first is in none of the out-of-flow passes at all (#386) |
dom::abs_badge_scroll.direct_500 / ancestor_500 | One scroll of that scroller with no layout. ancestor puts a static wrapper around each badge, so its row is a containing block Taffy does not know; the scroller is still between no badge and its containing block, so the scroll re-places nothing |
dom::full_paint.text_page_warm | A full TinySkiaPainter paint of 40 wrapped paragraphs, with the glyph cache already warm |
dom::shadow_paint.fields_40 | A full TinySkiaPainter paint of 40 input-like boxes, each with a 1px border, a 6px radius and a blurred inset shadow (#974: a blurred mask built and drawn per box) |
dom::text_shadow_paint.paragraphs_40 | The same 40 paragraphs with text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) on each, painted again: every blurred mask is served from the cache (#980) |
dom::text_shadow_paint.paragraphs_40_cold | The same page restyled to a 4.01px blur first, so no mask is cached: every visible shadow is rasterised into a coverage mask, blurred and filled |
shell::pointer_move_warm.warm_x50 | 50 pointer moves inside one row of a 500-row scroller, each followed by AboutToWait |
shell::pointer_move_cold.cold | The first move after a layout, which builds the hit-test cache |
shell::hover_frame.partial_repaint | A move onto another row, then the frame: layout and a partial software repaint |
shell::keyed_for.reverse_200 | Reverse a keyed for of 200 rows, then the frame |
shell::memo_flush.selection_40 | Move the selection among 40 rows, each with a Memo<bool> and an effect: the signal write and its effect flush |
shell::branch_hide.rows_1000 | Hide an if branch holding a keyed for of 1,000 rows and a nested captured handle (issue #732: one walk detaches the handle and drops the ownership records, then the discard), then the frame |
editor::collab_keystroke.paragraphs_2000 | One keystroke in the middle of a collaborating 2,000-paragraph document, projected onto the CRDT (CollabSession::record_local): reads back the paragraph it changed and no other (#1229’s first round read back all 2,000) |
Each benchmark builds its fixture in a setup that Callgrind does not count. The
setup also runs the operation once where that warms a cache, so what is counted
is the second hover and not the first. All text is set in the bundled
Inter-Regular.ttf, so no scenario measures or draws through the host’s
fonts. That does not make a count portable: resize_1px is 111.2M
instructions on the CI runner and 147.6M on a developer workstation, because
the toolchain, glibc’s CPU-specific memcpy and the CPU all move it. Compare
two counts only when one machine produced both. The CI job runs base and head
on the same runner for this reason, and a local before/after is only
comparable with another local run.
Reading the report
The job writes a table to its summary page and to one comment on the PR, which
it edits in place on each push. A row looks like this (the numbers are from a
deliberately slowed update_hover):
| Benchmark | base | head | Δ | |
|---|---|---|---|---|
dom::hover.list_500 | 177,782 | 185,785 | +4.50% | ❌ regression |
The columns are Callgrind’s Ir, instructions executed inside the measured
operation. Two runs of the same binary agree to within about 0.03%. The
remaining noise comes from hash-table probing, whose seeds vary per process. So
any Δ larger than a fraction of a percent is the code.
The base run first uses the head’s copy of crates/rinch-bench, so both sides
run the same scenarios and every Δ is the library alone. That copy does not
build on the base when the PR adds a benchmark that calls an API the same PR
introduces (#1036). The base then runs its own copy instead, restored with
its own Cargo.lock (.github/scripts/perf_bench_base.sh). Every benchmark
both copies define is still compared; one only the head defines shows as
new, and one only the base defines as removed. Above the table, the report
always says how many benchmarks were compared and names the ones that were
not.
Two copies of the sources are only comparable while the head’s copy merely
adds to the base’s: new functions, new #[bench::…] cases, new entries in
a group list. A PR that also edits an existing scenario could make it cheaper
and hide a real regression behind a small Δ. So in fallback mode
.github/scripts/perf_bench_changes.py looks for any line the head’s copy
removes or rewrites, and any line it inserts inside an existing item (a new
statement in an existing setup). If it finds one, the job fails and the
report lists each edit with the function it is in; the
perf-regression-accepted label downgrades that to a warning, and the PR then
says what the scenario change was. The check is a text proxy, not a Rust
parser, and errs towards reporting an edit.
When neither copy runs, the outcome depends on whether the base has the crate at all:
- The base has no
crates/rinch-bench. The report shows the head alone and the job passes. No merge commit’s first parent can lack the crate once it is onmain, so this applies only to PRs based before then. - The base has the crate and neither copy ran. The job fails. Usually one
of the base’s own benchmarks panics there. Otherwise, breaking the benchmarks
would be a way past the check. The
perf-regression-acceptedlabel downgrades this failure to a warning, the same as a regression.
The scripts the job runs have a self-test against a fake cargo
(python3 .github/scripts/test_perf_scripts.py), which the job runs first.
If the head’s own benchmarks do not build or run, the job fails. The PR comment then says so, rather than keeping the previous run’s table.
The threshold and the escape hatch
The job fails when any benchmark’s Ir grows by more than 3%. Change the
threshold with the repository variable PERF_REGRESSION_THRESHOLD, a number of
percent.
A PR that makes a path more expensive on purpose can carry the
perf-regression-accepted label. With the label, the regression is still
reported, as a warning, and the job passes. Adding or removing the label runs
the job again. So does changing the PR’s base branch to main: that run
comes from .github/workflows/base-changed.yml, which calls perf.yml, and
its check is named perf / instruction-counts. A docs-only PR, which the
Perf workflow otherwise skips, gets one run there. Say in the PR which benchmark moved and why, in the pull request
template’s Performance section, the same way a counter-baseline update does
(see The baselines are the contract). The
two checks complement each other. The baselines are exact and say which work
a frame did, while the instruction counts say what that work cost. A change
can move one without the other.
The comment is posted by a separate comment job, which is the only job with
pull-requests: write and runs none of the PR’s code. It updates the comment
that carries the <!-- rinch-perf-bench --> marker and is authored by
github-actions[bot], so a human comment that quotes the marker is left alone.
A pull request from a fork gets a read-only token and cannot comment. For those
PRs the report is on the job’s summary page only. A PR that changes only
docs/** or Markdown files does not run the job.
The cargo cache holds only dependency artifacts. A fresh checkout makes every
workspace crate look stale to cargo, so those rebuild on every run anyway. Only
a push to main writes the cache, and only when its key misses. The key hashes
every Cargo.lock and Cargo.toml, the toolchain and the compiler environment.
Pull requests only read it.
Base and head are both compiled with the Rust version the head’s
rust-toolchain.toml pins. A pull request that moves the pin therefore shows no
difference from the new compiler’s code generation, and its dependency build is
cold. The job does not measure what a toolchain bump itself costs.
Allocation is not what it costs in a real frame
glibc’s malloc and free cost a different number of instructions depending
on what the heap already holds. Which blocks can merge and which bin a free
lands in depend on everything the process did before, and the order of a
setup’s allocations follows HashMap iteration, which std seeds randomly.
Measured on set_text_content, identical runs gave 5.34M, 5.71M and 5.83M
instructions, and every instruction of the difference was inside malloc.c.
So the benchmark binary installs rinch_bench::alloc::BenchAlloc. Inside the
measured operation, every allocation bumps a pointer into a region reserved in
setup, and every free does nothing. Both cost the same few instructions each
time. Outside the operation, the system allocator handles everything. The cost:
an allocation counts as a few instructions instead of glibc’s 50 to 150, so a
change that only adds allocations moves the count less than it moves a real
frame. The code that builds what it allocates is still counted in full.
Running it locally
You need Valgrind and the Gungraun runner, at exactly the version of the
gungraun library in Cargo.lock:
sudo apt-get install valgrind
cargo install gungraun-runner --version 0.19.4 --locked
cargo bench -p rinch-bench # all benchmarks, human-readable
cargo bench -p rinch-bench -- '*::hover::*' # one benchmark (a glob over the module path)
Each run is compared with the previous run of the same benchmark on disk, so running before and after a change prints the Δ directly. To reproduce the CI table, save each side as JSON and compare the two files:
cargo bench -p rinch-bench -- --output-format=json --callgrind-args=--cache-sim=no > before.jsonl
# ...make the change...
cargo bench -p rinch-bench -- --output-format=json --callgrind-args=--cache-sim=no > after.jsonl
python3 .github/scripts/perf_compare.py --base before.jsonl --head after.jsonl
Each benchmark leaves a Callgrind profile at
target/gungraun/rinch-bench/hot_paths/<group>/<bench>/callgrind.*.out.
callgrind_annotate --inclusive=yes <file> or KCachegrind shows where the
instructions went.
A full run takes about 40 seconds on a 24-core workstation with
--parallel=4. Valgrind runs code about 50 times slower than native, and the
benchmarks are sized for that: 500 rows where the operation touches one row,
200 where it touches every row.
Architecture Overview
Rinch is a lightweight cross-platform GUI library for Rust using fine-grained reactive rendering. The architecture emphasizes surgical DOM updates rather than full re-renders, with platform abstraction for desktop and web targets.
Core Principle: Fine-Grained Reactivity
Signal.set()
→ Effect runs
→ NodeHandle.set_text() / set_attribute() / set_style()
→ Direct RinchDocument mutation
→ mark_dirty() for re-layout
Key insight: app() runs once to build the DOM. Effects handle all reactive updates surgically. No HTML regeneration for simple updates.
Crate Structure
rinch/
├── crates/
│ ├── rinch/ # Main application crate
│ │ ├── src/app/ # Platform-agnostic RinchApp
│ │ └── src/shell/ # Desktop backend (winit + wgpu)
│ ├── rinch-core/ # Core types, reactive primitives, DOM abstractions
│ ├── rinch-macros/ # rsx! proc macro
│ ├── rinch-dom/ # HTML/CSS DOM (Taffy + Parley + Stylo + Vello)
│ ├── rinch-platform/ # Platform abstraction traits
│ ├── rinch-web/ # Browser-native DOM backend (WebDocument over web_sys)
│ ├── rinch-editor-core/ # Rich-text editor model (pure, renderer-agnostic)
│ ├── rinch-editor-view/ # Rich-text editor view (desktop + web projection)
│ ├── rinch-editor-collab/# Optional CRDT collaboration adapter (yrs)
│ ├── rinch-theme/ # Theme system (CSS variables)
│ ├── rinch-components/ # UI component library
│ ├── rinch-editable/ # Single-line <input>/<textarea> editing primitives
│ ├── rinch-clipboard/ # Cross-platform clipboard
│ ├── rinch-tabler-icons/ # 5000+ Tabler Icons
│ ├── rinch-debug/ # Debug IPC server
│ └── rinch-mcp-server/ # MCP server for Claude
└── examples/
├── ui-zoo-desktop/ # Desktop component showcase + rich-text editor
└── ui-zoo-web/ # Web (WASM) component showcase using browser-native DOM
These are the principal crates; the workspace Cargo.toml has the full member list
(networking, storage, media, testing and platform-specific crates are omitted here).
Layer Diagram
┌──────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (your app, ui-zoo-desktop, ui-zoo, etc.) │
├──────────────────────────────────────────────────────────────┤
│ rinch │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RinchApp (app/) │ │
│ │ Platform-agnostic application logic │ │
│ │ handle_event(PlatformEvent, …) -> Vec<AppAction> │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Desktop │ │ Web │ │ (future) │ │
│ │ winit+wgpu │ │ web-sys │ │ mobile │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ rinch-platform │
│ PlatformWindow, PlatformRenderer, PlatformEventLoop │
│ PlatformEvent, AppAction, KeyCode, Modifiers │
├──────────────────────────────────────────────────────────────┤
│ rinch-core │
│ Signal, Effect, Memo, RenderScope, NodeHandle, DomDocument │
├──────────────────────────────────────────────────────────────┤
│ rinch-dom │ rinch-theme │ rinch-components │
│ (HTML/CSS) │ (CSS vars) │ (component library) │
├──────────────────────────────────────────────────────────────┤
│ External Crates │
│ Taffy (layout) │ Parley (text) │ Stylo (CSS) │ Vello/tiny-skia │
└──────────────────────────────────────────────────────────────┘
Cross-Platform Architecture
Rinch uses a platform abstraction pattern to support multiple backends (desktop, web, mobile):
#![allow(unused)]
fn main() {
// Platform-agnostic application logic
impl RinchApp {
pub fn handle_event(
&mut self,
event: PlatformEvent,
window_size: (u32, u32), // physical pixels (pointer coords are logical)
scale_factor: f64,
) -> Vec<AppAction> {
match event {
PlatformEvent::MouseDown { x, y, button } => {
// Handle click, update DOM
vec![AppAction::RequestRedraw]
}
PlatformEvent::KeyDown { key, modifiers, .. } => {
// Handle keyboard input
vec![AppAction::RequestRedraw]
}
// ...
}
}
}
}
window_size is always the physical surface size — the one genuinely
physical quantity crossing this boundary. Every shell converts it to the
logical (CSS-pixel) layout viewport with the shared rinch_platform::to_logical
— never by dividing inline — so that mount, resize and every other relayout
agree on the same viewport. Inside RinchApp this conversion happens once per
handle_event call via RinchApp::layout_viewport.
The pointer coordinates carried by PlatformEvent are the opposite: they
are logical on every host, and it is each shell’s job to convert before
constructing the event, with rinch_platform::to_logical_point. The document is
laid out in CSS pixels and hit_test probes that layout tree directly, so a
shell that forwards its windowing system’s physical pointer position displaces
every click by the scale factor times its distance from the window origin
(issue #299 — the desktop shell did exactly this until it was fixed).
MouseWheel’s pixel delta converts the same way.
Platform backends implement traits from rinch-platform:
PlatformWindow- Window creation, properties, frame buffer accessPlatformRenderer- GPU rendering (wgpu for desktop, browser-native DOM for web)PlatformEventLoop- Event loop integrationPlatformMenu- Native menu support
Event flow: Platform backend → PlatformEvent → RinchApp.handle_event() → Vec<AppAction> → Platform backend
This separation allows:
- Core app logic to be platform-agnostic
- Easy testing with mock platforms
- Adding new platforms by implementing the traits
- Sharing code between desktop and web builds
Key Components
rinch-core
The foundation layer containing:
- Reactive primitives -
Signal::new(),Effect::new(),Memo::new(), plus scopes/ownership andcreate_context()/create_store() - DOM abstractions -
RenderScope,NodeHandle,DomDocumenttrait - Control flow -
show_dom(),for_each_dom_typed(),match_dom()(what rsxif/for/matchdesugar to) - Element types - Minimal enum:
Html,Fragment,Componentonly - Event handling - Input, click, keyboard and drag event dispatch
rinch-macros
The #[component] attribute macro and rsx! proc macro that generate DOM construction code:
#![allow(unused)]
fn main() {
// This RSX syntax:
rsx! {
div { class: "container",
p { "Count: " {|| count.get().to_string()} }
}
}
// Generates code that:
// 1. Creates DOM nodes via RenderScope
// 2. Sets up Effects for reactive expressions {|| ...}
// 3. Wires event handlers
}
rinch
The main crate that ties everything together:
- RinchApp (
app/) - Platform-agnostic application logic (event dispatch, hit testing, focus arbitration, text selection) - Desktop backend (
shell/rinch_runtime.rs) - Event loop, window creation, rendering - Menu Manager - Native menu support via muda
- Editor wiring (
editor/) - Desktop-only editor pieces: block virtualization, AccessKit accessibility, the cross-thread collaboration inbound
rinch-platform
Platform abstraction layer defining cross-platform traits:
- Traits -
PlatformWindow,PlatformRenderer,PlatformEventLoop,PlatformMenu - Types -
PlatformEvent,AppAction,KeyCode,Modifiers,MouseButton
rinch-dom
HTML/CSS DOM implementation:
- RinchDocument - Implements
DomDocumenttrait from rinch-core - Layout - Taffy for flexbox/grid layout
- Text - Parley for text shaping and line breaking
- Styling - Stylo for CSS parsing and computed styles
- Rendering - Vello (GPU) or tiny-skia (software) via the Painter trait abstraction
rinch-web
Browser-native DOM backend (the WASM target). Consumed by the *-web examples —
ui-zoo-web, paint-web, editor-web, collab-editor-web, and others:
- WebDocument - Implements
DomDocumentviaweb_sys, creating real browser DOM elements - No Taffy/Parley/Vello - The browser handles layout, text shaping, and painting natively
- Event delegation -
setup_event_delegationinstalls document-level listeners that dispatch viadata-ridattributes (plus drag, render-surface routing, anddata-onsubmit/data-oninput) mounthelper - One call wires the WebDocument, builds the component tree, installs event delegation, and injects theme CSS- Rich-text editor - Re-exports
Editor/EditorHandle/create_editorfromrinch-editor-viewand owns the browser input glue, so editor app code is identical to desktop - Smaller binary - ~3.2MB WASM (vs 11MB+ with Vello rendering)
rinch-theme
Theme system with CSS variables:
- Color palettes (10 shades per color, Mantine-inspired)
- Spacing, radius, typography scales
- Dark mode support
- CSS variable generation for components
rinch-editor-core / rinch-editor-view / rinch-editor-collab
The ProseMirror-style rich-text editor, split by how much each crate may know about a renderer:
- rinch-editor-core — the model-first source of truth, and pure: no rinch-dom,
winit, web_sys, parley, taffy, vello, or CRDT engine, and it compiles to
wasm32. It holds the immutableNode/Mark/Fragment/Slicedocument tree, one char-basedPosspace, aContentMatchschema that is enforced at the step boundary, invertibleSteps,EditorState/Transaction, the command catalogue and keymap, markdown input rules, Step-based undo/redo, and total schema-derived serialization (DocNodeJSON, HTML, markdown). It defines theEditorViewseam and knows nothing about any renderer. - rinch-editor-view — the projection:
RinchDomEditorViewrenders anEditorStateonto anyDomDocumenthost, so the desktop (rinch-dom) and browser (web_sys) editors share one view. Also theEditor {}component, the imperativeEditorHandle, the mounted-editor registry and the default stylesheet. - rinch-editor-collab — optional, off by default: projects the model onto a yrs
(Yjs) CRDT for real-time collaboration and rebuilds remote changes back into
Steps. The only crate in the workspace that links a CRDT engine.
Mutation flows one way: every edit is a Transaction applied by
EditorState::apply, after which the view diffs the document and patches the host.
The host tree is never read back for content, and commands query state, never the DOM.
See Editor Architecture for technical details.
rinch-components
UI component library:
- Input components: TextInput, Checkbox, Switch, Select, Slider
- Display components: Button, Badge, Alert, Card, Notification
- Layout components: Stack, Group, Grid, Container, Accordion
- Navigation components: Tabs, NavLink, Breadcrumbs, Pagination
- Overlay components: Modal, Drawer, Tooltip, Popover, Menu
- Typography components: Text, Title, Code, Blockquote
- Data display: Table, List, Timeline, Avatar
rinch-editable
Editing primitives for single-line <input> and <textarea> widgets — a separate
engine, unrelated to the rich-text editor above (they share no code and no types,
despite both having a Selection):
- EditableDocument - Trait for text editing operations
- StringDocument - Single-line text document
- EditCommand - Enum of editing commands (insert, delete, etc.)
- EditableState - Cursor position, selection state
- InputHandler - Maps keyboard input to commands
rinch-clipboard
Cross-platform clipboard abstraction:
- arboard for native platforms
- web-sys for WASM
- Unified API:
copy_text(),paste_text(),has_text()
rinch-tabler-icons
5000+ Tabler Icons with type-safe enum:
- Build-time download - Icons fetched from Tabler CDN during
cargo build - Type-safe -
TablerIconenum instead of strings - Two styles - Outline and Filled variants
- Tree-shaking friendly - Rust dead code elimination removes unused icons
- render_tabler_icon() - Helper function for rendering icons
rinch-debug
Debug IPC server for development tools:
- TCP listener - Auto-starts on random localhost port
- Discovery files - Writes
~/.rinch/debug/{pid}.json - Protocol - Length-prefixed JSON over TCP
- Commands - DOM inspection, screenshot, input simulation
rinch-mcp-server
MCP server for Claude integration:
- Standalone binary - Connects to running rinch apps via TCP
- Discovery - Scans
~/.rinch/debug/*.jsonto find apps - MCP tools -
screenshot,dom_tree,click,type_text,query_selector, etc. - Auto-connect - Automatically connects when only one app is running
Data Flow
User Code (app function)
│
▼
┌──────────────────────────────┐
│ rsx! macro │ Compile time
│ (generates DOM construction│
│ code with Effects) │
└──────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ RenderScope │ Initial render
│ (builds DOM tree once) │ (runs app() once)
└──────────────────────────────┘
│
┌────────┴────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ NodeHandle │ │ Effects │
│ (DOM refs)│ │ (reactive) │
└─────────────┘ └─────────────┘
│ │
│ Signal.set() │
│ ↓ │
│ Effect runs │
│ ↓ │
└────────┬────────┘
│
▼
┌──────────────────────────────┐
│ RinchDocument │ DOM mutation
│ (surgical updates via │
│ NodeHandle methods) │
└──────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Taffy Layout Engine │ Compute layout
│ (flexbox/grid) │
└──────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Parley Text Shaping │ Shape text
│ (line breaking, glyphs) │
└──────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Vello / tiny-skia │ GPU or software rendering
│ (re-paint) │
└──────────────────────────────┘
│
▼
Display
Reactive Update Flow
When a signal changes:
- Signal.set() - User code updates a signal
- Dependency notification - Signal notifies subscribed Effects
- Effect execution - Each Effect re-runs its closure
- DOM mutation - Effect uses
NodeHandleto update specific DOM nodes - Mark dirty - Changed nodes are marked for re-layout
- Re-paint - Vello re-renders the affected region
This is much more efficient than regenerating HTML and replacing the entire document.
External Dependencies
| Crate | Purpose |
|---|---|
| Taffy | Flexbox and grid layout engine |
| Parley | Text shaping, line breaking, bidirectional text |
| Stylo | CSS parsing and computed style resolution |
| Vello | GPU-accelerated 2D rendering (GPU mode) |
| tiny-skia | CPU-based 2D rendering (software mode) |
| softbuffer | Software window presentation (software mode) |
| wgpu | Cross-platform GPU abstraction (WebGPU API) |
| winit | Cross-platform windowing and input |
| muda | Native menu support (macOS/Windows/Linux) |
| arboard | Cross-platform clipboard access |
| yrs | CRDT for collaborative editing (rinch-editor-collab, opt-in) |
Design Principles
- Fine-grained updates - Only update what changed, never full re-render
- Declarative UI - RSX syntax describes UI structure
- Reactive by default - Signals and Effects for state management
- Web standards - HTML/CSS for layout via Taffy and Stylo
- Platform abstraction - Write once, run on desktop and web
- Native integration - Native menus, file dialogs, clipboard, system tray
- Developer experience - MCP integration for visual testing and debugging
Fine-Grained Reactive Rendering
Rinch uses fine-grained reactive rendering to achieve efficient UI updates. Instead of regenerating the entire DOM on every state change, Rinch surgically updates only the specific nodes that depend on changed signals.
Core Concepts
The Problem with Full Re-rendering
Traditional approaches regenerate HTML on every state change:
Signal.set() → re-run app() → generate full HTML → replace entire Document
This is inefficient because:
- Small changes cause full tree reconstruction
- Scroll positions are lost
- Focus state is lost
- Animation state is reset
- Layout is recalculated for the entire document
The Fine-Grained Solution
Rinch’s approach runs app() once and uses Effects for updates:
Signal.set() → Effect runs → NodeHandle.set_text() → Minimal re-layout
Benefits:
- Only changed nodes are updated
- Scroll and focus preserved
- Sub-millisecond updates for text changes
- No HTML parsing overhead
Reactive Primitives
Signal
A reactive state container that notifies subscribers when its value changes.
#![allow(unused)]
fn main() {
let count = Signal::new(0);
// Read (subscribes the current Effect)
let value = count.get();
// Write (notifies all subscribers)
count.set(5);
// Update (read-modify-write)
count.update(|n| *n += 1);
}
Note:
Signal<T>implementsCopy, so you can use the same signal in multiple closures without.clone().
Tracking: When get() is called inside an Effect, that Effect becomes a subscriber. When set() is called, all subscribers are notified and re-run.
Effect
A side-effect that re-runs when its dependencies change.
#![allow(unused)]
fn main() {
let count = Signal::new(0);
let node = __scope.create_element("span");
// This Effect will re-run whenever count changes
// Signal and NodeHandle are both Copy — no .clone() needed
__scope.create_effect(move || {
node.set_text(&count.get().to_string());
});
}
Lifecycle:
- Creation - Effect runs immediately, tracking any signals accessed
- Dependency change - When a tracked signal changes, Effect is queued
- Re-execution - Effect runs again, updating its output
- Cleanup - When the scope is disposed, Effects are cleaned up
Memo
A cached computed value that only recomputes when dependencies change.
#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5]);
// Only recomputes when items changes
// Signal is Copy — no .clone() needed
let sum = Memo::new(move || items.get().iter().sum::<i32>());
// Reading sum.get() returns cached value if items hasn't changed
}
Laziness: Memos are lazy - they only compute when first accessed and recompute only when dependencies change AND the value is accessed again.
Dependency Tracking
Rinch uses automatic dependency tracking. You don’t need to declare dependencies - they are discovered at runtime.
How It Works
- When an Effect runs, it sets itself as the “current observer”
- Any
signal.get()call checks for a current observer - If present, the signal adds the observer to its subscriber list
- When the signal changes, it notifies all subscribers
#![allow(unused)]
fn main() {
// Automatic tracking example
let a = Signal::new(1);
let b = Signal::new(2);
__scope.create_effect(move || {
// Both a and b are automatically tracked
let sum = a.get() + b.get();
println!("Sum: {}", sum);
});
a.set(10); // Effect re-runs, prints "Sum: 12"
b.set(20); // Effect re-runs, prints "Sum: 30"
}
Conditional Tracking
Dependencies are tracked dynamically. If a branch isn’t taken, those signals aren’t tracked:
#![allow(unused)]
fn main() {
let show_a = Signal::new(true);
let a = Signal::new("A");
let b = Signal::new("B");
__scope.create_effect(move || {
if show_a.get() {
println!("{}", a.get()); // Only tracked when show_a is true
} else {
println!("{}", b.get()); // Only tracked when show_a is false
}
});
}
DOM Integration
NodeHandle
A stable reference to a DOM node that enables surgical updates:
#![allow(unused)]
fn main() {
let node = __scope.create_element("div");
// Text content
node.set_text("Hello");
// Attributes
node.set_attribute("class", "active");
node.remove_attribute("disabled");
// Styles
node.set_style("color", "red");
node.set_style("display", "none");
// Classes
node.add_class("highlighted");
node.remove_class("dimmed");
// Tree manipulation
node.append_child(&child_node);
node.insert_before(&new_node, &reference_node);
node.remove(); // detach — the subtree can be inserted again
node.discard(); // finished with it — the backend may let go
}
remove() and discard() are not synonyms. A removed node stays re-insertable
on every backend (issue #719), which is what lets a reactive branch re-show a
captured handle; discard() is how you tell the backend you are finished, and
after it the ids name nothing. See
RenderScope & NodeHandle.
Reactive DOM Updates
The rsx! macro creates Effects for reactive expressions:
#![allow(unused)]
fn main() {
rsx! {
div {
// Static text - rendered once
"Hello, "
// Reactive expression - creates an Effect
{|| name.get()}
// Reactive style - creates an Effect
style: {|| format!("color: {}", color.get())},
}
}
}
Under the hood, this generates:
#![allow(unused)]
fn main() {
let div = __scope.create_element("div");
// Static text
let text1 = __scope.create_text("Hello, ");
div.append_child(&text1);
// Reactive text - creates Effect
let text2 = __scope.create_text(&name.get().to_string());
div.append_child(&text2);
// Signal and NodeHandle are Copy — no .clone() needed
__scope.create_effect(move || {
text2.set_text(&name.get().to_string());
});
// Reactive style - creates Effect
__scope.create_effect(move || {
div.set_style("color", &color.get());
});
}
Batch Updates
Multiple signal changes can be batched to avoid redundant Effect executions:
#![allow(unused)]
fn main() {
// Without batching - Effect runs 3 times
a.set(1);
b.set(2);
c.set(3);
// With batching - Effect runs once
batch(|| {
a.set(1);
b.set(2);
c.set(3);
});
}
Conditional Rendering (Show)
The show_dom() function handles conditional rendering with fine-grained updates:
#![allow(unused)]
fn main() {
show_dom(
__scope,
&parent,
move || condition.get(), // Condition closure
|scope| { // Then branch
let div = scope.create_element("div");
div.set_text("Visible");
div
},
Some(|scope| { // Else branch (optional)
let span = scope.create_element("span");
span.set_text("Hidden");
span
}),
)
}
In RSX, use the Show component instead:
#![allow(unused)]
fn main() {
rsx! {
Show {
when: {move || condition.get()},
fallback: |__scope: &mut RenderScope| rsx! { span { "Hidden" } },
div { "Visible" }
}
}
}
When the condition changes:
- The Effect runs
- The old content’s scope is disposed (cleaning up nested effects)
- Old DOM content nodes are removed
- A new child scope is created and new content is rendered after the marker
List Rendering (For)
The for_each_dom() function handles keyed list rendering using ForItem:
#![allow(unused)]
fn main() {
for_each_dom(
__scope,
&parent,
move || items.get().into_iter().map(|item| { // Items closure returning Vec<ForItem>
ForItem::new(item.id.clone(), item)
}).collect(),
|item, scope| { // Item renderer
let data = item.downcast::<Item>().unwrap();
let li = scope.create_element("li");
li.set_text(&data.name);
li
},
)
}
In RSX, use the For component instead:
#![allow(unused)]
fn main() {
rsx! {
For {
each: {move || items.get().into_iter().map(|item| {
ForItem::new(item.id.clone(), item)
}).collect()},
|item: &ForItem| {
let data = item.downcast::<Item>().unwrap();
rsx! { li { {data.name.clone()} } }
}
}
}
}
When items change:
- Diff algorithm (LIS-based) compares old and new key lists
- Items with unchanged keys are preserved (not re-rendered)
- New items are inserted at correct positions
- Removed items have their scopes disposed and nodes cleaned up
- Moved items are repositioned in the DOM
Comparison with Other Approaches
| Approach | Update Granularity | Performance | Complexity |
|---|---|---|---|
| Full re-render | Entire DOM | O(n) | Simple |
| Virtual DOM | Subtree patches | O(log n) | Medium |
| Fine-grained | Single nodes | O(1) | Complex |
Rinch’s fine-grained approach provides the best performance for reactive updates, at the cost of more sophisticated compilation and runtime machinery.
Performance Characteristics
- Text updates: < 1ms (single node mutation)
- Style changes: < 1ms (single node mutation)
- List item add/remove: ~5ms (DOM operations + layout)
- Full conditional swap: ~10ms (subtree rebuild)
These are typical measurements; actual performance depends on document complexity and hardware.
RenderScope and NodeHandle API
This document specifies the DOM abstraction layer that enables fine-grained reactive rendering.
Overview
The DOM abstraction consists of three key types:
| Type | Purpose |
|---|---|
RenderScope | Context for building DOM trees with effect tracking |
NodeHandle | Stable reference to a DOM node for surgical updates |
DomDocument | Trait abstracting DOM mutation operations |
RenderScope
RenderScope is the context passed to component functions. It provides methods for creating DOM nodes and Effects.
Creating Nodes
The recommended approach uses the #[component] macro, which injects __scope automatically:
#![allow(unused)]
fn main() {
#[component]
fn my_component() -> NodeHandle {
rsx! {
div {
"Hello, world!"
}
}
}
}
For manual DOM construction, use __scope directly:
#![allow(unused)]
fn main() {
fn my_component(__scope: &mut RenderScope) -> NodeHandle {
// Create an element node
let div = __scope.create_element("div");
// Create a text node
let text = __scope.create_text("Hello, world!");
// Create a comment node (useful for anchors)
let comment = __scope.create_comment("placeholder");
// Build the tree
div.append_child(&text);
div
}
}
Creating Effects
#![allow(unused)]
fn main() {
fn counter(__scope: &mut RenderScope) -> NodeHandle {
let count = Signal::new(0);
let span = __scope.create_element("span");
// Create an Effect that updates the span when count changes
// Signal and NodeHandle are Copy — no .clone() needed
__scope.create_effect(move || {
span.set_text(&count.get().to_string());
});
span
}
}
Child Scopes
Child scopes inherit the document but have their own Effect tracking. The child_scope method takes a parent NodeHandle reference and returns a mutable borrow:
#![allow(unused)]
fn main() {
fn parent(__scope: &mut RenderScope) -> NodeHandle {
let container = __scope.create_element("div");
// Create a child scope for a nested component
let child_scope = __scope.child_scope(&container);
let child_content = child_component(child_scope);
container.append_child(&child_content);
container
}
}
When a child scope is dropped, all its Effects are cleaned up.
Event Handling
Rinch uses a register_handler + data-rid pattern for event dispatch. There is no add_event_listener on NodeHandle. Instead, handlers are registered on the RenderScope and linked to elements via a data-rid attribute:
#![allow(unused)]
fn main() {
fn my_button(__scope: &mut RenderScope) -> NodeHandle {
let button = __scope.create_element("button");
button.set_text("Click me");
// Register a handler and link it to the element
let handler_id = __scope.register_handler(move || {
println!("Button clicked!");
});
button.set_attribute("data-rid", &handler_id.to_string());
button
}
}
The rsx! macro handles this automatically with onclick::
#![allow(unused)]
fn main() {
#[component]
fn my_button() -> NodeHandle {
rsx! {
button { onclick: move || println!("Clicked!"),
"Click me"
}
}
}
}
The runtime uses event delegation (a single document-level listener) that dispatches to the correct handler by looking up the data-rid attribute on the clicked element.
RenderScope API Reference
| Method | Description |
|---|---|
create_element(tag: &str) -> NodeHandle | Create an element node (div, span, etc.) |
create_text(content: &str) -> NodeHandle | Create a text node |
create_comment(content: &str) -> NodeHandle | Create a comment node |
create_effect(f: impl FnMut() + 'static) | Create a reactive Effect |
child_scope(&mut self, parent: &NodeHandle) -> &mut RenderScope | Create a child scope rooted at a parent node |
register_handler(callback: impl Fn() + 'static) -> EventHandlerId | Register an event handler, returns ID for data-rid |
register_input_handler(callback: impl Fn(String) + 'static) -> EventHandlerId | Register an input handler for text input events |
parent() -> NodeHandle | Get the parent node for this scope |
doc_weak() -> Weak<RefCell<dyn DomDocument>> | Get a weak reference to the underlying document |
body_handle() -> NodeHandle | Get the body element as a NodeHandle |
dispose(self) | Dispose this scope, cleaning up all Effects |
NodeHandle
NodeHandle is a stable reference to a DOM node. It remains valid even as the document changes around it. Internally it holds a NodeId and a Weak<RefCell<dyn DomDocument>>.
Text Content
#![allow(unused)]
fn main() {
let text_node = __scope.create_text("initial");
// Update text content
text_node.set_text("updated");
}
Attributes
#![allow(unused)]
fn main() {
let button = __scope.create_element("button");
// Set attribute
button.set_attribute("disabled", "true");
button.set_attribute("aria-label", "Submit form");
// Get attribute
let label = button.get_attribute("aria-label"); // Some("Submit form")
// Remove attribute
button.remove_attribute("disabled");
}
Attribute names are ASCII case-insensitive in HTML content and case-sensitive
in SVG content (issue #688), as in a browser. set_attribute("ID", …) on a
<div> stores id, so #up, [id], getAttribute("id") and an uppercase
STYLE= (which has to reach the inline-style cache) all agree; get_attribute
and remove_attribute fold their own name the same way, so either spelling
finds what either spelling wrote. On an element whose tag is an SVG one the
author’s spelling is kept, because viewBox, preserveAspectRatio,
gradientUnits and their kind are distinct names in SVG.
The decision is made from the element’s tag, not from an <svg> ancestor,
because rsx! and the Element::Html parser both write an element’s attributes
before appending it to its parent — at the write there is no ancestor to walk.
crates/rinch-dom/src/attr_name.rs holds the tag list. The attribute value
is never folded: an id still matches #CamelId case-sensitively.
A form control’s live text: live_value
get_attribute("value") reads the value attribute, and on the web that is
not what a text field shows once the user has typed: typing moves the element’s
.value property and leaves the attribute at whatever was last written
programmatically. live_value() asks the question a component usually means —
“what does the field say right now?” — on either backend (issue #238):
#![allow(unused)]
fn main() {
if input.live_value().as_deref() != Some(new_text.as_str()) {
input.set_attribute("value", &new_text);
}
}
- Desktop answers with the
valueattribute, which is the live text there: the runtime mirrors each edit into it before dispatchingoninput, and adopts a programmatic write to the focused field back into the text engine. - Web answers with the
.valueproperty of an<input>,<textarea>or<select>; any other element answers with its attribute.
None means the node carries no value this backend can name — on desktop, a
control with no value attribute yet. A web form control always answers
Some, "" when empty. Compare against the text you are about to write and
treat None as “differs”.
TextInput, PasswordInput, Textarea and NumberInput bind value_fn with exactly that guard
(rinch_components::value_binding::bind_value_fn), so the echo of the user’s own
keystroke is never written back; a custom component binding a value_fn can
call the same function.
Styles
#![allow(unused)]
fn main() {
let div = __scope.create_element("div");
// Set individual styles
div.set_style("color", "blue");
div.set_style("font-size", "16px");
div.set_style("display", "flex");
// Remove a style
div.set_style("color", ""); // Empty string removes
}
set_style means what style.setProperty means in a browser: afterwards the
element’s inline style gives the property that value, whatever the attribute
declared before. On the web it is setProperty. On desktop the attribute is
a string of declarations and a property already in it is rewritten where it
stands — unless a declaration after it covers it (a shorthand such as
inset after left, a longhand after a shorthand, or a logical twin such as
inset-inline-start after left), in which case the write moves to the end so
that it wins, as the browser’s longhand list makes it win
(issue #470). One case still
differs: a later covering declaration marked !important keeps beating a
normal desktop write (issue #1298).
Classes
#![allow(unused)]
fn main() {
let element = __scope.create_element("div");
// Set the class attribute directly
element.set_class("active highlighted");
// Add/remove individual classes
element.add_class("active");
element.add_class("highlighted");
element.remove_class("active");
// Toggle based on condition
element.toggle_class("selected");
// Or conditionally:
if is_selected {
element.add_class("selected");
} else {
element.remove_class("selected");
}
}
Tree Manipulation
#![allow(unused)]
fn main() {
let parent = __scope.create_element("ul");
let item1 = __scope.create_element("li");
let item2 = __scope.create_element("li");
let item3 = __scope.create_element("li");
// Append children
parent.append_child(&item1);
parent.append_child(&item3);
// Insert before a reference node
parent.insert_before(&item2, &item3); // item1, item2, item3
// Remove a node
item2.remove(); // item1, item3
// Replace a node
let new_item = __scope.create_element("li");
item1.replace_with(&new_item);
}
Removal is a detach;
discard()is the end of a node’s life.remove()andreplace_with()take a node out of the tree and leave it re-insertable on every backend: append the handle again and the whole subtree comes back, and you may read, style or restructure it while it is out. That is what a reactive branch re-showing a captured handle rests on (issue #719).Because a removed node is still the backend’s to keep, tell it when you are finished with the subtree for good:
#![allow(unused)] fn main() { panel.remove(); // hidden for now — I may show it again row.discard(); // gone for good — let go of it }Treat a discarded handle as dead: build a fresh node rather than reviving one. A backend that retires makes every operation on it a silent no-op, and
rinch-weband the testMockDomDocumentboth do, so the mistake failscargo testas well as a browser. A discarded id is never handed to a different node on either backend, so a stale discard handle can only name nothing. (That is a claim aboutdiscardalone:rinch-domfrees slab keys throughset_inner_htmland pseudo-element pruning, and those are recycled — issue #304, live today and independent of this API.)A subtree you cache from inside a
forbody, a branch or arender_fnmust not be built through the scope that closure is handed: that scope owns what it builds, and the helper discards it when the row or branch goes. Build it throughscope.cache_scope()(withbuild, so its signals and handlers are the cache scope’s too), keep that scope beside the node, and when the cache lets go — including when its owner unmounts —dispose()the scope anddiscard()the node: dropping the scope alone leaves the node in the backend. See A cache filled from inside afor(issue #733).Which to use. If the same handle can be inserted again,
remove(). If it cannot — a pool shrunk, a glyph replaced, a panel rebuilt —discard(). Reaching forremove()where you meantdiscard()costs memory; reaching fordiscard()where you meantremove()costs the subtree. Neither is reported, so choose deliberately.The reactive helpers do not choose by hand.
show_dom,match_dom,reactive_component_domandfor/virtual_listrows each run their user closure inside aRenderScopeof their own, and go by ownership: a node that scope minted is the helper’s to discard, a node the closure was handed is the caller’s and is only detached.RenderScope::createdis the whole rule, and it is the rule #141 gave signals and effects, applied to nodes. It is whyif open { p { "hi" } }reclaims its markup on every hide whileif open { {panel} }keeps yours.Two edges of that rule. The verb is chosen before the scope is disposed, so an
on_cleanupthat re-parents a scope-built node cannot rescue it — build it outside the closure and hand it in instead. The verb is applied after the scope is disposed, in all four helpers (afororvirtual_listrow since issue #356), so anon_cleanupstill sees its own node live and mounted — onrinch-weba node already discarded answers every read withNone. And ownership answers for nodes in a subtree: a node attached to nothing is reached by no walk, which is whatrinch_core::dom::release_scratch_containerexists for.
remove()’s post-condition is the same on both backends;discard()’s is not.rinch-webholds a strongweb_sys::Nodein two page-global maps, so adiscard()is what releases the browser node against GC (issue #184).rinch-domreclaims nothing: a discarded node there still re-inserts, still keeps its subtree and still takes writes, because its slab is per-document and freeing the slot would recycle the id (issue #304) — so the desktop slab only grows, which is issue #723. The contract is therefore one-sided: adiscard()is at least aremove()and may be much more, and you may not rely on it being less.
Focus
#![allow(unused)]
fn main() {
let input = __scope.create_element("input");
input.focus(); // Give focus to this element
}
An overlay also has to read the current focus and give it back later, so three
more handle methods answer the questions focus() alone cannot (issue #695):
#![allow(unused)]
fn main() {
let opener = panel.active_element(); // who holds it right now
panel.focus_into(FocusIntoPolicy::FirstFocusable); // showModal()'s focusing steps
// …later, when the overlay closes:
panel.restore_focus(opener.as_ref()); // give it back, or let it go
}
active_element()is a document-level question reached through a handle, likeset_scroll_locked.Nonemeans unknown — on the web, an element rinch did not create carries no__nidand cannot be named — not nothing is focused.focus_into(policy)moves focus into this subtree the wayshowModal()does — theautofocusdescendant if there is one, else the first focusable underFocusIntoPolicy::FirstFocusableand nothing underAutofocusOnly. “The first focusable” is each backend’s own computation, which is why this is a backend method rather than a walk a component could write.restore_focus(opener)is the close half, and it is one call rather than a blur plus a hopeful focus because the caller cannot make the decision: whether the keyboard is even this overlay’s to return, and whetheropenercan still take it, are both questions about boxes.
Desktop resolves both overlay calls after the next layout, since the boxes
are a layout out of date at the moment an effect makes them; the web answers
immediately and lets the browser arbitrate. There is no bare blur(): releasing
the keyboard is only ever the fallback half of a restore.
rinch-components’ overlay_focus::arm_overlay_focus is the one in-tree caller,
and the focus guide
describes the behaviour it builds.
NodeHandle API Reference
| Method | Description |
|---|---|
set_text(content: &str) | Set text content (for text nodes) |
set_attribute(name: &str, value: &str) | Set an attribute — the name folds to lowercase in HTML content, verbatim in SVG (#688) |
get_attribute(name: &str) -> Option<String> | Get an attribute value (same fold) |
remove_attribute(name: &str) | Remove an attribute (same fold) |
set_style(property: &str, value: &str) | Set a CSS style property |
set_class(class: &str) | Set the class attribute |
add_class(name: &str) | Add a CSS class |
remove_class(name: &str) | Remove a CSS class |
toggle_class(name: &str) | Toggle a CSS class |
append_child(child: &NodeHandle) | Append a child node |
insert_before(node: &NodeHandle, reference: &NodeHandle) | Insert before reference |
remove() | Remove this node from its parent — a detach; the handle and its subtree stay re-insertable |
replace_with(new_node: &NodeHandle) | Replace this node with another — also a detach; the displaced handle stays re-insertable |
discard() | Remove this node and release the backend’s bookkeeping for its whole subtree — retires the ids |
focus() | Give focus to this element |
active_element() -> Option<NodeHandle> | Who holds the keyboard in this node’s document |
focus_into(policy: FocusIntoPolicy) | Move focus into this subtree (showModal()’s focusing steps) |
restore_focus(opener: Option<&NodeHandle>) | This overlay has closed: hand the keyboard back, or release it |
children() -> Vec<NodeHandle> | Get child nodes as NodeHandles |
is_valid() -> bool | Check if this handle still points to a valid node |
node_id() -> NodeId | Get the internal node ID |
clone() -> NodeHandle | Clone the handle (same underlying node) |
Watching a subtree change
A container whose prop is a default for its items renders after them, so it
cannot hand the default over as a prop — it patches the rendered items instead.
Two free functions in rinch_core::dom let it do that again when the items
change afterwards:
| Function | Told about | Handed |
|---|---|---|
on_child_inserted(root, f) | a subtree landing anywhere beneath root (issue #716) | the node that landed |
on_child_removed(root, f) | a subtree leaving anywhere beneath root (issue #745) | the node it left — its former parent |
Both call every registered ancestor, nearest first, synchronously with the
mutation, so a patch is in place before the frame that shows the change is laid
out. Both are released by the ambient scope’s on_cleanup and by
discard() on the container, and dispatch is suppressed for the duration of a
callback so a container’s own edits do not call it back. The scope’s cleanup
releases the registration it made and no other: one that replaced it since (a
second call on the node, or another container on a node id rinch-dom
re-issued) stays (issue #1490).
The removal half is handed the parent rather than the node that went because
that node is detached by then, and after a discard() the backend may have
retired it — there is nothing left to walk. A move fires both halves, unless
it is a reorder inside one parent, which fires only the insertion.
NodeHandle::set_text on an element that has children, and
NodeHandle::set_inner_html, replace the node’s whole child list: each fires
the removal half once, for that node (issue #1440), and neither fires the
insertion half for the text or the parsed markup. set_text only detaches the
children; the ones the element’s own render built — minted by the scope that
minted the element or one descended from it — are discarded when that scope
goes, if they are still detached then (issue #1487). Until then a handle to one
re-inserts. UpdateBatch::apply calls the
backend with the document already borrowed and fires neither half, so a
DomUpdate::SetText should target a text node.
crates/rinch-components/src/late_children.rs wraps both with the
boundary rule a nested container of the same kind needs.
DomDocument Trait
DomDocument is the trait that abstracts DOM operations. The primary desktop implementation is RinchDocument which uses Taffy + Parley + Vello. The web implementation is WebDocument which uses browser-native DOM via web_sys.
#![allow(unused)]
fn main() {
pub trait DomDocument {
/// Create an element node
fn create_element(&mut self, tag: &str) -> NodeId;
/// Create a text node
fn create_text(&mut self, content: &str) -> NodeId;
/// Create a comment node
fn create_comment(&mut self, content: &str) -> NodeId;
/// Set text content of a node
fn set_text_content(&mut self, node: NodeId, content: &str);
/// Set an attribute
fn set_attribute(&mut self, node: NodeId, name: &str, value: &str);
/// Remove an attribute
fn remove_attribute(&mut self, node: NodeId, name: &str);
/// Append a child to a parent
fn append_child(&mut self, parent: NodeId, child: NodeId);
/// Insert a node before a reference node
fn insert_before(&mut self, parent: NodeId, node: NodeId, reference: NodeId);
/// Remove a node from its parent
fn remove_child(&mut self, parent: NodeId, child: NodeId);
/// Get children of a node
fn get_children(&self, node: NodeId) -> Vec<NodeId>;
/// Get the body element
fn body(&self) -> NodeId;
/// Mark a node as needing re-layout
fn mark_dirty(&mut self, node: NodeId);
}
}
RinchDocument
RinchDocument is the desktop implementation of DomDocument that uses Taffy for layout, Parley for text, and Vello for rendering.
Key Features
- Direct DOM manipulation - Efficient node creation and mutation
- Automatic dirty marking - Calls
mark_ancestors_dirty()after mutations - Event handler storage - Stores handlers as
data-ridattributes for dispatch
Usage
#![allow(unused)]
fn main() {
use rinch_dom::RinchDocument;
// Create document
let doc = Rc::new(RefCell::new(RinchDocument::new()));
// Create RenderScope from shared document
let mut scope = RenderScope::new(Rc::downgrade(&doc) as _, parent_id);
// Build DOM
let root = my_app(&mut scope);
}
Thread Safety
RinchDocument is wrapped in Rc<RefCell<>> for interior mutability.
Effects capture clones of the shared document and can mutate the DOM when they run.
RenderScope itself holds a Weak<RefCell<dyn DomDocument>> to avoid preventing cleanup.
Integration with Reactive System
The RenderScope and NodeHandle APIs integrate with the reactive system:
- Initial render - Component function receives
RenderScope(via__scopeor#[component]macro), builds DOM tree - Effect creation -
__scope.create_effect()registers reactive computations - NodeHandle capture - Effects capture
NodeHandleclones for later updates - Signal changes - Effects re-run and use
NodeHandlemethods to update DOM - Cleanup - When scope is dropped, Effects are disposed
This architecture ensures that:
- Components run once (no re-render overhead)
- Updates are surgical (only affected nodes change)
- Cleanup is automatic (scope disposal cleans up Effects)
- Memory is efficient (NodeHandle is a lightweight ID + weak reference wrapper)
Reactive System Architecture
Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This document describes the architecture and design decisions.
Core Concepts
Signals
A Signal is a reactive container that holds a value and notifies subscribers when it changes.
#![allow(unused)]
fn main() {
let count = Signal::new(0);
// Read the value
let value = count.get();
// Update the value (triggers subscribers)
count.set(5);
// Update based on current value
count.update(|n| *n += 1);
}
Key properties:
- Reading a signal inside an effect automatically subscribes to it
- Setting a signal schedules dependent effects to re-run
- Signals are
Cloneand can be shared across closures
Effects
An Effect is a side-effect that re-runs when its dependencies change.
#![allow(unused)]
fn main() {
let count = Signal::new(0);
Effect::new(move || {
println!("Count is now: {}", count.get());
});
count.set(1); // Prints: "Count is now: 1"
count.set(2); // Prints: "Count is now: 2"
}
Key properties:
- Dependencies are tracked automatically (no dependency arrays)
- Effects run immediately when created, then re-run when dependencies change
- Effects are cleaned up when their scope is disposed
Memos
A Memo is a cached computed value that only recomputes when dependencies change.
#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);
doubled.get(); // Returns 4
count.set(3);
doubled.get(); // Returns 6 (recomputed)
doubled.get(); // Returns 6 (cached)
}
Key properties:
- Lazily evaluated (only computes when read)
- Caches the result until dependencies change
- Can be read inside effects (creates a subscription)
Dependency Tracking
Rinch uses automatic dependency tracking at runtime:
- When an effect runs, it registers itself as the “current observer”
- When a signal is read, it checks for a current observer and subscribes it
- When a signal changes, it notifies all subscribers to re-run
┌─────────────┐ read ┌─────────────┐
│ Effect │ ───────────── │ Signal │
│ │ │ │
│ observer │ ◄──subscribe──│ subscribers │
└─────────────┘ └─────────────┘
│
│ set()
▼
notify observers
Runtime Architecture
┌────────────────────────────────────────────────────┐
│ Runtime │
├────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Observer │ │ Pending │ │
│ │ Stack │ │ Effects │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Signal Storage │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │Signal 1│ │Signal 2│ │Signal 3│ ... │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
Observer Stack
The runtime maintains a stack of “current observers”:
- When an effect starts, it pushes itself onto the stack
- When a signal is read, it subscribes the top of the stack
- When an effect ends, it pops itself
This allows nested effects to work correctly.
Batching
Multiple signal updates can be batched to avoid redundant effect runs:
#![allow(unused)]
fn main() {
batch(|| {
count.set(1);
name.set("Alice");
// Effects only run once, after the batch
});
}
Scheduling
Effects are scheduled to run after the current synchronous code completes:
- Signal is set → effect is marked as “dirty”
- Dirty effects are queued for execution
- After current execution, queued effects run
- This prevents infinite loops and ensures consistent state
Memory Management
Scopes
A Scope manages the lifetime of reactive primitives. In practice, RenderScope serves as the scope for component rendering:
#![allow(unused)]
fn main() {
// RenderScope is the scope used during rendering.
// Effects created via __scope.create_effect() are tracked
// and disposed when the scope is dropped.
fn my_component(__scope: &mut RenderScope) -> NodeHandle {
let signal = Signal::new(0);
__scope.create_effect(|| { /* tracked by this scope */ });
// signal and effect belong to this scope
rsx! { div { } }
}
// When __scope is disposed, all its effects are cleaned up
}
Note:
RenderScopeis what application code sees — it tracks effects and child scopes for automatic cleanup. Underneath, eachRenderScopeowns a reactiveScope, andScope::run(f)makes that scope the ambient owner forf: signals, memos, effects and event handlers created inside are attributed to it. Disposing the scope frees everything it owns — attribution is acted on, not merely recorded (issue #141). Disposal is a priority fixpoint rather than a linear pass: handlers, then effects, then cleanups, then memos, then signals, then the values themselves — restarting from the top whenever a step enqueues more work, because dropping a value can own the only handle to a child scope.Two independent stacks are in play, and they are easy to confuse:
untrackedsuspends the observer stack (who subscribes to a read), whileunownedsuspends the owner stack (who owns an allocation). With no ambient owner, a resource has app lifetime — which is why signals created inmain()or in startup code keep working untouched.
Ownership
Signal<T>andMemo<T>areCopyhandles, not smart pointers. Each is a(index, generation)pair into a thread-local slab; the value itself lives in the slab as a type-erasedBox<dyn Any>. Freeing a slot bumps nothing but the free list — the generation is what makes a stale handle detectable, since a recycled index carries a generation no old handle holds.- Effects live in a registry keyed by
ObserverId, not in theEffecthandle. An effect is queued and run by id, so its closure has to outlive the handle; disposal is what removes the entry, and it removes it rather than emptying a slot that would stay in the container forever. - Disposing a scope frees every primitive it owns, and the
Rcs are always dropped after the registry borrow is released — an effect closure commonly captures the only handle to a child scope, so dropping it in place would re-enter the registry that is still mutably borrowed.
Integration with UI
The reactive system integrates with the rendering pipeline:
- Component functions run inside a
RenderScope - RSX expressions use closure syntax for reactive reads:
{|| count.get().to_string()} - When signals change, Effects re-run and surgically update affected DOM nodes
- Fine-grained updates - only the specific nodes bound to changed signals are updated
#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
let count = Signal::new(0);
rsx! {
div {
// Reactive text - closure syntax {|| ...} creates an Effect
"Count: " {|| count.get().to_string()}
button { onclick: move || count.update(|n| *n += 1),
"Increment"
}
}
}
}
}
Note: The closure syntax
{|| expr}is required for reactive updates — without it, values are captured once at initial render and never update.
Thread Safety
The implementation is deliberately single-threaded, and stores state in thread-local slabs rather than in shared smart pointers:
- Thread-local runtime by default
- All reactive primitives (
Signal,Effect,Memo) are!Sendand!Sync - This is intentional: GUI frameworks are inherently single-threaded (main thread)
- The observer stack and effect scheduling are thread-local
Comparison with Other Systems
| Feature | Rinch | React | Solid.js | Leptos |
|---|---|---|---|---|
| Reactivity | Fine-grained | Coarse (VDOM) | Fine-grained | Fine-grained |
| Tracking | Automatic | Manual (deps array) | Automatic | Automatic |
| Scheduling | Batched | Batched | Synchronous | Batched |
| Memory | Scoped | GC | Scoped | Scoped |
Rendering Pipeline
Rinch uses a multi-stage rendering pipeline that transforms component code into pixels on the desktop backend. Two rendering backends are available: GPU (Vello/wgpu) and software (tiny-skia/softbuffer). The web backend uses browser-native DOM instead (see note at the end).
Pipeline Stages (Desktop)
┌───────────────────────────────────────────────────────────────┐
│ 1. Component Input │
│ #[component] functions + rsx! macro generate DOM construction │
│ code via __scope.create_element(), create_text(), │
│ create_effect() │
└───────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ 2. DOM Construction │
│ DomDocument creates nodes programmatically via RenderScope │
│ (RinchDocument uses Taffy + Parley on desktop) │
└───────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ 3. Style Resolution │
│ Stylo (Firefox's CSS engine) computes styles for each node │
└───────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ 4. Layout │
│ Taffy computes the position and size of each element │
└───────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ 5. Painting (via Painter trait) │
│ paint_document() walks the DOM tree and emits drawing │
│ commands through the abstract Painter interface │
└───────────────────────────────────────────────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ 6a. GPU (Vello) │ │ 6b. Software (tiny-skia) │
│ Scene graph → wgpu → │ │ Rasterize to RGBA pixmap │
│ GPU compositing │ │ → softbuffer → display │
└──────────────────────────┘ └──────────────────────────┘
│ │
└─────────┬─────────┘
▼
Display
Input Stage
User code defines components using the #[component] macro and rsx! macro:
#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
let count = Signal::new(0);
rsx! {
div {
p { "Count: " {|| count.get().to_string()} }
button { onclick: move || count.update(|n| *n += 1), "+" }
}
}
}
}
The #[component] macro injects a __scope: &mut RenderScope parameter. The rsx! macro generates calls to __scope.create_element(), __scope.create_text(), and __scope.create_effect() to build the DOM tree programmatically. No HTML strings are generated or parsed at runtime.
Layout Stage
RinchDocument::resolve_layout is the whole of it, and it has three paths
rather than one. Which it takes is decided by two flags on the tree, and the
flags are the part worth knowing, because a change that dirties neither is a
change the pass will not see.
| flag | set by | what it buys |
|---|---|---|
layout_dirty | a structural mutation, a text-content change, a viewport change, a decoded image, a Taffy style that actually changed, and a restyle that changes how text measures | the Taffy compute |
ifc_dirty | a structural mutation, a display/position change, a DisplayMode change, a decoded <img>, a full restyle | the inline-formatting-context setup passes, including the measure of every atomic inline |
- Neither dirty — styles are resolved, dirty Parley layouts are rebuilt, and
the pass returns. A
:hover { color }costs this and nothing more; it is the reason the early return exists. layout_dirtyonly — Taffy runs over the existing IFC structure. Text measure contexts are refreshed incrementally, and the atomic inlines something changed under are re-measured from a dirty set.- Both — the IFC structure is rebuilt from scratch and every atomic inline in the document is measured.
A decoded <img> is on the ifc_dirty list for a reason worth knowing: it is
how a newly loaded image reaches a detached inline-block box, which no other
invalidation in the table would have reached.
Why a typography change is a layout change
font-family, font-weight, font-style, line-height, letter-spacing,
word-spacing, text-transform, white-space and overflow-wrap are not Taffy
properties, and neither is font-size for a box whose own sizes are in px. A
declaration change in any of them re-wraps the text and therefore moves the box
around it, so it sets layout_dirty even though no Taffy field changed
(ComputedStyle::same_measured_text_inputs, issue #678). That predicate is
deliberately narrower than the one that decides whether the shaped glyphs have to
be rebuilt (same_text_layout_inputs, issue #654): color is baked into the
glyphs and moves nothing, and keeping it out of the first list is what keeps the
cheap path cheap.
A transition or an animation writes computed_style directly rather than through
the cascade, so a transition: font-size frame reaches none of that gating on
its own; both ticks invalidate the text measure of the nodes they are
interpolating.
A color frame needs no new layout at all, and gets none (issue #679). The
colour of each run is a brush inside the shaped layout, so paint asks every text
range what colour its element computes now and draws the ranges that answer
differently from the build in the new colour (paint::text::LiveColours); an
underline follows where it was currentcolor, and a wavy underline and an
inline element’s background are read from their element’s style on every paint.
The layout is rebuilt once, with no Taffy compute, on the frame the colour stops
moving, so a finished fade costs what a colour-only hover costs and later frames
recolour nothing (each paint still compares every range’s colour with the one it
was shaped in). A span on a line cut by text-overflow: ellipsis is the
exception: that line is a flat rebuild with no text ranges, so the span keeps
its start colour until the run ends (#1451).
Why an atomic inline needs its own pass
An inline-block, inline-flex or inline-grid box is detached from its
parent’s Taffy child list so the enclosing inline formatting context can
measure it as a Parley InlineBox. The root compute therefore never reaches it,
and three passes give one a size, all through measure_inline_blocks: the
ifc_dirty pass, which measures every atomic inline in the document; since
issue #661, a re-measure of the boxes a change actually reached
(dirty_atomic_inlines); and resolve_percentage_inline_blocks, which runs
after the root compute and is what lets a width that needs the containing
block’s — a percentage, fit-content/stretch, or an auto width, which is
shrink-to-fit and so capped at that block (#658) — resolve against a containing
block that only has a width once the compute has run. The inline formatting
context around such a box is measured before that, and asked about widths
the box is never laid out at; a measure lines an auto-width box up at what it
contributes to the width asked about (ifc::AtomicContributions, #1476): its
max-content size, its min-content size, or the shrink-to-fit of the two. That
is what lets a container sized from its content — a flex item, a grid auto
track, a shrink-to-fit absolute box, another auto atomic inline — come out
narrower than the box, so the cap can bind. The min-content size is measured
only when a line narrower than the box asks for it, after the compute that
asked (a measure cannot start a compute), and the compute then runs again,
followed by the pass once more: a pass that found such a request is looked at
again after its compute (until no measure asks any more;
ifc::MAX_INLINE_ROUNDS only guards against a cycle), and one that found none is
followed by a single compute as before. A component
that declares display: inline-flex —
Badge, Button and the rest of the list in CLAUDE.md — is one of these
whenever it sits beside text rather than inside a Stack.
Why an absolute box can need a second compute
Taffy lays an out-of-flow box out against its direct parent. CSS lays a
position: absolute box out against its containing block: the padding box of
the nearest positioned or transformed ancestor, or the viewport when there is
none. crates/rinch-dom/src/out_of_flow.rs closes the gap in two halves.
The size is baked into the box’s Taffy style before the compute, so the
box’s own children — a percentage child, wrapping content — are laid out inside
the right box. For the viewport that is known up front. For an ancestor it is
not: the ancestor’s used size is what the compute produces. So a style site
bakes from the ancestor’s last laid-out size, and after each root compute
resolve_ancestor_absolutes compares every absolute box whose containing block
is a non-parent ancestor with the size that ancestor now has, rewrites the ones
that differ and asks for another compute. An out-of-flow box sizes nothing
above it, so one extra compute settles it (one more per level of nesting, with
calc()’s fixpoint and its cap of 8). A box whose size does not depend on its
containing block is never rewritten. The counter is
abs_containing_block_passes.
The position is written as a parent-relative value, so LayoutResult means
the same thing for every node and paint, stacking, hit testing and the debug
protocol need no special case: the containing block’s border, plus the inset
and margin, minus the offsets of the boxes between — plus those boxes’ scroll
offsets, because a scroller between a box and its containing block does not
carry the box. A scroll runs no layout, so NodeTree::mark_scrolled writes
those positions again.
None of this touches a box Taffy already resolves — an absolute child of its
own positioned parent, the usual badge or close button. The size check iterates
only the boxes under a non-parent ancestor, a scroll re-places only when the
scrolled box lies between a corrected box and its containing block, and the
abs_boxes_visited counter reads 0 for a page of ordinary badges. A box under
a non-parent ancestor costs about 0.8k instructions per layout (the size
compare and the placement).
Key Technologies
rinch-dom
The custom DOM and layout engine built specifically for Rinch:
- rinch-dom - DOM implementation with Taffy layout, Parley text shaping, and a
Paintertrait for backend-agnostic rendering - VelloPainter - GPU backend: records drawing commands into a Vello scene graph
- TinySkiaPainter - Software backend: rasterizes directly to an RGBA pixel buffer
Stylo
Mozilla’s CSS engine (from Firefox) provides:
- Full CSS specification support
- Efficient style computation
- Media query handling
- CSS custom properties
Taffy
A flexbox/grid layout engine that computes:
- Element positions (x, y)
- Element sizes (width, height)
- Flexbox alignment and distribution
- CSS Grid support
Painter Trait
All rendering goes through the Painter trait, which abstracts over both backends:
#![allow(unused)]
fn main() {
pub trait Painter {
fn fill(&mut self, fill: Fill, transform: Affine, brush: &Brush, shape: &PaintShape);
fn stroke(&mut self, stroke: &Stroke, transform: Affine, brush: &Brush, shape: &PaintShape);
fn draw_glyphs(&mut self, font: &FontData, font_size: f32, ...);
fn draw_image(&mut self, image: &PaintImage, transform: Affine);
fn push_clip(&mut self, fill: Fill, transform: Affine, shape: &PaintShape);
fn push_layer(&mut self, blend: BlendMode, opacity: f32, ...);
fn push_isolated_layer(&mut self, opacity: f32, ...); // never elided, even at opacity 1
fn pop_layer(&mut self);
// ...
}
}
Each text run casts its own element’s text-shadow (#1048). The property
is inherited, so an IFC’s text casts its root’s list unless an inline element on
the way down declared another (none included). When every run casts the
root’s list the text is drawn exactly as one list; otherwise each distinct list
is drawn through a mask that keeps it to the text casting it
(paint::text::ShadowGroup, the #829 visibility mask’s per-range rule), every
shadow before any text, as for one list. Chrome 153 paints shadow-then-text per
text node instead: in <p style="text-shadow: -29px 0 blue">A<span>B</span></p>
the shadow of B covers A there (measured) and not in rinch, whether or not
the span declares its own list. Such a list is ink of the root that draws it:
damage, the paint prune, the painted state and layer_bounds all count it.
Two limits. In a nowrap/pre root that text-overflow: ellipsis cuts, every
run casts the root’s list, because that layout records no text ranges, the same
gap as #853 (#1065); a wrapping root with such a span is left un-cut. And
each distinct list is a pass over the lines its text is on, so a paragraph of
many spans with different lists costs about lists × parley’s per-line item
walk: 193 ms for 400 distinct lists in one paragraph, against 4.6 ms for one
shared list (#1066).
A blurred text-shadow is a blurred mask (#980, paint/text_shadow.rs).
Vello has no general blur, so wherever the software rasteriser is compiled in
(every desktop and Android build) the shadow — glyphs, underline, line-through
and wavy underline (#981) — is rasterised once into a coverage mask over the
part of it that can be seen, blurred by a separable Gaussian of standard
deviation blur / 2 out to three of them, and handed to the painter as
Painter::draw_alpha_mask: the same mask to Vello (as an image) and to
tiny-skia (a solid fill through a pooled surface mask). Blurred masks are kept
between paints (32 MB, least recently used out), keyed by a hash of every call
that rasterised them in the terms that decide its pixels: each glyph by the
pixel the software painter rounds it to, a decoration to a quarter pixel. The
mask is always rasterised at the text’s true position. A repaint of a shadow
that is wholly on screen and unchanged, or has moved by whole pixels, or by a
fraction that moves none of its glyphs to another pixel, rasterises nothing; a shadow the surface, a clip or the damage crops is
rasterised again whenever the crop moves relative to it (a line scrolling
across a scroller’s edge), and so is any shadow whose text or style changed.
text_shadow_masks_rasterised counts what a paint rasterised. The Vello-only
build (embed without software-renderer) has no rasteriser and draws a
kernel of copies in BlendMode::Plus layers inside one
push_isolated_layer: 13 taps, a 5-tap cross or none, chosen by the shadow’s
own glyph count against TAP_GLYPHS_PER_SHADOW, with TAP_GLYPH_BUDGET as a
per-paint crash guard. Cost: dom::text_shadow_paint in the Perf job, 40
paragraphs of 14px text at 800x600 with 0 1px 4px — paragraphs_40 repaints
from the cache (61M instructions in CI), paragraphs_40_cold rasterises every
visible mask.
Application code never interacts with the Painter directly. Cargo features decide which backends a build carries, and a gpu build picks one of its two when the window opens (see below).
Vello (GPU Backend)
A GPU-accelerated 2D graphics library (enabled with features = ["gpu"]):
- Scene graph-based rendering
- Efficient batching
- High-quality anti-aliasing
- Path rendering (beziers, fills, strokes)
- Text rendering with proper shaping
- Requires wgpu (Vulkan, Metal, DX12, or WebGPU)
tiny-skia (Software Backend)
A CPU-based rasterizer, carried by every desktop build: the only backend without gpu, and the fallback with it:
- Direct pixel rendering to an RGBA buffer
- Presented via softbuffer (no GPU required)
- Dirty region caching — only changed areas are repainted
- Subtree pruning — nodes outside the dirty region, or outside the window, are skipped
- Works in headless environments, CI, containers, SSH sessions
Rendering Backends
Choosing a Backend
Your Cargo.toml decides which backends the build carries:
# GPU, with software as the fallback (recommended for most apps):
rinch = { workspace = true, features = ["desktop", "gpu"] }
# Software only (no GPU required, smaller build):
rinch = { workspace = true, features = ["desktop"] }
A gpu build then chooses when the window opens (crates/rinch/src/shell/renderer.rs): App::renderer(Renderer::Auto), the default, tries the GPU and presents with software when it will not start; Renderer::Gpu panics instead; Renderer::Software skips the GPU. The RINCH_RENDERER environment variable (auto, gpu, software, cpu) overrides the app’s choice. An app that configured the GPU device itself (gpu_config / external_gpu) always presents on the GPU. The details are in Rendering Backends.
GPU Rendering Flow
#![allow(unused)]
fn main() {
// Simplified GPU rendering flow
fn paint_gpu(&mut self) {
let scene = self.app.build_scene(scale, size);
self.renderer.render_to_surface(&scene, ¶ms, &surface);
}
}
Software Rendering Flow
#![allow(unused)]
fn main() {
// Simplified software rendering flow
fn paint_software(&mut self) {
let (pixels, w, h) = self.app.build_pixels(scale, size, &layers);
// pixels are blitted to the window via softbuffer
}
}
The software renderer includes dirty region caching: when only a small part of the UI changes (e.g., cursor blink, hover feedback), only the affected rectangular region is cleared and repainted. Nodes outside the dirty region are skipped entirely during the paint traversal.
Every dirty node contributes two rects: its current box, grown by how far its own outset box-shadow, outline and text-shadow reach (an inset shadow paints inside its padding box), and the rect it was last painted in. The second is summed up the box-tree chain from what each node was last painted with — Node::prev_layout and Node::painted (own ink reach, own transform), parent-relative, written only by the paint that consumes the region (NodeTree::consume_paint_dirty: the software frame, a full repaint and a GPU scene build alike). So the old pixels are found however many layout passes ran between two paints, when an ancestor moved rather than the node, and when a box moved and dropped its shadow, lost a child or changed its transform in the same frame; a removal records the painted rect of every node it takes out. A positioned box (absolute, fixed or relative) that moved is also grown by its whole painted subtree’s reach, which carries an untouched overflowing child with it. That is what lets an editor keystroke, a caret move or a dragged absolute panel repaint a region instead of the whole window. Once the region reaches half the surface (FULL_REPAINT_FRACTION) it stops being measured and the frame repaints in full.
The region is the union of the changed nodes’ rects, so anything painted outside the node tree has to contribute its own. The drag ghost is the one such overlay: it is blitted into the framebuffer after the document paint, so RinchApp remembers the rect it covered and folds that into the next frame’s dirty region — otherwise the frame that stops drawing the ghost would never clear where it had been, leaving it stuck on screen (issue #173). The GPU backend rebuilds the whole Vello scene every dirty frame and so has no equivalent case.
Incremental Updates
When content changes, the pipeline can skip unchanged stages:
- Style cache - Styles are cached per element selector
- Layout cache - Layout is only recomputed for affected subtrees
- Scene diffing - Only changed primitives are re-rendered
Performance Characteristics
| Stage | Complexity | Caching |
|---|---|---|
| DOM Build | O(n) | Incremental (surgical updates) |
| Style Resolve | O(n x rules) | Selector cache |
| Layout | O(n) | Subtree cache |
| Paint | O(visible) | Dirty region caching (software) |
| GPU Render | O(primitives) | GPU buffers (GPU mode) |
Web Backend
The pipeline above is desktop-only. The web backend (ui-zoo-web) takes a completely different path:
#[component] + rsx! → DOM construction code → WebDocument (web_sys) → Browser-native DOM
On the web, WebDocument implements DomDocument using web_sys to create real browser DOM elements. The browser handles style resolution, layout, painting, and compositing natively. No Taffy, Parley, Stylo, Vello, or wgpu are needed for the web backend, resulting in a much smaller WASM binary.
The frame clock
Every shell must send PlatformEvent::AboutToWait once per iteration of its
event loop. It is not an optimisation and it is not optional: it is the only
place RinchApp advances CSS transitions and CSS animations, marks the scene
dirty when either moved, resolves the dirty state the input handlers
deliberately leave for it to batch, and drains the focus requests effects
raise. The winit shell sends it from ActiveEventLoop::about_to_wait; the
Android shell sends it from android_frame::pump_frame, once per 16ms poll;
embed::RinchContext sends it from update.
A shell that omits it does not look broken. Every screen still paints and every un-animated control still works — but a transitioned property is sampled once, at the instant the transition starts, which is its old value, and stays there for ever, while un-transitioned properties on the same element apply immediately. A bottom sheet then answers a tap by becoming pointer-active without moving: open, invisible, and covering the screen.
Turning the clock is only half of it. A transition on a paint-only property —
opacity, transform, a colour — marks its node PAINT-dirty and nothing
else, so has_pending_layout() stays false and no RequestRedraw is raised. A
shell decides whether to present from RinchApp::scene_dirty, which
AboutToWait sets whenever a transition or an animation moved; a shell that
does not consult it turns the clock in private, and the surface keeps the frame
from before the change until something unrelated forces a present.
And scene_dirty is set for every tick that had something to tick, not only
for ticks after which something is still running. A transition that finishes
on a tick applies its end value and then reports nothing active — and on a
shell whose first paint after the change is slower than the transition is long
(Android’s is around 300ms against a typical 220ms), that is the only tick the
transition ever gets.
Starting a transition
Turning the clock is what advances a transition; style resolution is what
starts, retargets, reverses or cancels one. transition::start_transitions
implements css-transitions-1 §3, “Starting of transitions”, against the
properties the style differ found a change in:
| The running transition | What happens |
|---|---|
| none | start from the before-change value over the declared duration (§3 item 1) |
| its end value still equals the after-change value | left exactly as it is — same endpoints, same clock |
| it has already reached the after-change value | cancelled; nothing is started (§3 item 4.1) |
the combined duration (duration + delay) is zero or less | cancelled; nothing is started (§3 item 4.2) |
| the after-change value is the value it would reverse back to | cancelled, and a shortened reversal started (§3 item 4.3) |
| anything else | cancelled and restarted from the current interpolated value, over the full declared duration (§3 item 4.4) |
The second row is the one that is easy to get wrong, because the caller diffs
the node’s computed_style — which holds the interpolated value while a
transition runs — against the freshly resolved target. Every restyle of a
transitioning node therefore looks like a change, and rinch used to restart
the transition on each one with a brand new clock (#652). A declared 150ms
animation then ran for as long as restyles kept arriving, its duration a
function of how much else on the page happened to be animating; with ease,
which is slow near t=0, each restart advanced the value by a sliver and the box
crawled toward its target instead of arriving.
A property whose transition was left alone is still reported as transitioning to the caller. That is what tells style resolution to write the interpolated value back over the after-change style it has just assigned wholesale — without it the box would snap to its end value for one frame, which is all #489 needed.
A reversal is shortened. A reversal taken when the transition is half way
there is half as much travel back, so it gets half the duration and lands on
its old start value at the moment the cancelled transition would have reached
its end value — the behaviour a hover in and straight back out depends on. The
factor folds the running transition’s own factor back in
(|f·progress + (1 − f)|), so reversing a reversal is measured against the
declared duration rather than compounding toward zero.
Note that it keys on progress, not on elapsed time. Under linear the two
coincide, so reversing a 150ms transition after 75ms takes 75ms. Under ease —
which is what every transition in rinch-components declares, and linear what
none of them do — the output at input 0.5 is 0.8024, so the same reversal takes
120.4ms. It has to: 80% of the distance has been covered and 80% of it has
to be covered again.
The delay is not shortened with it, unless it is negative. A negative
transition-delay is an offset into the curve, so a shortened curve is entered
proportionally further along; a nonnegative one is a wait before the curve
begins, and the spec uses it as declared. HoverCard is what this protects: its
close direction carries transition-delay: 150ms as a grace period for moving
the pointer onto the card, and scaling that with the factor would halve the
grace period for anyone who leaves part way through the fade-in.
A transform is interpolated as a function list, not matrix entry by entry
(#414). A computed transform keeps the list it was composed from
(TransformValue::functions), and transition::transform::interpolate_lists
interpolates it the way Chrome does: functions that pair by type interpolate
their arguments (rotate(0deg) → rotate(90deg) is rotate(45deg) half way,
full size), a shorter list — none included — is padded with identity
functions, and from the first pair that does not match, the rest of both lists
is composed and interpolated by Chromium’s 2D decomposition (translation,
scale, shear and the shorter way round for the angle; a singular matrix flips
half way). Lerping the six matrix entries, which is what rinch did before,
gives the right angle with a uniform scale of cos(Δθ/2): a 90° turn shrank to
71% at its midpoint and a 180° one to a point. Two consequences: the
interpolated value is itself a function list, so a reversal or a retarget goes
on pairing by function; and “did the transform change” compares the lists, so
rotate(0deg) → rotate(360deg) is a change and spins, while none →
rotate(0deg) is not (Chrome starts an invisible transition there; rinch runs
no frames for it, since rotate(0deg) at rest is already the stacking context
a running transition would be — #415). @keyframes stops go through the same code.
tests/transform_interpolation_tests.rs pins it against Chrome 153’s
getComputedStyle numbers.
The 3D transform functions are drawn flat (#405). rinch paints in 2D, and
every 3D function but translate3d used to compute to the identity — so
rotateZ(45deg), the same rotation as rotate(45deg), did not rotate. Now a
list holding rotateX/rotateY/rotateZ/rotate3d, scaleZ/scale3d,
translateZ, matrix3d or perspective() is composed as 4×4 matrices and the
result is projected the way Chrome draws an element with no perspective or
preserve-3d ancestor: applied to the plane z = 0, divided by w, the
resulting z dropped. So rotateX(60deg) squashes the box to half its height,
and perspective(100px) translateZ(10px) magnifies it by 100/90. A plane at or
behind the viewer (w <= 0, perspective(10px) translateZ(20px)) is neither
drawn nor hit, as in Chrome — it flattens to the zero matrix scale(0) gives.
So is a box whose 4×4 is singular (scaleZ(0), scale3d(1, 1, 0), a
matrix3d() with a zero column), although its flattening may be invertible —
scaleZ(0) flattens to the identity — and its subtree with it, a position: fixed descendant aside, as for a hidden backface below (#1051). The cut
is where Chrome 153’s is, measured: a determinant that is not a normal f32,
so scaleZ(1e-20) is still drawn and scaleZ(1e-38) is not. (Chrome’s
getBoundingClientRect() still reports the flattened rect for such a box.)
Only the
composed matrix is flattened — rotateX(90deg) scaleY(2) rotateX(-90deg) is
the identity, where flattening each function would draw a line. For
interpolation every rotate* is one primitive (a common axis lerps the angle,
two axes slerp a quaternion the shorter way), perspective(d) interpolates
1/d, and matrix3d() pairs with matrix3d() but not with matrix().
tests/transform_3d_tests.rs pins all of it against Chrome 153. What is not
Chrome’s: the perspective and transform-style properties are not
implemented at all, so a parent’s perspective does nothing; a transform whose
w depends on x or y (perspective(200px) rotateY(30deg), a trapezoid in
Chrome) is drawn with those terms dropped, since no affine draws a trapezoid;
a mismatched remainder holding a 3D function is decomposed as a flattened
2D matrix, where Chrome decomposes the 4×4 (#989).
transform-origin’s z component is applied before the flattening — a list is
composed as M · T(0, 0, -z) and flattened, and paint applies the x and y about
the flattened matrix as before — so rotateY(45deg) about 50% 50% 100px
lands where Chrome’s does. backface-visibility: hidden hides a box whose
own transform turns its back to the viewer (the (3, 3) entry of the
inverse 4×4 is negative, Chrome’s test): it composes to the zero matrix, so it
and its whole subtree are neither drawn nor hit — except a position: fixed
descendant, which rinch does not contain in a transformed ancestor (#1372),
so it keeps the body’s transform and is still drawn. A mirror (scaleX(-1)) is not
a turn, and a child under a turned parent is judged by its own transform alone,
as in Chrome without transform-style: preserve-3d (#997).
One thing rinch does not implement from §3: the transitionability
precondition, which appears in item 1 and again in item 4.2: a pair of values
that cannot be interpolated — a length against a percentage, which needs a
calc() that ComputedStyle cannot hold — still gets an ActiveTransition,
which then idles for its whole duration because AnimatableValue::interpolate
answers None for it. The property snaps either way; cancelling instead would
only save the idle ticks.
Item 3 is implemented (issue #693): a running transition whose property has
stopped matching transition-property is cancelled on the next cascade of its
node, whether or not the property also changed on that restyle, and the
after-change value stands. It used to be left running, so the box snapped to the
target on the restyle and jumped backwards on the next tick. It is also what
makes a delayed visibility hide safe to reopen through (see
Components).
The before-change style, and who has one
Everything above assumes there is a before-change style to leave. §3 only runs
for a node that has one, and in rinch that is one bool on the node:
has_been_styled, set by apply_stylo_styles_to_taffy the first time the node
is cascaded. With it clear, the resolved style is assigned wholesale and no
transition is considered — which is what makes a freshly mounted element appear
at its final size instead of animating to it.
Two rules keep that flag honest, and they are the same rule read from the two ends:
- A node outside the document is never styled, so it never acquires the flag
(#651/#668).
resolve_stylesdrops astyle_rootsentry whose node is not connected totree.root_id. - A subtree that leaves the document loses the flag, and any transition or
animation running on it is cancelled (#699).
RinchDocument::detach_subtree_stylesdoes all three, for the whole removed subtree. Five places indom_impl/dom_document_impl.rswriteparent = Noneand four call it:remove_node,remove_child,replace_node’s displacedold, andset_text_contenton an element with children, which orphans every one of them without freeing the slab. The fifth,set_inner_html, is safe by destruction —NodeTree::remove_subtreefrees the entries and drops both animation maps with them.
Without the second, a node styled while it was connected, then detached, keeps
the flag and the computed_style it had in the document. If an ancestor’s
class changes while it is out, its re-insertion resolves to a different value,
§3 sees old ≠ new on an already-styled node, and the box animates in from a
style the user never saw. A browser does not: a removed element is not rendered,
it has no before-change style, and re-insertion is a first style.
What the detach does not touch is the computed_style itself, or the shaped
text_layout. A detached node still reads back as it last did in the document —
dom_tree(root_id: <a detached id>) depends on that, and so do the staleness
gates that decide whether a re-inserted subtree needs re-shaping. The flag is
what the transition reads; the value is what everything else reads.
A move is not a detach. append_child, insert_before and insert_child
unlink a node from its old parent with the same lines remove_child uses, but
the node is back in the document before the call returns, so it never stopped
being rendered and a mid-flight transition goes on running — which is what a
keyed for reorder depends on, since it moves rows with insert_after.
A move within one parent is not re-cascaded either (#914): its ancestor
chain is the one it was styled against, and the selector flags on the parent
restyle it when a positional selector (:nth-child, +, ~, …) can see the
move. Nor does it drop its own shaped text — only the inline formatting context
it was a member of, if any. A move to another parent re-cascades the subtree.
Unless the destination is itself detached (#702). A mounted node moved into
a parent that is not connected to tree.root_id has left the document while
keeping a parent, so it fails the parent = None test the four detach routes
share — and #696 established that connectivity, not the parent field, is the
question that matters. Splice that parent in somewhere else later and the node
animates in from its pre-move style, which is #699’s symptom by a fifth route.
detach_subtree_styles_if_moved_out is the answer, at four sites: the three
move verbs and replace_node, which splices its incoming new into old’s
parent and is a move out whenever that parent is detached.
Two guards decide, and the order matters. The destination must differ from the
old parent — a move within one container cannot change whether the child is
connected, because the child’s reachability is its parent’s — and the child
must already have a parent, since a node created moments ago cannot be a
move. Only a reparenting move reaches depth_if_connected, the same O(depth)
walk #696 filters style_roots with.
Counted, on 500 of each shape:
| workload | helper | walks | resets | nodes reset |
|---|---|---|---|---|
| rows built straight into their final parent | 0 | 0 | 0 | 0 |
| keyed reorder, each row to the front | 499 | 0 | 0 | 0 |
| rows reparented into a second connected list | 500 | 500 | 0 | 0 |
rsx! component sites, 20 nodes each | 500 | 500 | 500 | 10,000 |
The keyed reorder is the hot path and it never walks. The last row is the one to
know about: rsx! does not build a component site by appending fresh nodes
into their final parent. component_codegen puts a site’s children into a
<template> attached to nothing (#719) and Component::render adopts them into
a root that is also still detached, so every adoption is a move into a detached
parent — it walks, and it takes the whole subtree reset. The reset is
semantically a no-op there (a node created moments ago is already unstyled, with
empty transition and animation maps) and the cost does not show: best of 40,
release, three alternated rounds, the build with the helper was the faster of
the two every time. Say “a node appended straight into its final parent pays
nothing”, not “building a tree pays nothing”.
One behaviour change follows, and it is narrower than it looks. A node that is
mounted and still connected when it is moved into a detached parent, and
adopted straight back out in the same pass, loses its running transitions and
restarts its animations — a browser never sees that intermediate state, because
its style recalc is batched to the end of the task. The component re-render
path does not reach it (reactive_component_dom removes the old output first, so
#699 has already reset that subtree); handing a component a handle that is
mounted elsewhere and still connected does.
display: none is not a detach either, and it does not need to be. §3’s
question is not is this node in the document but is it being rendered, and
a hidden node is not — nor is anything inside one, since display does not
inherit and a box under a hidden wrapper computes display: block. So the flag
is only half the answer, and the cascade asks the other half directly (#703):
before starting a transition, is_rendered_for_transition checks the node’s
display before the change as well as after, then walks its ancestors. A
style change made while an element is hidden lands on it outright, the way a
browser applies one, so the element is already at its new value when it is
shown. In the other direction, an element that stops being rendered has its
transitions cancelled, and so does everything under it — a descendant’s own
cascade need not run at all when an ancestor is hidden.
Three things about that are worth knowing.
- It reads the old display, not just the new one. A single class write can
un-hide a box and retarget it at once. At that cascade the new display is
already
block; only the old one says there was nothing to transition from. was_hiddenexists because parents cascade first. An ancestor restyled on the same pass is carrying its new display by the time a descendant is reached, so the pass records the nodes it found hidden as it goes.visibility: hiddenis rendered. The box is generated, laid out and takes up space; it has a before-change style and its transitions run. Folding it in here would be wrong, and invisible in everydisplayfixture.
The walk is O(depth) and sits at the last gate before a transition starts —
after diff_animatable has found an animatable change on a node that declares a
transition — so a node with neither never pays for it.
This caught a shipped component, and the rule it puts on the library is worth
stating on its own: an overlay that animates must stay rendered. Animate
opacity, visibility or transform; never toggle display. A display flip
cannot transition on either backend — a browser refuses it for the same
reason, which is why @starting-style and transition-behavior: allow-discrete exist — and rinch-components ships one stylesheet to both.
The Drawer was the one component with it backwards (issue #751): its root
carried display: none while closed and its panel carried
transition: transform, and one reactive effect un-hid the root and retargeted
the panel in a single pass, exactly the shape above. So its 300ms slide-in ran
on desktop only until this change, and had never run on rinch-web at all. Its
closed state is visibility: hidden now, which is where Popover already was —
hidden, still rendered, and — once its close has run — out of paint, hit
testing and the Tab order on both backends. Since issue #759 the close holds the
root visible for the panel’s 300ms slide-out (a delayed visibility
transition), so for those 300ms it still paints, takes clicks and is tabbable,
as in a browser.
The blast radius is wider than “a control restyled in an inactive tab”:
any component that un-hides an ancestor and retargets a transitioned property
in the same style pass loses its animation. Modal, Notification,
Tooltip, Select, DropdownMenu, Tabs and Stepper are audited one
fixture each in crates/rinch/src/app/overlay_animation_audit_tests.rs, against
the rule “a transitioned property that changes on the reveal pass must have a
transition running” — which is the only form of the assertion that
discriminates, since “zero transitions ran” is what the bug looks like.
The reactive helpers rest on this now, and no longer on a second mechanism of
their own. show_dom, match_dom, for_each_dom_typed and the component
re-render effect used to call NodeHandle::clear_animations() before
remove(), which wrote an inline transition: none; animation: none over the
whole subtree — and nothing ever took it off, so a branch hidden once could
never animate again (issue #704). It also hid the detach reset: through one of
those helpers, deleting detach_subtree_styles changed nothing observable. The
method and all five call sites are gone. Every one of them was
clear_animations(); remove(); and NodeHandle::remove is
DomDocument::remove_node, the first of the five routes listed above; on
rinch-web the browser already cancels a removed element’s transitions and
treats re-insertion as a first style. A removal path must not write styles:
the node outlives the removal, so anything stamped there is permanent.
The page-load guard arms transitions, and only transitions
has_been_styled is per node. There is a second, whole-document suppressor
beside it: NodeTree::transitions_enabled, false at construction and set at the
very end of the first resolve_layout (“Enable transitions after first layout
completes”, layout_engine.rs). has_been_styled alone would not do the job,
because a tree is cascaded more than once before its first layout — appending a
<style> element re-resolves the whole document there and then
(maybe_load_style_css), so a component that appends its own stylesheet after
building its markup leaves every node already styled, and the next rule it loads
is a change on an already-styled node. Without the flag that is a transition
running on page load. recompute_all_styles_full forces the same flag off for
the duration of its re-cascade, for the same kind of reason: a theme change
applies instantly rather than every element transitioning from the old palette
to the new one.
Neither rule is about animations, and since #762 neither reaches one. A
@keyframes animation has no before-change style to be wrong about — it does
not interpolate from a previous style, it plays its own — and a browser runs one
on the very first frame the element exists. The animation half of
apply_stylo_styles_to_taffy sat inside the same if as the transition half,
and two faults followed:
- An animation present in the first frame never started. Nothing re-cascades
those nodes afterwards, so it was simply gone. On desktop the first resize or
scale-factor event happens to re-cascade the tree and the spinner starts,
which is why this went unnoticed; an embedded
RinchContextat a fixed size gets no such event and kept a dead spinner for the life of the context. - A theme change stopped every animation in the document, permanently.
recompute_all_styles_fullclearedtree.active_animationsand re-registered none, because the flag it had just forced off was the gate. Toggling dark mode killed everyLoader,Skeleton,Progressstripe and spinner in the app.
So the animation block reads no flag, and recompute_all_styles_full no longer
clears the map. The re-cascade it runs is what reconciles it:
animation::start_animations matches a running animation by name, keeps its
clock — start_time_ms, paused_elapsed_ms, play_state — and drops one whose
declaration the new sheet no longer carries. On this pass, and only this one
(NodeTree::refreshing_animations, a flag of its own), a kept animation takes
everything else afresh: the @keyframes rule is looked up again and the
animation dropped if it is gone, the stops are re-extracted from the new base
style, and the duration and delay come from the new declaration.
Preserving rather than restarting is what a browser does, measured in Chrome
150.0.7871.100 by replacing a <style> element’s textContent under a running
animation: … 10s linear infinite and reading Element.getAnimations():
| The swap | getAnimations() | currentTime | effect |
|---|---|---|---|
| identical rules, one unrelated declaration changed | 1 | unchanged | unchanged |
same animation-name, changed @keyframes body | 1 | unchanged | new keyframes, applied at once |
@keyframes deleted, declaration kept | 0 | — | back to the base style |
animation declaration deleted | 0 | — | back to the base style |
rinch matches all four on the theme path. Rows 2 and 3 took the refresh, and
row 3 is worth a sentence because an earlier revision of this page called it
unreachable: the theme sheet is replaceable and can carry @keyframes — rinch’s
own generate_theme_css_string puts component keyframes in it — so a new theme
can drop one, and keeping the entry whole left that animation running where
Chrome 153 cancels it. A later measurement in Chrome 153.0.8010.36, under a
seeked currentTime, added three more rows the refresh also matches:
| The swap | Chrome 153 |
|---|---|
animation-name k → k2 | a new animation, currentTime 0 |
animation-duration 10s → 20s | same animation, currentTime 3000, progress 0.15 |
a panel shown by the theme, which also moves font-size 10px → 40px under a 1em → 11em spinner | width 80px at 1000ms |
The refresh is scoped to recompute_all_styles_full on purpose, and that is
not the only pass that re-cascades the whole document. Two others drop every
node’s cached style too and do not set the flag: appending a <style> element
(maybe_load_style_css), and a viewport change in resolve_layout. Measured
after the first layout, a <style> appended with a redefined @keyframes kk
leaves a running kk on its old body. Those two passes, and every targeted
restyle — a class change, a hover — still keep a kept animation’s stops and
timing whole: an edited @keyframes body (issue #766), a changed duration or
delay (#780), and a stop derived from the base style, such as the implicit
from of a to-only rule carrying color (#781), stay stale. #781
tracks the two whole-document passes. The full restyle is the pass a theme
toggle takes, it runs rarely, and it can afford a keyframes lookup and a stop
extraction per animated node; a targeted restyle runs per hover and should not
pay that.
crates/rinch-dom/tests/full_restyle_animation_refresh_tests.rs pins every row.
One divergence the theme path now reaches, not fixed here: a finished one-shot
animation replays on a theme toggle. An animation with a finite iteration
count and no forwards fill is removed from tree.active_animations by
tick_animations once it completes. The full restyle re-cascades its node,
start_animations finds no entry of that name, and mints a new one from t=0.
Chrome does not replay it, and neither did main before #762 — its full restyle
cleared the map with the animation block gated off, so nothing was minted, which
was right by accident. It is the mechanism of issue #783 (a finished
animation’s entry is forgotten, so any later cascade of the node restarts it),
reached through the theme path. Component CSS declares only infinite
animations; the one finite iteration count rinch ships is the theme’s
prefers-reduced-motion rule (animation-duration: 0.01ms; animation-iteration-count: 1), whose replay would last 0.01ms.
One consequence comes with the first-frame start, and it is pre-existing rather
than new: an animation’s clock begins at the cascade that styles the node —
append_child, for a freshly appended element — not at the frame that first
shows it. So the first frame is already a few milliseconds in, where a browser
defers an animation’s start to the first frame it is rendered in. Every
post-mount insertion has always behaved that way; #762 only lets the first frame
join them. Issue #768.
crates/rinch-dom/tests/animation_start_gating_tests.rs is the pin, and its
module doc carries the measurement above plus a table of which fixture kills
which mutant — including the two results that are easy to get wrong by
reasoning: no animation fixture moves when the flag is removed from the
transition gate, and “a transition declared before the first layout does not
run” does not distinguish the flag from has_been_styled, because on a
first cascade both suppress.
A hidden element’s @keyframes animations
@keyframes is the same question with a stricter answer and a second consumer,
and it took its own change (issue #747).
The answer is stricter because css-animations-1 §3 does not merely refuse to
start something on an element that is not being rendered: such an element has
no animation effect at all, and showing it again starts a new animation from
the beginning rather than resuming the one it had. So rinch drops the
ActiveAnimation entries rather than parking them, and starts fresh ones on the
way back.
The second consumer is the frame clock. A transition self-limits — it has a
declared duration and dies after it — but animation: … infinite does not, and
AboutToWait schedules another frame whenever tree.active_animations holds an
animation that is not paused. An animation left running on something nobody
paints therefore keeps a desktop app rendering at full rate indefinitely: a Loader in a closed panel
or an inactive tab, with nothing on screen moving. That is why parking the
entries was not an option, and it is the same symptom #699 fixed for a removed
Loader.
Three sites do it, all in apply_stylo_styles_to_taffy:
- Nothing starts on a node that is not rendered.
animation_is_renderedis the transition gate’s ancestor walk asked of the state after the cascade — “is it rendered now” — so it takes no old display and nowas_hiddenlist. Reusing the transition gate here would be wrong in a way nothing else notices:was_hiddennames the nodes that were hidden before the change, so an ancestor un-hidden on this same pass is on it, and an inner wrapper shown beneath it would refuse its own restart. The gate also takes away what the node was already running, which the subtree walks cannot: a node moved into a hidden panel changes nobody’sdisplay, so the only thing that runs is its own re-cascade. - A subtree that stops being rendered loses its animations, whole, whatever
each box’s own
displaycomputes to —cancel_animations_in_subtree, beside the transition cancel and under its own guard. - A subtree that starts being rendered again gets them back, from t=0 —
restart_animations_in_subtree, at the same site that resets the scroll offset. This is the half that has no counterpart on the transition side, and it is not symmetry for its own sake: a node shown by an ancestor need not be re-cascaded at all.set_styledrops the cached Stylo data of the node it was written to and of nothing else, so a panel un-hidden withset_style("display", "block")re-cascades the panel alone; without the walk its spinner would stop on the way in and never start again. Aclasswrite invalidates the subtree and so restarts descendants through the ordinary per-node path — the two routes differ, and the fixtures say which is which. The walk stops at any box whose owndisplayisnone, and skips the node it was called on, whose own cascade has already restarted it.
It costs O(subtree) on a change from none to rendered, which forces a full
layout and paint of that same subtree anyway; there is deliberately no “does
anything under here animate” guard, because there is no answer to that cheaper
than the walk. Measured on the worst case that can be built for it — hiding and
showing a 4000-node, animation-free panel, debug build — the walk costs +4.2%
of the cycle (21.06 → 21.95ms).
visibility: hidden animates, and that is a frame clock nobody switches off
visibility is rendered here for exactly the reason it is rendered for a
transition: the box is generated, laid out and takes up space. It is also what a
browser does — a visibility: hidden element’s animation runs in Chrome, it is
merely not painted.
The cost is worth stating plainly, because it is not the one-off a transition’s
would be. An animation: … infinite has no duration to expire, so a rendered-
but-invisible spinner asks for a frame forever. That is now reachable through a
shipped component: #751 made the closed Drawer’s root visibility: hidden
precisely so its panel could transition, so a Loader placed inside a closed
drawer kept the app rendering until #912 paused it (below). Measured before
that, software backend, 804x600, closed drawer, 20 idle frames:
| closed-state spelling | active_animations | idle frames asking to redraw | ms per tick+paint |
|---|---|---|---|
display: none | 0 | 0 / 20 | 0.001 |
visibility: hidden, running (the Drawer before #912) | 1 | 20 / 20 | 2.53 |
visibility: hidden, paused (the Drawer since #912) | 1 | 0 / 20 | — |
| open drawer, either spelling | 1 | 20 / 20 | 8.8 – 9.7 |
This is accepted rather than overlooked. Refusing an animation to a
visibility: hidden box would put desktop at odds with both the browser and the
transition rule next to it, for one component’s benefit. The cure belongs to the
component — animation-play-state: paused on a closed overlay’s subtree — and
since issue #763 it works. A paused animation keeps its entry, because its
frozen sample still has to reach computed_style on each cascade, but it has
nothing to advance: tick_animations neither counts it nor marks its node dirty,
and AboutToWait’s “was there anything to tick” guard asks
NodeTree::has_running_animations() instead of whether active_animations is
empty. So a paused spinner schedules no frame, and resuming it continues from the
time it was paused at. A paused font-size (or any other text-measure) animation
is measured by each cascade that writes its sample, and never per tick.
That is the one cost this change adds, so it is stated with numbers: every cascade of a node carrying a text-measure animation re-measures its text and re-runs Taffy, whether or not the sample moved. Measured on 500 rows, release, min of 20 (hover) / 12 (restyle):
| workload | no animation | with a paused font-size animation |
|---|---|---|
| one-row colour-only hover on that row | 0.143ms, 0 Taffy computes | 0.679ms, 1 compute |
| whole-document colour-only restyle | 16.1ms | 45.1ms (all 500 rows animated) |
It is bounded by how many nodes carry such an animation, which is rare, and the
behaviour it replaces paid a compute on every frame for the same node. The
narrowing is available and deliberately not done here: compare the node’s
old computed_style against the post-animation style, rather than asking only
whether one of its animations has a font-size stop. That would make a hover
over an unchanged paused sample free.
The invalidation is also the whole of the guarantee, which is why issue
#784 mattered: compute_inline_block_layouts measures an atomic inline out
of Taffy’s cache, so text inside an inline-block was re-measured by nothing
when a none → rendered crossing changed it, and a paused sample stayed frozen
in the old font for good. mark_atomic_inline_dirty now marks those Taffy nodes
on an ifc_dirty pass instead of returning early.
A finished animation with animation-fill-mode: forwards or both is the same
shape (issue #782): its fill is as constant as a paused sample. The tick that
finishes it writes the fill and marks the node dirty — that frame shows the end —
and records it in ActiveAnimation::fill_settled; later ticks re-apply it
without dirtying anything, and neither tick_animations nor
has_running_animations() counts it.
The component library declares the pause itself (issue #912). Drawer’s
closed rule, and the closed states of Popover’s and HoverCard’s dropdowns —
the three overlays that close with visibility: hidden and take content —
pause every animation beneath them:
.rinch-drawer__root--hidden * { animation-play-state: paused !important; }
.rinch-popover__dropdown:not(.rinch-popover--opened .rinch-popover__dropdown) * {
animation-play-state: paused !important;
}
Every descendant, not a list of known spinners, because any component or app
rule can declare an animation. Not the pseudo-elements, although
animation-play-state does not inherit into them, so an ::after spinner
under a closed overlay still runs — on rinch-web, and since #1004 on desktop
too, where a generated box carries its pseudo cascade and its animation starts
(#925; desktop restarts it at every cascade of its originator, #1023), keeping
the frame clock running. They are left out because rinch-dom matches
::before/::after rules with no ancestor bloom filter (#935), so *::before/*::after rules cost +10% of style instructions
on every page that loads the component CSS and +71% under a closed drawer.
!important because the animation shorthand
resets animation-play-state to running: without it the pause loses to any
shorthand of equal specificity declared later (.rinch-loader__oval) or higher
(.rinch-button--loading .rinch-button__loader). An app that really wants an
animation to run inside a closed overlay needs an !important of its own. The
:not() is the exact complement of the rule that makes the dropdown visible.
A closed drawer holding a Loader therefore idles, and opening it resumes the
spinner where it stopped, as a browser keeps currentTime.
None of the three sites reads transitions_enabled (see “The page-load guard
arms transitions, and only transitions” above), and for the restart walk that is
a separate decision rather than a consequence. The walk shipped behind the flag,
and the flag is off for every cascade before the first layout completes — so a
panel shown by an inline display write on one of those passes dropped its
descendants’ animations on the way out and did not start them again until
something unrelated re-cascaded the subtree. animation_start_gating_tests.rs
pins that shape twice, once for a spinner that never ran and once for one that
did, whose clock must restart inside the show pass.
The other flag-off pass, recompute_all_styles_full, reaches the walk too, and
an earlier revision of this page said it did not. A theme that un-hides a panel
runs the walk on the panel’s cascade, before the descendants’ own, so the
walk mints their entries from their pre-restyle computed_style — for an
em-sized spinner under a theme that also changes the font-size, stops on the
old basis (20px where Chrome gives 80px). Counting running animations cannot see
it, because every node is re-cascaded on that pass and the spinner runs either
way. What repairs it is the refresh above: each descendant’s own cascade comes
after the walk and re-extracts the stops from its new style.
Optimizations
Current and planned improvements to the rendering pipeline:
- Dirty region caching (software) - Only repaint the rectangular area covering changed nodes
- Subtree pruning (software) - Skip paint traversal for nodes outside the dirty region
- Viewport culling (both backends) - Skip nodes that fall outside the window. This is the dirty-region test against a second region that is always present, so it applies to a full repaint too. It matters most on the GPU: Vello discards invisible paths in its coarse stage, but only after flattening every one of them.
- Clip elision (both backends) - A clip layer that provably removes no drawn pixel is not pushed. Two cases qualify: a clip whose rect contains the whole render target (it can only remove pixels that are discarded anyway) and a clip nothing inside reaches past. Both require square corners — a rounded clip cuts the corners of its own box, so “nothing overflows” does not mean “nothing is cut”.
- Sensitivity flags - Hover/active/focus only trigger repaints for nodes with matching CSS selectors
- Batched redraws - Multiple state changes are batched into a single repaint via the frame clock (
AboutToWait, above) - Layer compositing - GPU layers for transformed content (planned)
- Text caching - Glyph atlas for repeated text (planned)
Two things the first two do not do, because both would be visible bugs rather than optimisations.
Neither an ancestor’s culling nor its elision can remove a position: fixed descendant. A
fixed box is painted in viewport space, from the sequence of its nearest stacking-context
ancestor, so that ancestor’s box says nothing about whether the fixed box is on screen — an
off-window stacking context holding one is therefore not pruned, and a clip whose subtree holds a
fixed box is never elided. Note the shape of that claim: it is about what an ancestor may
conclude, not about the fixed box itself. A fixed box is culled and elided on its own terms like
any other — one covering the window has its own clip elided, one 600px below the window is
culled — and both are correct, because in viewport space its own rect is the whole truth about
where it lands.
How an ancestor knows: it measures the subtree, it does not read the node’s category. The
cull asks layer_bounds::subtree_is_entirely_outside — the same walk that sizes an opacity
layer — and prunes a stacking context only on a definite extent that misses the render target.
Every not-knowing means paint in full: a position: fixed descendant (which is what keeps the
paragraph above true, by construction), a position: sticky one (paint places it by an ancestor
walk the subtree does not contain), a position: absolute one that would escape a clip below its
containing block (#550), the walk’s visit budget, and its depth cap.
The cull used to answer the syntactic question instead — decline for every stacking context
with children — and that was correct and expensive: an opacity: 0.5 sheet parked below the fold
allocated, filled and composited a whole-surface pixmap every frame, 26.7ms against 0.8ms on
three of them at 1080x2460 (#562). Narrowing it to stacking contexts that also clip is the
obvious repair and is wrong: the skip-draw-and-recurse arm it hands the rest to runs before
any layer is pushed, so the sheet’s fixed descendant came back unfaded, with the whole suite
green. Measuring the subtree is what makes the cheap answer and the correct answer the same one.
And the cull tests a box’s layout rect against the window grown by a margin, not against the
window itself, because a box-shadow, an outline or a text run wider than its own box all put
ink outside the rect being tested.
Rich-Text Editor Architecture
This page is the structural view of the editor: which crate owns what, how data flows between them, which invariants each boundary exists to protect, and why the boundaries are where they are.
It deliberately does not duplicate the concepts or the API:
- Rich-Text Editor — the model, positions, schema, steps, transactions, commands, history, plugins. Read that first if you want to understand the editor.
- Rich-text editing —
create_editor(), theEditor {}component,EditorHandle, the command catalogue. docs/design/editor-rearchitecture.md(in the repo, not in this book) — the design record: the milestone plan and the numbered decisions (§6, A3, A7, A22 …) that the source comments cite.
Crate map
Three editor crates plus two platform-wiring crates, split along one axis: how much a crate is allowed to know about a renderer.
| Crate | Knows about | Role |
|---|---|---|
rinch-editor-core | nothing | The pure model: document tree, positions, schema, steps, state, commands, plugins, serialization. Defines the EditorView seam. |
rinch-editor-view | rinch-core’s DomDocument trait | The projection: RinchDomEditorView, EditorHandle, the Editor {} component, the mounted-editor registry, the default stylesheet, the caret-blink clock. |
rinch-editor-collab | a CRDT engine (yrs) | Optional, off by default: projects the model onto a CRDT and rebuilds remote changes back into Steps. |
rinch | rinch-dom, winit, AT-SPI | Desktop wiring: input translation, the layout-coupled caret pass, block virtualization, accessibility, the cross-thread collab inbound. |
rinch-web | web_sys | Browser wiring: the same jobs against the real browser DOM. |
rinch-editor-core’s manifest states the ban list as a contract — no rinch-dom,
winit, web_sys, parley, taffy, vello, and no CRDT engine — and it compiles
to wasm32 unchanged. That is not tidiness for its own sake: it is what makes the
model testable without a renderer, shareable between desktop and browser, and
projectable onto a CRDT without either side learning about the other.
┌──────────────────── rinch-editor-core (pure, wasm-clean) ─────────────────────┐
│ model/ Node · Mark · Fragment · Slice pos/ Pos · ResolvedPos │
│ schema/ NodeSpec · MarkSpec · ContentMatch transform/ Step · Mapping │
│ state/ EditorState · Transaction commands/ plugins/ serialize/ │
│ view.rs trait EditorView ◄── the only outward seam │
└───────────────────────────────┬───────────────────────────────────────────────┘
│ implements EditorView
┌───────────────────────────────▼───────────────────────────────────────────────┐
│ rinch-editor-view — RinchDomEditorView over ANY rinch-core DomDocument │
│ EditorHandle (owns state + view) · Editor {} component · registry · styles │
└──────┬───────────────────────────────┬────────────────────────────┬───────────┘
│ desktop host │ browser host │ optional
┌──────▼─────────────────────┐ ┌──────▼──────────────────┐ ┌──────▼───────────┐
│ rinch — rinch-dom renderer │ │ rinch-web — web_sys DOM │ │ rinch-editor- │
│ input glue · caret pass │ │ input glue · caret pass │ │ collab (yrs) │
│ a11y · block virtualization│ │ │ │ `collaboration` │
└────────────────────────────┘ └─────────────────────────┘ └──────────────────┘
The invariant everything else serves
The model is the only source of truth, and the host tree is derived from it.
EditorState { doc, selection, stored_marks, plugin_state } is a value.
state.apply(tr) is pure and returns a new state. The host tree — rinch-dom nodes
on desktop, real DOM elements in the browser — is a projection of that state and is
never read back for content. There is no contenteditable attribute anywhere;
the editor container is marked data-pm-editor precisely so nothing mistakes it for
a browser-managed editing surface.
Everything downstream is a consequence:
- One mutation path. Every edit becomes a
Transactionof invertibleSteps, applied byEditorState::apply, then projected. Inrinch-editor-viewthat funnel is literally one function,EditorCore::commit: store the new state, call the view’s phase-1 update, and (if collaborating) record the change onto the CRDT.EditorHandle::updateandEditorHandle::commandare the only two entry points that reach it, and typing, paste, IME commit and toolbar clicks all route through one of those two. - Commands read state, never the DOM. Toolbar enablement (
can_run), mark state (is_mark_active), block type (current_block_type) are all state queries. A command that consulted the host would be reading its own output. - “The DOM and the model disagree” is structurally impossible — the failure class that motivated the rewrite. The previous architecture had several mutation paths writing to different sources of truth (the host tree, an Automerge document, a second cursor model) with no reconciliation between them; the design record’s audit names that as the root cause.
rinch-editor-core — the pure layer
The guide covers what these are; the architectural points are why each is a single thing rather than several:
- One position space.
Posis a single depth-aware integer space (one unit per node boundary, one per Unicode scalar of text). Byte offsets exist only transiently at platform seams — Parley layout, IME — and never in the model. A second position representation would need a conversion at every boundary, and every conversion is a place for the two to drift. - The schema is enforced at the step boundary, not by convention. Content
expressions compile to a
ContentMatchNFA. Every replace reconstructs its parent through one gate that checks the resulting child sequence against that parent’s content expression and each child’s marks against the allowed set; a violation is aStepError, the step is never accumulated into the transaction, and the edit is a no-op. Invalid structure is therefore unrepresentable in a committed state, so no downstream consumer — view, serializer, CRDT projection — needs a defensive path for it. - A deliberately minimal
Stepset.ReplaceStep,ReplaceAroundStep,AddMarkStep,RemoveMarkStep,SetNodeAttrStep,SetDocAttrStep. Steps are invertible (undo), mappable (redo, decoration tracking, collaboration rebase) and composable. Collaboration adds no new step kinds — that is the test of whether the set is actually primitive.BatchStep(#1200) is the one addition, and it adds no expressiveness: it is a list of disjoint replaces and attribute changes that builds the document thoseReplaceSteps andSetNodeAttrSteps would one at a time, maps positions as they would, and inverts — as one step, so a table command touching every row is linear rather than quadratic. Where it differs (content checked on the result only; a rebase that brings two replaces together) is indocs/src/guide/editor.md. - Persistent tree, so identity is cheap.
NodeisRc-shared and every edit produces a new tree sharing unchanged subtrees.Node::same_ref(anRc::ptr_eq) therefore answers “did this subtree change?” in constant time — which is what makes history cheap to keep and the view diff cheap to run. - Nothing is special-cased. The command catalogue + keymap
(
BaseCommandsPlugin), the markdown input rules (MarkdownInputRulesPlugin) and undo/redo (HistoryPlugin) are all plugins;default_plugins()just lists them. History has no privileged hook inapply— it folds its state forward like any other plugin. Features that cannot be added as plugins are a design smell, not an excuse to reach into the core.
The view seam
rinch-editor-core::view defines the whole outward contract in two methods:
#![allow(unused)]
fn main() {
pub trait EditorView {
fn update_dom(&mut self, prev: &EditorState, next: &EditorState) -> Vec<ViewRequest>;
fn update_caret(&mut self, next: &EditorState) -> Vec<ViewRequest>;
}
}
Three structural decisions live in that shape.
It is two phases because layout sits between them. update_dom runs before
layout and mutates the host. update_caret runs after layout, once the host has
fresh geometry, and computes caret/selection rectangles and the IME candidate box.
Caret geometry computed in the call that mutates the host would read stale layout —
so the seam straddles the host’s mutate → layout → measure pipeline rather than
pretending it isn’t there.
Requests cross back as data. The view owns no window; only the runtime does. So
“scroll the selection into view” is a returned ViewRequest the runtime fulfils, not
a call the view makes.
Input translation is deliberately absent from the trait. Turning a key, pointer
or IME event into a command is irreducibly platform-specific — desktop reads Parley
geometry to map a click to a Pos; the browser has its own event model — so each
runtime owns that glue. The trait covers only the state→host direction, which is the
part that can be shared.
rinch-editor-view — one projection, two hosts
RinchDomEditorView implements EditorView against
rinch_core::dom::DomDocument, the same trait the reactive layer uses. It holds
only a Weak reference to the document and never names a concrete renderer — which
is why the desktop (rinch-dom) and browser (web_sys) editors are the same view
code, and why its own tests project onto a mock document with no renderer present at
all.
The ViewDesc tree mirrors the document: one descriptor per model node, holding
its host node, its mark-wrapper chain, and its children. Diffing walks descriptors
against the new document, with Node::same_ref as a fast skip — an unchanged subtree
costs one pointer comparison. The diff is not keyed: it first matches the children
the new node shares by reference at the start and at the end, then diffs the changed
stretch between them positionally, inserting new children before the unchanged suffix.
An edit typically changes one contiguous stretch of siblings, so that isolates it without a
keyed/LIS pass. Matching the suffix is what keeps a split or join local: a purely
positional diff re-pointed every later block at its neighbour’s host and rewrote its
text, so one Enter re-shaped every paragraph below the caret (issue #905). A node whose kind changed (tag or mark set)
is rebuilt and swapped rather than patched in place, because a changed mark set
alters the wrapper chain around it. The root’s host element is created by the caller
(the Editor component) and is never replaced — only its children reconcile.
Decorations diff independently of the document. A transaction that changes only
decorations — an IME preedit, a placeholder appearing — must still produce a visible
update, so update_dom runs the document diff and a separate decoration sync. Folding
them together would make preedit invisible whenever the document happened not to
change.
EditorHandle owns both the state and its projection, behind an Rc<RefCell<…>>
so it is cheap to clone into toolbar closures and the runtime alike. It works
unmounted: create_editor() builds a handle over a fresh document with no host at
all, and load_html / command / set_selection operate on the owned state, which
the view renders when the Editor {} component mounts it. This is what lets a handle
be created in a component body and handed to buttons and to Editor {} in the same
rsx! block.
Read-only is one check where local changes land, not one per input path. Every
local change — update, command, insert_image, toggle_link, load_doc — is
stored by EditorCore::commit, and that is where set_read_only is enforced: a
transaction that changes the document or sets stored marks is refused, a
selection-only one applies, and a load applies unless a collaboration session would
record it. The rule reads the two states, not the caller, so the desktop key handler,
the web’s beforeinput and mirror-diff paths, an app’s toolbar and any input path
added later are covered without knowing the switch exists. Remote integration
(collab_receive) has never gone through commit — it must not be recorded back
onto the CRDT — so a read-only editor keeps receiving by construction: there is no
flag to lower around it and none to forget to raise again. The platform crates add
only what a refusal cannot express (the OS input method off, the context menu’s Cut
and Paste greyed, the web capture field readonly so no soft keyboard rises).
The registry is keyed by (doc_key, container_id), not by container id alone.
Container ids are per-document slab indices, so two documents on one thread — two
desktop windows, or two embedded RinchContexts — collide at the same id (issue
#134). Keyboard focus is not tracked here: the platform runtime is the single focus
authority, and it resolves its focused container id to a handle through the registry.
The Editor component registers on mount and unregisters via scope.on_cleanup, so
a hidden tab’s editor stops being driven while its handle (and state) may live on.
Platform wiring
The wiring crates hold exactly what cannot cross the DomDocument seam.
Desktop (crates/rinch/src/editor/) re-exports rinch-editor-view wholesale and
adds three things that reach past the seam into the rinch-dom renderer or a native
platform API:
| Path | Why it can’t be in the shared view |
|---|---|
virtualization.rs, virtual_window.rs | Block virtualization gives off-screen blocks a fixed estimated height so Taffy skips their Parley measurement. It manipulates the concrete Taffy layout tree. |
a11y.rs | The accessibility derivation is portable (rinch_editor_core::a11y → an accesskit::TreeUpdate); only the adapter is not. Linux uses accesskit_unix over AT-SPI; other desktops get a no-op bridge. |
post_remote_delta | The Send-safe collaboration inbound: marshals a delta from a network thread onto the main thread, then integrates it and wakes the runtime. |
Input translation and the caret pass live in the runtime proper:
app/event_dispatch.rs turns platform key/pointer/IME events into KeyBindings and
handle calls, app/focus.rs holds the focus arbiter, and shell/rinch_runtime.rs
drives the post-layout caret pass and the blink clock.
Browser (crates/rinch-web/) does the same jobs against the browser DOM in
editor_input.rs, and re-exports Editor / EditorHandle / create_editor so web
app code is identical to desktop app code. rinch-web depends on
rinch-editor-view unconditionally; on desktop the editor arrives with the desktop
feature.
Serialization
rinch-editor-core::serialize is the durability boundary, and its contract is
totality: a node or mark either round-trips faithfully or the boundary returns an
error. Nothing is dropped silently and nothing is rendered to a fallback tag.
| Module | Shape | Notes |
|---|---|---|
doc_json (feature serde) | DocNode | The durable save/load shape. Node::to_doc() ⇄ Schema::node_from_doc(). Unknown types and missing required attrs are hard errors. |
html | HTML string | Schema-driven tags (node_dom_tag / mark_dom_tag), whitelist parse. The same table the view uses to pick host tags and that Editor { content: … } / load_html parse with. |
markdown (feature markdown) | markdown | Via pulldown-cmark. |
text | plain text | The lossy text/plain clipboard fallback. |
That the view and the serializers share one tag table is the point: copy-out,
paste-in, and on-screen rendering cannot disagree about what a heading is.
Collaboration — an optional projection
rinch-editor-collab projects the model onto a yrs (Yjs) CRDT and rebuilds
remote CRDT changes back into Steps. Architecturally it is a peer of the view: a
second projection of the same model, sitting behind the same one-way mutation path.
The model does not know it exists.
- Off by default. Gated behind the
collaborationfeature, so default builds — desktop and web — link zero CRDT code. This is the only crate in the workspace that links a CRDT engine. - One invariant:
model ≡ project(model). Every local step is projected onto the CRDT; every remote change is rebuilt into the model. Convergence then follows from the CRDT’s own convergence rather than from hand-written merge logic. - Fail loud, never silently drop. Content outside the staged scope returns
CollabError::Unsupportedand leaves the CRDT untouched — projection is all-or-nothing. A silent drop would reintroduce exactly the divergence class the rewrite eliminated. - wasm-compatible with no shims, so the desktop and browser editors share one adapter instead of bridging to a separate JS CRDT.
For the API, the staged scope, the transport contract and the engine-choice rationale
see the Collaboration section of CLAUDE.md, the
Rich-text editing guide, and
docs/design/yrs-migration.md.
Not the rich-text editor: rinch-editable
rinch-editable is a separate, unrelated engine for single-line <input> and
<textarea> widgets: StringDocument, EditCommand, EditableState,
InputHandler, and its own Position/Selection/UndoStack types. It shares no
code and no types with rinch-editor-core — the identical names live in different
crates. The quickest tell: rinch-editable’s Selection is a struct of two
Positions, while rinch-editor-core’s is an enum of Text/Node/Cell.
The split is intentional: a text field does not need a schema, steps, or a CRDT, and the rich editor should not carry a second document model for the sake of one.
Related
- Rich-Text Editor — the model and its concepts.
- Rich-text editing — the component and command API.
- Architecture Overview — where these crates sit in the whole system.
- RenderScope API — the
DomDocument/NodeHandleseam the view projects onto. - Fine-Grained Reactivity — how the surrounding UI updates.
rinch
The main rinch crate provides the application entry point, shell runtime, and re-exports commonly used types from rinch-core, rinch-macros, rinch-theme, and rinch-components.
Entry Point
rinch::App
App is the entry point. Every startup option is one method, and they all
compose — which the old run_* functions could not do (a menu bar and window
props needed two different functions, and neither could take anything added
later).
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div {
h1 { "Hello, rinch!" }
p { "A lightweight GUI framework for Rust." }
}
}
}
fn main() {
App::new(app).title("My App").size(800, 600).run();
}
| Method | Purpose |
|---|---|
App::new(component) | Start configuring around the root component |
.title(title) | Window title |
.size(width, height) | Initial size, in logical pixels |
.theme(theme) | ThemeProviderProps — colors, radius, dark mode |
.menu(menus) | Native menu bar, as Vec<(&str, Menu)> |
.window_props(props) | Full WindowProps — borderless, transparent, icon, app_id, … |
.gpu_config(cfg) | Raise the compositor device’s features/limits (gpu feature) |
.external_gpu(gpu) | Composite onto an embedder-provided device (gpu feature) |
.run() | Start on desktop; takes over the thread until the event loop exits |
.run_android(android_app) | Start on Android (android feature, Android target) |
.title() and .size() are applied over .window_props() whatever the call
order, so a title set explicitly is never silently lost to a later
window_props. Every other window field comes from props.
Nothing happens until a terminal method is called. A terminal method takes over the thread and returns only when the application exits its event loop, so it is normally the last statement in main.
With a theme
use rinch::prelude::*;
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("cyan".into()),
default_radius: Some("md".into()),
..Default::default()
};
App::new(app)
.title("Themed App")
.size(800, 600)
.theme(theme)
.run();
}
With full window configuration
use rinch::prelude::*;
fn main() {
let props = WindowProps {
title: "My App".into(),
width: 1024,
height: 768,
borderless: true,
transparent: true,
..Default::default()
};
App::new(app).window_props(props).run();
}
Everything at once
This combination is the reason App exists — no run_* signature could express
it:
#![allow(unused)]
fn main() {
App::new(app)
.window_props(props)
.theme(theme)
.menu(vec![("File", file_menu), ("Edit", edit_menu)])
.run();
}
Deprecated run_* functions
run, run_with_theme, run_with_menu, run_with_window_props,
run_with_window_props_and_menu, run_with_gpu_config and
run_with_external_device still exist as thin shims over App, but they are
#[deprecated], and each one’s deprecation note names the equivalent builder
chain. The two Android entry points (run_android, run_android_with_theme)
are deprecated the same way, in favour of .run_android(android_app).
Behaviour through the shims is unchanged with one deliberate exception:
unifying the startup paths also unified the Linux Wayland app_id, which is
now derived per application from the executable name rather than defaulting to
the shared constant "rinch-app". An explicit WindowProps::app_id still
wins; X11 WM_CLASS and non-Linux platforms are unaffected.
Prelude
Import commonly used types with the prelude:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
}
This includes:
Entry points (desktop feature):
App— the builder;run,run_with_theme(deprecated shims)
Element and prop types (from rinch_core::element::*):
Element,Children,WindowProps,ThemeProviderPropsCallback,SectionRenderer
Menu types (from rinch::menu):
Menu,MenuItem— unified builder API for native menus and tray menus
Reactive primitives:
Signal,Effect,Memo,Scopebatch,derived,untracked
Hooks:
Signal::new,Memo::new,Effect::newcreate_context,use_context,try_use_context
DOM construction:
NodeHandle,RenderScope,with_render_scope
Component trait:
Component
Control flow:
show_dom,FineShowBuilder(conditional rendering)for_each_dom,ForItem,FineForBuilder(list rendering)
Event handling:
ClickContext,InputCallback,get_click_context,start_drag
Icons:
Icon
Macros:
rsx!,#[component]
Window controls (desktop feature):
close_current_window,minimize_current_window,toggle_maximize_current_window
Theme types (theme feature):
- All types from
rinch_theme(colors, spacing, radius, etc.)
Component types (components feature):
- All component structs from
rinch_components(Button, TextInput, Stack, Group, etc.)
Macros
#![allow(unused)]
fn main() {
pub use rinch_macros::rsx; // RSX macro for DOM construction
pub use rinch_macros::component; // #[component] attribute macro
}
Re-exports
Element Types
#![allow(unused)]
fn main() {
pub use rinch_core::element::{
Children,
Element,
ThemeProviderProps,
WindowProps,
};
}
Reactive Primitives
#![allow(unused)]
fn main() {
pub use rinch_core::{
batch,
derived,
untracked,
Effect,
Memo,
Scope,
Signal,
};
}
Sub-crates
#![allow(unused)]
fn main() {
pub use rinch_core as core;
pub use rinch_renderer as renderer; // desktop feature
}
Modules
rinch::shell
Application runtime and event loop. The entry point is rinch::App.
Deprecated shims over App:
run(),run_with_theme(),run_with_menu()- title/size, plus theme or menu barrun_with_window_props(),run_with_window_props_and_menu()- full window propsrun_with_gpu_config(),run_with_external_device()- GPU device selection (gpufeature)
Lower-level runtime entry points, deprecated since 0.2.0 and not shims over
App — they predate it and sit below the layer that installs theme CSS, so
unlike App::run they load no theme at all:
run_rinch(),run_rinch_with_window_props()
rinch::menu
Unified menu builder API for native menus and tray context menus:
Menu- Builder with.item(),.separator(),.submenu()methodsMenuItem- Builder with.shortcut(),.enabled(),.on_click()methods
rinch::window
Window utilities (currently minimal, window management is in shell).
rinch::windows
Window control functions for custom window chrome:
close_current_window()minimize_current_window()toggle_maximize_current_window()
rinch::fine_grained
Fine-grained rendering types (re-exported from rinch-core):
NodeHandle,RenderScope
rinch::theme (theme feature)
Theme system types from rinch-theme.
rinch::components (components feature)
Component library from rinch-components.
rinch::dialogs (file-dialogs feature)
File dialog wrappers via rfd.
rinch::clipboard (clipboard feature)
Clipboard operations.
rinch::tray (system-tray feature)
System tray support.
rinch-core
Core types and traits for rinch, including elements, reactive primitives, the DOM abstraction layer, hooks, and the Component trait.
Element Types
Element
The fundamental building block enum. Note that it is minimal - shell-level constructs (windows, menus, themes) are handled at the runtime level, not as Element variants.
#![allow(unused)]
fn main() {
pub enum Element {
/// Raw HTML content rendered by the DOM backend.
Html(String),
/// A fragment containing multiple children.
Fragment(Children),
/// A custom component implementing the Component trait.
Component(Rc<dyn Component>, Children),
}
}
WindowProps
Configuration for a window (passed at the runtime level via App::window_props):
#![allow(unused)]
fn main() {
pub struct WindowProps {
pub title: String,
pub width: u32,
pub height: u32,
pub x: Option<i32>,
pub y: Option<i32>,
pub borderless: bool,
pub resizable: bool,
pub transparent: bool,
pub always_on_top: bool,
pub visible: bool,
pub resize_inset: Option<f32>,
}
}
DOM Abstraction Layer
The fine-grained reactive rendering system is built on three core types that abstract DOM operations away from any specific backend (desktop via Taffy/Parley/Vello, or browser-native via web_sys).
DomDocument Trait
The backend abstraction. All DOM operations go through this trait:
#![allow(unused)]
fn main() {
pub trait DomDocument {
fn create_element(&self, tag: &str) -> NodeId;
fn create_text(&self, content: &str) -> NodeId;
fn set_attribute(&self, node: NodeId, name: &str, value: &str);
fn remove_attribute(&self, node: NodeId, name: &str);
fn set_text(&self, node: NodeId, content: &str);
fn append_child(&self, parent: NodeId, child: NodeId);
fn insert_before(&self, parent: NodeId, child: NodeId, reference: NodeId);
fn remove_child(&self, parent: NodeId, child: NodeId);
fn register_handler(&self, handler: Box<dyn Fn()>) -> HandlerId;
// ... and more
}
}
RenderScope
Context for building DOM trees. Wraps a DomDocument and provides the API that the rsx! macro calls:
#![allow(unused)]
fn main() {
impl RenderScope {
pub fn create_element(&mut self, tag: &str) -> NodeHandle;
pub fn create_text(&mut self, content: &str) -> NodeHandle;
pub fn register_handler(&mut self, handler: impl Fn() + 'static) -> HandlerId;
}
}
Component functions receive a RenderScope (injected automatically by #[component]):
#![allow(unused)]
fn main() {
#[component]
fn my_component() -> NodeHandle {
rsx! { div { "Hello" } }
}
// Expands to: fn my_component(__scope: &mut RenderScope) -> NodeHandle { ... }
}
NodeHandle
A stable reference to a DOM node. Delegates all operations via Weak<RefCell<dyn DomDocument>>:
#![allow(unused)]
fn main() {
impl NodeHandle {
pub fn set_attribute(&self, name: &str, value: &str);
// Renders a value into an attribute: for an HTML boolean attribute
// (`disabled`, `checked`, `selected`, …) a truthy value writes the bare
// presence form and a falsey one removes it. `set_attribute` stays literal.
pub fn write_attribute(&self, name: &str, value: &str);
pub fn remove_attribute(&self, name: &str);
pub fn set_text(&self, content: &str);
pub fn append_child(&self, child: &NodeHandle);
pub fn insert_before(&self, child: &NodeHandle, reference: &NodeHandle);
pub fn remove_child(&self, child: &NodeHandle);
}
}
NodeHandles are used by Effects for surgical DOM updates:
#![allow(unused)]
fn main() {
// Signal change -> Effect runs -> NodeHandle.set_text() -> Minimal re-layout
}
Component Trait
Components implement the Component trait to render directly to DOM nodes:
#![allow(unused)]
fn main() {
pub trait Component: std::fmt::Debug + 'static {
fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle;
}
}
Reactive Module
Signal<T>
A reactive container for mutable state. Create signals directly with Signal::new(value).
#![allow(unused)]
fn main() {
impl<T> Signal<T> {
pub fn new(value: T) -> Self;
pub fn set(&self, value: T);
pub fn update(&self, f: impl FnOnce(&mut T));
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R;
}
impl<T: Clone> Signal<T> {
pub fn get(&self) -> T;
}
}
Preferred usage via hooks:
#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
let count = Signal::new(0);
rsx! {
p { {|| count.get().to_string()} }
button { onclick: move || count.update(|n| *n += 1), "+" }
}
}
}
Effect
A side-effect that tracks signal dependencies and re-runs when they change.
#![allow(unused)]
fn main() {
impl Effect {
pub fn new<F: FnMut() + 'static>(f: F) -> Self;
pub fn new_deferred<F: FnMut() + 'static>(f: F) -> Self;
pub fn run(&self);
pub fn dispose(&self);
}
}
In practice, Effects are created automatically by the rsx! macro for reactive expressions ({|| expr}), and by Effect::new() for explicit side effects.
Memo<T>
A cached computed value that automatically re-computes when its dependencies change.
#![allow(unused)]
fn main() {
impl<T: Clone + 'static> Memo<T> {
pub fn new<F: Fn() -> T + 'static>(f: F) -> Self;
pub fn get(&self) -> T;
}
}
Scope
Manages the lifetime of reactive primitives.
#![allow(unused)]
fn main() {
impl Scope {
pub fn new() -> Self;
pub fn run<R>(&self, f: impl FnOnce() -> R) -> R;
pub fn add_effect(&self, effect: Effect);
pub fn dispose(&self);
}
}
Reactive Primitives
| Primitive | Purpose |
|---|---|
Signal::new(value) | Reactive state container |
Effect::new(closure) | Side effects with auto-tracked dependencies |
Memo::new(closure) | Cached computed values |
create_context(value) | Create shared context |
use_context::<T>() | Access shared context (panics if missing) |
try_use_context::<T>() | Access shared context (returns Option) |
Utility Functions
batch
Batch multiple signal updates to defer effect execution:
#![allow(unused)]
fn main() {
pub fn batch<R>(f: impl FnOnce() -> R) -> R;
}
derived
Create a memo (convenience function):
#![allow(unused)]
fn main() {
pub fn derived<T: Clone + 'static>(f: impl Fn() -> T + 'static) -> Memo<T>;
}
untracked
Read signals without tracking dependencies:
#![allow(unused)]
fn main() {
pub fn untracked<R>(f: impl FnOnce() -> R) -> R;
}
Control Flow
show_dom / Show
Reactive conditional rendering. Swaps DOM content when the condition changes:
#![allow(unused)]
fn main() {
#[component]
fn example() -> NodeHandle {
let visible = Signal::new(true);
rsx! {
Show {
when: {move || visible.get()},
div { "Visible!" }
}
}
}
}
for_each_dom / For
Keyed list rendering with minimal DOM operations via LIS-based reconciliation:
#![allow(unused)]
fn main() {
#[component]
fn example() -> NodeHandle {
let items = Signal::new(vec!["a", "b", "c"]);
rsx! {
For {
each: {move || items.get().into_iter().map(|s| ForItem::new(s, s)).collect()},
|item: &ForItem| rsx! { div { {item.downcast::<&str>().unwrap()} } }
}
}
}
}
rinch-macros
Procedural macros for rinch: the rsx! macro for building UI and the #[component] attribute macro for ergonomic component definitions.
rsx!
A JSX-like macro for building UI elements via DOM construction.
Basic Usage
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div { class: "container",
h1 { "Hello, World!" }
p { "Welcome to rinch." }
}
}
}
}
Syntax
Elements
HTML elements use lowercase names:
#![allow(unused)]
fn main() {
rsx! {
div { }
span { }
button { }
input { }
}
}
Rinch components and control-flow use PascalCase:
#![allow(unused)]
fn main() {
rsx! {
Button { label: "Click me" }
TextInput { placeholder: "Enter text..." }
Stack { }
Group { }
Show { when: {move || visible.get()}, div { "Visible!" } }
For { each: {move || items.get()}, |item: &ForItem| rsx! { div { } } }
Fragment { }
}
}
Attributes
Attributes are key-value pairs before children:
#![allow(unused)]
fn main() {
rsx! {
div { class: "container", id: "main",
// children
}
}
}
Text Content
Text is included directly:
#![allow(unused)]
fn main() {
rsx! {
p { "Hello, World!" }
span { "Multiple " "strings " "work" }
}
}
Static Expressions
Rust expressions in curly braces are captured once at render time:
#![allow(unused)]
fn main() {
let name = "World";
rsx! {
p { "Hello, " {name} "!" }
}
}
Reactive Expressions
Use closure syntax {|| expr} for values that update automatically when signals change:
#![allow(unused)]
fn main() {
let count = Signal::new(0);
rsx! {
// Static - captured once, never updates
p { {count.get().to_string()} }
// Reactive - creates an Effect, updates when count changes
p { {|| count.get().to_string()} }
// Reactive attribute
div { class: {|| if count.get() > 5 { "high" } else { "low" }}, "Value" }
// Reactive style
div { style: {|| format!("width: {}px", count.get() * 10)}, "Bar" }
}
}
Event Handlers
Events use onevent: handler syntax with no-argument closures:
#![allow(unused)]
fn main() {
rsx! {
button {
onclick: || println!("Clicked!"),
"Click me"
}
}
}
Expansion
The rsx! macro expands to DOM construction code using RenderScope and NodeHandle. It does not generate HTML strings or Element enum variants.
#![allow(unused)]
fn main() {
// This:
rsx! {
div { class: "wrapper",
p { "Hello" }
}
}
// Expands to approximately:
{
let __elem0 = __scope.create_element("div");
__elem0.set_attribute("class", "wrapper");
let __elem1 = __scope.create_element("p");
let __text0 = __scope.create_text("Hello");
__elem1.append_child(&__text0);
__elem0.append_child(&__elem1);
__elem0
}
}
Reactive expressions expand to Effects that surgically update DOM nodes:
#![allow(unused)]
fn main() {
// This:
rsx! { p { {|| count.get().to_string()} } }
// Creates an Effect that calls node.set_text() when the signal changes
}
PascalCase components invoke the component’s render() method or the component function, passing __scope and any children.
Notes
- The macro requires
__scope: &mut RenderScopeto be in scope (provided automatically by#[component]) - HTML elements become
create_element()calls, not HTML strings - Component props use default values where not specified
- The macro is compile-time, so syntax errors appear at build time
- Helpful error messages include typo suggestions for misspelled attributes
#[component] Attribute Macro
Auto-injects __scope: &mut RenderScope as the first parameter of a component function. This is the recommended way to define components.
Usage
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! { div { "Hello!" } }
}
// Expands to: fn app(__scope: &mut RenderScope) -> NodeHandle { ... }
// With extra parameters:
#[component]
fn card(title: &str) -> NodeHandle {
rsx! { div { {title} } }
}
// Expands to: fn card(__scope: &mut RenderScope, title: &str) -> NodeHandle { ... }
}
Why Use It
- Eliminates boilerplate: no need to manually write
__scope: &mut RenderScope - Makes component signatures cleaner and focused on the component’s own props
- Works with any number of additional parameters
- The
rsx!macro requires__scopeto be in scope, and#[component]provides it automatically