codex-skills/yororen-ui-state-inputs/SKILL.md
State management and interactive components for end users building Yororen UI apps with gpui. Use when implementing TextInput/TextArea/SearchInput/PasswordInput/NumberInput/FilePathInput/KeybindingInput/ComboBox, building forms (form + form_field + submit_button), wiring on_change/on_submit/on_toggle/on_pick handlers, opening/closing modals/popovers/dropdowns/tooltips/select/menus, configuring the AnimatedVisibility lifecycle on composites, using virtual_list with infinite loading, or debugging typing lag and focus issues. Not for contributing to yororen-ui itself.
npx skillsauth add meowlynxsea/yororen-ui yororen-ui-state-inputsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Yororen UI's inputs and composites are all the same shape under the hood:
a headless props builder with a .render(cx) (or .render(cx, window))
that produces a fully wired element. This skill covers the wiring.
The framework separates what a component knows from who owns its state:
Select, that is open: bool, highlighted_index: usize,
animation: AnimatedVisibility. None of this is in your app state.on_change / on_pick /
on_close callbacks to write into your own Entity<MyState>.This split is what makes v0.3 inputs work without feedback loops: the
component is the single source of truth for interaction state, your
app is the single source of truth for business state, and the
on_change callback is the only bridge between them.
component internal state your app state
(caret, selection, value (text, validation,
as it types, animation, submit status, …)
open/closed, …)
▲ ▲
│ │
│ on_change / │
│ on_pick / │
│ on_close / │
│ on_toggle │
│ │
└──── write your state ────────┘
via Entity<T>::update
Every headless factory exposes both .apply(div) and .render(cx).
They do very different things.
| API | What it returns | What it does |
|---|---|---|
| props.apply(div) | Stateful<Div> | Sets id, track_focus, on_click. No visual feedback. |
| props.render(cx) | Stateful<Div> (or AnyElement for inputs) | Looks up the registered renderer, calls compose, then wires the same a11y callbacks on top. |
| (Custom) | anything you build | You write the painter yourself (see material_button in layers_demo) |
.render(cx). The renderer paints
bg / border / padding / radius / hover / active from the theme..apply(div())...child("Save").
You write the div(), the renderer only contributes the focus ring
and click handler.gpui::Element
(the MaterialRippleElement in layers_demo/src/material_button.rs
is the canonical example). You still call props.apply(...) to keep
the focus + click wiring.The layers_demo puts all three in one window — read it to see them
side by side.
All seven share a single state machine (TextInputCore in
yororen-ui-core/src/headless/text_input_core.rs): caret, selection,
scroll, blink, IME. They differ only in their wrapper UI (placeholder
icon, mask char, stepper, browse button, etc.) and their on_change
signature.
| Factory | Builder extras | on_change |
|---|---|---|
| text_input(id) | .placeholder, .disabled, .max_length | Fn(&str, &mut Window, &mut App) |
| password_input(id) | .mask_char | Fn(&str, &mut Window, &mut App) |
| search_input(id) | .placeholder | Fn(&str, …) + .on_clear(F) (renderer fires after Escape) |
| number_input(id) | .min, .max, .step, .value(seed) | Fn(f64, …) + .on_increment(F), .on_decrement(F) |
| file_path_input(id) | .placeholder | Fn(&str, …) + .on_browse(F) (renderer fires after native picker) |
| text_area(id) | .max_length | Fn(&str, …) (Enter inserts \n) |
| keybinding_input(id) | .mode(Idle or Capturing) | Fn(&str, …) + .on_start_capture(F), .on_cancel_capture(F) |
All seven return AnyElement from .render(cx, window) (they need
window for the IME handler registration).
cx.entity().clone() patternEvery on_change is a move closure that needs to reach back into
your app state. The canonical way is:
use yororen_ui::headless::text_input::text_input;
text_input("email")
.placeholder("[email protected]")
.on_change({
let entity = cx.entity(); // Entity<MyApp> from Context<MyApp>
move |new: &str, _window, cx| {
entity.update(cx, |s, _cx| {
s.email = new.to_string();
});
}
})
.render(cx, window)
Why cx.entity() and not state.global_clone() or anything else:
Entity<MyApp> from inside a
Context<MyApp> render closure.Entity is internally Arc).move closure that needs it must clone it once at construction
time; you cannot borrow it.The inputs_demo (crates/yororen-ui-demos/inputs_demo/src/inputs_app.rs)
has all seven inputs wired this way. Copy from it.
text_input::init(cx) binds the keyboard keymap (Backspace, Delete,
Left, Right, Shift+Left/Right, Cmd-A/V/C/X, Home, End, Enter, Escape,
Ctrl-Cmd-Space for the character palette) to the "UITextInput"
context. Call it once at boot, before opening any window that contains
a text input. It's idempotent. If you don't call it, the inputs still
render but the first keystroke will be silently dropped.
yororen_ui::headless::text_input::init(cx);
select, combo_box, modal, popover, dropdown_menu, tooltip,
listbox, menu, overlay — every one of these is a stateful
composite: a headless props builder that takes a caller-owned
Entity<XxxState> and a renderer that reads the state's
is_open() / is_visible() to decide what to paint.
The lifecycle is uniform:
state.update(cx, |s, _| s.open()) // user clicks trigger
→ AnimatedVisibility::show() // target=true
→ renderer paints enter animation
state.update(cx, |s, _| s.close()) // user picks / presses Escape
→ AnimatedVisibility::hide() // target=false
→ renderer paints exit animation
// Pseudo-code — the actual public API is one method per state.
state = XxxState::new(cx) // mints Entity<XxxState>
state.update(cx, |s, _| {
s.open() // show animation
s.close() // hide animation
s.toggle() // open ? close : open
});
state.read(cx).is_open() // query current target
state.read(cx).is_visible() // query target || progress > 0
state.set_on_change(F) // wire the callback
state.set_on_close(F) // for overlay-family
state.set_on_select(F) // for menu-family
The renderer is responsible for painting the trigger; the caller is
responsible for the on_* callback.
pick 3-in-1Both expose a single pick(value, window, cx) method that does
set_value + close + invoke_change atomically:
use yororen_ui::headless::select::{select, SelectOption, SelectState};
let entity_for_pick = entity.clone();
let state: Entity<SelectState> = /* from app state */;
let state_for_pick = state.clone();
state.update(cx, |s, _| {
s.set_options(vec![
SelectOption::new("apple", "Apple"),
SelectOption::new("pear", "Pear"),
]);
s.set_on_change(move |value, _w, cx| {
let v = value.to_string();
entity_for_pick.update(cx, |s, _cx| s.picked = v);
});
});
// Then in the click handler for an option row:
state_for_pick.update(cx, |s, cx| {
s.pick(SharedString::from("apple"), window, &mut *cx);
});
The &mut *cx is the in-place coercion of &mut Context<App> to
&mut App (via DerefMut). It's required because pick needs
&mut App to schedule the animation tick.
The modal renderer wires FocusTrap, Escape, scrim-click for you. You just own the state and the body:
use yororen_ui::headless::modal::{modal, ModalCloseReason, ModalState};
let modal = modal("settings", app.modal_state.clone())
.child(/* title */)
.child(/* body */)
.child(/* footer with close button */)
.render(cx);
// Close handler:
app.modal_state.update(cx, |s, _| s.close());
// Or with reason:
app.modal_state.update(cx, |s, cx| {
s.invoke_close(ModalCloseReason::Programmatic, window, &mut *cx);
});
A modal needs to be rendered at the scroll-root level (sibling
to your main content, not inside it) and wrapped in
gpui::deferred(...).with_priority(2) so it paints above the page
content but below the toast host. The gallery_demo shows the
exact placement.
All three follow the same shape: pass a trigger element, pass a content element, the renderer places the content next to the trigger.
use yororen_ui::headless::popover::{popover, PopoverState};
let popover = popover("user-menu", app.popover_state.clone())
.trigger(button("user-btn", cx).on_click(...).render(cx))
.content(/* menu element */)
.render(cx);
dropdown_menu is the same shape with a built-in items API
(state.set_items(vec![...])); menu is the body-only variant for
use inside popovers.
use yororen_ui::headless::tooltip::{tooltip, TooltipPlacement, TooltipState};
let tip = tooltip("help", "Click to save (⌘S)", app.tooltip_state.clone())
.trigger(button("save", cx).render(cx))
.placement(TooltipPlacement::Bottom)
.render(cx);
TooltipState::set_delay_ms(400) for the show delay; renderer hides
on trigger blur or Escape.
Forms are a thin layer over form_field. The form props stores
field values + errors; form_field is a labelled wrapper for a
single input.
use yororen_ui::headless::form::{form, FormValue};
use yororen_ui::headless::form_field::form_field;
let entity_for_form = entity.clone();
let form_el = form("settings", cx)
.value("email", app.email.clone())
.error("email", app.email_error.as_deref())
.submit("Save")
.on_submit(move |vals: HashMap<SharedString, String>, _w, cx| {
entity_for_form.update(cx, |s, _cx| {
s.submit_count += 1;
if let Some(e) = vals.get("email") {
s.email = e.to_string();
s.email_error = if e.contains('@') { None } else { Some("must contain @".into()) };
}
});
});
// The submit button is auto-generated:
let submit_btn = form_el.submit_button(cx).expect("submit label was set");
// Each field is a form_field:
let email_field = form_field("settings-email", "email", cx)
.label(cx.t("demo.form.email_label"))
.required(true)
.input(text_input("email").placeholder("[email protected]").render(cx, window))
.render(cx);
The renderer's job is to lay the fields out (label above input,
error below, required marker). Your job is the validation logic in
on_submit.
A scrollable single-select list. The shared keyboard-nav
algorithm lives in ListNavigable; ListboxState reuses it
via highlight_next / highlight_prev. The renderer paints
one row per option and wires each row's click to
state.pick(value, …) which writes selected_value and fires
on_change.
use yororen_ui::headless::listbox::{listbox, ListboxOption, ListboxState};
// `cx` here is `&mut gpui::App`. Build the entity once per
// component instance and store it on your model.
let listbox_state = cx.new(|_| ListboxState::new(cx));
listbox_state.update(cx, |s, _cx| {
s.set_options(vec![
ListboxOption::new("a", "Apple"),
ListboxOption::new("b", "Banana"),
]);
s.set_on_change(|value, _window, cx| {
// `cx` inside this callback is `&mut App`.
// Update your model here. `value` is a `SharedString`.
let _ = value;
let _ = cx;
});
});
// `.render(cx)` looks up the registered `ListboxRenderer`
// (default / brutalism) and returns a `Stateful<Div>` containing
// one row per option. Caller can chain `.child(...)` to add
// trailing elements (e.g. a footer hint).
listbox("fruit", listbox_state).render(cx)
Stateless data + stateful expansion. Build a TreeData, then emit
tree_item per row:
use yororen_ui::headless::tree::{tree, TreeData, tree_node_id};
use yororen_ui::headless::tree_item::tree_item;
let mut data = TreeData::new();
data.add(None, tree_node_id("root"), "Root");
data.add(Some(tree_node_id("root")), tree_node_id("child"), "Child");
let mut expanded = std::collections::BTreeSet::new();
expanded.insert(tree_node_id("root"));
tree("my-tree", cx)
.data(data)
.expanded(/* the BTreeSet */)
.selected(tree_node_id("child"))
For each row, emit a tree_item with its depth, has_children, etc.
The renderer handles indentation and the chevron.
Column-driven grid:
use yororen_ui::headless::table::{table, TableColumn};
let table_el = table("users", cx)
.column(TableColumn::new("name", "Name").width(200.0))
.column(TableColumn::new("age", "Age"))
.row(vec!["Alice".into(), "30".into()])
.row(vec!["Bob".into(), "25".into()])
.selected(0)
.on_select(|row_idx, _w, cx| { /* ... */ });
For long lists, use virtual_list (variable-height) or
uniform_virtual_list (fixed-height, faster). The controller carries
the data; the .row(closure) paints one item at a given index.
use yororen_ui::headless::virtual_list::{virtual_list, VirtualListController};
// 1. Keep a controller in your app state; bump it when the data changes.
let controller = cx.new(|_| MyController::new());
controller.update(cx, |c, _| c.reset(10_000)); // 10k items
// 2. Render:
let entity_for_vl = entity.clone();
virtual_list("rows", &controller, cx)
.row(move |ix, _window, cx| {
// emit a single row element for index `ix`
let app = entity_for_vl.read(cx);
list_item(format!("row-{ix}"), &app.row_label(ix), cx)
.selected(app.selected == ix)
.on_click(/* ... */)
.render(cx).into_any_element()
})
.on_visible_range_change(move |range, total, _w, cx| {
// Lazy-load more data here. `total` is the current controller
// size; bump it and notify to extend the list.
})
.render(cx)
uniform_virtual_list(id, count, &controller, cx) is the same idea
for fixed-height rows; it leans on gpui's uniform_list for speed.
After wiring an input, verify before moving on:
on_change fires for each keystroke (your state.field updates)on_submit fires on Enter (text inputs + form)state.read(cx).is_open() toggles correctly&mut *cx coercion compiles in pick / invoke_close callsIf any of these fail, the failure mode is almost always one of:
text_input::init(cx) was not called before opening the windowon_change closure was given &mut App instead of the
captured Entity<MyApp>Entity<XxxState> was re-minted (a different
cx.new(|_| SelectState::default()) per render) — state resets
every frame$yororen-ui-user — entry point, hard rules$yororen-ui-app-core — bootstrap, state pattern, theme, i18n$yororen-ui-recipes — full working examplesdevelopment
High-quality app code generation for end users building Rust desktop GUIs with gpui + Yororen UI (yororen_ui). Use when a user asks to build, scaffold, or modify an application using Yororen UI/gpui, or when working in a Rust project that depends on yororen_ui. Triggers include "build a counter with yororen ui", "make a form with TextInput", "add a modal", "add a theme switcher", "use yororen_ui". Routes to sibling skills ($yororen-ui-app-core, $yororen-ui-state-inputs, $yororen-ui-recipes) for deep dives. Not for contributing to yororen-ui itself.
development
End-to-end recipe patterns for end users building gpui apps with Yororen UI (yororen_ui). Use when the user asks for a complete working example, wants a screen layout pattern, or needs guidance composing components, modals, forms, list rendering, virtualized lists, notifications, theme switching, or i18n. Triggers include "show me a full example", "copy from the gallery", "how do I do theme switching", "how do I compose a modal + form", "how do I virtualize a long list". Not for contributing to yororen-ui itself.
development
App bootstrap and core architecture for end users building a gpui desktop app with Yororen UI (yororen_ui). Use when generating or refactoring main.rs, window setup, the one-call renderer install, the i18n bootstrap, NotificationCenter global, custom theme JSON, project module layout (state.rs, *app.rs, components/), Entity<T> state pattern, or theme reads via cx.theme(). Not for contributing to yororen-ui itself.
tools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.