Tailor: what gets generated
The output is a Rust file you own. Nothing in it references Tailor, nothing
loads the .tailor document at runtime, and there is no Tailor crate in the
dependency list — the export depends on gpui and guise-ui and stops there.
The document decides the shape
Not a setting. Anything that owns state — a text field, a picker, a state
variable, an action — has to be a Render entity, and everything else can be
the RenderOnce builder you would have written by hand.
| The document has | It generates |
|---|---|
| a screen, or any state, entity or action | struct + impl Render with pub fn new(cx) |
| a component with none of those | #[derive(IntoElement, Default)] struct + impl RenderOnce |
A component that holds state is promoted to an entity, and the export says so in
its notes — it is worth knowing, because a RenderOnce builder is placed as
Name::new() while an entity has to be built and held by whoever places it.
A screen, end to end
This is the People screen from the tutorial — an app
shell, a bound text input, a bound switch, a wired button, and a component of
its own placed three times.
//! People — generated by Tailor from Roster. Edit the design and regenerate,
//! or take this file and own it; it has no dependency on Tailor.
use gpui::prelude::*;
use gpui::{Entity, Window, div, px};
use guise::prelude::*;
use super::PersonRow;
pub struct People {
pub search: Entity<TextInput>,
pub select: Entity<Select>,
pub query: Signal<String>,
pub only_active: Signal<bool>,
}
impl People {
pub fn new(cx: &mut Context<Self>) -> Self {
let query = Signal::new(cx, "".to_string());
let only_active = Signal::new(cx, true);
let search = cx.new(|cx| {
TextInput::new(cx)
.placeholder("Name or role")
.label("Search")
});
let select = cx.new(|cx| {
Select::new(cx)
.data(["Everyone", "Engineering", "Design", "Support"])
.label("Role")
});
TextInput::bind(&search, &query, cx);
People {
search,
select,
query,
only_active,
}
}
pub fn add_person(&mut self, cx: &mut Context<Self>) {
// TODO
let _ = cx;
}
}
Four rules are visible in that constructor, and they are the ones that decide the order of everything:
- State first, as locals. A field built afterwards can read a signal, and
there is no
selfyet to read it from. - Then the entities, in build order — a field that captures another field is built after the one it captures.
- Then the bindings, because
X::bindneeds both sides to exist. - Then the struct, out of the locals.
Fields are public because those handles are how a host reads a value or drives a
control later: self.query.get(cx) from anywhere in your own code.
Hoisted colours
guise's own convention is that a theme(cx) read must not be held across a
cx.listener — the theme borrows the context immutably and a listener needs it
mutably. Every resolved colour is therefore lifted into a let at the top of
render:
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let violet_6 = theme(cx).color(ColorName::Violet, 6).hsla();
div()
.bg(violet_6)
// …
}
That is not a generator quirk; it is the rule you would have to follow writing it by hand, and the file follows it so you can keep editing without tripping over it.
Animation
A node with an entrance generates .animate(..) on the box it already had:
div()
.w_full()
.child(Button::new("node-4", "Continue"))
.animate(
"node-4",
Motion::enter_from(TransitionKind::SlideUp, 8.)
.duration(260.)
.delay(120.)
.ease(Easing::Out(Curve::Cubic)),
)
Two things about that are deliberate. It lands on the node's own box rather
than in a wrapper of its own, so the generated tree has exactly the same shape
whether a node animates or not — a wrapper would be a new flex item, and a
w_full child would start measuring against it instead of the row it was in. A
node with no box styling grows one for the animation, which is the same div
the style system would have emitted for a padding.
And it is built from the same Motion the canvas plays, through the same
resolved settings — a designer who previews something other than what ships is
worse off than one with no preview at all.
A node inside a free-form container gets .as_margins() on the end of that
chain: a pinned node is its inset, and animating one would drag it off its
pin. Margins offset it from where it was pinned instead, for the same visible
slide.
A container with a stagger hands its motion to each child with the index
folded into the delay, and does not animate itself. So a staggered list of
three generates three .animate(..) calls at 0, 60, 120 — and a child
with its own entrance keeps it.
In the macros flavour it prints a motion! block instead, next to the
style! block for the box:
div()
.apply(style! { width: full; })
.child(Button::new("node-4", "Continue"))
.animate(
"node-4",
motion! {
enter: slide_up 8.;
duration: 260.;
delay: 120.;
ease: out cubic;
},
)
See motion & transitions for what Motion can do beyond the
entrances Tailor exposes, and macros
for the block's full grammar.
What is left out
Defaults. A prop you never touched does not appear, because the file is meant to
be what you would have written and not a dump of every value a component can
take. A badge left on light generates Badge::new("Active").color(..) with no
.variant(..) at all.
Named imports too: the use gpui::{…} line is filtered to what the file
actually uses, because a generated file that warns on its first build reads as
sloppy.
Two flavours
- plain — builder calls and gpui
Styledmethods. Reads like the rest of an app. - macros — the same layout through
style! { … }blocks and therow!/col!macros, and animation throughmotion! { … }.
Switch in the code panel or the Generator section of the document inspector. Both compile; it is a house-style choice, and the project remembers yours.
Export
File → Export Code… (⌘E) writes a directory:
Cargo.toml # gpui + guise-ui, and the release profile
src/
├── main.rs # a window on the first screen
├── theme.rs # the theme you designed against
└── ui/
├── mod.rs # the module that ties them together
├── people.rs # one file per screen…
└── person_row.rs # …and per component
main.rs installs the theme, opens a window at the canvas size, and shows the
first screen — it is a real entry point, not a sketch:
fn main() {
Application::new().run(|cx: &mut gpui::App| {
theme::build().init(cx);
let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some("Roster".into()),
..Default::default()
}),
..Default::default()
},
|_, cx| cx.new(ui::People::new),
)
.unwrap();
cx.activate(true);
});
}
Every file is written whole; nothing is merged. An export is a snapshot of the design, and quietly merging into a file someone has since edited by hand is how a builder eats your work. Keep your own code out of the generated files — put behaviour in the action methods and the types they call — or take the file and stop exporting.
An export only ever writes below the directory you name.
The .tailor file
JSON, and meant to be read in a diff. Defaults are dropped on save, so a node that was placed and never styled writes just its id and its kind:
{ "id": 4, "kind": "button", "props": { "label": { "t": "text", "v": "Save" } } }
The format field is a version. A file from a newer Tailor is refused rather
than half-read.
A hand-edited file is repaired on load: unreachable nodes are dropped, dangling slot references are cleaned up, the id counter is re-pointed, and the tree is made a tree — one parent per node, no loops, bounded depth. A file with a cycle in it comes back with a short answer instead of hanging the app that opened it.
Saving refuses a project holding an infinity or a NaN rather than writing the
null that serde would produce and leaving a file that no longer loads. Every
field those could come from rejects them on the way in, so it should never get
that far.
The theme
Tailor wears the project's theme. guise reads its colours from an app-wide global at the moment a component paints, not at the moment you build it, so there is no way to scope a second theme to the canvas without it leaking.
Rather than fight that, switching the project to light switches the editor to light — which is also the most honest preview a builder can give you. The panels keep a neutral graphite surface ramp so they never read as part of the design.
The document inspector's Theme section sets scheme, primary colour, radius and
font; theme.rs in the export is that choice, as code.
What runs where
Everything an edit causes that is not drawing happens off the main thread, on gpui's background executor — the same arrangement Zed uses for its own derived state.
- The project is shared, not copied. It lives behind an
Arc, so an undo snapshot and the canvas's view of it are refcount bumps rather than deep copies. Editing goes throughArc::make_mut, which pays for exactly one copy per edit — the one undo needs anyway — instead of one per commit and one per frame. An idle or hovering frame copies nothing at all. - Regenerating the Rust and running the lint happen on a background thread
against that shared project, debounced by 120 ms, and are applied only if no
newer edit has landed. Every refresh bumps a revision; a result carrying an
old one is dropped, and the held
Taskcancels work nobody is waiting for. - Autosave is debounced by 600 ms and both serializes and writes in the background, so a burst of typing costs one file write rather than one per keystroke.
- Export generates every document and writes the crate in the background; the window keeps drawing while it runs.
- The file watcher stats, reads, and parses off the main thread too.
Measured on a debug build of a 3,744-node project, one keystroke used to cost about 7.9 ms on the main thread — half a frame, before drawing anything. It now costs about 2.4 ms, which is the copy undo requires and nothing else.
The exception is the entity cache: a text field or a picker on the canvas is a gpui entity, and entities can only be built on the main thread. It is also the cheap part.