Vectrum
Sign In
1404 lines · 62.6 KB · Rust
//! ` ```openui ` blocks as native UI. The program is parsed on the Markdown
//! parser's thread and evaluated on every render against the block's state,
//! then the value tree is drawn with MetalUI's pieces: cards, wells, caps.
//!
//! A block can only compose these components, open http(s) links and send
//! text to the agent, both after a click. Remote images load only when the
//! user asks, so an answer can't carry a tracking pixel.

use std::cell::Cell;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read as _;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;

use gpui_kit::component::{
    Icon, IconName, h_flex,
    chart::{AreaChart, PieChart},
    input::{Input, InputEvent, InputState, Textarea, TextareaState},
    menu::{DropdownMenu as _, PopupMenuItem},
    slider::{Slider, SliderEvent, SliderState, SliderValue},
    text::TextView,
    tooltip::Tooltip,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::{Body, Fence, InlineCx, Key, Store, skeleton};
use crate::inline_ui::eval::{self, Effect, Value};
use crate::inline_ui::lang::Program;
use crate::settings::{self, FollowUp};
use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};

/// Past these a block is more than an answer should hold, and renders an
/// error instead.
const MAX_NODES: usize = 2000;
const MAX_CELLS: usize = 10_000;
const CHART_HEIGHT: f32 = 220.;
/// The most a remote or inline image may weigh.
const MAX_IMAGE_BYTES: u64 = 8 << 20;

static NULL: Value = Value::Null;

/// What an OpenUI block remembers between renders. Kept in the global
/// store, since transcript rows forget their element state when they
/// scroll away.
#[derive(Clone, Default)]
pub(super) struct UiState {
    /// `$variables` the user changed; the rest keep their declared defaults.
    state: eval::State,
    /// Values of inputs named by a string, by form and name.
    fields: BTreeMap<(String, String), Value>,
    /// The open tab of each `Tabs`, by its id.
    tabs: HashMap<String, String>,
    /// Remote images the user chose to load.
    images: HashSet<String>,
    /// Validation messages from the last submit, by binding.
    errors: HashMap<String, String>,
    /// A line under the block: what was sent, or what couldn't be done.
    notice: Option<(bool, String)>,
}

/// Where an input keeps its value.
#[derive(Clone, Debug, PartialEq)]
enum Binding {
    /// `$name`, shared with every expression that reads it.
    Var(String),
    /// A plain name, kept with the enclosing form (or none).
    Field(String, String),
}

impl Binding {
    fn key(&self) -> String {
        match self {
            Binding::Var(name) => format!("${name}"),
            Binding::Field(form, name) => format!("{form}/{name}"),
        }
    }

    fn name(&self) -> &str {
        match self {
            Binding::Var(name) | Binding::Field(_, name) => name,
        }
    }
}

/// An input inside a form, for collecting and checking it on submit.
#[derive(Clone)]
struct FormField {
    binding: Binding,
    rules: Vec<String>,
}

pub(super) fn render(id: &ElementId, key: Key, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body {
    let p = metal::palette(cx);
    let Some(program) = fence.program.clone() else {
        return Body::Failed("OpenUI: nothing to render".into());
    };
    if fence.closed {
        if let Some(error) = program.errors.first() {
            return Body::Failed(format!("OpenUI: {error}"));
        }
        let missing = program.undefined();
        if !missing.is_empty() {
            return Body::Failed(format!("OpenUI: `{}` is never defined", missing.join("`, `")));
        }
    }
    if program.root().is_none() {
        return Body::Ready(skeleton("Building UI…", px(120.), p));
    }
    let ui = cx.try_global::<Store>().and_then(|store| store.blocks.get(&key)).map(|block| block.ui.clone()).unwrap_or_default();
    let root = eval::evaluate(&program, &ui.state);
    let mut renderer = Renderer {
        prefix: id.to_string(),
        key,
        program,
        icx: icx.clone(),
        p,
        ui,
        nodes: 0,
        cells: 0,
        counter: 0,
        form: None,
        forms: HashMap::new(),
    };
    let element = renderer.node(&root, window, cx);
    if renderer.nodes > MAX_NODES {
        return Body::Failed(format!("OpenUI: more than {MAX_NODES} components"));
    }
    if renderer.cells > MAX_CELLS {
        return Body::Failed(format!("OpenUI: more than {MAX_CELLS} table cells"));
    }
    let carded = matches!(&root, Value::Component { name, .. } if name == "Card");
    let notice = renderer.ui.notice.clone();
    let block = v_flex()
        .w_full()
        .gap(px(6.))
        .child(if carded {
            element
        } else {
            v_flex().w_full().p(px(16.)).rounded(px(14.)).region(p).child(element).into_any_element()
        })
        .when_some(notice, |this, (error, text)| {
            this.child(
                h_flex()
                    .px(px(4.))
                    .gap(px(6.))
                    .text_size(px(12.5))
                    .text_color(if error { p.red } else { p.ink3 })
                    .child(Icon::new(if error { IconName::TriangleAlert } else { IconName::Check }).size(px(13.)))
                    .child(div().min_w_0().truncate().child(text)),
            )
        });
    Body::Ready(block.into_any_element())
}

struct Renderer {
    prefix: String,
    key: Key,
    program: Arc<Program>,
    icx: InlineCx,
    p: &'static Palette,
    ui: UiState,
    nodes: usize,
    cells: usize,
    counter: usize,
    /// The form being rendered, which plain-named inputs belong to.
    form: Option<String>,
    forms: HashMap<String, Vec<FormField>>,
}

fn arg(args: &[Value], ix: usize) -> &Value {
    args.get(ix).unwrap_or(&NULL)
}

/// A string argument, or `default` when it's missing or not text.
fn text_arg(args: &[Value], ix: usize, default: &str) -> String {
    match arg(args, ix).get() {
        Value::Null | Value::Pending => default.to_string(),
        value => value.text(),
    }
}

fn items(value: &Value) -> Vec<Value> {
    match value.get() {
        Value::Array(items) => items.clone(),
        Value::Null => Vec::new(),
        other => vec![other.clone()],
    }
}

fn gap(value: &Value) -> Pixels {
    px(match value.text().as_str() {
        "none" => 0.,
        "xs" => 4.,
        "s" | "sm" | "small" => 8.,
        "l" | "lg" | "large" => 20.,
        "xl" => 28.,
        _ => 12.,
    })
}

impl Renderer {
    fn id(&mut self, kind: &str) -> ElementId {
        self.counter += 1;
        ElementId::Name(format!("{}-{kind}-{}", self.prefix, self.counter).into())
    }

    fn binding_id(&self, kind: &str, binding: &Binding) -> ElementId {
        ElementId::Name(format!("{}-{kind}-{}", self.prefix, binding.key()).into())
    }

    fn node(&mut self, value: &Value, window: &mut Window, cx: &mut App) -> AnyElement {
        self.nodes += 1;
        if self.nodes > MAX_NODES {
            return div().into_any_element();
        }
        let p = self.p;
        match value.get() {
            Value::Null | Value::Action(_) => div().into_any_element(),
            Value::Pending => div().h(px(18.)).w(relative(0.6)).rounded(px(6.)).region(p).into_any_element(),
            Value::Error(error) => div().text_size(px(12.5)).text_color(p.red).child(error.clone()).into_any_element(),
            Value::Array(items) => {
                let children: Vec<AnyElement> = items.iter().map(|item| self.node(item, window, cx)).collect();
                v_flex().w_full().gap(px(12.)).children(children).into_any_element()
            }
            Value::Component { name, args } => self.component(name, args, window, cx),
            value @ (Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Object(_)) => {
                div().text_color(p.ink).child(value.text()).into_any_element()
            }
            Value::State(..) => unreachable!("`get` sees through state"),
        }
    }

    fn children(&mut self, value: &Value, window: &mut Window, cx: &mut App) -> Vec<AnyElement> {
        items(value).iter().map(|item| self.node(item, window, cx)).collect()
    }

    fn component(&mut self, name: &str, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
        let p = self.p;
        match name {
            "Stack" | "Card" => {
                let card = name == "Card";
                let (direction, spacing, align, justify, wrap) = if card {
                    (arg(args, 2), arg(args, 3), arg(args, 4), arg(args, 5), arg(args, 6))
                } else {
                    (arg(args, 1), arg(args, 2), arg(args, 3), arg(args, 4), arg(args, 5))
                };
                let row = matches!(direction.text().as_str(), "row" | "horizontal");
                let children = items(arg(args, 0));
                let rendered: Vec<AnyElement> = children
                    .iter()
                    .map(|child| {
                        let element = self.node(child, window, cx);
                        // Cards and tiles in a row share it; buttons and tags keep their size.
                        let grows = matches!(child.get(), Value::Component { name, .. } if matches!(name.as_str(),
                            "Card" | "Stat" | "Stack" | "Table" | "Callout" | "FormControl" | "BarChart" | "LineChart" | "AreaChart" | "PieChart" | "Form"));
                        if row && grows { div().flex_1().min_w(px(160.)).child(element).into_any_element() } else { element }
                    })
                    .collect();
                let base = if row { h_flex().items_start() } else { v_flex() };
                base.w_full()
                    .gap(gap(spacing))
                    .when(wrap.truthy(), |this| this.flex_wrap())
                    .map(|this| match align.text().as_str() {
                        "center" => this.items_center(),
                        "end" => this.items_end(),
                        "start" => this.items_start(),
                        _ => this,
                    })
                    .map(|this| match justify.text().as_str() {
                        "center" => this.justify_center(),
                        "end" => this.justify_end(),
                        "between" | "space-between" => this.justify_between(),
                        "around" | "space-around" => this.justify_around(),
                        _ => this,
                    })
                    .when(card, |this| {
                        this.p(px(16.)).rounded(px(14.)).map(|this| match arg(args, 1).text().as_str() {
                            "well" | "sunk" | "inset" | "sunken" => this.well(p),
                            "plain" | "flat" | "clear" | "ghost" | "transparent" => this,
                            "outline" | "outlined" | "bordered" => this.border_1().border_color(p.rule),
                            _ => this.raised(p),
                        })
                    })
                    .children(rendered)
                    .into_any_element()
            }
            "CardHeader" => v_flex()
                .gap(px(2.))
                .child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(text_arg(args, 0, "")))
                .when(!arg(args, 1).get().eq(&Value::Null), |this| {
                    this.child(div().text_size(px(13.)).text_color(p.ink3).child(text_arg(args, 1, "")))
                })
                .into_any_element(),
            "TextContent" | "Text" | "Heading" | "Label" => {
                let size = if name == "Heading" { "large-heavy".to_string() } else { text_arg(args, 1, "default") };
                let text = div().text_color(p.ink).child(text_arg(args, 0, ""));
                match size.as_str() {
                    "small" | "sm" | "caption" => text.text_size(px(12.5)).text_color(p.ink2),
                    "large" | "lg" => text.text_size(px(17.)).font_weight(FontWeight::MEDIUM),
                    "large-heavy" | "heading" | "title" | "xl" => text.text_size(px(20.)).line_height(px(26.)).font_weight(FontWeight::SEMIBOLD),
                    _ => text,
                }
                .into_any_element()
            }
            "Markdown" => TextView::markdown(self.id("markdown"), text_arg(args, 0, ""))
                .style(crate::ui::chat::markdown_style(p))
                .selectable(true)
                .into_any_element(),
            "CodeBlock" | "Code" => {
                let fence = format!("```{}\n{}\n```", text_arg(args, 0, ""), text_arg(args, 1, "").trim_end());
                TextView::markdown(self.id("code"), fence).style(crate::ui::chat::markdown_style(p)).selectable(true).into_any_element()
            }
            "Callout" | "Alert" => {
                let variant = text_arg(args, 0, "info");
                let (color, icon) = match variant.as_str() {
                    "success" => (p.green_ink, IconName::CircleCheck),
                    "warning" | "warn" => (p.amber, IconName::TriangleAlert),
                    "error" | "danger" | "destructive" => (p.red, IconName::CircleX),
                    _ => (p.blue_ink, IconName::Info),
                };
                h_flex()
                    .w_full()
                    .items_start()
                    .gap(px(10.))
                    .p(px(12.))
                    .rounded(px(12.))
                    .bg(color.opacity(if p.dark { 0.12 } else { 0.08 }))
                    .border_1()
                    .border_color(color.opacity(0.3))
                    .child(div().pt(px(2.)).child(Icon::new(icon).size(px(16.)).text_color(color)))
                    .child(
                        v_flex()
                            .flex_1()
                            .min_w_0()
                            .gap(px(2.))
                            .child(div().text_color(p.ink).font_weight(FontWeight::MEDIUM).child(text_arg(args, 1, "")))
                            .when(!matches!(arg(args, 2).get(), Value::Null), |this| {
                                this.child(div().text_size(px(13.)).text_color(p.ink2).child(text_arg(args, 2, "")))
                            }),
                    )
                    .into_any_element()
            }
            "Tag" | "Badge" => {
                let variant = text_arg(args, 1, "");
                let color = match variant.as_str() {
                    "success" | "green" => Some(p.green_ink),
                    "warning" | "amber" | "yellow" => Some(p.amber),
                    "error" | "danger" | "red" => Some(p.red),
                    "info" | "blue" => Some(p.blue_ink),
                    _ => None,
                };
                div()
                    .flex()
                    .child(metal::tag(text_arg(args, 0, ""), p).when_some(color, |this, color| this.text_color(color)))
                    .into_any_element()
            }
            "Separator" | "Divider" => metal::rule(p).w_full().into_any_element(),
            "Progress" => {
                let raw = arg(args, 0).as_number().unwrap_or(0.);
                let fraction = (if raw > 1. { raw / 100. } else { raw }).clamp(0., 1.);
                v_flex()
                    .w_full()
                    .gap(px(6.))
                    .child(
                        h_flex()
                            .text_size(px(12.5))
                            .text_color(p.ink2)
                            .child(div().flex_1().child(text_arg(args, 1, "")))
                            .child(div().font_family(metal::MONO).child(format!("{:.0}%", fraction * 100.))),
                    )
                    .child(
                        div()
                            .w_full()
                            .h(px(8.))
                            .rounded_full()
                            .well(p)
                            .child(div().h_full().w(relative(fraction as f32)).rounded_full().bg(metal::vertical(p.primary))),
                    )
                    .into_any_element()
            }
            "Stat" | "Metric" | "KPI" => {
                let delta = arg(args, 2).get().clone();
                let delta_text = delta.text();
                let delta_color = match delta.as_number() {
                    Some(number) if number < 0. => p.red,
                    Some(_) => p.green_ink,
                    None if delta_text.trim_start().starts_with('-') => p.red,
                    None => p.green_ink,
                };
                v_flex()
                    .gap(px(4.))
                    .p(px(14.))
                    .rounded(px(12.))
                    .raised(p)
                    .child(metal::engraved(text_arg(args, 0, ""), p))
                    .child(div().text_size(px(24.)).line_height(px(30.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(text_arg(args, 1, "")))
                    .when(!delta_text.is_empty(), |this| this.child(div().text_size(px(12.5)).text_color(delta_color).child(delta_text)))
                    .into_any_element()
            }
            "Image" => self.image(text_arg(args, 0, ""), text_arg(args, 1, ""), cx),
            "Table" => self.table(args, window, cx),
            "BarChart" => self.bar_chart(args),
            "LineChart" | "AreaChart" => self.area_chart(name == "AreaChart", args),
            "PieChart" | "DonutChart" => self.pie_chart(args, name == "DonutChart"),
            "Tabs" => self.tabs(args, window, cx),
            "Form" => {
                let form = text_arg(args, 0, "form");
                let outer = self.form.replace(form.clone());
                let fields = self.children(arg(args, 1), window, cx);
                let buttons = self.node(arg(args, 2), window, cx);
                self.form = outer;
                v_flex().w_full().gap(px(14.)).children(fields).child(buttons).into_any_element()
            }
            "FormControl" | "Field" => {
                let input = self.node(arg(args, 1), window, cx);
                let error = match arg(args, 1).get() {
                    Value::Component { args: inner, .. } => {
                        let binding = self.binding(inner);
                        binding.and_then(|binding| self.ui.errors.get(&binding.key()).cloned())
                    }
                    _ => None,
                };
                v_flex()
                    .w_full()
                    .gap(px(6.))
                    .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(text_arg(args, 0, "")))
                    .child(input)
                    .map(|this| match error {
                        Some(error) => this.child(div().text_size(px(12.)).text_color(p.red).child(error)),
                        None => this.when(!matches!(arg(args, 2).get(), Value::Null), |this| {
                            this.child(div().text_size(px(12.)).text_color(p.ink3).child(text_arg(args, 2, "")))
                        }),
                    })
                    .into_any_element()
            }
            "Input" | "TextInput" | "TextArea" | "Textarea" => {
                let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Input needs a name".into()), window, cx) };
                let shift = self.shift(args);
                let rules = items(arg(args, 3 + shift)).iter().map(Value::text).collect();
                self.register(&binding, rules);
                let multiline = matches!(name, "TextArea" | "Textarea");
                let placeholder = text_arg(args, 1 + shift, "");
                if multiline {
                    let rows = arg(args, 2 + shift).as_number().unwrap_or(3.).clamp(1., 20.) as usize;
                    self.textarea(binding, placeholder, rows, window, cx)
                } else {
                    let kind = text_arg(args, 2 + shift, "text");
                    self.input(binding, placeholder, kind, window, cx)
                }
            }
            "Select" | "Dropdown" | "RadioGroup" => {
                let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Select needs a name".into()), window, cx) };
                let shift = self.shift(args);
                self.register(&binding, items(arg(args, 3 + shift)).iter().map(Value::text).collect());
                self.select(binding, arg(args, 1 + shift), text_arg(args, 2 + shift, "Choose…"))
            }
            "Checkbox" | "Switch" | "Toggle" => {
                let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Checkbox needs a name".into()), window, cx) };
                let shift = self.shift(args);
                self.register(&binding, Vec::new());
                self.check(name == "Checkbox", binding, text_arg(args, 1 + shift, ""))
            }
            "Slider" => {
                let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Slider needs a name".into()), window, cx) };
                let shift = self.shift(args);
                self.register(&binding, Vec::new());
                let min = arg(args, 1 + shift).as_number().unwrap_or(0.);
                let max = arg(args, 2 + shift).as_number().unwrap_or(100.).max(min + f64::EPSILON);
                let step = arg(args, 3 + shift).as_number().filter(|step| *step > 0.).unwrap_or((max - min) / 100.);
                self.slider(binding, min, max, step, window, cx)
            }
            "Buttons" | "ButtonGroup" => {
                let column = matches!(text_arg(args, 1, "row").as_str(), "column" | "vertical");
                let buttons = self.children(arg(args, 0), window, cx);
                if column {
                    v_flex().items_start().gap(px(8.)).children(buttons).into_any_element()
                } else {
                    h_flex().flex_wrap().gap(px(8.)).children(buttons).into_any_element()
                }
            }
            "Button" => self.button(args),
            "Query" | "Mutation" => div()
                .text_size(px(12.5))
                .text_color(p.ink3)
                .child(format!("{name} isn't supported in Vectrum Code"))
                .into_any_element(),
            _ => {
                // An unknown component keeps its name and its children, so new
                // names degrade softly.
                let nested: Vec<Value> = args
                    .iter()
                    .filter(|arg| matches!(arg.get(), Value::Component { .. } | Value::Array(_)))
                    .cloned()
                    .collect();
                let texts: Vec<String> = args.iter().filter_map(|arg| arg.as_str().map(str::to_string)).collect();
                let children: Vec<AnyElement> = nested.iter().map(|child| self.node(child, window, cx)).collect();
                v_flex()
                    .w_full()
                    .gap(px(8.))
                    .child(h_flex().gap(px(8.)).child(metal::tag(name.to_string(), p)).children(texts.into_iter().take(2).map(|text| {
                        div().text_size(px(13.)).text_color(p.ink2).child(text)
                    })))
                    .children(children)
                    .into_any_element()
            }
        }
    }

    /// How many places an input's arguments move right. OpenUI's own docs
    /// also write `Select("days", $days, [...])`, a name then the binding.
    fn shift(&self, args: &[Value]) -> usize {
        usize::from(!matches!(arg(args, 0), Value::State(..)) && matches!(arg(args, 1), Value::State(..)))
    }

    /// Where an input's value lives: a `$var` handed to it, else its name in
    /// the enclosing form.
    fn binding(&self, args: &[Value]) -> Option<Binding> {
        if let Value::State(name, _) = arg(args, 0) {
            return Some(Binding::Var(name.clone()));
        }
        if let Value::State(name, _) = arg(args, 1) {
            return Some(Binding::Var(name.clone()));
        }
        let name = arg(args, 0).as_str()?.trim().to_string();
        (!name.is_empty()).then(|| Binding::Field(self.form.clone().unwrap_or_default(), name))
    }

    fn register(&mut self, binding: &Binding, rules: Vec<String>) {
        if let Some(form) = &self.form {
            let fields = self.forms.entry(form.clone()).or_default();
            if !fields.iter().any(|field| field.binding == *binding) {
                fields.push(FormField { binding: binding.clone(), rules });
            }
        }
    }

    fn value(&self, binding: &Binding) -> Value {
        match binding {
            Binding::Var(name) => eval::state_value(&self.program, &self.ui.state, name),
            Binding::Field(form, name) => self.ui.fields.get(&(form.clone(), name.clone())).cloned().unwrap_or(Value::Null),
        }
    }

    /// What event handlers need to change the block's state.
    fn setter(&self) -> Setter {
        Setter { key: self.key, icx: self.icx.clone() }
    }

    fn input(&mut self, binding: Binding, placeholder: String, kind: String, window: &mut Window, cx: &mut App) -> AnyElement {
        let current = self.value(&binding).text();
        let created = Rc::new(Cell::new(false));
        let state = window.use_keyed_state(self.binding_id("input", &binding), cx, {
            let (created, current) = (created.clone(), current.clone());
            move |window, cx| {
                created.set(true);
                InputState::new(window, cx).placeholder(placeholder).masked(kind == "password").default_value(current)
            }
        });
        if created.get() {
            let setter = self.setter();
            let binding = binding.clone();
            cx.subscribe(&state, move |state, event: &InputEvent, cx| {
                if matches!(event, InputEvent::Change) {
                    let text = state.read(cx).value().to_string();
                    setter.set(&binding, typed(text), cx);
                }
            })
            .detach();
        } else if state.read(cx).value() != current && !state.read(cx).focus_handle(cx).is_focused(window) {
            // Changed by an action, like `@Reset`.
            state.update(cx, |state, cx| state.set_value(current, window, cx));
        }
        h_flex()
            .w_full()
            .h(px(32.))
            .px(px(10.))
            .rounded(px(10.))
            .well(self.p)
            .text_size(px(13.5))
            .text_color(self.p.ink)
            .child(div().flex_1().min_w_0().child(Input::new(&state).appearance(false)))
            .into_any_element()
    }

    fn textarea(&mut self, binding: Binding, placeholder: String, rows: usize, window: &mut Window, cx: &mut App) -> AnyElement {
        let current = self.value(&binding).text();
        let created = Rc::new(Cell::new(false));
        let state = window.use_keyed_state(self.binding_id("textarea", &binding), cx, {
            let (created, current) = (created.clone(), current.clone());
            move |window, cx| {
                created.set(true);
                TextareaState::new(window, cx).rows(rows).placeholder(placeholder).default_value(current)
            }
        });
        if created.get() {
            let setter = self.setter();
            let binding = binding.clone();
            cx.subscribe(&state, move |state, event: &InputEvent, cx| {
                if matches!(event, InputEvent::Change) {
                    let text = state.read(cx).value().to_string();
                    setter.set(&binding, Value::String(text), cx);
                }
            })
            .detach();
        } else if state.read(cx).value() != current && !state.read(cx).focus_handle(cx).is_focused(window) {
            state.update(cx, |state, cx| state.set_value(current, window, cx));
        }
        div()
            .w_full()
            .px(px(10.))
            .py(px(6.))
            .rounded(px(10.))
            .well(self.p)
            .text_size(px(13.5))
            .text_color(self.p.ink)
            .child(Textarea::new(&state).appearance(false))
            .into_any_element()
    }

    fn select(&mut self, binding: Binding, options: &Value, placeholder: String) -> AnyElement {
        let options: Vec<(String, Value)> = items(options)
            .into_iter()
            .map(|option| match &option {
                Value::Object(fields) => {
                    let value = fields.get("value").or(fields.get("label")).cloned().unwrap_or(Value::Null);
                    let label = fields.get("label").map(Value::text).unwrap_or_else(|| value.text());
                    (label, value)
                }
                other => (other.text(), other.clone()),
            })
            .collect();
        let current = self.value(&binding);
        let label = options
            .iter()
            .find(|(_, value)| eval_equals(value, &current))
            .map(|(label, _)| label.clone())
            .unwrap_or(placeholder);
        let setter = self.setter();
        let id = self.binding_id("select", &binding);
        div()
            .w_full()
            .child(MetalButton::new(id).label(label).caret().stretch().dropdown_menu(move |menu, _, _| {
                options.iter().fold(menu, |menu, (label, value)| {
                    let (setter, binding, value) = (setter.clone(), binding.clone(), value.clone());
                    menu.item(PopupMenuItem::new(label.clone()).checked(eval_equals(&value, &current)).on_click(move |_, _, cx| {
                        setter.set(&binding, value.clone(), cx);
                    }))
                })
            }))
            .into_any_element()
    }

    fn check(&mut self, checkbox: bool, binding: Binding, label: String) -> AnyElement {
        let on = self.value(&binding).truthy();
        let setter = self.setter();
        if checkbox {
            // A MetalUI box: a small well that lights up when checked.
            let p = self.p;
            return h_flex()
                .id(self.binding_id("check", &binding))
                .gap(px(10.))
                .cursor_pointer()
                .on_click(move |_, _, cx| setter.set(&binding, Value::Bool(!on), cx))
                .child(
                    div()
                        .flex()
                        .flex_none()
                        .items_center()
                        .justify_center()
                        .size(px(18.))
                        .rounded(px(5.))
                        .map(|this| if on { this.bg(metal::vertical(p.primary)).shadow(p.primary_sh.clone()) } else { this.well(p) })
                        .when(on, |this| this.child(Icon::new(IconName::Check).size(px(13.)).text_color(p.primary_ink))),
                )
                .child(div().text_size(px(13.5)).text_color(p.ink).child(label))
                .into_any_element();
        }
        let id = self.binding_id("switch", &binding);
        h_flex()
            .gap(px(10.))
            .child(metal::toggle(id, on, self.p, move |on, _, cx| setter.set(&binding, Value::Bool(on), cx)))
            .child(div().text_size(px(13.5)).text_color(self.p.ink).child(label))
            .into_any_element()
    }

    fn slider(&mut self, binding: Binding, min: f64, max: f64, step: f64, window: &mut Window, cx: &mut App) -> AnyElement {
        let current = self.value(&binding).as_number().unwrap_or(min).clamp(min, max);
        let created = Rc::new(Cell::new(false));
        let state = window.use_keyed_state(self.binding_id("slider", &binding), cx, {
            let created = created.clone();
            move |_, _| {
                created.set(true);
                SliderState::new().min(min as f32).max(max as f32).step(step as f32).default_value(current as f32)
            }
        });
        if created.get() {
            let setter = self.setter();
            let binding = binding.clone();
            cx.subscribe(&state, move |_, event: &SliderEvent, cx| {
                if let SliderEvent::Change(SliderValue::Single(value)) = event {
                    // Steps are floats; keep `0.1 * 3` reading as 0.3.
                    let value = (*value as f64 / step).round() * step;
                    setter.set(&binding, Value::Number((value * 1e9).round() / 1e9), cx);
                }
            })
            .detach();
        } else if (f64::from(match state.read(cx).value() {
            SliderValue::Single(value) | SliderValue::Range(_, value) => value,
        }) - current)
            .abs()
            > step / 2.
        {
            state.update(cx, |state, cx| state.set_value(current as f32, window, cx));
        }
        h_flex()
            .w_full()
            .gap(px(12.))
            .child(div().flex_1().child(Slider::new(&state)))
            .child(div().min_w(px(40.)).text_size(px(13.)).font_family(metal::MONO).text_color(self.p.ink2).child(eval::format_number(current)))
            .into_any_element()
    }

    fn button(&mut self, args: &[Value]) -> AnyElement {
        let label = text_arg(args, 0, "Button");
        let action = arg(args, 1).get().clone();
        let variant = text_arg(args, 2, "secondary");
        let id = self.id("button");
        let handler = ActionHandler {
            setter: self.setter(),
            program: self.program.clone(),
            form: self.form.clone(),
            forms: self.forms.clone(),
        };
        MetalButton::new(id)
            .label(label)
            .map(|button| match variant.as_str() {
                "primary" | "default" => button.primary(),
                "danger" | "destructive" => button.danger(),
                "ghost" | "link" | "tertiary" => button.ghost(),
                _ => button,
            })
            .on_click(move |_, window, cx| handler.run(&action, window, cx))
            .into_any_element()
    }

    fn tabs(&mut self, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
        let p = self.p;
        let tabs: Vec<(String, String, Value)> = items(arg(args, 0))
            .into_iter()
            .filter_map(|item| match item {
                Value::Component { args, .. } => {
                    let value = text_arg(&args, 0, "");
                    let trigger = text_arg(&args, 1, &value);
                    Some((value, trigger, arg(&args, 2).clone()))
                }
                _ => None,
            })
            .collect();
        let id = self.id("tabs");
        let tabs_key = id.to_string();
        let selected = self.ui.tabs.get(&tabs_key).filter(|value| tabs.iter().any(|(tab, ..)| tab == *value)).cloned();
        let selected = selected.or_else(|| tabs.first().map(|(value, ..)| value.clone())).unwrap_or_default();
        let content = tabs.iter().find(|(value, ..)| *value == selected).map(|(_, _, content)| content.clone()).unwrap_or(Value::Null);
        let content = self.node(&content, window, cx);
        let setter = self.setter();
        v_flex()
            .w_full()
            .gap(px(12.))
            .child(
                h_flex().child(
                    h_flex().flex_wrap().p(px(3.)).gap(px(2.)).rounded(px(15.)).well(p).children(tabs.iter().enumerate().map(|(ix, (value, trigger, _))| {
                        let on = *value == selected;
                        let (setter, tabs_key, value) = (setter.clone(), tabs_key.clone(), value.clone());
                        div()
                            .id(ElementId::Name(format!("{id}-{ix}").into()))
                            .flex()
                            .items_center()
                            .h(px(24.))
                            .px(px(12.))
                            .rounded_full()
                            .text_size(px(12.5))
                            .font_weight(FontWeight::MEDIUM)
                            .map(|this| {
                                if on {
                                    this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink)
                                } else {
                                    this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink))
                                }
                            })
                            .on_click(move |_, _, cx| {
                                setter.update(cx, |ui| {
                                    ui.tabs.insert(tabs_key.clone(), value.clone());
                                });
                            })
                            .child(trigger.clone())
                    })),
                ),
            )
            .child(content)
            .into_any_element()
    }

    fn table(&mut self, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
        let p = self.p;
        let columns: Vec<(String, Vec<Value>, String)> = items(arg(args, 0))
            .into_iter()
            .map(|column| match column {
                Value::Component { args, .. } => (text_arg(&args, 0, ""), items(arg(&args, 1)), text_arg(&args, 2, "text")),
                other => (other.text(), Vec::new(), "text".into()),
            })
            .collect();
        let rows = columns.iter().map(|(_, data, _)| data.len()).max().unwrap_or(0);
        self.cells += rows * columns.len();
        if self.cells > MAX_CELLS {
            return div().into_any_element();
        }
        let cell = |this: &mut Self, value: &Value, kind: &str, window: &mut Window, cx: &mut App| -> AnyElement {
            let base = div().flex_1().min_w(px(60.)).px(px(10.)).py(px(7.)).overflow_hidden();
            match (kind, value.get()) {
                (_, Value::Component { .. }) => base.child(this.node(value, window, cx)).into_any_element(),
                ("tag", value) if !value.text().is_empty() => base.child(div().flex().child(metal::tag(value.text(), p))).into_any_element(),
                ("number" | "currency" | "percent", value) => base
                    .flex()
                    .justify_end()
                    .font_family(metal::MONO)
                    .text_size(px(12.5))
                    .child(value.text())
                    .into_any_element(),
                (_, value) => base.child(value.text()).into_any_element(),
            }
        };
        let header = h_flex().w_full().children(columns.iter().map(|(label, _, kind)| {
            div()
                .flex_1()
                .min_w(px(60.))
                .px(px(10.))
                .py(px(8.))
                .flex()
                .when(matches!(kind.as_str(), "number" | "currency" | "percent"), |this| this.justify_end())
                .child(metal::engraved(label.clone(), p))
        }));
        let mut body = Vec::new();
        for row in 0..rows {
            let mut cells = Vec::new();
            for (_, data, kind) in &columns {
                cells.push(cell(self, data.get(row).unwrap_or(&NULL), kind, window, cx));
            }
            body.push(v_flex().w_full().child(metal::rule(p).w_full()).child(h_flex().w_full().items_center().children(cells)));
        }
        v_flex()
            .w_full()
            .rounded(px(12.))
            .well(p)
            .overflow_hidden()
            .text_size(px(13.))
            .text_color(p.ink)
            .child(header)
            .children(body)
            .into_any_element()
    }

    /// Labels, and one `(name, values)` per series: `[Series(name, values)]`,
    /// a single series, or a bare array of numbers.
    fn series(args: &[Value]) -> (Vec<String>, Vec<(String, Vec<f64>)>) {
        let labels: Vec<String> = items(arg(args, 0)).iter().map(Value::text).collect();
        let numbers = |value: &Value| items(value).iter().map(|value| value.as_number().unwrap_or(0.)).collect::<Vec<f64>>();
        let series_value = arg(args, 1).get();
        let list = items(series_value);
        let series = if list.iter().all(|item| item.as_number().is_some()) && !list.is_empty() {
            vec![(String::new(), numbers(series_value))]
        } else {
            list.iter()
                .filter_map(|item| match item.get() {
                    Value::Component { args, .. } => Some((text_arg(args, 0, ""), numbers(arg(args, 1)))),
                    Value::Object(fields) => Some((
                        fields.get("name").map(Value::text).unwrap_or_default(),
                        numbers(fields.get("values").or(fields.get("data")).unwrap_or(&NULL)),
                    )),
                    _ => None,
                })
                .collect()
        };
        (labels, series)
    }

    fn color(&self, ix: usize) -> Hsla {
        self.p.tints[ix % self.p.tints.len()]
    }

    fn legend(&self, names: Vec<String>) -> Option<AnyElement> {
        (names.len() > 1 || names.first().is_some_and(|name| !name.is_empty())).then(|| {
            h_flex()
                .flex_wrap()
                .gap(px(14.))
                .text_size(px(12.))
                .text_color(self.p.ink2)
                .children(names.into_iter().enumerate().map(|(ix, name)| {
                    h_flex().gap(px(6.)).child(div().size(px(8.)).rounded(px(2.)).bg(self.color(ix))).child(name)
                }))
                .into_any_element()
        })
    }

    /// Axis titles around a chart, and its legend.
    fn chart_frame(&self, chart: AnyElement, names: Vec<String>, x_label: String, y_label: String) -> AnyElement {
        let p = self.p;
        v_flex()
            .w_full()
            .gap(px(8.))
            .when(!y_label.is_empty(), |this| this.child(div().text_size(px(12.)).text_color(p.ink3).child(y_label)))
            .child(chart)
            .when(!x_label.is_empty(), |this| this.child(h_flex().justify_center().text_size(px(12.)).text_color(p.ink3).child(x_label)))
            .children(self.legend(names))
            .into_any_element()
    }

    /// Bars drawn as boxes, grouped or stacked, over a light grid. GPUI's
    /// bar chart takes one series, and answers often compare several.
    fn bar_chart(&mut self, args: &[Value]) -> AnyElement {
        let p = self.p;
        let (labels, series) = Self::series(args);
        let stacked = text_arg(args, 2, "grouped") == "stacked";
        let bands = labels.len().max(series.iter().map(|(_, values)| values.len()).max().unwrap_or(0));
        let value = |series: &(String, Vec<f64>), band: usize| series.1.get(band).copied().unwrap_or(0.).max(0.);
        let top = (0..bands)
            .map(|band| {
                let values = series.iter().map(|series| value(series, band));
                if stacked { values.sum::<f64>() } else { values.fold(0., f64::max) }
            })
            .fold(0., f64::max);
        let (_, top, ticks) = nice_scale(0., top);
        let ticks = ticks - 1;
        let id = self.id("bars");
        let plot_height = CHART_HEIGHT - 24.;
        let grid = v_flex().absolute().inset_0().justify_between().children((0..=ticks).map(|_| div().h(px(1.)).w_full().bg(p.rule.opacity(0.6))));
        let axis = v_flex()
            .w(px(44.))
            .h(px(plot_height))
            .justify_between()
            .items_end()
            .pr(px(6.))
            .text_size(px(11.))
            .font_family(metal::MONO)
            .text_color(p.ink3)
            .children((0..=ticks).rev().map(|tick| div().h(px(0.)).flex().items_center().child(short_number(top * tick as f64 / ticks as f64))));
        let bars = h_flex().absolute().inset_0().items_end().px(px(4.)).children((0..bands).map(|band| {
            let tip = |series_ix: usize| {
                let (name, _) = &series[series_ix];
                let label = labels.get(band).cloned().unwrap_or_default();
                let amount = eval::format_number(value(&series[series_ix], band));
                let tooltip: SharedString = if name.is_empty() { format!("{label}: {amount}") } else { format!("{label} · {name}: {amount}") }.into();
                move |window: &mut Window, cx: &mut App| Tooltip::new(tooltip.clone()).build(window, cx)
            };
            let bar_id = |series_ix: usize| ElementId::Name(format!("{id}-{band}-{series_ix}").into());
            let height = |amount: f64| relative((amount / top) as f32);
            let column = if stacked {
                v_flex()
                    .h_full()
                    .w(relative(0.6))
                    .max_w(px(48.))
                    .justify_end()
                    .children((0..series.len()).rev().map(|ix| {
                        div().id(bar_id(ix)).w_full().h(height(value(&series[ix], band))).bg(self.color(ix)).tooltip(tip(ix))
                    }))
                    .rounded_t(px(4.))
                    .overflow_hidden()
                    .into_any_element()
            } else {
                h_flex()
                    .h_full()
                    .w(relative(0.8))
                    .max_w(px(28. * series.len() as f32 + 8.))
                    .items_end()
                    .gap(px(2.))
                    .children((0..series.len()).map(|ix| {
                        div().id(bar_id(ix)).flex_1().h(height(value(&series[ix], band))).rounded_t(px(4.)).bg(self.color(ix)).tooltip(tip(ix))
                    }))
                    .into_any_element()
            };
            h_flex().flex_1().h_full().items_end().justify_center().child(column)
        }));
        let labels_row = h_flex().pl(px(48.)).pr(px(4.)).children((0..bands).map(|band| {
            div()
                .flex_1()
                .min_w_0()
                .flex()
                .justify_center()
                .text_size(px(11.5))
                .text_color(p.ink2)
                .child(div().truncate().child(labels.get(band).cloned().unwrap_or_default()))
        }));
        let chart = v_flex()
            .w_full()
            .gap(px(6.))
            .child(h_flex().w_full().child(axis).child(div().flex_1().h(px(plot_height)).relative().child(grid).child(bars)))
            .child(labels_row)
            .into_any_element();
        let names = series.iter().map(|(name, _)| name.clone()).collect();
        self.chart_frame(chart, names, text_arg(args, 3, ""), text_arg(args, 4, ""))
    }

    fn area_chart(&mut self, area: bool, args: &[Value]) -> AnyElement {
        let (labels, series) = Self::series(args);
        let points = labels.len().max(series.iter().map(|(_, values)| values.len()).max().unwrap_or(0));
        let rows: Vec<(SharedString, Vec<f64>)> = (0..points)
            .map(|ix| {
                let label: SharedString = labels.get(ix).cloned().unwrap_or_default().into();
                (label, series.iter().map(|(_, values)| values.get(ix).copied().unwrap_or(0.)).collect())
            })
            .collect();
        let mut chart = AreaChart::new(rows).id(self.id("area")).x(|row: &(SharedString, Vec<f64>)| row.0.clone());
        // Straight segments: a smoothed curve overshoots, and can dip below
        // zero between points that never do.
        for ix in 0..series.len() {
            let color = self.color(ix);
            chart = chart
                .y(move |row: &(SharedString, Vec<f64>)| row.1.get(ix).copied().unwrap_or(0.))
                .stroke(color)
                .fill(if area { color.opacity(0.18) } else { transparent_black() })
                .linear();
        }
        let top = series.iter().flat_map(|(_, values)| values.iter().copied()).fold(0., f64::max);
        let bottom = series.iter().flat_map(|(_, values)| values.iter().copied()).fold(0., f64::min);
        let (low, high, ticks) = nice_scale(bottom, top);
        let chart = chart.y_axis(true).y_domain(low, high).y_padding(0., 0.).y_tick_count(ticks).y_tick_format(short_number);
        let chart = div().w_full().h(px(CHART_HEIGHT)).child(chart).into_any_element();
        let names = series.iter().map(|(name, _)| name.clone()).collect();
        let (x, y) = (text_arg(args, 2, ""), text_arg(args, 3, ""));
        self.chart_frame(chart, names, x, y)
    }

    fn pie_chart(&mut self, args: &[Value], donut: bool) -> AnyElement {
        let labels: Vec<String> = items(arg(args, 0)).iter().map(Value::text).collect();
        let values: Vec<f64> = items(arg(args, 1)).iter().map(|value| value.as_number().unwrap_or(0.).max(0.)).collect();
        let total: f64 = values.iter().sum();
        let rows: Vec<(String, f64, Hsla)> =
            values.iter().enumerate().map(|(ix, value)| (labels.get(ix).cloned().unwrap_or_default(), *value, self.color(ix))).collect();
        let p = self.p;
        let legend = v_flex().gap(px(6.)).text_size(px(12.5)).text_color(p.ink2).children(rows.iter().map(|(label, value, color)| {
            let share = if total > 0. { value / total * 100. } else { 0. };
            h_flex()
                .gap(px(8.))
                .child(div().size(px(9.)).rounded(px(2.)).bg(*color))
                .child(div().child(label.clone()))
                .child(div().font_family(metal::MONO).text_color(p.ink3).child(format!("{share:.0}%")))
        }));
        let chart = PieChart::new(rows)
            .id(self.id("pie"))
            .value(|row: &(String, f64, Hsla)| row.1 as f32)
            .color(|row: &(String, f64, Hsla)| row.2)
            .outer_radius(90.)
            .when(donut, |chart| chart.inner_radius(54.))
            .tooltip_name(|row: &(String, f64, Hsla)| row.0.clone().into());
        h_flex()
            .w_full()
            .gap(px(20.))
            .flex_wrap()
            .items_center()
            .child(div().size(px(200.)).flex_none().child(chart))
            .child(legend)
            .into_any_element()
    }

    fn image(&mut self, src: String, alt: String, cx: &mut App) -> AnyElement {
        let p = self.p;
        let frame = |child: AnyElement| div().flex().max_w_full().rounded(px(12.)).overflow_hidden().child(child).into_any_element();
        let placeholder = |label: String| {
            h_flex()
                .gap(px(8.))
                .p(px(10.))
                .rounded(px(12.))
                .well(p)
                .text_size(px(12.5))
                .text_color(p.ink3)
                .child(Icon::new(crate::ui::icons::Lucide::Image).size(px(14.)))
                .child(div().min_w_0().truncate().child(label))
        };
        if src.starts_with("data:image/") {
            return match images::inline(&src, cx) {
                Ok(image) => frame(img(image).max_w_full().max_h(px(420.)).into_any_element()),
                Err(error) => placeholder(format!("{alt} ({error})")).into_any_element(),
            };
        }
        if !(src.starts_with("https://") || src.starts_with("http://")) {
            return placeholder(if alt.is_empty() { "Image".into() } else { alt }).into_any_element();
        }
        if self.ui.images.contains(&src) {
            return match images::remote(&src, self.setter().icx, cx) {
                images::Remote::Loading => skeleton("Loading image…", px(120.), p),
                images::Remote::Ready(image) => frame(img(image).max_w_full().max_h(px(420.)).into_any_element()),
                images::Remote::Failed(error) => placeholder(format!("{alt} ({error})")).into_any_element(),
            };
        }
        let host = src.split('/').nth(2).unwrap_or_default().to_string();
        let setter = self.setter();
        let id = self.id("image");
        placeholder(if alt.is_empty() { format!("Image from {host}") } else { format!("{alt} · {host}") })
            .child(MetalButton::new(id).compact().label("Load image").on_click(move |_, _, cx| {
                let src = src.clone();
                setter.update(cx, |ui| {
                    ui.images.insert(src);
                });
            }))
            .into_any_element()
    }
}

/// A value from an input as the type it looks like, so `$qty * 2` works.
/// Text that wouldn't read back the same stays text, so typing `1.` isn't
/// turned into `1` under the cursor.
fn typed(text: String) -> Value {
    match text.trim().parse::<f64>() {
        Ok(number) if eval::format_number(number) == text => Value::Number(number),
        _ => Value::String(text),
    }
}

fn eval_equals(a: &Value, b: &Value) -> bool {
    a.get() == b.get() || (!matches!(a.get(), Value::Null) && a.text() == b.text())
}

/// The next round number at or above `value`, for a chart's top line.
fn nice_ceiling(value: f64) -> f64 {
    if value <= 0. || !value.is_finite() {
        return 1.;
    }
    let magnitude = 10f64.powf(value.log10().floor());
    [1., 2., 2.5, 5., 10.].iter().map(|step| step * magnitude).find(|nice| *nice >= value).unwrap_or(10. * magnitude)
}

/// A chart's value range widened to round numbers, and how many ticks
/// split it evenly: about four steps of 1, 2, 2.5 or 5 times a power of ten.
fn nice_scale(min: f64, max: f64) -> (f64, f64, usize) {
    let (min, max) = (min.min(0.), max.max(0.));
    if max - min <= 0. || !(max - min).is_finite() {
        return (0., 1., 5);
    }
    let step = nice_ceiling((max - min) / 4.);
    let low = (min / step).floor() * step;
    let high = (max / step).ceil() * step;
    (low, high, ((high - low) / step).round() as usize + 1)
}

/// `1200` as `1.2k`, for axis ticks.
fn short_number(value: f64) -> String {
    let (scaled, unit) = match value.abs() {
        v if v >= 1e9 => (value / 1e9, "B"),
        v if v >= 1e6 => (value / 1e6, "M"),
        v if v >= 1e4 => (value / 1e3, "k"),
        _ => (value, ""),
    };
    format!("{}{unit}", eval::format_number((scaled * 10.).round() / 10.))
}

/// Changes a block's state from an event handler, then redraws it.
#[derive(Clone)]
struct Setter {
    key: Key,
    icx: InlineCx,
}

impl Setter {
    fn update<R>(&self, cx: &mut App, change: impl FnOnce(&mut UiState) -> R) -> R {
        let result = change(&mut cx.default_global::<Store>().blocks.entry(self.key).or_default().ui);
        self.icx.remeasure(cx);
        cx.refresh_windows();
        result
    }

    fn set(&self, binding: &Binding, value: Value, cx: &mut App) {
        self.update(cx, |ui| {
            ui.errors.remove(&binding.key());
            match binding {
                Binding::Var(name) => {
                    ui.state.insert(name.clone(), value);
                }
                Binding::Field(form, name) => {
                    ui.fields.insert((form.clone(), name.clone()), value);
                }
            }
        });
    }
}

/// What a button does when clicked.
struct ActionHandler {
    setter: Setter,
    program: Arc<Program>,
    /// The form the button sits in, for a bare `"submit"`.
    form: Option<String>,
    /// The fields of every form rendered before the button.
    forms: HashMap<String, Vec<FormField>>,
}

impl ActionHandler {
    fn run(&self, action: &Value, _: &mut Window, cx: &mut App) {
        match action {
            Value::Action(action) => {
                let program = self.program.clone();
                let effects = self.setter.update(cx, |ui| {
                    ui.notice = None;
                    eval::run(&program, &mut ui.state, action)
                });
                for effect in effects {
                    match effect {
                        Effect::ToAssistant(text) if !text.trim().is_empty() => self.send(text, cx),
                        Effect::ToAssistant(_) => {}
                        Effect::OpenUrl(url) if url.starts_with("https://") || url.starts_with("http://") => {
                            crate::ui::workspace::open_url(&url)
                        }
                        Effect::OpenUrl(url) => self.notice(true, format!("Only web links open from here, not {url:?}"), cx),
                        Effect::Unsupported(step) => self.notice(true, format!("{step} isn't supported in Vectrum Code"), cx),
                    }
                }
            }
            Value::String(action) => {
                let action = action.trim();
                if let Some(form) = action.strip_prefix("submit:").or((action == "submit").then_some("")) {
                    let form = if form.is_empty() { self.form.clone().unwrap_or_default() } else { form.to_string() };
                    self.submit(&form, cx);
                } else if !action.is_empty() {
                    let name = action.strip_prefix("action:").unwrap_or(action);
                    self.send(format!("[UI] action: {name}"), cx);
                }
            }
            _ => {}
        }
    }

    /// Checks a form's rules, then sends its values to the agent.
    fn submit(&self, form: &str, cx: &mut App) {
        let fields = self.forms.get(form).cloned().unwrap_or_default();
        let program = self.program.clone();
        let result = self.setter.update(cx, |ui| {
            let mut values = serde_json::Map::new();
            ui.errors.clear();
            ui.notice = None;
            for field in &fields {
                let value = match &field.binding {
                    Binding::Var(name) => eval::state_value(&program, &ui.state, name),
                    Binding::Field(form, name) => ui.fields.get(&(form.clone(), name.clone())).cloned().unwrap_or(Value::Null),
                };
                let text = value.text();
                for rule in &field.rules {
                    let problem = match rule.as_str() {
                        "required" if text.trim().is_empty() || value.get() == &Value::Bool(false) => Some("Required"),
                        "email" if !text.is_empty() && !(text.contains('@') && text.contains('.')) => Some("Enter an email address"),
                        "number" | "numeric" if !text.is_empty() && value.as_number().is_none() => Some("Enter a number"),
                        _ => None,
                    };
                    if let Some(problem) = problem {
                        ui.errors.entry(field.binding.key()).or_insert_with(|| problem.to_string());
                    }
                }
                values.insert(field.binding.name().to_string(), value.to_json());
            }
            ui.errors.is_empty().then_some(values)
        });
        if let Some(values) = result {
            let json = serde_json::Value::Object(values);
            self.send(format!("[UI] Submitted {}: {json}", serde_json::Value::String(form.to_string())), cx);
        }
    }

    fn notice(&self, error: bool, text: String, cx: &mut App) {
        self.setter.update(cx, |ui| ui.notice = Some((error, text)));
    }

    /// Sends text to the thread's agent as the user, like the composer: while
    /// it works, the follow-up setting decides between steering and queueing.
    fn send(&self, text: String, cx: &mut App) {
        let Some(session) = self.setter.icx.session.upgrade() else { return };
        let queue = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
        let queued = session.update(cx, |session, cx| {
            if (session.is_working() || session.preparing) && queue {
                session.queue_message(text, Vec::new(), cx);
                true
            } else {
                session.send_message(text, Vec::new(), cx);
                false
            }
        });
        self.notice(false, if queued { "Queued for the agent".into() } else { "Sent to the agent".into() }, cx);
        if let Some(scroller) = self.setter.icx.scroller.upgrade() {
            scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
        }
    }
}

/// Images in blocks: data URIs decoded once, and remote images fetched
/// after the user asks.
mod images {
    use super::*;

    pub(super) enum Remote {
        Loading,
        Ready(Arc<Image>),
        Failed(String),
    }

    #[derive(Default)]
    struct Images {
        entries: HashMap<String, Remote>,
    }

    impl Global for Images {}

    fn format(bytes: &[u8]) -> Option<ImageFormat> {
        Some(match bytes {
            [0x89, b'P', b'N', b'G', ..] => ImageFormat::Png,
            [0xff, 0xd8, ..] => ImageFormat::Jpeg,
            [b'G', b'I', b'F', b'8', ..] => ImageFormat::Gif,
            [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => ImageFormat::Webp,
            _ if String::from_utf8_lossy(&bytes[..bytes.len().min(512)]).contains("<svg") => ImageFormat::Svg,
            _ => return None,
        })
    }

    pub(super) fn inline(src: &str, cx: &mut App) -> Result<Arc<Image>, String> {
        let key = format!("{:x}", super::super::hash(src));
        if let Some(Remote::Ready(image)) = cx.default_global::<Images>().entries.get(&key) {
            return Ok(image.clone());
        }
        let (_, data) = src.split_once(',').ok_or("not a data URI")?;
        if data.len() as u64 > MAX_IMAGE_BYTES * 4 / 3 {
            return Err("too large".into());
        }
        use base64::Engine as _;
        let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|_| "not base64")?;
        let format = format(&bytes).ok_or("unknown format")?;
        let image = Arc::new(Image::from_bytes(format, bytes));
        cx.default_global::<Images>().entries.insert(key, Remote::Ready(image.clone()));
        Ok(image)
    }

    pub(super) fn remote(src: &str, icx: InlineCx, cx: &mut App) -> Remote {
        match cx.default_global::<Images>().entries.get(src) {
            Some(Remote::Ready(image)) => return Remote::Ready(image.clone()),
            Some(Remote::Failed(error)) => return Remote::Failed(error.clone()),
            Some(Remote::Loading) => return Remote::Loading,
            None => {}
        }
        cx.default_global::<Images>().entries.insert(src.to_string(), Remote::Loading);
        let url = src.to_string();
        let fetch = cx.background_executor().spawn(async move {
            let response = ureq::get(&url).timeout(Duration::from_secs(20)).call().map_err(|err| err.to_string())?;
            let mut bytes = Vec::new();
            response.into_reader().take(MAX_IMAGE_BYTES).read_to_end(&mut bytes).map_err(|err| err.to_string())?;
            let format = format(&bytes).ok_or("not an image")?;
            Ok::<_, String>(Image::from_bytes(format, bytes))
        });
        let src = src.to_string();
        cx.spawn(async move |cx| {
            let result = fetch.await;
            cx.update(|cx| {
                let entry = match result {
                    Ok(image) => Remote::Ready(Arc::new(image)),
                    Err(error) => Remote::Failed(error),
                };
                cx.default_global::<Images>().entries.insert(src, entry);
                icx.remeasure(cx);
                cx.refresh_windows();
            });
        })
        .detach();
        Remote::Loading
    }
}

#[cfg(test)]
mod tests {
    use super::{nice_ceiling, short_number, typed};
    use crate::inline_ui::eval::Value;

    #[::core::prelude::v1::test]
    fn chart_scales() {
        assert_eq!(nice_ceiling(87.), 100.);
        assert_eq!(nice_ceiling(120.), 200.);
        assert_eq!(nice_ceiling(2.2), 2.5);
        assert_eq!(nice_ceiling(0.), 1.);
        assert_eq!(short_number(12_500.), "12.5k");
        assert_eq!(short_number(2_000_000.), "2M");
        assert_eq!(short_number(250.), "250");
    }

    #[::core::prelude::v1::test]
    fn scales() {
        assert_eq!(super::nice_scale(0., 170.), (0., 200., 5));
        assert_eq!(super::nice_scale(-2., 3.), (-2., 4., 4));
        assert_eq!(super::nice_scale(0., 8.), (0., 8., 5));
        assert_eq!(super::nice_scale(0., 0.), (0., 1., 5));
    }

    #[::core::prelude::v1::test]
    fn typed_input() {
        assert_eq!(typed("12".into()), Value::Number(12.));
        assert_eq!(typed("1.".into()), Value::String("1.".into()));
        assert_eq!(typed("007".into()), Value::String("007".into()));
        assert_eq!(typed("Ada".into()), Value::String("Ada".into()));
    }
}