//! ` ```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, ¤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<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()));
}
}