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. Pick at compile time.
- 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() {
run("Counter", 800, 600, app);
}
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
- Rust nightly (Rinch uses a few unstable features —
let_chains, etc.) - 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"— GPU rendering via Vello/wgpu instead of the software renderer. 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() {
run_with_theme("My App", 800, 600, app, ThemeProviderProps {
primary_color: Some("cyan".into()),
..Default::default()
});
}
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.run_with_themeopened a window, loaded theme CSS, mounted the component, and started the event loop.
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" }
}
}
}
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: Icon::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.
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" }
}
}
}
}
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
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).
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"
}
}
}
}
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" },
}
}
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}).
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
Shorthands are applied via set_style() after component rendering and after the style: prop. This means shorthands win over conflicting properties in style:.
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.
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.
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 — just an index into a global slot map.
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));
}
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.
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
#![allow(unused)]
fn main() {
let effect = Effect::new(move || {
println!("Count: {}", count.get());
});
effect.dispose(); // Effect stops running
count.set(1); // Nothing happens
}
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().
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
Signal(count) ──► Memo(doubled) ──► cached value
│ │
.set(3) .get()
│ │
▼ ▼
marks memo recomputes if
as "dirty" dirty
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.
#![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: Icon::Check | Some(Icon::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}).
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) enables GPU rendering via Vello/wgpu instead of the software renderer"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()
};
run_with_theme("My App", 800, 600, app, theme);
}
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
NumberInput {
label: "Quantity",
placeholder: "0"
}
// Textarea
Textarea {
label: "Description",
placeholder: "Enter your message..."
}
// 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
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 |
#![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
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()
};
run_with_window_props(app, window_props, None);
}
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
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
}
}
}
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 |
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()
};
run_with_theme("Custom Components", 800, 600, app, theme);
}
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 with a curated library of SVG icons. Instead of passing arbitrary HTML strings, components accept Icon enum values for discoverability and consistency.
Basic Usage
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::components::*;
#[component]
fn app() -> NodeHandle {
rsx! {
Alert { icon: Icon::InfoCircle, color: "blue", title: "Information",
"This is an informational message."
}
Alert { icon: Icon::CheckCircle, color: "green", title: "Success",
"Operation completed successfully."
}
Alert { icon: Icon::AlertTriangle, color: "yellow", title: "Warning",
"Please review this carefully."
}
Alert { icon: Icon::XCircle, color: "red", title: "Error",
"Something went wrong."
}
}
}
}
Available Icons
| Category | Icons |
|---|---|
| Navigation | ChevronUp, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown, ArrowLeft, ArrowRight |
| Actions | Close, Check, Plus, Minus, Search, Settings, Edit, Trash |
| Status/Alerts | InfoCircle, CheckCircle, AlertCircle, AlertTriangle, XCircle |
| Content | User, Mail, Phone, Calendar, Clock, File, Folder, Image, Link, ExternalLink |
| UI | Eye, EyeOff, Menu, MoreHorizontal, MoreVertical, Loader, Quote |
Components with Icon Support
| Component | Icon Props |
|---|---|
Alert | icon: Option<Icon> |
Notification | icon: Option<Icon> |
AccordionControl | icon: Option<Icon> |
Blockquote | icon: Option<Icon> |
List | icon: Option<Icon> |
ListItem | icon: Option<Icon> |
Stepper | completed_icon, progress_icon: Option<Icon> |
StepperStep | icon, completed_icon, progress_icon: Option<Icon> |
NavLink | left_section, right_section: Option<Icon> |
DropdownMenuItem | left_section, right_section: Option<Icon> |
Tab | left_section, right_section: Option<Icon> |
Examples
#![allow(unused)]
fn main() {
// NavLink with icons
NavLink {
label: "Dashboard",
left_section: Icon::Settings,
active: true
}
// Tab with icon
Tabs {
TabsList {
Tab { value: "home", left_section: Icon::User, "Profile" }
Tab { value: "settings", left_section: Icon::Settings, "Settings" }
}
}
// Dropdown menu items with icons
DropdownMenu {
DropdownMenuTarget {
Button { "Actions" }
}
DropdownMenuDropdown {
DropdownMenuItem { left_section: Icon::Edit, "Edit" }
DropdownMenuItem { left_section: Icon::Trash, color: "red", "Delete" }
}
}
// Blockquote with quote icon
Blockquote { icon: Icon::Quote, cite: "— Unknown",
"The best code is no code at all."
}
// Stepper with custom icons
Stepper { active: 1, completed_icon: Icon::CheckCircle,
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: | Yes | Surgical DOM update |
| 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
},
}
}
}
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
run_with_theme("My App", 800, 600, app, theme);
}
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.
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}.
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).
Layout
Stack
Vertical flex container.
| Prop | Type | Default | Description |
|---|---|---|---|
gap | Option<String> | None | Spacing between children (xs, sm, md, lg, xl or CSS value) |
align | Option<String> | None | CSS align-items (e.g., “center”, “flex-start”) |
justify | Option<String> | None | CSS justify-content |
Group
Horizontal flex container.
| Prop | Type | Default | Description |
|---|---|---|---|
gap | Option<String> | None | Spacing between children (xs, sm, md, lg, xl or CSS value) |
align | Option<String> | None | CSS align-items |
justify | Option<String> | None | 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 | Option<String> | None | Min column width for auto-fill; overrides cols |
spacing | Option<String> | None | Gap between items (xs, sm, md, lg, xl or CSS value) |
vertical_spacing | Option<String> | None | Vertical gap (xs, sm, md, lg, xl or CSS value); falls back to spacing |
Container
Centered max-width wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
size | Option<String> | None | 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 | Option<String> | None | Width (spacing scale or CSS value) |
h | Option<String> | None | Height (spacing scale or CSS value) |
Buttons
Button
| Prop | Type | Default | Description |
|---|---|---|---|
variant | Option<String> | None | “filled”, “outline”, “light”, “subtle”, “transparent”, “white”, “default”, “gradient” |
size | Option<String> | None | xs, sm, md, lg, xl |
color | Option<String> | None | Theme color name |
disabled | bool | false | |
loading | bool | false | |
full_width | bool | false | |
radius | Option<String> | None | 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 | Option<String> | None | Same variants as Button |
size | Option<String> | None | xs, sm, md, lg, xl |
color | Option<String> | None | Theme color name |
radius | Option<String> | None | |
disabled | bool | false | |
loading | bool | false | |
onclick | Option<Callback> | None | Click handler |
CloseButton
| Prop | Type | Default | Description |
|---|---|---|---|
size | Option<String> | None | xs, sm, md, lg, xl |
radius | Option<String> | None | |
disabled | bool | false | |
icon_size | Option<u32> | None | Custom icon size in pixels |
onclick | Option<Callback> | None | Click handler |
Form Inputs
TextInput
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
placeholder | Option<String> | None | |
description | Option<String> | None | Help text below input |
error | Option<String> | None | Error message; shows error styling |
size | Option<String> | None | xs, sm, md, lg, xl |
disabled | bool | false | |
required | bool | false | |
radius | Option<String> | None | |
input_type | Option<String> | None | HTML input type (“text”, “email”, etc.) |
value | Option<String> | None | Static value |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
oninput | Option<InputCallback> | None | Receives String |
onsubmit | Option<Callback> | None | Fires on Enter key |
Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
error | Option<String> | None | |
placeholder | Option<String> | None | |
size | Option<String> | None | |
disabled | bool | false | |
required | bool | false | |
autosize | bool | false | Auto-resize textarea |
min_rows | Option<u32> | None | |
max_rows | Option<u32> | None | |
value | Option<String> | None | |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
oninput | Option<InputCallback> | None | Receives String |
PasswordInput
Custom Default: toggle_visibility defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
error | Option<String> | None | |
placeholder | Option<String> | None | |
value | Option<String> | None | |
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 | Option<String> | None | |
radius | Option<String> | None | |
toggle_visibility | bool | true | Show/hide the eye toggle button |
ontoggle | Option<Callback> | None | Fires when visibility toggled |
oninput | Option<InputCallback> | None | Receives String |
NumberInput
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
error | Option<String> | None | |
placeholder | Option<String> | None | |
value | Option<f64> | None | |
default_value | Option<f64> | None | |
min | Option<f64> | None | |
max | Option<f64> | None | |
step | Option<f64> | None | |
decimal_scale | Option<u32> | None | Number of decimal places |
prefix | Option<String> | None | e.g., “$” |
suffix | Option<String> | None | e.g., “kg” |
disabled | bool | false | |
hide_controls | bool | false | Hide +/- buttons |
required | bool | false | |
size | Option<String> | None | |
radius | Option<String> | None | |
onincrement | Option<Callback> | None | |
ondecrement | Option<Callback> | None | |
oninput | Option<InputCallback> | None | Receives String from direct text entry |
Checkbox
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
size | Option<String> | None | |
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 | Option<String> | None | |
description | Option<String> | None | |
size | Option<String> | None | |
disabled | bool | false | |
checked | bool | false | |
checked_fn | Option<ReactiveBool> | None | Reactive checked binding (auto-wrapped) |
label_position | Option<String> | None | “left” or “right” |
onchange | Option<Callback> | None |
Select
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
error | Option<String> | None | |
placeholder | Option<String> | None | |
size | Option<String> | None | |
disabled | bool | false | |
required | bool | false | |
value | Option<String> | None | |
value_fn | Option<ReactiveString> | None | Reactive value binding (auto-wrapped) |
onchange | Option<InputCallback> | None | Receives selected value as String |
Options are passed as children: option { value: "us", "United States" }
Radio / RadioGroup
Radio:
| Prop | Type | Default | Description |
|---|---|---|---|
name | Option<String> | None | Radio group name |
value | Option<String> | None | Radio value |
label | Option<String> | None | |
description | Option<String> | None | |
checked | bool | false | |
checked_fn | Option<ReactiveBool> | None | Reactive checked binding (auto-wrapped) |
disabled | bool | false | |
size | Option<String> | None | |
color | Option<String> | None | |
error | bool | false | |
onchange | Option<Callback> | None |
RadioGroup:
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
error | Option<String> | None | |
size | Option<String> | None | |
orientation | Option<String> | None | “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 | Option<String> | None | |
color | Option<String> | None | |
radius | Option<String> | None | |
disabled | bool | false | |
label | Option<String> | None | 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 |
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 | "" | Input size |
radius | String | "" | Border radius |
disabled | bool | false | Disable the input |
value | String | "" | Current color value |
value_fn | Option<ReactiveString> | None | Reactive value binding |
onchange | Option<InputCallback> | None | Fires formatted color string on change |
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 |
close_on_click_outside | bool | false | Close dropdown on outside click |
disallow_input | bool | false | Disallow typing (picker only) |
Typography
Text
| Prop | Type | Default | Description |
|---|---|---|---|
size | Option<String> | None | xs, sm, md, lg, xl |
weight | Option<String> | None | CSS font-weight |
color | Option<String> | None | Theme color or “dimmed” |
align | Option<String> | None | 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 | Option<String> | None | CSS text-align |
size | Option<String> | None | Override size independent of order |
Code
| Prop | Type | Default | Description |
|---|---|---|---|
block | bool | false | Block display (<pre>) vs inline (<code>) |
color | Option<String> | None |
Kbd
| Prop | Type | Default | Description |
|---|---|---|---|
size | Option<String> | None | xs, sm, md, lg, xl |
Anchor
| Prop | Type | Default | Description |
|---|---|---|---|
href | Option<String> | None | |
target | Option<String> | None | e.g., “_blank” |
size | Option<String> | None | |
underline | bool | false |
Blockquote
| Prop | Type | Default | Description |
|---|---|---|---|
cite | Option<String> | None | Citation source |
icon | Option<Icon> | None | |
color | Option<String> | None | |
radius | Option<String> | None |
Mark
| Prop | Type | Default | Description |
|---|---|---|---|
color | Option<String> | None | Highlight background color |
Highlight
Custom Default: ignore_case defaults to true.
| Prop | Type | Default | Description |
|---|---|---|---|
text | Option<String> | None | Full text to display |
highlight | Option<String> | None | Substring(s) to highlight |
color | Option<String> | None | Highlight color |
ignore_case | bool | true | Case-insensitive matching |
Data Display
Avatar
| Prop | Type | Default | Description |
|---|---|---|---|
src | Option<String> | None | Image URL |
alt | Option<String> | None | |
name | Option<String> | None | For initials fallback |
size | Option<String> | None | |
radius | Option<String> | None | |
color | Option<String> | None | |
variant | Option<String> | None | “filled”, “light”, “outline” |
AvatarGroup: spacing: Option<String> — overlap spacing.
Badge
| Prop | Type | Default | Description |
|---|---|---|---|
variant | Option<String> | None | “filled”, “light”, “outline”, “dot”, “transparent”, “white”, “default”, “gradient” |
size | Option<String> | None | |
color | Option<String> | None | |
radius | Option<String> | None | |
full_width | bool | false |
Card
| Prop | Type | Default | Description |
|---|---|---|---|
shadow | Option<String> | None | xs, sm, md, lg, xl |
padding | Option<String> | None | |
radius | Option<String> | None | |
with_border | bool | false |
CardSection: inherit_padding: bool, with_border: bool.
Paper
| Prop | Type | Default | Description |
|---|---|---|---|
shadow | Option<String> | None | xs, sm, md, lg, xl |
p | Option<String> | None | Padding (spacing scale) |
radius | Option<String> | None | |
with_border | bool | false |
Divider
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | Option<String> | None | “horizontal” or “vertical” |
size | Option<String> | None | |
label | Option<String> | None | Text label in the divider |
label_position | Option<String> | None | “left”, “center”, “right” |
Fieldset
| Prop | Type | Default | Description |
|---|---|---|---|
legend | Option<String> | None | |
variant | Option<String> | None | “default”, “filled”, “unstyled” |
size | Option<String> | None | |
disabled | bool | false |
Image
| Prop | Type | Default | Description |
|---|---|---|---|
src | Option<String> | None | Image URL |
alt | Option<String> | None | |
width | Option<String> | None | CSS width |
height | Option<String> | None | CSS height |
fit | Option<String> | None | CSS object-fit |
radius | Option<String> | None | |
fallback_src | Option<String> | None | Fallback image URL |
caption | Option<String> | None | Caption text below image |
List / ListItem
List:
| Prop | Type | Default | Description |
|---|---|---|---|
type | Option<String> | None | “ordered” or “unordered” |
size | Option<String> | None | |
spacing | Option<String> | None | |
center | bool | false | Center items with icons |
icon | Option<Icon> | None | Default icon for all items |
with_padding | bool | false |
ListItem: icon: Option<Icon> — per-item icon override.
Feedback
Alert
| Prop | Type | Default | Description |
|---|---|---|---|
color | Option<String> | None | |
variant | Option<String> | None | “filled”, “light”, “outline”, “transparent”, “white”, “default” |
title | Option<String> | None | |
radius | Option<String> | None | |
with_close_button | bool | false | |
icon | Option<Icon> | None | |
onclose | Option<Callback> | None |
Loader
| Prop | Type | Default | Description |
|---|---|---|---|
type | Option<String> | None | “oval”, “bars”, “dots” |
size | Option<String> | None | |
color | Option<String> | None |
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
value | Option<f32> | None | Percentage 0-100 |
value_fn | Option<ReactiveF32> | None | Reactive value binding (auto-wrapped) |
color | Option<String> | None | |
size | Option<String> | None | |
radius | Option<String> | None | |
striped | bool | false | |
animated | bool | false |
Skeleton
Custom Default: animate and visible default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
width | Option<String> | None | |
height | Option<String> | None | |
radius | Option<String> | None | |
circle | bool | false | |
animate | bool | true | |
visible | bool | true |
Overlays
Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | Tooltip text |
position | Option<String> | None | “top”, “bottom”, “left”, “right” |
color | Option<String> | None | |
opened | bool | false | |
disabled | bool | false | |
with_arrow | bool | false | |
multiline | bool | false | |
width | Option<String> | None |
Modal
Custom Default: with_overlay, close_on_click_outside, close_on_escape, with_close_button, lock_scroll, trap_focus all default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
title | Option<String> | None | |
size | Option<String> | None | |
radius | Option<String> | None | |
with_overlay | bool | true | |
overlay_opacity | Option<f32> | None | |
overlay_blur | Option<String> | None | |
centered | bool | false | |
close_on_click_outside | bool | true | |
close_on_escape | bool | true | |
with_close_button | bool | true | |
padding | Option<String> | None | |
z_index | Option<i32> | None | |
lock_scroll | bool | true | |
trap_focus | bool | true | |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
opened_fn | Option<ReactiveBool> | None | Reactive open state (auto-wrapped) |
title | Option<String> | None | |
position | Option<String> | None | “left”, “right”, “top”, “bottom” |
size | Option<String> | None | |
with_overlay | bool | true | |
overlay_opacity | Option<f32> | None | |
close_on_click_outside | bool | true | |
close_on_escape | bool | true | |
with_close_button | bool | true | |
padding | Option<String> | None | |
z_index | Option<i32> | None | |
lock_scroll | bool | true | |
trap_focus | bool | true | |
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 | Option<String> | None | |
color | Option<String> | None | |
position | Option<String> | None | Toast position |
radius | Option<String> | None | |
with_close_button | bool | true | |
with_border | bool | false | |
icon | Option<Icon> | None | |
auto_close | u32 | 0 | Auto-close delay in ms (0 = disabled) |
loading | bool | false | |
z_index | Option<i32> | None | |
onclose | Option<Callback> | None |
Popover
Custom Default: close_on_click_outside and close_on_escape default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
position | Option<String> | None | |
offset | Option<i32> | None | |
radius | Option<String> | None | |
shadow | Option<String> | None | |
with_arrow | bool | false | |
arrow_size | Option<f32> | None | |
arrow_offset | Option<f32> | None | |
close_on_click_outside | bool | true | |
close_on_escape | bool | true | |
width | Option<String> | None | |
z_index | Option<i32> | None | |
trap_focus | bool | false |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
opened | bool | false | |
position | Option<String> | None | |
offset | Option<i32> | None | |
radius | Option<String> | None | |
shadow | Option<String> | None | |
close_on_click_outside | bool | true | |
close_on_item_click | bool | true | |
width | Option<String> | None | |
z_index | Option<i32> | None |
DropdownMenuTarget, DropdownMenuDropdown, DropdownMenuLabel, DropdownMenuDivider: No props.
DropdownMenuItem:
| Prop | Type | Default | Description |
|---|---|---|---|
left_section | Option<Icon> | None | |
right_section | Option<Icon> | None | |
color | Option<String> | None | |
disabled | bool | false | |
onclick | Option<Callback> | None |
HoverCard
| Prop | Type | Default | Description |
|---|---|---|---|
position | Option<String> | None | |
offset | Option<i32> | None | |
radius | Option<String> | None | |
shadow | Option<String> | None | |
width | Option<String> | None | |
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 | Option<String> | None | |
loader_type | Option<String> | None | |
loader_size | Option<String> | None | |
loader_color | Option<String> | None | |
radius | Option<String> | None | |
z_index | Option<i32> | None | |
transition_duration | Option<u32> | None |
Navigation
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
value | Option<String> | None | Active tab value |
default_value | Option<String> | None | |
variant | Option<String> | None | “default”, “outline”, “pills” |
orientation | Option<String> | None | “horizontal”, “vertical” |
position | Option<String> | None | |
grow | bool | false | |
color | Option<String> | None | |
radius | Option<String> | None |
TabsList: grow: bool, justify: Option<String>.
Tab:
| Prop | Type | Default | Description |
|---|---|---|---|
value | Option<String> | None | Tab identifier |
disabled | bool | false | |
left_section | Option<Icon> | None | |
right_section | Option<Icon> | None | |
onclick | Option<Callback> | None |
TabsPanel: value: Option<String> — matches the Tab value.
Accordion
| Prop | Type | Default | Description |
|---|---|---|---|
value | Option<String> | None | Active item value |
default_value | Option<String> | None | |
variant | Option<String> | None | “default”, “contained”, “filled”, “separated” |
radius | Option<String> | None | |
multiple | bool | false | Allow multiple open items |
chevron_position | Option<String> | None | “left”, “right” |
disable_chevron_rotation | bool | false |
AccordionItem: value: Option<String>.
AccordionControl: disabled: bool, icon: Option<Icon>, onclick: Option<Callback>.
AccordionPanel: No props.
Breadcrumbs
| Prop | Type | Default | Description |
|---|---|---|---|
separator | Option<String> | None | Custom separator character |
separator_margin | Option<String> | None | Spacing around separator |
BreadcrumbsItem: href: Option<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 | Option<String> | None | |
radius | Option<String> | None | |
with_edges | bool | false | Show first/last page buttons |
with_controls | bool | true | Show prev/next buttons |
color | Option<String> | None | |
disabled | bool | false | |
gap | Option<String> | None | |
onchange | Option<ValueCallback<u32>> | None | Receives page number |
NavLink
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
href | Option<String> | None | |
active | bool | false | |
active_fn | Option<ReactiveBool> | None | Reactive active binding (auto-wrapped) |
variant | Option<String> | None | “light”, “filled”, “subtle” |
color | Option<String> | None | |
left_section | Option<Icon> | None | |
right_section | Option<Icon> | None | |
disabled | bool | false | |
children_offset | Option<String> | None | 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 |
size | Option<String> | None | |
orientation | Option<String> | None | “horizontal”, “vertical” |
color | Option<String> | None | |
radius | Option<String> | None | |
icon_size | Option<String> | None | |
allow_next_steps_select | bool | false | |
completed_icon | Option<Icon> | None | Default completed icon for all steps |
progress_icon | Option<Icon> | None | Default in-progress icon |
StepperStep:
| Prop | Type | Default | Description |
|---|---|---|---|
label | Option<String> | None | |
description | Option<String> | None | |
icon | Option<Icon> | None | Default icon |
completed_icon | Option<Icon> | None | Per-step override |
progress_icon | Option<Icon> | None | Per-step override |
allow_step_click | bool | false | |
allow_step_select | bool | false | |
loading | bool | false | |
state | Option<String> | None | “step-progress”, “step-completed”, “step-inactive” |
step | Option<u32> | None | Step index |
StepperCompleted: No props.
Tree
Custom Default: level_offset defaults to Some("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 | Option<String> | Some("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 |
TreeNodeData: value: String, label: String, children: Vec<TreeNodeData>, disabled: bool, icon: Option<Icon>, payload: Option<Rc<dyn Any>>.
Window
BorderlessWindow
Custom Default: show_minimize, show_maximize, show_close all default to true.
| Prop | Type | Default | Description |
|---|---|---|---|
title | Option<String> | None | Window title in titlebar |
radius | Option<String> | None | 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 |
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()
};
run_with_theme("My App", 800, 600, app, theme);
}
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.
Use run() instead of run_with_theme() to get the default theme (blue primary, light mode, small radius).
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.
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)
Shadows
var(--rinch-shadow-xs)
var(--rinch-shadow-sm)
var(--rinch-shadow-md)
var(--rinch-shadow-lg)
var(--rinch-shadow-xl)
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"
}
}
}
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.
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.
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
WASM targets live outside the main workspace because Parley’s fontconfig dependency doesn’t cross-compile to wasm32. 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() {
run("My App", 800, 600, my_app::app);
}
The WASM Entry Point
# my-app-web/Cargo.toml
[package]
name = "my-app-web"
[dependencies]
rinch = { git = "...", default-features = false, features = ["web", "components", "theme"] }
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::*;
#[wasm_bindgen(start)]
pub fn main() {
console_error_panic_hook::set_once();
// Mount to browser DOM — see examples/ui-zoo-web for the full pattern
}
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
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.
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 — Browsers have their own menu system. Use Rinch components instead.
- 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.
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, width, height, and app function
run("My Application", 800, 600, app);
}
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
};
run_with_window_props(app, props, None);
}
| 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 | Initial visibility state |
resize_inset | Option<f32> | None | Resize handle inset for borderless windows |
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()
};
run_with_window_props(app, props, None);
}
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.
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()
};
run_with_window_props(app, props, None);
}
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_inset + 8pxfrom each edge - Corner resize areas are larger (
resize_inset + 16px) for easier diagonal resizing - The cursor automatically changes to indicate resize direction when hovering edges
- Only works when both
borderless: trueandresizable: trueare set - 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()
};
run_with_window_props(app, props, None);
}
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 applications that need to save and restore window positions and sizes, use the WindowState API.
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()
}
}
Note: Window state is automatically tracked and updated when windows are moved or resized. The state is available immediately after calling
open_window()orWindowBuilder::open().
Rendering Backends
Rinch supports two rendering backends, selected at compile time:
GPU Mode (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 Mode (default)
Without the gpu feature, windows are rendered using tiny-skia (CPU rasterizer) and presented via softbuffer. This provides:
- No GPU required — works in headless, CI, containers, SSH sessions
- Full rendering fidelity (same visual output as GPU mode)
- Dirty region caching — only changed areas are repainted for fast incremental updates
- Subtree pruning — nodes outside the dirty region are skipped during paint
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.
Native Menus
Use run_with_menu to add a native menu bar to your window:
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)));
run_with_menu("My App", 800, 600, app, vec![("File", file_menu)]);
}
For apps without menus, use run("My App", 800, 600, app).
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 |
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.
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
| Modifier | macOS | Windows/Linux |
|---|---|---|
Cmd | Command | Ctrl |
Ctrl | Control | Ctrl |
Alt | Option | Alt |
Shift | Shift | Shift |
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
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.
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, the menu appears in the window (similar to Windows) unless a global menu system is available.
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"
}
}
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);
}));
run_with_menu("My App", 800, 600, app, vec![
("File", file_menu),
("Edit", edit_menu),
("Help", help_menu),
]);
}
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.
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
You can implement the ImageLoader trait for custom loading strategies (e.g., embedded assets, authenticated downloads):
#![allow(unused)]
fn main() {
use rinch_core::image::{ImageLoader, ImageLoadResult};
struct AssetLoader;
impl ImageLoader for AssetLoader {
fn load(&self, src: &str) -> ImageLoadResult {
match load_from_assets(src) {
Ok(bytes) => ImageLoadResult::Loaded(bytes),
Err(e) => ImageLoadResult::Failed(e.to_string()),
}
}
}
}
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"
}
}
}
}
}
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()?;
}
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));
}
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()
};
}
Enabling Features
Add features to your Cargo.toml:
[dependencies]
rinch = { version = "0.1", features = ["file-dialogs", "clipboard", "system-tray"] }
Platform Support
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| File Dialogs | ✓ | ✓ | ✓ |
| Clipboard (Text) | ✓ | ✓ | ✓ |
| Clipboard (Image) | ✓ | ✓ | ✓* |
| System Tray | ✓ | ✓ | ✓** |
* 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() {
run("My App", 1280, 720, app);
}
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()
let gpu = gpu_handle().unwrap();
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 view for compositing
let view = texture.create_view(&Default::default());
registrar.set_texture_source(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()).
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 |
SurfaceKeyData contains key, code, ctrl, shift, alt, meta.
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) |
layout_size() | Get current (width, height) from CSS layout |
set_texture_source(view, w, h) | 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 once, call update() each frame.
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
}
}
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);
}
}
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());
}
Platform Events
Translate your engine’s events to rinch_platform::PlatformEvent:
#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, 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,
text: Some("a".into()),
modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}
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 |
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 if a text input is focused |
needs_update() -> bool | True if UI needs repaint |
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 |
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)
ContentEditable API
Rinch provides a DOM-level contenteditable system for building rich-text editors. This is the low-level API that powers all text editing — the contenteditable HTML attribute on a <div>, combined with the ContentEditableApi trait for programmatic control.
Two layers: The Rich-Text Editor guide covers the higher-level
rinch-editorcrate (schemas, extensions, document model). This guide covers the lower-level CE API that it’s built on. Most users will use both — CE for the editing surface, and rinch-editor for serialization/collaboration.
Quick Start
Create a contenteditable element and interact with it:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::ce::with_active_ce_api;
#[component]
fn my_editor() -> NodeHandle {
rsx! {
div {
// Toolbar
div {
button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
button { onclick: move || ce_do(|api| api.toggle_wrap("em")), "Italic" }
button { onclick: move || ce_do(|api| api.set_block_type("h1")), "H1" }
}
// Editing surface
div {
contenteditable: "true",
style: "min-height: 200px; padding: 8px; border: 1px solid #ccc;",
}
}
}
}
/// Helper: run a closure on the currently focused CE element.
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}
Setting contenteditable: "true" on a <div> activates rinch’s built-in editing behavior: cursor rendering, text selection, keyboard input handling, clipboard support, and undo/redo.
How It Works
Keyboard Event
↓
InputHandler maps key → EditCommand
↓
CeOps mutates EditorDocument (CRDT, source of truth)
↓
Affected DOM blocks re-rendered from EditorDocument state
↓
CeEvent dispatched to all listeners
↓
SelectionChanged + oninput fired
↓
Scene marked dirty → repaint
- The rinch runtime captures keyboard events on the focused CE element
- Keys are mapped to
EditCommands (InsertText, DeleteBackward, ToggleBold, etc.) CeOps— the runtime’s implementation ofContentEditableApi— mutates theEditorDocument(Automerge CRDT)- Only the affected block(s) are re-rendered in the DOM from EditorDocument state
- Each mutation dispatches a
CeEventso observers can react - The cursor/selection is updated and the scene is repainted
CRDT-first architecture: Every mutation flows through
EditorDocumentfirst, then the DOM is updated as a view. This ensures the CRDT and DOM never diverge, and makes collaboration work automatically — remote changes just load into EditorDocument, then re-render.
ContentEditableApi Trait
The ContentEditableApi trait is the single mutation interface for all CE operations. Every method mutates the DOM and dispatches a corresponding CeEvent.
Text Operations
#![allow(unused)]
fn main() {
fn insert_text(&mut self, text: &str); // Insert at cursor
fn delete_backward(&mut self); // Backspace
fn delete_forward(&mut self); // Delete key
fn delete_selection(&mut self); // Delete selected range
}
Block Structure
#![allow(unused)]
fn main() {
fn split_block(&mut self); // Enter key — split block at cursor
fn set_block_type(&mut self, tag: &str); // Change block to h1, p, blockquote, etc.
}
Inline Formatting
#![allow(unused)]
fn main() {
fn wrap_selection(&mut self, tag: &str); // Wrap in <strong>, <em>, etc.
fn unwrap_selection(&mut self, tag: &str); // Remove formatting wrapper
fn toggle_wrap(&mut self, tag: &str); // Toggle on/off
}
Supported tags: "strong", "em", "u", "s", "code".
List Operations
#![allow(unused)]
fn main() {
fn indent(&mut self); // Convert to list item or increase nesting
fn outdent(&mut self); // Decrease nesting or convert from list item
}
Selection
#![allow(unused)]
fn main() {
fn get_selection(&self) -> CeSelection;
fn set_selection(&mut self, sel: CeSelection);
}
Undo/Redo
#![allow(unused)]
fn main() {
fn undo(&mut self);
fn redo(&mut self);
}
Query Methods
#![allow(unused)]
fn main() {
fn has_active_mark(&self, tag: &str) -> bool; // Is cursor inside <strong>, <em>, etc.?
fn cursor_block_tag(&self) -> Option<String>; // Block tag at cursor ("p", "h1", "ul", etc.)
}
Content Interchange
#![allow(unused)]
fn main() {
fn extract_content(&self) -> Vec<BlockData>; // Read content as structured blocks
fn load_content(&mut self, blocks: &[BlockData]); // Replace content from blocks
fn load_html(&mut self, html: &str); // Replace content from HTML string
fn clear_formatting(&mut self); // Strip all inline formatting
}
Accessing the CE API
There are two ways to access the CE API, depending on whether you need to target the focused element or a specific element.
Active CE API (Focused Element)
Use with_active_ce_api() to operate on whichever CE element currently has focus. This is the typical pattern for toolbar buttons:
#![allow(unused)]
fn main() {
use rinch_core::ce::with_active_ce_api;
// Helper function (recommended pattern)
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
// In toolbar buttons:
button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
button { onclick: move || ce_do(|api| api.set_block_type("h2")), "H2" }
button { onclick: move || ce_do(|api| api.undo()), "Undo" }
}
Returns None if no CE element is focused.
Per-Element CE API (NodeHandle)
Use NodeHandle::with_ce_api() to target a specific CE element. This works whether or not the element has focus — useful for loading initial content:
#![allow(unused)]
fn main() {
let editor_div = rsx! {
div { contenteditable: "true" }
};
// Load content into a specific CE element
editor_div.with_ce_api(|api| {
api.borrow_mut().load_html("<p>Hello <strong>world</strong></p>");
});
}
Per-Node Registry
For advanced use cases, access CE APIs by node ID:
#![allow(unused)]
fn main() {
use rinch_core::ce::{with_ce_api_for_node, register_ce_api, unregister_ce_api};
// Access CE API for a known node ID
with_ce_api_for_node(node_id, |api| {
api.borrow_mut().insert_text("Hello");
});
}
CeEvent System
Every CE mutation dispatches a CeEvent to all subscribed listeners. This is how the editor bridge stays in sync with DOM changes.
Subscribing to Events
#![allow(unused)]
fn main() {
use rinch_core::ce::{subscribe_ce_events, CeEvent};
use std::rc::Rc;
subscribe_ce_events(Rc::new(move |event| {
match event {
CeEvent::TextInserted { node_id, offset, text } => {
println!("Inserted '{}' at node {} offset {}", text, node_id, offset);
}
CeEvent::TextDeleted { node_id, offset, length } => {
println!("Deleted {} bytes at node {} offset {}", length, node_id, offset);
}
CeEvent::SelectionChanged { selection } => {
// Update toolbar active states, etc.
}
_ => {}
}
}));
}
Event Reference
Text Mutations
| Event | Fields | When |
|---|---|---|
TextInserted | node_id, offset, text | Text inserted at cursor |
TextDeleted | node_id, offset, length | Text deleted (backspace, delete, selection) |
TextNodeCreated | node_id, parent_id, text | New text node created (e.g. first char in empty block) |
NodeRemoved | node_id, parent_id | A DOM node was removed |
Selection
| Event | Fields | When |
|---|---|---|
SelectionChanged | selection: CeSelection | Cursor moved or selection changed |
Block Structure
| Event | Fields | When |
|---|---|---|
BlockSplit | original_block_id, new_block_id, split_offset | Enter key splits a block |
BlockJoined | surviving_block_id, removed_block_id, merge_offset | Backspace joins two blocks |
BlockTypeChanged | old_node_id, new_node_id, old_tag, new_tag | Block type changed (e.g. p → h1) |
Inline Formatting
| Event | Fields | When |
|---|---|---|
SelectionWrapped | tag, wrapper_node_id, wrapped_node_ids | Selection wrapped in formatting element |
SelectionUnwrapped | tag, unwrapped_node_ids | Formatting removed from selection |
List Structure
| Event | Fields | When |
|---|---|---|
ListItemOutdented | old_li_id, new_block_id | List item outdented |
BlockIndented | old_block_id, new_li_id, list_id | Block indented into list |
Tables
| Event | Fields | When |
|---|---|---|
TableInserted | block_node_id, rows, cols | Table inserted |
TableDeleted | block_node_id | Table removed |
History & Clipboard
| Event | Fields | When |
|---|---|---|
UndoApplied | (none) | Undo operation completed |
RedoApplied | (none) | Redo operation completed |
HtmlPasted | created_node_ids | HTML pasted from clipboard |
DomCursor and CeSelection
The CE system uses DOM-level cursor positions, not document-level byte offsets.
#![allow(unused)]
fn main() {
/// A position in the DOM: which text node, and byte offset within it.
pub struct DomCursor {
pub node_id: usize, // ID of the text node (or element for empty blocks)
pub offset: usize, // Byte offset within the text node
}
/// A selection: anchor (where it started) + head (current caret position).
pub struct CeSelection {
pub anchor: DomCursor,
pub head: DomCursor,
}
impl CeSelection {
fn collapsed(cursor: DomCursor) -> Self // Cursor (no selection)
fn range(anchor: DomCursor, head: DomCursor) -> Self
fn is_collapsed(&self) -> bool
}
}
Key difference from document positions: DomCursor references specific DOM node IDs, not abstract document offsets. Node IDs can change when the DOM is restructured (e.g. block splits, formatting changes).
Data Interchange Types
Use BlockData for structured content interchange (e.g. syncing with EditorDocument):
#![allow(unused)]
fn main() {
pub struct BlockData {
pub block_type: String, // "paragraph", "heading", etc.
pub attrs: HashMap<String, String>, // e.g. {"level": "2"} for headings
pub content: Vec<InlineRunData>,
}
pub struct InlineRunData {
pub text: String,
pub marks: Vec<InlineMarkData>,
}
pub struct InlineMarkData {
pub mark_type: String, // "bold", "italic", "code", etc.
pub attrs: HashMap<String, String>,
}
}
Keyboard Shortcuts
The CE system provides these built-in keyboard shortcuts:
Text Formatting
| Shortcut | Action |
|---|---|
| Ctrl+B | Toggle bold (<strong>) |
| Ctrl+I | Toggle italic (<em>) |
| Ctrl+U | Toggle underline (<u>) |
| Ctrl+Shift+X | Toggle strikethrough (<s>) |
| Ctrl+E | Toggle inline code (<code>) |
Editing
| Shortcut | Action |
|---|---|
| Enter | Split block |
| Backspace | Delete backward (joins blocks at boundary) |
| Delete | Delete forward |
| Tab | Indent (increase list nesting or insert tab) |
| Shift+Tab | Outdent (decrease list nesting) |
| Ctrl+Z | Undo |
| Ctrl+Y | Redo |
Clipboard
| Shortcut | Action |
|---|---|
| Ctrl+C | Copy selection |
| Ctrl+X | Cut selection |
| Ctrl+V | Paste (HTML preferred, falls back to plain text) |
Navigation
All standard cursor movement keys work: arrow keys, Home/End, Ctrl+arrow for word movement, Shift+arrow for selection, Ctrl+A for select all, Page Up/Down.
Building a Toolbar
A typical pattern for editor toolbars uses reactive signals to track active formatting state:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::ce::{with_active_ce_api, subscribe_ce_events, CeEvent};
#[component]
fn editor_toolbar() -> NodeHandle {
let is_bold = Signal::new(false);
let is_italic = Signal::new(false);
let block_type = Signal::new(String::from("p"));
// Subscribe to selection changes to update toolbar state
subscribe_ce_events(Rc::new(move |event| {
if let CeEvent::SelectionChanged { .. } = event {
with_active_ce_api(|api| {
let api = api.borrow();
is_bold.set(api.has_active_mark("strong"));
is_italic.set(api.has_active_mark("em"));
if let Some(tag) = api.cursor_block_tag() {
block_type.set(tag);
}
});
}
}));
rsx! {
div { class: "toolbar",
button {
class: {|| if is_bold.get() { "active" } else { "" }},
onclick: move || ce_do(|api| api.toggle_wrap("strong")),
"B"
}
button {
class: {|| if is_italic.get() { "active" } else { "" }},
onclick: move || ce_do(|api| api.toggle_wrap("em")),
"I"
}
}
}
}
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}
Loading Initial Content
Use load_html to set the initial content of a CE element:
#![allow(unused)]
fn main() {
#[component]
fn editor_with_content() -> NodeHandle {
let editor = rsx! {
div { contenteditable: "true", style: "min-height: 200px;" }
};
// Load content (works before the element receives focus)
editor.with_ce_api(|api| {
api.borrow_mut().load_html(r#"
<h1>Welcome</h1>
<p>This is <strong>rich text</strong> content.</p>
<ul>
<li>First item</li>
<li>Second item</li>
</ul>
"#);
});
editor
}
}
Extracting Content
Read the current CE content as structured data:
#![allow(unused)]
fn main() {
// As BlockData (for syncing with EditorDocument or serialization)
let blocks = with_active_ce_api(|api| {
api.borrow().extract_content()
}).unwrap_or_default();
// Process blocks
for block in &blocks {
println!("Block type: {}", block.block_type);
for run in &block.content {
let marks: Vec<_> = run.marks.iter().map(|m| m.mark_type.as_str()).collect();
println!(" '{}' marks={:?}", run.text, marks);
}
}
}
Architecture Notes
CeOps
CeOps is the runtime’s implementation of ContentEditableApi. It holds a reference to the RinchDocument and the CE element’s node ID. Created lazily when a CE element first receives focus.
Event Flow
User types "a"
→ KeyEvent captured by winit
→ RinchApp::handle_contenteditable_key()
→ InputHandler maps to EditCommand::InsertText("a")
→ CeOps::insert_text("a")
→ DOM: set_text_content on text node
→ dispatch_ce_event(TextInserted { ... })
→ dispatch SelectionChanged
→ dispatch oninput
→ mark scene dirty → repaint
Thread-Local Storage
The CE system uses thread-local storage for global access:
- Event dispatcher:
dispatch_ce_event()/subscribe_ce_events()— broadcasts events to all listeners - Active CE API:
set_active_ce_api()/with_active_ce_api()— tracks which CE element has focus - CE API registry:
register_ce_api()/with_ce_api_for_node()— per-element API lookup
All are thread_local! — safe for single-threaded GUI but not shareable across threads.
Key Source Files
| File | Purpose |
|---|---|
crates/rinch-core/src/ce.rs | Core types: CeEvent, ContentEditableApi, DomCursor, CeSelection, dispatchers |
crates/rinch/src/ce_ops.rs | CeOps — runtime implementation of ContentEditableApi (CRDT-first mutations) |
crates/rinch/src/ce_render.rs | Block rendering, BlockMap, position conversion (EditorPosition ↔ DomCursor) |
crates/rinch/src/app/contenteditable/mod.rs | Keyboard input handler, cursor management |
crates/rinch/src/app/contenteditable/ce_selection.rs | Selection, copy/cut, HTML extraction |
crates/rinch/src/app/contenteditable/ce_paste.rs | HTML paste handling |
crates/rinch/src/app/contenteditable/ce_navigation.rs | Cursor navigation |
crates/rinch/src/app/contenteditable/ce_virtualization.rs | Large document virtualization |
crates/rinch-editable/src/ | Generic editing primitives (EditCommand, InputHandler) |
Rich-Text Editor
Rinch provides a comprehensive rich-text editor with collaborative editing support through Automerge CRDT, a schema-driven document model, and a powerful extension system.
Looking for the ContentEditable API? This guide covers the high-level
rinch-editorcrate (schemas, extensions, document model). For the lower-level DOM editing API (ContentEditableApi,CeEvent,DomCursor), see the ContentEditable API guide. The editor uses the CE API internally — the CE layer handles DOM mutations while this layer handles document structure and serialization.
Quick Start
Create a new editor with the StarterKit (22 default extensions):
#![allow(unused)]
fn main() {
use rinch_editor::prelude::*;
#[component]
fn editor_component() -> NodeHandle {
// Create schema with all standard editing features
let schema = Schema::starter_kit();
let config = EditorConfig::default();
// Create editor instance
let mut editor = Editor::new(schema, config)?;
// Render editor content...
todo!()
}
}
Editor Configuration
The Editor struct holds the document model, schema, selection state, and all editing capabilities:
#![allow(unused)]
fn main() {
pub struct Editor {
pub doc: EditorDocument, // The document being edited
pub schema: Schema, // Validation rules
pub selection: SelectionState, // Current cursor/selection
pub history: History, // Undo/redo
pub commands: CommandDispatcher, // All mutations
pub extensions: ExtensionRegistry, // Loaded extensions
pub input_rules: InputRuleSet, // Auto-transforms
pub shortcuts: ShortcutRegistry, // Keyboard bindings
pub events: EventDispatcher, // Event handling
pub config: EditorConfig, // Editor settings
}
}
Configuration Options
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct EditorConfig {
pub autofocus: AutoFocus, // Focus behavior on mount
pub editable: bool, // Allow editing
}
pub enum AutoFocus {
Start, // Focus at document start
End, // Focus at document end
None, // Don't auto-focus (default)
}
}
StarterKit Extensions
The StarterKit provides 22 pre-configured extensions covering all standard editing operations. These are organized into node and mark extensions.
Node Extensions (12 types)
| Extension | Tag | Purpose |
|---|---|---|
| Document | doc | Root container (content: block+) |
| Paragraph | <p> | Default text block (content: inline*) |
| Text | - | Inline text content |
| Heading | <h1>-<h6> | Section headings with level attribute |
| Blockquote | <blockquote> | Quoted content (content: block+) |
| Bullet List | <ul> | Unordered list (content: list_item+) |
| Ordered List | <ol> | Numbered list (content: list_item+) |
| List Item | <li> | List entry (content: block+) |
| Code Block | <pre><code> | Fenced code with language attr |
| Horizontal Rule | <hr> | Visual divider (atomic) |
| Hard Break | <br> | Line break (inline, atomic) |
| Image | <img> | Embedded image (requires src) |
Mark Extensions (10 types)
| Extension | HTML | Shortcut | Purpose |
|---|---|---|---|
| Bold | <strong> | Mod-B | Bold text |
| Italic | <em> | Mod-I | Italic text |
| Underline | <u> | Mod-U | Underlined text |
| Strikethrough | <s> | Mod-Shift-X | |
| Code | <code> | Mod-E | Inline code |
| Link | <a href> | - | Clickable links |
| Highlight | <mark> | Mod-Shift-H | Highlighted text |
| Subscript | <sub> | Mod-, | x₂ subscript |
| Superscript | <sup> | Mod-. | x² superscript |
| Text Color | <span> | - | Colored text (requires color attr) |
Using StarterKit
#![allow(unused)]
fn main() {
use rinch_editor::prelude::*;
// Create schema with all 22 extensions
let schema = Schema::starter_kit();
// Or manually load extensions
let mut editor = Editor::new(schema, EditorConfig::default())?;
for ext in StarterKit::extensions() {
editor.extensions.register(ext)?;
}
}
Keyboard Shortcuts
All StarterKit extensions come with keyboard shortcuts. Here’s the complete reference:
Text Formatting
| Shortcut | Action |
|---|---|
| Mod-B | Toggle bold |
| Mod-I | Toggle italic |
| Mod-U | Toggle underline |
| Mod-Shift-X | Toggle |
| Mod-E | Toggle code |
| Mod-Shift-H | Toggle highlight |
| Mod-, | Toggle subscript |
| Mod-. | Toggle superscript |
Note: “Mod” means Ctrl on Windows/Linux and Cmd on macOS.
Block Structure
| Shortcut | Action |
|---|---|
| Mod-Alt-0 | Convert to paragraph |
| Mod-Alt-1 | Convert to heading 1 |
| Mod-Alt-2 | Convert to heading 2 |
| Mod-Alt-3 | Convert to heading 3 |
| Mod-Alt-4 | Convert to heading 4 |
| Mod-Alt-5 | Convert to heading 5 |
| Mod-Alt-6 | Convert to heading 6 |
Lists and Quotes
| Shortcut | Action |
|---|---|
| Mod-Shift-B | Toggle blockquote |
| Mod-Shift-8 | Toggle bullet list |
| Mod-Shift-7 | Toggle ordered list |
Special
| Shortcut | Action |
|---|---|
| Mod-Alt-C | Toggle code block |
| Shift-Enter | Insert hard break |
Markdown Input Rules
StarterKit includes markdown-style input rules that auto-convert patterns to formatted content:
Headings
Type any of these at the start of a line, followed by space:
# <space> → Heading 1
## <space> → Heading 2
### <space> → Heading 3
#### <space> → Heading 4
##### <space> → Heading 5
###### <space> → Heading 6
Lists
- <space> → Bullet list (dash)
* <space> → Bullet list (asterisk)
1. <space> → Ordered list
Quotes and Code
> <space> → Blockquote
```<space> → Code block
--- → Horizontal rule
Inline Formatting
These patterns auto-toggle marks while typing:
**text** → Bold
*text* → Italic
~~text~~ → Strikethrough
Commands
All mutations go through the command system. Commands are registered by extensions and dispatched by name:
#![allow(unused)]
fn main() {
// Toggle bold on current selection
editor.commands.execute("toggle_bold")?;
// Set heading level
editor.commands.execute("set_heading_1")?;
// Insert elements
editor.commands.execute("insert_table")?;
}
Command Categories
Commands are organized into three categories:
Text Commands
insert_text(text)- Insert charactersdelete_range(range)- Delete text rangereplace_range(range, text)- Replace with text
Formatting Commands
toggle_mark(mark)- Toggle mark on selectionremove_mark(mark)- Remove mark from selectionset_mark(mark, attrs)- Set mark with attributes
Structure Commands
set_block_type(type)- Change node typewrap_in(type)- Wrap in containerlift()- Lift out of containersplit_block()- Split at cursorjoin_blocks()- Merge adjacent blocks
Table Editing
The optional TableExtension provides full table editing support with 11 commands:
Table Commands
| Command | Purpose |
|---|---|
insert_table | Insert 3x3 table |
delete_table | Remove table |
add_row_before | Insert row before cursor |
add_row_after | Insert row after cursor |
delete_row | Delete current row |
add_column_before | Insert column before cursor |
add_column_after | Insert column after cursor |
delete_column | Delete current column |
merge_cells | Merge selected cells |
split_cell | Split cell (if colspan/rowspan > 1) |
toggle_header_row | Promote/demote first row as header |
Table Navigation
| Shortcut | Action |
|---|---|
| Tab | Move to next cell |
| Shift-Tab | Move to previous cell |
Table Structure
Tables contain:
<table>- Container (group: block)<table_row>- Row (content:table_cell+) |<table_cell>- Cell (content:block+, attrs: colspan, rowspan)<table_header>- Header cell (content:block+, attrs: colspan, rowspan)
Enable tables:
#![allow(unused)]
fn main() {
let mut editor = Editor::new(Schema::starter_kit(), config)?;
editor.extensions.register(Box::new(TableExtension))?;
}
Document Model
The editor document is backed by Automerge CRDT, enabling offline editing and automatic conflict resolution:
#![allow(unused)]
fn main() {
pub struct EditorDocument {
// Automerge-backed CRDT document
}
impl EditorDocument {
// Insert text at position
pub fn insert_text(&mut self, pos: Position, text: &str) -> Result<(), EditorError>
// Delete text range
pub fn delete_range(&mut self, range: Range) -> Result<(), EditorError>
// Add mark (formatting) to range
pub fn add_mark(&mut self, range: Range, mark: &str, attrs: Option<HashMap<String, String>>) -> Result<(), EditorError>
// Remove mark from range
pub fn remove_mark(&mut self, range: Range, mark: &str) -> Result<(), EditorError>
// Split block at position
pub fn split_block(&mut self, pos: Position) -> Result<(), EditorError>
// Get block type at position
pub fn block_type(&self, pos: usize) -> Option<String>
// Get document length
pub fn length(&self) -> usize
}
}
Position and Range
#![allow(unused)]
fn main() {
// Position: byte offset in document (0 = start)
pub struct Position {
pub offset: usize,
}
// Range: contiguous span from start to end
pub struct Range {
pub start: Position,
pub end: Position,
}
}
Schema System
The schema defines what content is valid. Every extension contributes node and mark specifications:
#![allow(unused)]
fn main() {
// Create a custom schema
let mut schema = Schema::new();
// Add node type
schema.add_node(NodeSpec::builder("paragraph")
.content("inline*")
.group("block")
.build());
// Add mark type
schema.add_mark(MarkSpec::simple("bold"));
}
Node Specs
#![allow(unused)]
fn main() {
pub struct NodeSpec {
pub name: String,
pub content: Option<String>, // Content model (e.g., "inline*", "block+")
pub group: Option<String>, // Grouping (block, inline)
pub attrs: HashMap<String, AttrSpec>,
pub inline: bool, // Inline vs block
pub atom: bool, // Atomic (no content)
pub isolating: bool, // Boundary for operations
pub marks: MarkSet, // Which marks allowed
pub parse_html_tags: Vec<String>,
}
}
Mark Specs
#![allow(unused)]
fn main() {
pub struct MarkSpec {
pub name: String,
pub attrs: HashMap<String, AttrSpec>,
pub parse_html_tags: Vec<String>,
pub inclusive: bool, // Extend to typing (default: true)
pub excludes: Option<String>, // Conflicts (e.g., "bold italic")
}
}
Selection and Cursor
The selection tracks the current cursor position or selection range:
#![allow(unused)]
fn main() {
pub struct Selection {
pub anchor: Position, // Selection start
pub head: Position, // Selection end (same as anchor for cursor)
}
impl Selection {
// Cursor at position
pub fn cursor(pos: Position) -> Self
// Range from start to end
pub fn range(start: Position, end: Position) -> Self
// Check if collapsed (cursor, not range)
pub fn is_collapsed(&self) -> bool
}
// Update selection
editor.set_selection(Selection::cursor(Position::new(10)));
}
Undo/Redo
Rinch editor includes local undo/redo for collaborative editing. Operations are stored with inverses for reversal:
#![allow(unused)]
fn main() {
pub struct History {
pub undo_stack: Vec<UndoOperation>,
pub redo_stack: Vec<UndoOperation>,
}
pub struct UndoOperation {
pub changes: Vec<DocumentChange>,
pub inverse: Vec<DocumentChange>, // Undo reverses these
}
// Undo last operation
editor.history.undo();
// Redo last undone operation
editor.history.redo();
}
Each operation is atomic - undo/redo are single steps regardless of how many DOM mutations happened.
Extension System
Create custom extensions by implementing the Extension trait:
#![allow(unused)]
fn main() {
use rinch_editor::prelude::*;
#[derive(Debug)]
struct MyCustomExtension;
impl Extension for MyCustomExtension {
fn name(&self) -> &str {
"my_custom"
}
fn nodes(&self) -> Vec<NodeSpec> {
vec![NodeSpec::builder("custom_node")
.content("inline*")
.group("block")
.build()]
}
fn marks(&self) -> Vec<MarkSpec> {
vec![MarkSpec::simple("custom_mark")]
}
fn commands(&self) -> Vec<CommandRegistration> {
vec![CommandRegistration::new("my_command", |editor| {
// Command logic here
Ok(())
})]
}
fn keyboard_shortcuts(&self) -> Vec<(KeyboardShortcut, String)> {
vec![(
KeyboardShortcut::new("Mod-K", "My command"),
"my_command".into(),
)]
}
fn input_rules(&self) -> Vec<InputRule> {
vec![InputRule::new(
regex::Regex::new(r"^:wave: $").unwrap(),
"Wave emoji",
|_editor, _caps| Ok(()),
)]
}
}
// Register extension
editor.extensions.register(Box::new(MyCustomExtension))?;
}
Extension Priorities
Extensions can specify priority (lower = earlier initialization):
#![allow(unused)]
fn main() {
impl Extension for MyExtension {
fn priority(&self) -> i32 {
50 // Lower than default (100)
}
}
}
Use priorities to control initialization order when extensions have dependencies.
Events
The editor dispatches events for state changes:
#![allow(unused)]
fn main() {
pub struct EventDispatcher {
// Event routing system
}
// Listen to updates
editor.events.on("update", |event| {
// Handle update
});
}
Testing
Rinch provides testing utilities:
#![allow(unused)]
fn main() {
use rinch_editor::prelude::*;
#[test]
fn test_bold_toggle() {
let schema = Schema::starter_kit();
let mut editor = Editor::new(schema, EditorConfig::default()).unwrap();
// Insert text
editor.doc.insert_text(Position::new(0), "hello").unwrap();
// Toggle bold
editor.commands.execute("toggle_bold").unwrap();
// Verify mark applied
let marks = editor.doc.marks_at(Position::new(2));
assert!(marks.iter().any(|m| m.name == "bold"));
}
}
Advanced: Custom Document Rendering
For embedding the editor in your Rinch app, implement DOM rendering:
#![allow(unused)]
fn main() {
#[component]
fn render_editor(editor: &Editor) -> NodeHandle {
let div = __scope.create_element("div");
div.set_attribute("class", "editor");
// Render document
for node in editor.doc.nodes() {
let rendered = render_node(__scope, node);
div.append_child(&rendered);
}
// Render cursor/selection
if let Some(cursor_node) = render_cursor(__scope, &editor.selection) {
div.append_child(&cursor_node);
}
div
}
}
Architecture
The editor uses a Hidden Textarea + Virtual DOM approach:
- A hidden
<textarea>captures keyboard input, IME, and clipboard - The document model (Automerge CRDT) is the source of truth
- DOM nodes are rendered from the document using Rinch’s NodeHandle API
- Selection and cursor overlays provide visual feedback
This design enables:
- Full IME support (Chinese, Japanese, etc.)
- Native clipboard handling
- Undo/redo compatible with collaborative editing
- Surgical DOM updates (only changed nodes update)
See Editor Architecture for deep technical details.
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.rs # 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/ # WASM backend stubs
│ ├── rinch-editor/ # Rich-text editor (CRDT)
│ ├── rinch-theme/ # Theme system (CSS variables)
│ ├── rinch-components/ # UI component library (~55 components)
│ ├── rinch-editable/ # Text editing abstractions
│ ├── 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
Layer Diagram
┌──────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (your app, ui-zoo-desktop, ui-zoo, etc.) │
├──────────────────────────────────────────────────────────────┤
│ rinch │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RinchApp (app.rs) │ │
│ │ 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) │ (~55 components) │
├──────────────────────────────────────────────────────────────┤
│ 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 {
fn handle_event(&mut self, event: PlatformEvent) -> Vec<AppAction> {
match event {
PlatformEvent::MouseDown { x, y, button } => {
// Handle click, update DOM
vec![AppAction::RequestRedraw]
}
PlatformEvent::KeyPress { key, modifiers } => {
// Handle keyboard input
vec![AppAction::RequestRedraw]
}
// ...
}
}
}
}
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<T>,Effect,Memo<T>for state management - DOM abstractions -
RenderScope,NodeHandle,DomDocumenttrait - Reactive Primitives -
Signal::new(),Effect::new(),Memo::new(),create_context() - Element types - Minimal enum:
Html,Fragment,Componentonly - Event handling - Input and click event dispatch
- Icon enum - Curated set of ~40 common icons for components
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.rs) - Platform-agnostic application logic - Desktop backend (
shell/rinch_runtime.rs) - Event loop, window creation, rendering - Event loop (
shell/rinch_runtime.rs) - Desktop runtime: event loop, window creation, rendering - Menu Manager - Native menu support via muda
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 / ui-zoo-web
WASM backend using browser-native DOM:
- WebDocument - Implements
DomDocumentviaweb_sys, creating real browser DOM elements - No Taffy/Parley/Vello - The browser handles layout, text shaping, and painting natively
- Event delegation - Document-level listeners dispatch via
data-ridattributes - 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
Rich-text editor with collaborative editing support:
- CRDT-backed document - Automerge for offline editing and conflict resolution
- Schema system - Define valid document structure with nodes and marks
- 22 StarterKit extensions - Complete editing experience out of the box
- Command system - All mutations go through named commands
- Extension system - Add custom nodes, marks, commands, shortcuts
- Keyboard shortcuts - 16+ built-in shortcuts (Mod-B, Mod-I, etc.)
- Markdown input rules - Auto-convert markdown patterns (# heading, bold, etc.)
- Table editing - Full table support with merge/split/navigation
- Local undo/redo - Compatible with collaborative editing
See Editor Architecture for technical details.
rinch-components
UI component library (~55 components):
- 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
Text editing abstractions:
- EditableDocument - Trait for text editing operations
- EditCommand - Enum of editing commands (insert, delete, etc.)
- EditableState - Cursor position, selection state
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 |
| Automerge | CRDT for collaborative editing (rinch-editor) |
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();
}
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");
}
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
}
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);
}
Focus
#![allow(unused)]
fn main() {
let input = __scope.create_element("input");
input.focus(); // Give focus to this element
}
NodeHandle API Reference
| Method | Description |
|---|---|
set_text(content: &str) | Set text content (for text nodes) |
set_attribute(name: &str, value: &str) | Set an attribute |
get_attribute(name: &str) -> Option<String> | Get an attribute value |
remove_attribute(name: &str) | Remove an attribute |
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 |
replace_with(new_node: &NodeHandle) | Replace this node with another |
focus() | Give focus to this element |
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) |
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:
Scope::new()andScope::run()exist in the codebase but are currently placeholders. The active scope mechanism isRenderScope, which tracks effects and child scopes for automatic cleanup.
Ownership
- Signals are reference-counted (
Rc<RefCell<T>>) - Effects hold strong references to their closures
- Disposing a scope drops all its primitives
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 current implementation uses Rc<RefCell<T>> for single-threaded use:
- 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.
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 pop_layer(&mut self);
// ...
}
}
Application code never interacts with the Painter directly — the backend is selected at compile time via Cargo features.
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 (the default when gpu is not enabled):
- 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 are skipped
- Works in headless environments, CI, containers, SSH sessions
Rendering Backends
Choosing a Backend
Set it in your Cargo.toml:
# GPU mode (recommended for most apps):
rinch = { workspace = true, features = ["desktop", "gpu"] }
# Software mode (default — no GPU required):
rinch = { workspace = true, features = ["desktop"] }
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.
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.
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
- 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
AboutToWait - Layer compositing - GPU layers for transformed content (planned)
- Text caching - Glyph atlas for repeated text (planned)
- Viewport culling - Skip off-screen content (planned)
Rich-Text Editor Architecture
The rinch-editor is a comprehensive rich-text editor built for the Rinch GUI framework with full collaborative editing support through Automerge CRDT.
Design Overview
The editor uses a Hidden Textarea + Custom Virtual DOM approach to balance:
- Full IME and clipboard support
- Native browser input handling (without being in a browser)
- Surgical DOM updates (only changed nodes update)
- Collaboration-friendly undo/redo
Key Principle
Hidden Textarea → Key Events → Editor Commands → Document Model → Virtual DOM Render
(Clipboard) (Dispatch) (Automerge) (NodeHandle)
The document model (Automerge CRDT) is the single source of truth. All mutations go through commands, which update the document, which then updates the DOM via Effects.
Core Components
Editor Instance
#![allow(unused)]
fn main() {
pub struct Editor {
pub doc: EditorDocument, // Automerge CRDT document
pub schema: Schema, // Validation rules
pub selection: SelectionState, // Cursor/selection
pub history: History, // Local undo/redo
pub commands: CommandDispatcher, // Command execution
pub extensions: ExtensionRegistry, // Loaded plugins
pub input_rules: InputRuleSet, // Auto-transforms
pub shortcuts: ShortcutRegistry, // Keyboard bindings
pub events: EventDispatcher, // Event routing
pub config: EditorConfig, // Settings
}
}
Document Model
The EditorDocument wraps an Automerge document:
#![allow(unused)]
fn main() {
pub struct EditorDocument {
// Contains:
// - Block nodes (paragraph, heading, list, code_block, etc.)
// - Inline content (text, marks)
// - Mark data (bold, italic, link, etc.)
//
// Operations:
// - insert_text(pos, text)
// - delete_range(range)
// - add_mark(range, mark, attrs)
// - remove_mark(range, mark)
// - split_block(pos)
}
}
Key properties:
- CRDT-backed: Changes can be merged with remote edits
- Immutable snapshots: Document state at any point can be accessed
- Collaborative: Multiple users can edit simultaneously
- Local undo: Operations track inverses for reversal
Schema System
The schema defines valid document structure:
#![allow(unused)]
fn main() {
pub struct Schema {
pub nodes: HashMap<String, NodeSpec>,
pub marks: HashMap<String, MarkSpec>,
pub top_node: String, // Usually "doc"
}
pub struct NodeSpec {
pub name: String,
pub content: Option<String>, // "block+", "inline*", etc.
pub group: Option<String>, // block, inline grouping
pub attrs: HashMap<String, AttrSpec>,
pub inline: bool,
pub atom: bool, // No content (leaf node)
pub isolating: bool, // Boundary for operations
pub marks: MarkSet, // Which marks allowed
}
pub struct MarkSpec {
pub name: String,
pub attrs: HashMap<String, AttrSpec>,
pub inclusive: bool, // Extend to new typing
pub excludes: Option<String>, // Conflicting marks
}
}
Extension System
Extensions are plugins that contribute to the editor:
#![allow(unused)]
fn main() {
pub trait Extension: Debug {
fn name(&self) -> &str;
fn priority(&self) -> i32 { 100 }
fn nodes(&self) -> Vec<NodeSpec> { vec![] }
fn marks(&self) -> Vec<MarkSpec> { vec![] }
fn commands(&self) -> Vec<CommandRegistration> { vec![] }
fn keyboard_shortcuts(&self) -> Vec<(KeyboardShortcut, String)> { vec![] }
fn input_rules(&self) -> Vec<InputRule> { vec![] }
fn on_init(&self, editor: &mut Editor) -> Result<(), EditorError> { Ok(()) }
}
}
Extension lifecycle:
- Extensions register nodes, marks, commands
- Schema is built from all extensions
- Shortcuts and input rules are collected
- Extensions are initialized in priority order
- Editor is ready for editing
Command System
All mutations are commands dispatched through CommandDispatcher:
#![allow(unused)]
fn main() {
pub type Command = fn(&mut Editor) -> Result<(), EditorError>;
pub struct CommandDispatcher {
commands: HashMap<String, Command>,
}
}
Command Categories
Text Commands:
- Direct text insertion/deletion
- Operations on the text content level
Formatting Commands:
- Toggle marks (bold, italic, etc.)
- Add/remove marks on ranges
- Set mark attributes (e.g., link href)
Structure Commands:
- Change block type (paragraph to heading)
- Wrap in container (create blockquote)
- Lift out of container (remove list wrapper)
- Split/join blocks
Command Execution Flow
User Input (keyboard/UI)
↓
Resolve to command name (shortcut or direct call)
↓
CommandDispatcher.execute(name)
↓
Call command function with &mut Editor
↓
Command updates EditorDocument
↓
Document mutations trigger Effects
↓
DOM nodes update (NodeHandle calls)
Input System
Keyboard Shortcuts
#![allow(unused)]
fn main() {
pub struct KeyboardShortcut {
pub key: String, // "Mod-B", "Ctrl+I", etc.
normalized: String, // Normalized for matching
pub description: String,
}
pub struct ShortcutRegistry {
shortcuts: HashMap<String, String>, // normalized → command name
}
}
Key parsing:
- Mod = Ctrl (Windows/Linux) or Cmd (Mac)
- Separators:
-or+(normalized to+) - Case-insensitive (normalized to lowercase)
Input Rules
#![allow(unused)]
fn main() {
pub struct InputRule {
pub pattern: Regex,
pub description: String,
pub handler: fn(&mut Editor, &Captures) -> Result<(), EditorError>,
}
pub struct InputRuleSet {
rules: Vec<InputRule>,
}
}
Examples:
- Pattern:
^# $→ Convert to Heading 1 - Pattern:
**(.+)\*\*$→ Apply bold mark - Pattern:
^---$→ Insert horizontal rule
Input rules run AFTER text insertion, checking if the last line matches a pattern. If matched, the handler modifies the document.
History and Undo/Redo
The History system is designed for collaborative editing:
#![allow(unused)]
fn main() {
pub struct History {
pub undo_stack: Vec<UndoOperation>,
pub redo_stack: Vec<UndoOperation>,
}
pub struct UndoOperation {
pub changes: Vec<DocumentChange>,
pub inverse: Vec<DocumentChange>, // Reverses these changes
}
}
Key design:
- Operations are atomic (multiple DOM changes = one undo step)
- Inverses are automatically computed
- Compatible with CRDT merging (local undo doesn’t break collaboration)
- Redo pushes undone operations back to undo stack
StarterKit: 22 Default Extensions
The StarterKit bundles 22 extensions for full-featured editing:
Nodes (12)
| Name | Group | Content | Purpose |
|---|---|---|---|
| doc | - | block+ | Root node |
| paragraph | block | inline* | Default text block |
| text | inline | - | Inline text (atomic) |
| heading | block | inline* | h1-h6 with level attr |
| blockquote | block | block+ | Quote wrapper |
| bullet_list | block | list_item+ | Unordered list |
| ordered_list | block | list_item+ | Numbered list |
| list_item | - | block+ | List entry |
| code_block | block | text* | Pre-formatted code |
| horizontal_rule | block | - | Visual divider (atomic) |
| hard_break | inline | - | Line break (atomic) |
| image | inline | - | Image embed (atomic) |
Marks (10)
| Name | Shortcut | HTML | Attributes |
|---|---|---|---|
| bold | Mod-B | <strong> | - |
| italic | Mod-I | <em> | - |
| underline | Mod-U | <u> | - |
| strike | Mod-Shift-X | <s> | - |
| code | Mod-E | <code> | excludes: bold, italic, underline, strike |
| link | - | <a> | href (required), title, target |
| highlight | Mod-Shift-H | <mark> | color (optional) |
| subscript | Mod-, | <sub> | excludes: superscript |
| superscript | Mod-. | <sup> | excludes: subscript |
| text_color | - | <span> | color (required) |
Table Extension
The optional TableExtension adds table editing:
#![allow(unused)]
fn main() {
pub struct TableExtension;
impl Extension for TableExtension {
fn nodes(&self) -> Vec<NodeSpec> {
vec![
NodeSpec::builder("table") // block container
.content("table_row+")
.isolating(true) // Operations don't cross boundary
.build(),
NodeSpec::builder("table_row")
.content("table_cell+")
.build(),
NodeSpec::builder("table_cell")
.content("block+")
.attr("colspan", AttrSpec::optional("1"))
.attr("rowspan", AttrSpec::optional("1"))
.build(),
NodeSpec::builder("table_header")
.content("block+")
.attr("colspan", AttrSpec::optional("1"))
.attr("rowspan", AttrSpec::optional("1"))
.build(),
]
}
}
}
Commands (11 total):
insert_table- Create 3x3 tabledelete_table- Remove tableadd_row_before,add_row_after,delete_rowadd_column_before,add_column_after,delete_columnmerge_cells- Combine selected cellssplit_cell- Undo colspan/rowspantoggle_header_row- Promote first row
Navigation:
- Tab = Next cell
- Shift-Tab = Previous cell
Selection and Positions
Position
#![allow(unused)]
fn main() {
pub struct Position {
pub offset: usize, // Byte offset in document
}
impl Position {
pub fn new(offset: usize) -> Self
}
}
Positions are 0-indexed from document start.
Range
#![allow(unused)]
fn main() {
pub struct Range {
pub start: Position,
pub end: Position,
}
}
Selection
#![allow(unused)]
fn main() {
pub struct Selection {
pub anchor: Position, // Selection start (fixed)
pub head: Position, // Selection end (can move)
}
impl Selection {
pub fn cursor(pos: Position) -> Self // anchor == head
pub fn range(start: Position, end: Position) -> Self
pub fn is_collapsed(&self) -> bool // Is it just a cursor?
}
}
Key insight: Even when selecting, both anchor and head point to positions in the document. Cursor operations check is_collapsed().
Rendering Integration
Hidden Textarea Approach
<!-- Editor container -->
<div class="editor">
<!-- Hidden textarea for input -->
<textarea style="position: absolute; opacity: 0;"></textarea>
<!-- Rendered document (from NodeHandle API) -->
<div class="document">
<!-- Rendered nodes from editor.doc -->
</div>
<!-- Selection overlay -->
<div class="selection-overlay">
<!-- Cursor and selection highlights -->
</div>
</div>
Why hidden textarea?
- Captures all keyboard input (arrow keys, meta keys, etc.)
- Full IME support (for CJK input)
- Native clipboard events (Ctrl-C/V, Cmd-C/V)
- Works without browser context
Document Rendering
The editor must render its document to Rinch NodeHandles:
#![allow(unused)]
fn main() {
fn render_editor_doc(__scope: &mut RenderScope, editor: &Editor) -> NodeHandle {
let container = __scope.create_element("div");
container.set_attribute("class", "editor-doc");
// Render document nodes
for node in &editor.doc.nodes {
let rendered = render_node(__scope, node);
container.append_child(&rendered);
}
container
}
fn render_node(__scope: &mut RenderScope, node: &DocumentNode) -> NodeHandle {
match node.type_name.as_str() {
"paragraph" => {
let p = __scope.create_element("p");
for child in &node.children {
let rendered = render_node(__scope, child);
p.append_child(&rendered);
}
p
}
"text" => {
let text = __scope.create_text(&node.content);
text
}
// ... other node types
_ => __scope.create_text(""),
}
}
}
Marks Rendering
Marks are applied after text nodes are created:
#![allow(unused)]
fn main() {
// For each mark span in the document:
let span = __scope.create_element("strong"); // For bold
for text in &mark.content {
span.append_child(&__scope.create_text(text));
}
}
Event Flow
Keyboard Event
↓
Hidden Textarea captures (e.g., "a" key)
↓
Check InputRules (does "a" complete a pattern?)
↓
Insert text in document
↓
Check shortcuts (is Mod-B pressed?)
↓
Execute command if matched
↓
EditorDocument updated
↓
Effects trigger DOM updates
↓
Render marks and styles
CRDT Collaboration
The Automerge document enables offline editing and collaboration:
Local Editor Remote Editor
│ │
├─ Edit locally ────→ Receive update
│ ├─ Merge into document
├─ Create change ←─── Send change
│ └─ Re-render
└─ Merge remote
Each change is recorded with metadata (actor, timestamp) allowing automatic merging without conflicts.
Error Handling
#![allow(unused)]
fn main() {
pub enum EditorError {
InvalidNodeType(String),
InvalidMarkType(String),
InvalidSelection,
DocumentError(String),
CommandNotFound(String),
// ...
}
}
All document mutations return Result<(), EditorError>, allowing graceful error handling:
#![allow(unused)]
fn main() {
editor.doc.insert_text(pos, "hello")?; // Propagate errors
match editor.commands.execute("toggle_bold") {
Ok(()) => { /* success */ }
Err(e) => eprintln!("Command failed: {}", e),
}
}
Performance Characteristics
Time Complexity
| Operation | Complexity | Notes |
|---|---|---|
| Insert text | O(n) | n = document length |
| Delete range | O(n) | Proportional to range size |
| Toggle mark | O(n) | Scans range for existing marks |
| Apply input rule | O(1) | Regex on last line only |
| Command dispatch | O(log k) | k = number of commands |
| Schema lookup | O(1) | HashMap |
Memory
- Document size proportional to content
- CRDT overhead: ~2x text size (metadata)
- Undo stack: One entry per atomic operation
- Selection state: Minimal (two positions)
Testing Utilities
Rinch provides test helpers:
#![allow(unused)]
fn main() {
#[cfg(test)]
fn test_example() {
let schema = Schema::starter_kit();
let config = EditorConfig::default();
let mut editor = Editor::new(schema, config).unwrap();
// Insert and verify
editor.doc.insert_text(Position::new(0), "hello").unwrap();
assert_eq!(editor.doc.length(), 5);
// Toggle formatting
editor.commands.execute("toggle_bold").unwrap();
// Check result
let marks = editor.doc.marks_at(Position::new(2));
assert!(marks.iter().any(|m| m.name == "bold"));
}
}
Extensibility Points
Custom Extensions
Implement Extension to add:
- New node types (with content rules)
- New mark types (with attributes)
- Commands (via
CommandRegistration) - Shortcuts (via
KeyboardShortcut) - Input rules (via
InputRule)
Custom Schema
Build your own schema without StarterKit:
#![allow(unused)]
fn main() {
let mut schema = Schema::new();
schema.add_node(my_custom_node);
schema.add_mark(my_custom_mark);
}
Custom Commands
Register commands directly:
#![allow(unused)]
fn main() {
editor.commands.register("my_command", |editor| {
// Command implementation
Ok(())
})?;
}
Custom Renderers
Implement rendering for your document structure using Rinch’s NodeHandle API (set_text, set_attribute, append_child, etc.).
Related Documentation
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::run
Runs a rinch application with the given root component:
use rinch::prelude::*;
#[component]
fn app() -> NodeHandle {
rsx! {
div {
h1 { "Hello, rinch!" }
p { "A lightweight GUI framework for Rust." }
}
}
}
fn main() {
run("My App", 800, 600, app);
}
rinch::run_with_theme
Runs with a theme configuration:
use rinch::prelude::*;
fn main() {
let theme = ThemeProviderProps {
primary_color: Some("cyan".into()),
default_radius: Some("md".into()),
..Default::default()
};
run_with_theme("Themed App", 800, 600, app, theme);
}
rinch::run_with_window_props
Runs 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()
};
run_with_window_props(app, props, None);
}
Prelude
Import commonly used types with the prelude:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
}
This includes:
Entry points (desktop feature):
run,run_with_theme
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:
run()- Entry point functionrun_with_theme()- Entry point with theme configurationrun_with_menu()- Entry point with native menu barrun_with_window_props()- Entry point with full window propsrun_with_window_props_and_menu()- Entry point with full window props and menurun_rinch(),run_rinch_with_window_props()- Lower-level runtime entry points (deprecated)
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 (used at the runtime level via run_with_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);
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