meowlynxsea/yororen-ui/yororen-ui-state-inputs

Claude Code and agent skill by meowlynxsea/yororen-ui

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install meowlynxsea/yororen-ui/yororen-ui-state-inputs

SKILL.md

Yororen UI State + Inputs

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.

1. Mental model

The framework separates what a component knows from who owns its state:

  • The component owns interaction state. For a text input, that is

caret position, selection, scroll, blink epoch, IME composition. For a Select, that is open: bool, highlighted_index: usize, animation: AnimatedVisibility. None of this is in your app state.

  • Your app owns business state. The text inside the input, the

currently selected option's value, whether the form is currently submitting. You wire the component's 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

2. The three render pathways

Every headless factory exposes both .apply(div) and .render(cx). They do very different things.

APIWhat it returnsWhat 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 buildYou write the painter yourself (see material_button in layers_demo)
When to use which
  • Default look, fastest path: .render(cx). The renderer paints

bg / border / padding / radius / hover / active from the theme.

  • Caller controls every visual: .apply(div())...child("Save").

You write the div(), the renderer only contributes the focus ring and click handler.

  • Bespoke animation / brand identity: hand-roll a 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.

3. The seven text inputs

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.

FactoryBuilder extrason_change
text_input(id).placeholder, .disabled, .max_lengthFn(&str, &mut Window, &mut App)
password_input(id).mask_charFn(&str, &mut Window, &mut App)
search_input(id).placeholderFn(&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).placeholderFn(&str, …) + .on_browse(F) (renderer fires after native picker)
text_area(id).max_lengthFn(&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).

The cx.entity().clone() pattern

Every on_change is a move closure that needs to reach back into your app state. The canonical way is:

rust
use yororen_ui::headless::text_input::text_input;
text_input("email")
.placeholder("you@example.com")
.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:

  • It's the only way to get an Entity<MyApp> from inside a

Context<MyApp> render closure.

  • Cloning it is cheap (Entity is internally Arc).
  • Each 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.

Required boot step

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.

rust
yororen_ui::headless::text_input::init(cx);

4. Stateful composites

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:

text
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
The shape of every composite state
rust
// 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.

Select + ComboBox — the pick 3-in-1

Both expose a single pick(value, window, cx) method that does set_value + close + invoke_change atomically:

rust
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.

Modal — focus trap + scrim + Escape

The modal renderer wires FocusTrap, Escape, scrim-click for you. You just own the state and the body:

rust
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.

Popover + Dropdown + Menu — trigger + content

All three follow the same shape: pass a trigger element, pass a content element, the renderer places the content next to the trigger.

rust
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.

Tooltip — delay + placement
rust
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.

5. Form + form_field

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.

rust
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("you@example.com").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.

6. Listbox, Tree, Table

Listbox

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.

rust
use yororen_ui::headless::listbox::{listbox, ListboxOption, ListboxState};

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
331
Repository forks
9
7-day trend score
29

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/meowlynxsea/yororen-ui/yororen-ui-state-inputs/badge)](https://verified-skill.com/skills/meowlynxsea/yororen-ui/yororen-ui-state-inputs)

Something wrong with this skill or its page? Tell us.