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:

  1. State first, as locals. A field built afterwards can read a signal, and there is no self yet to read it from.
  2. Then the entities, in build order — a field that captures another field is built after the one it captures.
  3. Then the bindings, because X::bind needs both sides to exist.
  4. 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 Styled methods. Reads like the rest of an app.
  • macros — the same layout through style! { … } blocks and the row! / col! macros, and animation through motion! { … }.

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
├── theme.json            # …and its source, when the project carries one
├── api.rs                # a module you added — written once, then yours
└── 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.

Modules you write#

An action's body is where a control's code goes. Everything else an app is made of — a data type, an API client, a parser — goes in a module you add: Your modules in the Generator section of the Document inspector, one name per line.

Each one is declared by main.rs and created once:

mod ui;
mod theme;
mod api;

The first export writes src/api.rs with a one-line comment saying it is yours, and no export ever writes it again. That is the whole contract, and it is what makes the crate somewhere you can build an app rather than a folder Tailor keeps flattening. An export reports them as kept.

Reach one from an action the ordinary way:

pub fn submit(&mut self, cx: &mut Context<Self>) {
    let who = crate::api::greeting();
    self.email.set(cx, who);
    cx.notify();
}

ui, theme and main are refused — those are the generator's, and a second mod theme; would not compile. Names are snake-cased, so "API client" becomes api_client.

The code pane shows these files as they are on disk, not as the scaffold: once the file exists it is yours, and showing the starting point instead would be showing something nobody has.

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 it, and theme.rs in the export is that choice as code. There are three ways to set it, and they resolve in this order:

  1. A theme file. Theme file → Load… reads a guise JSON theme and stores it inline in the .tailor file. A project is one file you can mail to someone; a path to a theme beside it would be a second file to lose, and one that only resolves on the machine that picked it. The file is parsed when you load it, so a bad one is a message then rather than a surprise at export. It exports as src/theme.json, and theme.rs reads it with Theme::from_json(include_str!("theme.json")).
  2. A preset. Base offers guise's six prebuilt themes — Catppuccin, Nord, Tokyo Night, Gruvbox, Dracula, Solarized Light. theme.rs gets Theme::dracula().
  3. Scheme and primary colour. The plain case: Theme::dark() plus a primary_color.

A preset or a theme file is a scheme and a palette, so the Scheme and Primary controls disappear while one is set rather than sitting there doing nothing. Radius and font are orthogonal to a palette and apply either way.

Tailor's own chrome is a fourth thing, set in Settings rather than in the document: the start screen follows dark, light, or system. All four go through one guise::ThemeManager, which is the only thing that writes the Theme global — two writers is how a picker and a toggle end up disagreeing about what the app is wearing.

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 through Arc::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 Task cancels 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.