//! ` ```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, /// Remote images the user chose to load. images: HashSet, /// Validation messages from the last submit, by binding. errors: HashMap, /// 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, } 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::().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, 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, forms: HashMap>, } 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 { 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 = 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 { 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 = 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 = args .iter() .filter(|arg| matches!(arg.get(), Value::Component { .. } | Value::Array(_))) .cloned() .collect(); let texts: Vec = args.iter().filter_map(|arg| arg.as_str().map(str::to_string)).collect(); let children: Vec = 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 { 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) { 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, ¤t)) .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, ¤t)).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, 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, Vec<(String, Vec)>) { let labels: Vec = 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::>(); 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) -> Option { (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, 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), 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::() } 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)> = (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)| 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)| 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 = items(arg(args, 0)).iter().map(Value::text).collect(); let values: Vec = 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::() { 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(&self, cx: &mut App, change: impl FnOnce(&mut UiState) -> R) -> R { let result = change(&mut cx.default_global::().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, /// The form the button sits in, for a bare `"submit"`. form: Option, /// The fields of every form rendered before the button. forms: HashMap>, } 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), Failed(String), } #[derive(Default)] struct Images { entries: HashMap, } impl Global for Images {} fn format(bytes: &[u8]) -> Option { 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(" ImageFormat::Svg, _ => return None, }) } pub(super) fn inline(src: &str, cx: &mut App) -> Result, String> { let key = format!("{:x}", super::super::hash(src)); if let Some(Remote::Ready(image)) = cx.default_global::().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::().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::().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::().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::().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())); } }