Vectrum
Sign In

Render openui blocks in answers as native UI

A ```openui fence is parsed on the Markdown parser's thread, evaluated
against the block's state on every render and drawn with MetalUI's
pieces: stacks and cards, text, callouts, tags, stats, progress, tables,
bar, line, area and pie charts, tabs, and forms with inputs, text areas,
selects, checkboxes, switches and sliders. Inputs bind to $state or to
their form; state lives in a global store, so values survive the row
scrolling away. Buttons run actions, submit forms (checking required and
email rules) as "[UI] Submitted ..." messages, or send "[UI] action: ..."
to the agent like the composer would. Links open only on a click, and
remote images load only when asked. Unfinished blocks show skeletons
while they stream; closed ones with errors or undefined names show their
source and the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 96a8712commit8d8c1c3

Showing 6 changed files with 1480 additions and 8 deletions.

+1−0src/inline_ui/eval.rsView File
@@ -140,6 +140,7 @@ impl Value {
140140 }
141141
142142 /// Whether this or anything inside is still `Pending`.
143+ #[cfg(test)]
143144 pub fn is_pending(&self) -> bool {
144145 match self {
145146 Value::Pending => true,
+54−0src/inline_ui/lang.rsView File
@@ -84,6 +84,54 @@ impl Program {
8484 .or_else(|| self.statements.iter().find(|statement| !statement.state))
8585 }
8686
87+ /// Names referenced but never defined, in order of first use. `@Each`
88+ /// loop variables count as defined everywhere, which is loose but keeps
89+ /// this a simple walk.
90+ pub fn undefined(&self) -> Vec<String> {
91+ fn walk<'a>(expr: &'a Expr, refs: &mut Vec<&'a str>, locals: &mut Vec<&'a str>) {
92+ match expr {
93+ Expr::Ref(name) => refs.push(name),
94+ Expr::Builtin(name, args) if name == "Each" => {
95+ if let Some(Expr::String(local) | Expr::Ref(local)) = args.get(1) {
96+ locals.push(local);
97+ }
98+ for (ix, arg) in args.iter().enumerate() {
99+ if ix != 1 {
100+ walk(arg, refs, locals);
101+ }
102+ }
103+ }
104+ Expr::Array(items) | Expr::Call(_, items) | Expr::Builtin(_, items) => {
105+ items.iter().for_each(|item| walk(item, refs, locals))
106+ }
107+ Expr::Object(fields) => fields.iter().for_each(|(_, value)| walk(value, refs, locals)),
108+ Expr::Member(target, _) | Expr::Unary(_, target) => walk(target, refs, locals),
109+ Expr::Binary(_, left, right) => {
110+ walk(left, refs, locals);
111+ walk(right, refs, locals);
112+ }
113+ Expr::Ternary(a, b, c) => {
114+ walk(a, refs, locals);
115+ walk(b, refs, locals);
116+ walk(c, refs, locals);
117+ }
118+ Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::String(_) | Expr::State(_) => {}
119+ }
120+ }
121+ let (mut refs, mut locals) = (Vec::new(), Vec::new());
122+ for statement in &self.statements {
123+ walk(&statement.expr, &mut refs, &mut locals);
124+ }
125+ let mut missing: Vec<String> = Vec::new();
126+ for name in refs {
127+ let defined = locals.contains(&name) || self.statements.iter().any(|statement| !statement.state && statement.name == name);
128+ if !defined && !missing.iter().any(|known| known == name) {
129+ missing.push(name.to_string());
130+ }
131+ }
132+ missing
133+ }
134+
87135 pub fn state_default(&self, name: &str) -> Option<&Expr> {
88136 self.statements.iter().rev().find(|statement| statement.state && statement.name == name).map(|statement| &statement.expr)
89137 }
@@ -618,6 +666,12 @@ form = Form("deploy", [FormControl("Environment", Select("env", ["staging", "pro
618666 assert_eq!(program.errors.len(), 1);
619667 }
620668
669+ #[::core::prelude::v1::test]
670+ fn undefined_references() {
671+ let program = parse("root = Stack([a, b, @Each(rows, \"r\", Tag(r.name)), b])\na = Tag(\"x\")\n$s = 1");
672+ assert_eq!(program.undefined(), ["b", "rows"]);
673+ }
674+
621675 #[::core::prelude::v1::test]
622676 fn statement_cap() {
623677 let source: String = (0..600).map(|ix| format!("s{ix} = {ix}\n")).collect();
+0−3src/inline_ui/mod.rsView File
@@ -2,8 +2,5 @@
22 //! its parser and evaluator, kept free of GPUI so they can be tested on
33 //! their own. `ui::inline` renders what they produce.
44
5- // Nothing renders these yet.
6- #![allow(dead_code)]
7-
85 pub mod eval;
96 pub mod lang;
+1−1src/ui/chat/timeline.rsView File
@@ -433,7 +433,7 @@ pub(super) fn render_item(
433433 if meta.is_some() {
434434 bottom = px(16.);
435435 }
436- let icx = crate::ui::inline::InlineCx { session: session.entity_id(), ix, scroller: scroller.downgrade() };
436+ let icx = crate::ui::inline::InlineCx { session: session.downgrade(), ix, scroller: scroller.downgrade() };
437437 render_assistant(ix, &answer.text, &answer.view, icx, meta, p)
438438 }
439439 ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p),
+20−4src/ui/inline/mod.rsView File
@@ -5,8 +5,10 @@
55 //! that goes wrong never hides what the agent wrote.
66
77 mod mermaid;
8+ mod openui;
89
910 use std::collections::HashMap;
11+ use std::sync::Arc;
1012 use std::hash::Hasher as _;
1113
1214 use gpui_kit::component::{
@@ -18,6 +20,8 @@ use gpui_kit::component::{
1820 use gpui_kit::prelude::FluentBuilder as _;
1921 use gpui_kit::*;
2022
23+ use crate::inline_ui::lang::{self, Program};
24+ use crate::session::Session;
2125 use crate::ui::icons::Lucide;
2226 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
2327
@@ -29,7 +33,7 @@ const NODE: &str = "inline-ui";
2933 /// belongs to and which transcript row to remeasure when its height changes.
3034 #[derive(Clone)]
3135 pub struct InlineCx {
32- pub session: EntityId,
36+ pub session: WeakEntity<Session>,
3337 pub ix: usize,
3438 pub scroller: WeakEntity<MessageScrollerState>,
3539 }
@@ -50,12 +54,14 @@ impl InlineCx {
5054 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5155 enum Lang {
5256 Mermaid,
57+ OpenUi,
5358 }
5459
5560 impl Lang {
5661 fn parse(lang: &str) -> Option<Self> {
5762 match lang.trim().to_ascii_lowercase().as_str() {
5863 "mermaid" => Some(Self::Mermaid),
64+ "openui" | "openui-lang" => Some(Self::OpenUi),
5965 _ => None,
6066 }
6167 }
@@ -63,12 +69,14 @@ impl Lang {
6369 fn label(self) -> &'static str {
6470 match self {
6571 Self::Mermaid => "Diagram",
72+ Self::OpenUi => "Interactive",
6673 }
6774 }
6875
6976 fn fence(self) -> &'static str {
7077 match self {
7178 Self::Mermaid => "mermaid",
79+ Self::OpenUi => "openui",
7280 }
7381 }
7482 }
@@ -81,6 +89,8 @@ struct Fence {
8189 /// Whether the closing fence has arrived. Streaming answers show
8290 /// unfinished blocks as skeletons, and never as errors.
8391 closed: bool,
92+ /// OpenUI's program, parsed here off the main thread.
93+ program: Option<Arc<Program>>,
8494 }
8595
8696 /// Adds the rich block hooks to an answer's Markdown view.
@@ -98,6 +108,7 @@ fn parse(node: &markdown_ast::Node, cx: &MarkdownParseContext) -> Option<Markdow
98108 lang,
99109 code: code.value.clone(),
100110 closed: is_closed(source),
111+ program: (lang == Lang::OpenUi).then(|| Arc::new(lang::parse(&code.value))),
101112 };
102113 Some(MarkdownNode::new(NODE, fence).text(source.to_string()).markdown(source.to_string()))
103114 }
@@ -128,14 +139,18 @@ fn hash(value: impl std::hash::Hash) -> u64 {
128139 /// element state when they scroll away, so this lives in a global.
129140 #[derive(Default)]
130141 struct Store {
131- blocks: HashMap<(EntityId, u64), BlockState>,
142+ blocks: HashMap<Key, BlockState>,
132143 }
133144
145+ /// A block: its thread, and a hash of where it starts and what it holds.
146+ type Key = (EntityId, u64);
147+
134148 impl Global for Store {}
135149
136150 #[derive(Default)]
137151 struct BlockState {
138152 show_source: bool,
153+ ui: openui::UiState,
139154 }
140155
141156 /// A rendered block body, or why it couldn't be drawn.
@@ -149,7 +164,7 @@ fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App
149164 return div().into_any_element();
150165 };
151166 let p = metal::palette(cx);
152- let key = (icx.session, hash((node.source_range().map(|range| range.start), &fence.code)));
167+ let key = (icx.session.entity_id(), hash((node.source_range().map(|range| range.start), &fence.code)));
153168 let id = ElementId::NamedInteger("inline-ui".into(), key.1);
154169 let show_source = cx.try_global::<Store>().and_then(|store| store.blocks.get(&key)).is_some_and(|block| block.show_source);
155170 let body = if show_source {
@@ -157,6 +172,7 @@ fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App
157172 } else {
158173 match fence.lang {
159174 Lang::Mermaid => mermaid::render(id.clone(), fence, icx, window, cx),
175+ Lang::OpenUi => openui::render(&id, key, fence, icx, window, cx),
160176 }
161177 };
162178 let error = match &body {
@@ -182,7 +198,7 @@ fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App
182198 fn header(
183199 id: ElementId,
184200 fence: &Fence,
185- key: (EntityId, u64),
201+ key: Key,
186202 show_source: bool,
187203 failed: bool,
188204 icx: &InlineCx,
+1404−0src/ui/inline/openui.rsaddedView File
@@ -0,0 +1,1404 @@
1+ //! ` ```openui ` blocks as native UI. The program is parsed on the Markdown
2+ //! parser's thread and evaluated on every render against the block's state,
3+ //! then the value tree is drawn with MetalUI's pieces: cards, wells, caps.
4+ //!
5+ //! A block can only compose these components, open http(s) links and send
6+ //! text to the agent, both after a click. Remote images load only when the
7+ //! user asks, so an answer can't carry a tracking pixel.
8+
9+ use std::cell::Cell;
10+ use std::collections::{BTreeMap, HashMap, HashSet};
11+ use std::io::Read as _;
12+ use std::rc::Rc;
13+ use std::sync::Arc;
14+ use std::time::Duration;
15+
16+ use gpui_kit::component::{
17+ Icon, IconName, h_flex,
18+ chart::{AreaChart, PieChart},
19+ input::{Input, InputEvent, InputState, Textarea, TextareaState},
20+ menu::{DropdownMenu as _, PopupMenuItem},
21+ slider::{Slider, SliderEvent, SliderState, SliderValue},
22+ text::TextView,
23+ tooltip::Tooltip,
24+ v_flex,
25+ };
26+ use gpui_kit::prelude::FluentBuilder as _;
27+ use gpui_kit::*;
28+
29+ use super::{Body, Fence, InlineCx, Key, Store, skeleton};
30+ use crate::inline_ui::eval::{self, Effect, Value};
31+ use crate::inline_ui::lang::Program;
32+ use crate::settings::{self, FollowUp};
33+ use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
34+
35+ /// Past these a block is more than an answer should hold, and renders an
36+ /// error instead.
37+ const MAX_NODES: usize = 2000;
38+ const MAX_CELLS: usize = 10_000;
39+ const CHART_HEIGHT: f32 = 220.;
40+ /// The most a remote or inline image may weigh.
41+ const MAX_IMAGE_BYTES: u64 = 8 << 20;
42+
43+ static NULL: Value = Value::Null;
44+
45+ /// What an OpenUI block remembers between renders. Kept in the global
46+ /// store, since transcript rows forget their element state when they
47+ /// scroll away.
48+ #[derive(Clone, Default)]
49+ pub(super) struct UiState {
50+ /// `$variables` the user changed; the rest keep their declared defaults.
51+ state: eval::State,
52+ /// Values of inputs named by a string, by form and name.
53+ fields: BTreeMap<(String, String), Value>,
54+ /// The open tab of each `Tabs`, by its id.
55+ tabs: HashMap<String, String>,
56+ /// Remote images the user chose to load.
57+ images: HashSet<String>,
58+ /// Validation messages from the last submit, by binding.
59+ errors: HashMap<String, String>,
60+ /// A line under the block: what was sent, or what couldn't be done.
61+ notice: Option<(bool, String)>,
62+ }
63+
64+ /// Where an input keeps its value.
65+ #[derive(Clone, Debug, PartialEq)]
66+ enum Binding {
67+ /// `$name`, shared with every expression that reads it.
68+ Var(String),
69+ /// A plain name, kept with the enclosing form (or none).
70+ Field(String, String),
71+ }
72+
73+ impl Binding {
74+ fn key(&self) -> String {
75+ match self {
76+ Binding::Var(name) => format!("${name}"),
77+ Binding::Field(form, name) => format!("{form}/{name}"),
78+ }
79+ }
80+
81+ fn name(&self) -> &str {
82+ match self {
83+ Binding::Var(name) | Binding::Field(_, name) => name,
84+ }
85+ }
86+ }
87+
88+ /// An input inside a form, for collecting and checking it on submit.
89+ #[derive(Clone)]
90+ struct FormField {
91+ binding: Binding,
92+ rules: Vec<String>,
93+ }
94+
95+ pub(super) fn render(id: &ElementId, key: Key, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body {
96+ let p = metal::palette(cx);
97+ let Some(program) = fence.program.clone() else {
98+ return Body::Failed("OpenUI: nothing to render".into());
99+ };
100+ if fence.closed {
101+ if let Some(error) = program.errors.first() {
102+ return Body::Failed(format!("OpenUI: {error}"));
103+ }
104+ let missing = program.undefined();
105+ if !missing.is_empty() {
106+ return Body::Failed(format!("OpenUI: `{}` is never defined", missing.join("`, `")));
107+ }
108+ }
109+ if program.root().is_none() {
110+ return Body::Ready(skeleton("Building UI…", px(120.), p));
111+ }
112+ let ui = cx.try_global::<Store>().and_then(|store| store.blocks.get(&key)).map(|block| block.ui.clone()).unwrap_or_default();
113+ let root = eval::evaluate(&program, &ui.state);
114+ let mut renderer = Renderer {
115+ prefix: id.to_string(),
116+ key,
117+ program,
118+ icx: icx.clone(),
119+ p,
120+ ui,
121+ nodes: 0,
122+ cells: 0,
123+ counter: 0,
124+ form: None,
125+ forms: HashMap::new(),
126+ };
127+ let element = renderer.node(&root, window, cx);
128+ if renderer.nodes > MAX_NODES {
129+ return Body::Failed(format!("OpenUI: more than {MAX_NODES} components"));
130+ }
131+ if renderer.cells > MAX_CELLS {
132+ return Body::Failed(format!("OpenUI: more than {MAX_CELLS} table cells"));
133+ }
134+ let carded = matches!(&root, Value::Component { name, .. } if name == "Card");
135+ let notice = renderer.ui.notice.clone();
136+ let block = v_flex()
137+ .w_full()
138+ .gap(px(6.))
139+ .child(if carded {
140+ element
141+ } else {
142+ v_flex().w_full().p(px(16.)).rounded(px(14.)).region(p).child(element).into_any_element()
143+ })
144+ .when_some(notice, |this, (error, text)| {
145+ this.child(
146+ h_flex()
147+ .px(px(4.))
148+ .gap(px(6.))
149+ .text_size(px(12.5))
150+ .text_color(if error { p.red } else { p.ink3 })
151+ .child(Icon::new(if error { IconName::TriangleAlert } else { IconName::Check }).size(px(13.)))
152+ .child(div().min_w_0().truncate().child(text)),
153+ )
154+ });
155+ Body::Ready(block.into_any_element())
156+ }
157+
158+ struct Renderer {
159+ prefix: String,
160+ key: Key,
161+ program: Arc<Program>,
162+ icx: InlineCx,
163+ p: &'static Palette,
164+ ui: UiState,
165+ nodes: usize,
166+ cells: usize,
167+ counter: usize,
168+ /// The form being rendered, which plain-named inputs belong to.
169+ form: Option<String>,
170+ forms: HashMap<String, Vec<FormField>>,
171+ }
172+
173+ fn arg(args: &[Value], ix: usize) -> &Value {
174+ args.get(ix).unwrap_or(&NULL)
175+ }
176+
177+ /// A string argument, or `default` when it's missing or not text.
178+ fn text_arg(args: &[Value], ix: usize, default: &str) -> String {
179+ match arg(args, ix).get() {
180+ Value::Null | Value::Pending => default.to_string(),
181+ value => value.text(),
182+ }
183+ }
184+
185+ fn items(value: &Value) -> Vec<Value> {
186+ match value.get() {
187+ Value::Array(items) => items.clone(),
188+ Value::Null => Vec::new(),
189+ other => vec![other.clone()],
190+ }
191+ }
192+
193+ fn gap(value: &Value) -> Pixels {
194+ px(match value.text().as_str() {
195+ "none" => 0.,
196+ "xs" => 4.,
197+ "s" | "sm" | "small" => 8.,
198+ "l" | "lg" | "large" => 20.,
199+ "xl" => 28.,
200+ _ => 12.,
201+ })
202+ }
203+
204+ impl Renderer {
205+ fn id(&mut self, kind: &str) -> ElementId {
206+ self.counter += 1;
207+ ElementId::Name(format!("{}-{kind}-{}", self.prefix, self.counter).into())
208+ }
209+
210+ fn binding_id(&self, kind: &str, binding: &Binding) -> ElementId {
211+ ElementId::Name(format!("{}-{kind}-{}", self.prefix, binding.key()).into())
212+ }
213+
214+ fn node(&mut self, value: &Value, window: &mut Window, cx: &mut App) -> AnyElement {
215+ self.nodes += 1;
216+ if self.nodes > MAX_NODES {
217+ return div().into_any_element();
218+ }
219+ let p = self.p;
220+ match value.get() {
221+ Value::Null | Value::Action(_) => div().into_any_element(),
222+ Value::Pending => div().h(px(18.)).w(relative(0.6)).rounded(px(6.)).region(p).into_any_element(),
223+ Value::Error(error) => div().text_size(px(12.5)).text_color(p.red).child(error.clone()).into_any_element(),
224+ Value::Array(items) => {
225+ let children: Vec<AnyElement> = items.iter().map(|item| self.node(item, window, cx)).collect();
226+ v_flex().w_full().gap(px(12.)).children(children).into_any_element()
227+ }
228+ Value::Component { name, args } => self.component(name, args, window, cx),
229+ value @ (Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Object(_)) => {
230+ div().text_color(p.ink).child(value.text()).into_any_element()
231+ }
232+ Value::State(..) => unreachable!("`get` sees through state"),
233+ }
234+ }
235+
236+ fn children(&mut self, value: &Value, window: &mut Window, cx: &mut App) -> Vec<AnyElement> {
237+ items(value).iter().map(|item| self.node(item, window, cx)).collect()
238+ }
239+
240+ fn component(&mut self, name: &str, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
241+ let p = self.p;
242+ match name {
243+ "Stack" | "Card" => {
244+ let card = name == "Card";
245+ let (direction, spacing, align, justify, wrap) = if card {
246+ (arg(args, 2), arg(args, 3), arg(args, 4), arg(args, 5), arg(args, 6))
247+ } else {
248+ (arg(args, 1), arg(args, 2), arg(args, 3), arg(args, 4), arg(args, 5))
249+ };
250+ let row = matches!(direction.text().as_str(), "row" | "horizontal");
251+ let children = items(arg(args, 0));
252+ let rendered: Vec<AnyElement> = children
253+ .iter()
254+ .map(|child| {
255+ let element = self.node(child, window, cx);
256+ // Cards and tiles in a row share it; buttons and tags keep their size.
257+ let grows = matches!(child.get(), Value::Component { name, .. } if matches!(name.as_str(),
258+ "Card" | "Stat" | "Stack" | "Table" | "Callout" | "FormControl" | "BarChart" | "LineChart" | "AreaChart" | "PieChart" | "Form"));
259+ if row && grows { div().flex_1().min_w(px(160.)).child(element).into_any_element() } else { element }
260+ })
261+ .collect();
262+ let base = if row { h_flex().items_start() } else { v_flex() };
263+ base.w_full()
264+ .gap(gap(spacing))
265+ .when(wrap.truthy(), |this| this.flex_wrap())
266+ .map(|this| match align.text().as_str() {
267+ "center" => this.items_center(),
268+ "end" => this.items_end(),
269+ "start" => this.items_start(),
270+ _ => this,
271+ })
272+ .map(|this| match justify.text().as_str() {
273+ "center" => this.justify_center(),
274+ "end" => this.justify_end(),
275+ "between" | "space-between" => this.justify_between(),
276+ "around" | "space-around" => this.justify_around(),
277+ _ => this,
278+ })
279+ .when(card, |this| {
280+ this.p(px(16.)).rounded(px(14.)).map(|this| match arg(args, 1).text().as_str() {
281+ "well" | "sunk" | "inset" | "sunken" => this.well(p),
282+ "plain" | "flat" | "clear" | "ghost" | "transparent" => this,
283+ "outline" | "outlined" | "bordered" => this.border_1().border_color(p.rule),
284+ _ => this.raised(p),
285+ })
286+ })
287+ .children(rendered)
288+ .into_any_element()
289+ }
290+ "CardHeader" => v_flex()
291+ .gap(px(2.))
292+ .child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(text_arg(args, 0, "")))
293+ .when(!arg(args, 1).get().eq(&Value::Null), |this| {
294+ this.child(div().text_size(px(13.)).text_color(p.ink3).child(text_arg(args, 1, "")))
295+ })
296+ .into_any_element(),
297+ "TextContent" | "Text" | "Heading" | "Label" => {
298+ let size = if name == "Heading" { "large-heavy".to_string() } else { text_arg(args, 1, "default") };
299+ let text = div().text_color(p.ink).child(text_arg(args, 0, ""));
300+ match size.as_str() {
301+ "small" | "sm" | "caption" => text.text_size(px(12.5)).text_color(p.ink2),
302+ "large" | "lg" => text.text_size(px(17.)).font_weight(FontWeight::MEDIUM),
303+ "large-heavy" | "heading" | "title" | "xl" => text.text_size(px(20.)).line_height(px(26.)).font_weight(FontWeight::SEMIBOLD),
304+ _ => text,
305+ }
306+ .into_any_element()
307+ }
308+ "Markdown" => TextView::markdown(self.id("markdown"), text_arg(args, 0, ""))
309+ .style(crate::ui::chat::markdown_style(p))
310+ .selectable(true)
311+ .into_any_element(),
312+ "CodeBlock" | "Code" => {
313+ let fence = format!("```{}\n{}\n```", text_arg(args, 0, ""), text_arg(args, 1, "").trim_end());
314+ TextView::markdown(self.id("code"), fence).style(crate::ui::chat::markdown_style(p)).selectable(true).into_any_element()
315+ }
316+ "Callout" | "Alert" => {
317+ let variant = text_arg(args, 0, "info");
318+ let (color, icon) = match variant.as_str() {
319+ "success" => (p.green_ink, IconName::CircleCheck),
320+ "warning" | "warn" => (p.amber, IconName::TriangleAlert),
321+ "error" | "danger" | "destructive" => (p.red, IconName::CircleX),
322+ _ => (p.blue_ink, IconName::Info),
323+ };
324+ h_flex()
325+ .w_full()
326+ .items_start()
327+ .gap(px(10.))
328+ .p(px(12.))
329+ .rounded(px(12.))
330+ .bg(color.opacity(if p.dark { 0.12 } else { 0.08 }))
331+ .border_1()
332+ .border_color(color.opacity(0.3))
333+ .child(div().pt(px(2.)).child(Icon::new(icon).size(px(16.)).text_color(color)))
334+ .child(
335+ v_flex()
336+ .flex_1()
337+ .min_w_0()
338+ .gap(px(2.))
339+ .child(div().text_color(p.ink).font_weight(FontWeight::MEDIUM).child(text_arg(args, 1, "")))
340+ .when(!matches!(arg(args, 2).get(), Value::Null), |this| {
341+ this.child(div().text_size(px(13.)).text_color(p.ink2).child(text_arg(args, 2, "")))
342+ }),
343+ )
344+ .into_any_element()
345+ }
346+ "Tag" | "Badge" => {
347+ let variant = text_arg(args, 1, "");
348+ let color = match variant.as_str() {
349+ "success" | "green" => Some(p.green_ink),
350+ "warning" | "amber" | "yellow" => Some(p.amber),
351+ "error" | "danger" | "red" => Some(p.red),
352+ "info" | "blue" => Some(p.blue_ink),
353+ _ => None,
354+ };
355+ div()
356+ .flex()
357+ .child(metal::tag(text_arg(args, 0, ""), p).when_some(color, |this, color| this.text_color(color)))
358+ .into_any_element()
359+ }
360+ "Separator" | "Divider" => metal::rule(p).w_full().into_any_element(),
361+ "Progress" => {
362+ let raw = arg(args, 0).as_number().unwrap_or(0.);
363+ let fraction = (if raw > 1. { raw / 100. } else { raw }).clamp(0., 1.);
364+ v_flex()
365+ .w_full()
366+ .gap(px(6.))
367+ .child(
368+ h_flex()
369+ .text_size(px(12.5))
370+ .text_color(p.ink2)
371+ .child(div().flex_1().child(text_arg(args, 1, "")))
372+ .child(div().font_family(metal::MONO).child(format!("{:.0}%", fraction * 100.))),
373+ )
374+ .child(
375+ div()
376+ .w_full()
377+ .h(px(8.))
378+ .rounded_full()
379+ .well(p)
380+ .child(div().h_full().w(relative(fraction as f32)).rounded_full().bg(metal::vertical(p.primary))),
381+ )
382+ .into_any_element()
383+ }
384+ "Stat" | "Metric" | "KPI" => {
385+ let delta = arg(args, 2).get().clone();
386+ let delta_text = delta.text();
387+ let delta_color = match delta.as_number() {
388+ Some(number) if number < 0. => p.red,
389+ Some(_) => p.green_ink,
390+ None if delta_text.trim_start().starts_with('-') => p.red,
391+ None => p.green_ink,
392+ };
393+ v_flex()
394+ .gap(px(4.))
395+ .p(px(14.))
396+ .rounded(px(12.))
397+ .raised(p)
398+ .child(metal::engraved(text_arg(args, 0, ""), p))
399+ .child(div().text_size(px(24.)).line_height(px(30.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(text_arg(args, 1, "")))
400+ .when(!delta_text.is_empty(), |this| this.child(div().text_size(px(12.5)).text_color(delta_color).child(delta_text)))
401+ .into_any_element()
402+ }
403+ "Image" => self.image(text_arg(args, 0, ""), text_arg(args, 1, ""), cx),
404+ "Table" => self.table(args, window, cx),
405+ "BarChart" => self.bar_chart(args),
406+ "LineChart" | "AreaChart" => self.area_chart(name == "AreaChart", args),
407+ "PieChart" | "DonutChart" => self.pie_chart(args, name == "DonutChart"),
408+ "Tabs" => self.tabs(args, window, cx),
409+ "Form" => {
410+ let form = text_arg(args, 0, "form");
411+ let outer = self.form.replace(form.clone());
412+ let fields = self.children(arg(args, 1), window, cx);
413+ let buttons = self.node(arg(args, 2), window, cx);
414+ self.form = outer;
415+ v_flex().w_full().gap(px(14.)).children(fields).child(buttons).into_any_element()
416+ }
417+ "FormControl" | "Field" => {
418+ let input = self.node(arg(args, 1), window, cx);
419+ let error = match arg(args, 1).get() {
420+ Value::Component { args: inner, .. } => {
421+ let binding = self.binding(inner);
422+ binding.and_then(|binding| self.ui.errors.get(&binding.key()).cloned())
423+ }
424+ _ => None,
425+ };
426+ v_flex()
427+ .w_full()
428+ .gap(px(6.))
429+ .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(text_arg(args, 0, "")))
430+ .child(input)
431+ .map(|this| match error {
432+ Some(error) => this.child(div().text_size(px(12.)).text_color(p.red).child(error)),
433+ None => this.when(!matches!(arg(args, 2).get(), Value::Null), |this| {
434+ this.child(div().text_size(px(12.)).text_color(p.ink3).child(text_arg(args, 2, "")))
435+ }),
436+ })
437+ .into_any_element()
438+ }
439+ "Input" | "TextInput" | "TextArea" | "Textarea" => {
440+ let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Input needs a name".into()), window, cx) };
441+ let shift = self.shift(args);
442+ let rules = items(arg(args, 3 + shift)).iter().map(Value::text).collect();
443+ self.register(&binding, rules);
444+ let multiline = matches!(name, "TextArea" | "Textarea");
445+ let placeholder = text_arg(args, 1 + shift, "");
446+ if multiline {
447+ let rows = arg(args, 2 + shift).as_number().unwrap_or(3.).clamp(1., 20.) as usize;
448+ self.textarea(binding, placeholder, rows, window, cx)
449+ } else {
450+ let kind = text_arg(args, 2 + shift, "text");
451+ self.input(binding, placeholder, kind, window, cx)
452+ }
453+ }
454+ "Select" | "Dropdown" | "RadioGroup" => {
455+ let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Select needs a name".into()), window, cx) };
456+ let shift = self.shift(args);
457+ self.register(&binding, items(arg(args, 3 + shift)).iter().map(Value::text).collect());
458+ self.select(binding, arg(args, 1 + shift), text_arg(args, 2 + shift, "Choose…"))
459+ }
460+ "Checkbox" | "Switch" | "Toggle" => {
461+ let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Checkbox needs a name".into()), window, cx) };
462+ let shift = self.shift(args);
463+ self.register(&binding, Vec::new());
464+ self.check(name == "Checkbox", binding, text_arg(args, 1 + shift, ""))
465+ }
466+ "Slider" => {
467+ let Some(binding) = self.binding(args) else { return self.node(&Value::Error("Slider needs a name".into()), window, cx) };
468+ let shift = self.shift(args);
469+ self.register(&binding, Vec::new());
470+ let min = arg(args, 1 + shift).as_number().unwrap_or(0.);
471+ let max = arg(args, 2 + shift).as_number().unwrap_or(100.).max(min + f64::EPSILON);
472+ let step = arg(args, 3 + shift).as_number().filter(|step| *step > 0.).unwrap_or((max - min) / 100.);
473+ self.slider(binding, min, max, step, window, cx)
474+ }
475+ "Buttons" | "ButtonGroup" => {
476+ let column = matches!(text_arg(args, 1, "row").as_str(), "column" | "vertical");
477+ let buttons = self.children(arg(args, 0), window, cx);
478+ if column {
479+ v_flex().items_start().gap(px(8.)).children(buttons).into_any_element()
480+ } else {
481+ h_flex().flex_wrap().gap(px(8.)).children(buttons).into_any_element()
482+ }
483+ }
484+ "Button" => self.button(args),
485+ "Query" | "Mutation" => div()
486+ .text_size(px(12.5))
487+ .text_color(p.ink3)
488+ .child(format!("{name} isn't supported in Vectrum Code"))
489+ .into_any_element(),
490+ _ => {
491+ // An unknown component keeps its name and its children, so new
492+ // names degrade softly.
493+ let nested: Vec<Value> = args
494+ .iter()
495+ .filter(|arg| matches!(arg.get(), Value::Component { .. } | Value::Array(_)))
496+ .cloned()
497+ .collect();
498+ let texts: Vec<String> = args.iter().filter_map(|arg| arg.as_str().map(str::to_string)).collect();
499+ let children: Vec<AnyElement> = nested.iter().map(|child| self.node(child, window, cx)).collect();
500+ v_flex()
501+ .w_full()
502+ .gap(px(8.))
503+ .child(h_flex().gap(px(8.)).child(metal::tag(name.to_string(), p)).children(texts.into_iter().take(2).map(|text| {
504+ div().text_size(px(13.)).text_color(p.ink2).child(text)
505+ })))
506+ .children(children)
507+ .into_any_element()
508+ }
509+ }
510+ }
511+
512+ /// How many places an input's arguments move right. OpenUI's own docs
513+ /// also write `Select("days", $days, [...])`, a name then the binding.
514+ fn shift(&self, args: &[Value]) -> usize {
515+ usize::from(!matches!(arg(args, 0), Value::State(..)) && matches!(arg(args, 1), Value::State(..)))
516+ }
517+
518+ /// Where an input's value lives: a `$var` handed to it, else its name in
519+ /// the enclosing form.
520+ fn binding(&self, args: &[Value]) -> Option<Binding> {
521+ if let Value::State(name, _) = arg(args, 0) {
522+ return Some(Binding::Var(name.clone()));
523+ }
524+ if let Value::State(name, _) = arg(args, 1) {
525+ return Some(Binding::Var(name.clone()));
526+ }
527+ let name = arg(args, 0).as_str()?.trim().to_string();
528+ (!name.is_empty()).then(|| Binding::Field(self.form.clone().unwrap_or_default(), name))
529+ }
530+
531+ fn register(&mut self, binding: &Binding, rules: Vec<String>) {
532+ if let Some(form) = &self.form {
533+ let fields = self.forms.entry(form.clone()).or_default();
534+ if !fields.iter().any(|field| field.binding == *binding) {
535+ fields.push(FormField { binding: binding.clone(), rules });
536+ }
537+ }
538+ }
539+
540+ fn value(&self, binding: &Binding) -> Value {
541+ match binding {
542+ Binding::Var(name) => eval::state_value(&self.program, &self.ui.state, name),
543+ Binding::Field(form, name) => self.ui.fields.get(&(form.clone(), name.clone())).cloned().unwrap_or(Value::Null),
544+ }
545+ }
546+
547+ /// What event handlers need to change the block's state.
548+ fn setter(&self) -> Setter {
549+ Setter { key: self.key, icx: self.icx.clone() }
550+ }
551+
552+ fn input(&mut self, binding: Binding, placeholder: String, kind: String, window: &mut Window, cx: &mut App) -> AnyElement {
553+ let current = self.value(&binding).text();
554+ let created = Rc::new(Cell::new(false));
555+ let state = window.use_keyed_state(self.binding_id("input", &binding), cx, {
556+ let (created, current) = (created.clone(), current.clone());
557+ move |window, cx| {
558+ created.set(true);
559+ InputState::new(window, cx).placeholder(placeholder).masked(kind == "password").default_value(current)
560+ }
561+ });
562+ if created.get() {
563+ let setter = self.setter();
564+ let binding = binding.clone();
565+ cx.subscribe(&state, move |state, event: &InputEvent, cx| {
566+ if matches!(event, InputEvent::Change) {
567+ let text = state.read(cx).value().to_string();
568+ setter.set(&binding, typed(text), cx);
569+ }
570+ })
571+ .detach();
572+ } else if state.read(cx).value() != current && !state.read(cx).focus_handle(cx).is_focused(window) {
573+ // Changed by an action, like `@Reset`.
574+ state.update(cx, |state, cx| state.set_value(current, window, cx));
575+ }
576+ h_flex()
577+ .w_full()
578+ .h(px(32.))
579+ .px(px(10.))
580+ .rounded(px(10.))
581+ .well(self.p)
582+ .text_size(px(13.5))
583+ .text_color(self.p.ink)
584+ .child(div().flex_1().min_w_0().child(Input::new(&state).appearance(false)))
585+ .into_any_element()
586+ }
587+
588+ fn textarea(&mut self, binding: Binding, placeholder: String, rows: usize, window: &mut Window, cx: &mut App) -> AnyElement {
589+ let current = self.value(&binding).text();
590+ let created = Rc::new(Cell::new(false));
591+ let state = window.use_keyed_state(self.binding_id("textarea", &binding), cx, {
592+ let (created, current) = (created.clone(), current.clone());
593+ move |window, cx| {
594+ created.set(true);
595+ TextareaState::new(window, cx).rows(rows).placeholder(placeholder).default_value(current)
596+ }
597+ });
598+ if created.get() {
599+ let setter = self.setter();
600+ let binding = binding.clone();
601+ cx.subscribe(&state, move |state, event: &InputEvent, cx| {
602+ if matches!(event, InputEvent::Change) {
603+ let text = state.read(cx).value().to_string();
604+ setter.set(&binding, Value::String(text), cx);
605+ }
606+ })
607+ .detach();
608+ } else if state.read(cx).value() != current && !state.read(cx).focus_handle(cx).is_focused(window) {
609+ state.update(cx, |state, cx| state.set_value(current, window, cx));
610+ }
611+ div()
612+ .w_full()
613+ .px(px(10.))
614+ .py(px(6.))
615+ .rounded(px(10.))
616+ .well(self.p)
617+ .text_size(px(13.5))
618+ .text_color(self.p.ink)
619+ .child(Textarea::new(&state).appearance(false))
620+ .into_any_element()
621+ }
622+
623+ fn select(&mut self, binding: Binding, options: &Value, placeholder: String) -> AnyElement {
624+ let options: Vec<(String, Value)> = items(options)
625+ .into_iter()
626+ .map(|option| match &option {
627+ Value::Object(fields) => {
628+ let value = fields.get("value").or(fields.get("label")).cloned().unwrap_or(Value::Null);
629+ let label = fields.get("label").map(Value::text).unwrap_or_else(|| value.text());
630+ (label, value)
631+ }
632+ other => (other.text(), other.clone()),
633+ })
634+ .collect();
635+ let current = self.value(&binding);
636+ let label = options
637+ .iter()
638+ .find(|(_, value)| eval_equals(value, &current))
639+ .map(|(label, _)| label.clone())
640+ .unwrap_or(placeholder);
641+ let setter = self.setter();
642+ let id = self.binding_id("select", &binding);
643+ div()
644+ .w_full()
645+ .child(MetalButton::new(id).label(label).caret().stretch().dropdown_menu(move |menu, _, _| {
646+ options.iter().fold(menu, |menu, (label, value)| {
647+ let (setter, binding, value) = (setter.clone(), binding.clone(), value.clone());
648+ menu.item(PopupMenuItem::new(label.clone()).checked(eval_equals(&value, &current)).on_click(move |_, _, cx| {
649+ setter.set(&binding, value.clone(), cx);
650+ }))
651+ })
652+ }))
653+ .into_any_element()
654+ }
655+
656+ fn check(&mut self, checkbox: bool, binding: Binding, label: String) -> AnyElement {
657+ let on = self.value(&binding).truthy();
658+ let setter = self.setter();
659+ if checkbox {
660+ // A MetalUI box: a small well that lights up when checked.
661+ let p = self.p;
662+ return h_flex()
663+ .id(self.binding_id("check", &binding))
664+ .gap(px(10.))
665+ .cursor_pointer()
666+ .on_click(move |_, _, cx| setter.set(&binding, Value::Bool(!on), cx))
667+ .child(
668+ div()
669+ .flex()
670+ .flex_none()
671+ .items_center()
672+ .justify_center()
673+ .size(px(18.))
674+ .rounded(px(5.))
675+ .map(|this| if on { this.bg(metal::vertical(p.primary)).shadow(p.primary_sh.clone()) } else { this.well(p) })
676+ .when(on, |this| this.child(Icon::new(IconName::Check).size(px(13.)).text_color(p.primary_ink))),
677+ )
678+ .child(div().text_size(px(13.5)).text_color(p.ink).child(label))
679+ .into_any_element();
680+ }
681+ let id = self.binding_id("switch", &binding);
682+ h_flex()
683+ .gap(px(10.))
684+ .child(metal::toggle(id, on, self.p, move |on, _, cx| setter.set(&binding, Value::Bool(on), cx)))
685+ .child(div().text_size(px(13.5)).text_color(self.p.ink).child(label))
686+ .into_any_element()
687+ }
688+
689+ fn slider(&mut self, binding: Binding, min: f64, max: f64, step: f64, window: &mut Window, cx: &mut App) -> AnyElement {
690+ let current = self.value(&binding).as_number().unwrap_or(min).clamp(min, max);
691+ let created = Rc::new(Cell::new(false));
692+ let state = window.use_keyed_state(self.binding_id("slider", &binding), cx, {
693+ let created = created.clone();
694+ move |_, _| {
695+ created.set(true);
696+ SliderState::new().min(min as f32).max(max as f32).step(step as f32).default_value(current as f32)
697+ }
698+ });
699+ if created.get() {
700+ let setter = self.setter();
701+ let binding = binding.clone();
702+ cx.subscribe(&state, move |_, event: &SliderEvent, cx| {
703+ if let SliderEvent::Change(SliderValue::Single(value)) = event {
704+ // Steps are floats; keep `0.1 * 3` reading as 0.3.
705+ let value = (*value as f64 / step).round() * step;
706+ setter.set(&binding, Value::Number((value * 1e9).round() / 1e9), cx);
707+ }
708+ })
709+ .detach();
710+ } else if (f64::from(match state.read(cx).value() {
711+ SliderValue::Single(value) | SliderValue::Range(_, value) => value,
712+ }) - current)
713+ .abs()
714+ > step / 2.
715+ {
716+ state.update(cx, |state, cx| state.set_value(current as f32, window, cx));
717+ }
718+ h_flex()
719+ .w_full()
720+ .gap(px(12.))
721+ .child(div().flex_1().child(Slider::new(&state)))
722+ .child(div().min_w(px(40.)).text_size(px(13.)).font_family(metal::MONO).text_color(self.p.ink2).child(eval::format_number(current)))
723+ .into_any_element()
724+ }
725+
726+ fn button(&mut self, args: &[Value]) -> AnyElement {
727+ let label = text_arg(args, 0, "Button");
728+ let action = arg(args, 1).get().clone();
729+ let variant = text_arg(args, 2, "secondary");
730+ let id = self.id("button");
731+ let handler = ActionHandler {
732+ setter: self.setter(),
733+ program: self.program.clone(),
734+ form: self.form.clone(),
735+ forms: self.forms.clone(),
736+ };
737+ MetalButton::new(id)
738+ .label(label)
739+ .map(|button| match variant.as_str() {
740+ "primary" | "default" => button.primary(),
741+ "danger" | "destructive" => button.danger(),
742+ "ghost" | "link" | "tertiary" => button.ghost(),
743+ _ => button,
744+ })
745+ .on_click(move |_, window, cx| handler.run(&action, window, cx))
746+ .into_any_element()
747+ }
748+
749+ fn tabs(&mut self, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
750+ let p = self.p;
751+ let tabs: Vec<(String, String, Value)> = items(arg(args, 0))
752+ .into_iter()
753+ .filter_map(|item| match item {
754+ Value::Component { args, .. } => {
755+ let value = text_arg(&args, 0, "");
756+ let trigger = text_arg(&args, 1, &value);
757+ Some((value, trigger, arg(&args, 2).clone()))
758+ }
759+ _ => None,
760+ })
761+ .collect();
762+ let id = self.id("tabs");
763+ let tabs_key = id.to_string();
764+ let selected = self.ui.tabs.get(&tabs_key).filter(|value| tabs.iter().any(|(tab, ..)| tab == *value)).cloned();
765+ let selected = selected.or_else(|| tabs.first().map(|(value, ..)| value.clone())).unwrap_or_default();
766+ let content = tabs.iter().find(|(value, ..)| *value == selected).map(|(_, _, content)| content.clone()).unwrap_or(Value::Null);
767+ let content = self.node(&content, window, cx);
768+ let setter = self.setter();
769+ v_flex()
770+ .w_full()
771+ .gap(px(12.))
772+ .child(
773+ h_flex().child(
774+ h_flex().flex_wrap().p(px(3.)).gap(px(2.)).rounded(px(15.)).well(p).children(tabs.iter().enumerate().map(|(ix, (value, trigger, _))| {
775+ let on = *value == selected;
776+ let (setter, tabs_key, value) = (setter.clone(), tabs_key.clone(), value.clone());
777+ div()
778+ .id(ElementId::Name(format!("{id}-{ix}").into()))
779+ .flex()
780+ .items_center()
781+ .h(px(24.))
782+ .px(px(12.))
783+ .rounded_full()
784+ .text_size(px(12.5))
785+ .font_weight(FontWeight::MEDIUM)
786+ .map(|this| {
787+ if on {
788+ this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink)
789+ } else {
790+ this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink))
791+ }
792+ })
793+ .on_click(move |_, _, cx| {
794+ setter.update(cx, |ui| {
795+ ui.tabs.insert(tabs_key.clone(), value.clone());
796+ });
797+ })
798+ .child(trigger.clone())
799+ })),
800+ ),
801+ )
802+ .child(content)
803+ .into_any_element()
804+ }
805+
806+ fn table(&mut self, args: &[Value], window: &mut Window, cx: &mut App) -> AnyElement {
807+ let p = self.p;
808+ let columns: Vec<(String, Vec<Value>, String)> = items(arg(args, 0))
809+ .into_iter()
810+ .map(|column| match column {
811+ Value::Component { args, .. } => (text_arg(&args, 0, ""), items(arg(&args, 1)), text_arg(&args, 2, "text")),
812+ other => (other.text(), Vec::new(), "text".into()),
813+ })
814+ .collect();
815+ let rows = columns.iter().map(|(_, data, _)| data.len()).max().unwrap_or(0);
816+ self.cells += rows * columns.len();
817+ if self.cells > MAX_CELLS {
818+ return div().into_any_element();
819+ }
820+ let cell = |this: &mut Self, value: &Value, kind: &str, window: &mut Window, cx: &mut App| -> AnyElement {
821+ let base = div().flex_1().min_w(px(60.)).px(px(10.)).py(px(7.)).overflow_hidden();
822+ match (kind, value.get()) {
823+ (_, Value::Component { .. }) => base.child(this.node(value, window, cx)).into_any_element(),
824+ ("tag", value) if !value.text().is_empty() => base.child(div().flex().child(metal::tag(value.text(), p))).into_any_element(),
825+ ("number" | "currency" | "percent", value) => base
826+ .flex()
827+ .justify_end()
828+ .font_family(metal::MONO)
829+ .text_size(px(12.5))
830+ .child(value.text())
831+ .into_any_element(),
832+ (_, value) => base.child(value.text()).into_any_element(),
833+ }
834+ };
835+ let header = h_flex().w_full().children(columns.iter().map(|(label, _, kind)| {
836+ div()
837+ .flex_1()
838+ .min_w(px(60.))
839+ .px(px(10.))
840+ .py(px(8.))
841+ .flex()
842+ .when(matches!(kind.as_str(), "number" | "currency" | "percent"), |this| this.justify_end())
843+ .child(metal::engraved(label.clone(), p))
844+ }));
845+ let mut body = Vec::new();
846+ for row in 0..rows {
847+ let mut cells = Vec::new();
848+ for (_, data, kind) in &columns {
849+ cells.push(cell(self, data.get(row).unwrap_or(&NULL), kind, window, cx));
850+ }
851+ body.push(v_flex().w_full().child(metal::rule(p).w_full()).child(h_flex().w_full().items_center().children(cells)));
852+ }
853+ v_flex()
854+ .w_full()
855+ .rounded(px(12.))
856+ .well(p)
857+ .overflow_hidden()
858+ .text_size(px(13.))
859+ .text_color(p.ink)
860+ .child(header)
861+ .children(body)
862+ .into_any_element()
863+ }
864+
865+ /// Labels, and one `(name, values)` per series: `[Series(name, values)]`,
866+ /// a single series, or a bare array of numbers.
867+ fn series(args: &[Value]) -> (Vec<String>, Vec<(String, Vec<f64>)>) {
868+ let labels: Vec<String> = items(arg(args, 0)).iter().map(Value::text).collect();
869+ let numbers = |value: &Value| items(value).iter().map(|value| value.as_number().unwrap_or(0.)).collect::<Vec<f64>>();
870+ let series_value = arg(args, 1).get();
871+ let list = items(series_value);
872+ let series = if list.iter().all(|item| item.as_number().is_some()) && !list.is_empty() {
873+ vec![(String::new(), numbers(series_value))]
874+ } else {
875+ list.iter()
876+ .filter_map(|item| match item.get() {
877+ Value::Component { args, .. } => Some((text_arg(args, 0, ""), numbers(arg(args, 1)))),
878+ Value::Object(fields) => Some((
879+ fields.get("name").map(Value::text).unwrap_or_default(),
880+ numbers(fields.get("values").or(fields.get("data")).unwrap_or(&NULL)),
881+ )),
882+ _ => None,
883+ })
884+ .collect()
885+ };
886+ (labels, series)
887+ }
888+
889+ fn color(&self, ix: usize) -> Hsla {
890+ self.p.tints[ix % self.p.tints.len()]
891+ }
892+
893+ fn legend(&self, names: Vec<String>) -> Option<AnyElement> {
894+ (names.len() > 1 || names.first().is_some_and(|name| !name.is_empty())).then(|| {
895+ h_flex()
896+ .flex_wrap()
897+ .gap(px(14.))
898+ .text_size(px(12.))
899+ .text_color(self.p.ink2)
900+ .children(names.into_iter().enumerate().map(|(ix, name)| {
901+ h_flex().gap(px(6.)).child(div().size(px(8.)).rounded(px(2.)).bg(self.color(ix))).child(name)
902+ }))
903+ .into_any_element()
904+ })
905+ }
906+
907+ /// Axis titles around a chart, and its legend.
908+ fn chart_frame(&self, chart: AnyElement, names: Vec<String>, x_label: String, y_label: String) -> AnyElement {
909+ let p = self.p;
910+ v_flex()
911+ .w_full()
912+ .gap(px(8.))
913+ .when(!y_label.is_empty(), |this| this.child(div().text_size(px(12.)).text_color(p.ink3).child(y_label)))
914+ .child(chart)
915+ .when(!x_label.is_empty(), |this| this.child(h_flex().justify_center().text_size(px(12.)).text_color(p.ink3).child(x_label)))
916+ .children(self.legend(names))
917+ .into_any_element()
918+ }
919+
920+ /// Bars drawn as boxes, grouped or stacked, over a light grid. GPUI's
921+ /// bar chart takes one series, and answers often compare several.
922+ fn bar_chart(&mut self, args: &[Value]) -> AnyElement {
923+ let p = self.p;
924+ let (labels, series) = Self::series(args);
925+ let stacked = text_arg(args, 2, "grouped") == "stacked";
926+ let bands = labels.len().max(series.iter().map(|(_, values)| values.len()).max().unwrap_or(0));
927+ let value = |series: &(String, Vec<f64>), band: usize| series.1.get(band).copied().unwrap_or(0.).max(0.);
928+ let top = (0..bands)
929+ .map(|band| {
930+ let values = series.iter().map(|series| value(series, band));
931+ if stacked { values.sum::<f64>() } else { values.fold(0., f64::max) }
932+ })
933+ .fold(0., f64::max);
934+ let (_, top, ticks) = nice_scale(0., top);
935+ let ticks = ticks - 1;
936+ let id = self.id("bars");
937+ let plot_height = CHART_HEIGHT - 24.;
938+ 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))));
939+ let axis = v_flex()
940+ .w(px(44.))
941+ .h(px(plot_height))
942+ .justify_between()
943+ .items_end()
944+ .pr(px(6.))
945+ .text_size(px(11.))
946+ .font_family(metal::MONO)
947+ .text_color(p.ink3)
948+ .children((0..=ticks).rev().map(|tick| div().h(px(0.)).flex().items_center().child(short_number(top * tick as f64 / ticks as f64))));
949+ let bars = h_flex().absolute().inset_0().items_end().px(px(4.)).children((0..bands).map(|band| {
950+ let tip = |series_ix: usize| {
951+ let (name, _) = &series[series_ix];
952+ let label = labels.get(band).cloned().unwrap_or_default();
953+ let amount = eval::format_number(value(&series[series_ix], band));
954+ let tooltip: SharedString = if name.is_empty() { format!("{label}: {amount}") } else { format!("{label} · {name}: {amount}") }.into();
955+ move |window: &mut Window, cx: &mut App| Tooltip::new(tooltip.clone()).build(window, cx)
956+ };
957+ let bar_id = |series_ix: usize| ElementId::Name(format!("{id}-{band}-{series_ix}").into());
958+ let height = |amount: f64| relative((amount / top) as f32);
959+ let column = if stacked {
960+ v_flex()
961+ .h_full()
962+ .w(relative(0.6))
963+ .max_w(px(48.))
964+ .justify_end()
965+ .children((0..series.len()).rev().map(|ix| {
966+ div().id(bar_id(ix)).w_full().h(height(value(&series[ix], band))).bg(self.color(ix)).tooltip(tip(ix))
967+ }))
968+ .rounded_t(px(4.))
969+ .overflow_hidden()
970+ .into_any_element()
971+ } else {
972+ h_flex()
973+ .h_full()
974+ .w(relative(0.8))
975+ .max_w(px(28. * series.len() as f32 + 8.))
976+ .items_end()
977+ .gap(px(2.))
978+ .children((0..series.len()).map(|ix| {
979+ div().id(bar_id(ix)).flex_1().h(height(value(&series[ix], band))).rounded_t(px(4.)).bg(self.color(ix)).tooltip(tip(ix))
980+ }))
981+ .into_any_element()
982+ };
983+ h_flex().flex_1().h_full().items_end().justify_center().child(column)
984+ }));
985+ let labels_row = h_flex().pl(px(48.)).pr(px(4.)).children((0..bands).map(|band| {
986+ div()
987+ .flex_1()
988+ .min_w_0()
989+ .flex()
990+ .justify_center()
991+ .text_size(px(11.5))
992+ .text_color(p.ink2)
993+ .child(div().truncate().child(labels.get(band).cloned().unwrap_or_default()))
994+ }));
995+ let chart = v_flex()
996+ .w_full()
997+ .gap(px(6.))
998+ .child(h_flex().w_full().child(axis).child(div().flex_1().h(px(plot_height)).relative().child(grid).child(bars)))
999+ .child(labels_row)
1000+ .into_any_element();
1001+ let names = series.iter().map(|(name, _)| name.clone()).collect();
1002+ self.chart_frame(chart, names, text_arg(args, 3, ""), text_arg(args, 4, ""))
1003+ }
1004+
1005+ fn area_chart(&mut self, area: bool, args: &[Value]) -> AnyElement {
1006+ let (labels, series) = Self::series(args);
1007+ let points = labels.len().max(series.iter().map(|(_, values)| values.len()).max().unwrap_or(0));
1008+ let rows: Vec<(SharedString, Vec<f64>)> = (0..points)
1009+ .map(|ix| {
1010+ let label: SharedString = labels.get(ix).cloned().unwrap_or_default().into();
1011+ (label, series.iter().map(|(_, values)| values.get(ix).copied().unwrap_or(0.)).collect())
1012+ })
1013+ .collect();
1014+ let mut chart = AreaChart::new(rows).id(self.id("area")).x(|row: &(SharedString, Vec<f64>)| row.0.clone());
1015+ // Straight segments: a smoothed curve overshoots, and can dip below
1016+ // zero between points that never do.
1017+ for ix in 0..series.len() {
1018+ let color = self.color(ix);
1019+ chart = chart
1020+ .y(move |row: &(SharedString, Vec<f64>)| row.1.get(ix).copied().unwrap_or(0.))
1021+ .stroke(color)
1022+ .fill(if area { color.opacity(0.18) } else { transparent_black() })
1023+ .linear();
1024+ }
1025+ let top = series.iter().flat_map(|(_, values)| values.iter().copied()).fold(0., f64::max);
1026+ let bottom = series.iter().flat_map(|(_, values)| values.iter().copied()).fold(0., f64::min);
1027+ let (low, high, ticks) = nice_scale(bottom, top);
1028+ let chart = chart.y_axis(true).y_domain(low, high).y_padding(0., 0.).y_tick_count(ticks).y_tick_format(short_number);
1029+ let chart = div().w_full().h(px(CHART_HEIGHT)).child(chart).into_any_element();
1030+ let names = series.iter().map(|(name, _)| name.clone()).collect();
1031+ let (x, y) = (text_arg(args, 2, ""), text_arg(args, 3, ""));
1032+ self.chart_frame(chart, names, x, y)
1033+ }
1034+
1035+ fn pie_chart(&mut self, args: &[Value], donut: bool) -> AnyElement {
1036+ let labels: Vec<String> = items(arg(args, 0)).iter().map(Value::text).collect();
1037+ let values: Vec<f64> = items(arg(args, 1)).iter().map(|value| value.as_number().unwrap_or(0.).max(0.)).collect();
1038+ let total: f64 = values.iter().sum();
1039+ let rows: Vec<(String, f64, Hsla)> =
1040+ values.iter().enumerate().map(|(ix, value)| (labels.get(ix).cloned().unwrap_or_default(), *value, self.color(ix))).collect();
1041+ let p = self.p;
1042+ let legend = v_flex().gap(px(6.)).text_size(px(12.5)).text_color(p.ink2).children(rows.iter().map(|(label, value, color)| {
1043+ let share = if total > 0. { value / total * 100. } else { 0. };
1044+ h_flex()
1045+ .gap(px(8.))
1046+ .child(div().size(px(9.)).rounded(px(2.)).bg(*color))
1047+ .child(div().child(label.clone()))
1048+ .child(div().font_family(metal::MONO).text_color(p.ink3).child(format!("{share:.0}%")))
1049+ }));
1050+ let chart = PieChart::new(rows)
1051+ .id(self.id("pie"))
1052+ .value(|row: &(String, f64, Hsla)| row.1 as f32)
1053+ .color(|row: &(String, f64, Hsla)| row.2)
1054+ .outer_radius(90.)
1055+ .when(donut, |chart| chart.inner_radius(54.))
1056+ .tooltip_name(|row: &(String, f64, Hsla)| row.0.clone().into());
1057+ h_flex()
1058+ .w_full()
1059+ .gap(px(20.))
1060+ .flex_wrap()
1061+ .items_center()
1062+ .child(div().size(px(200.)).flex_none().child(chart))
1063+ .child(legend)
1064+ .into_any_element()
1065+ }
1066+
1067+ fn image(&mut self, src: String, alt: String, cx: &mut App) -> AnyElement {
1068+ let p = self.p;
1069+ let frame = |child: AnyElement| div().flex().max_w_full().rounded(px(12.)).overflow_hidden().child(child).into_any_element();
1070+ let placeholder = |label: String| {
1071+ h_flex()
1072+ .gap(px(8.))
1073+ .p(px(10.))
1074+ .rounded(px(12.))
1075+ .well(p)
1076+ .text_size(px(12.5))
1077+ .text_color(p.ink3)
1078+ .child(Icon::new(crate::ui::icons::Lucide::Image).size(px(14.)))
1079+ .child(div().min_w_0().truncate().child(label))
1080+ };
1081+ if src.starts_with("data:image/") {
1082+ return match images::inline(&src, cx) {
1083+ Ok(image) => frame(img(image).max_w_full().max_h(px(420.)).into_any_element()),
1084+ Err(error) => placeholder(format!("{alt} ({error})")).into_any_element(),
1085+ };
1086+ }
1087+ if !(src.starts_with("https://") || src.starts_with("http://")) {
1088+ return placeholder(if alt.is_empty() { "Image".into() } else { alt }).into_any_element();
1089+ }
1090+ if self.ui.images.contains(&src) {
1091+ return match images::remote(&src, self.setter().icx, cx) {
1092+ images::Remote::Loading => skeleton("Loading image…", px(120.), p),
1093+ images::Remote::Ready(image) => frame(img(image).max_w_full().max_h(px(420.)).into_any_element()),
1094+ images::Remote::Failed(error) => placeholder(format!("{alt} ({error})")).into_any_element(),
1095+ };
1096+ }
1097+ let host = src.split('/').nth(2).unwrap_or_default().to_string();
1098+ let setter = self.setter();
1099+ let id = self.id("image");
1100+ placeholder(if alt.is_empty() { format!("Image from {host}") } else { format!("{alt} · {host}") })
1101+ .child(MetalButton::new(id).compact().label("Load image").on_click(move |_, _, cx| {
1102+ let src = src.clone();
1103+ setter.update(cx, |ui| {
1104+ ui.images.insert(src);
1105+ });
1106+ }))
1107+ .into_any_element()
1108+ }
1109+ }
1110+
1111+ /// A value from an input as the type it looks like, so `$qty * 2` works.
1112+ /// Text that wouldn't read back the same stays text, so typing `1.` isn't
1113+ /// turned into `1` under the cursor.
1114+ fn typed(text: String) -> Value {
1115+ match text.trim().parse::<f64>() {
1116+ Ok(number) if eval::format_number(number) == text => Value::Number(number),
1117+ _ => Value::String(text),
1118+ }
1119+ }
1120+
1121+ fn eval_equals(a: &Value, b: &Value) -> bool {
1122+ a.get() == b.get() || (!matches!(a.get(), Value::Null) && a.text() == b.text())
1123+ }
1124+
1125+ /// The next round number at or above `value`, for a chart's top line.
1126+ fn nice_ceiling(value: f64) -> f64 {
1127+ if value <= 0. || !value.is_finite() {
1128+ return 1.;
1129+ }
1130+ let magnitude = 10f64.powf(value.log10().floor());
1131+ [1., 2., 2.5, 5., 10.].iter().map(|step| step * magnitude).find(|nice| *nice >= value).unwrap_or(10. * magnitude)
1132+ }
1133+
1134+ /// A chart's value range widened to round numbers, and how many ticks
1135+ /// split it evenly: about four steps of 1, 2, 2.5 or 5 times a power of ten.
1136+ fn nice_scale(min: f64, max: f64) -> (f64, f64, usize) {
1137+ let (min, max) = (min.min(0.), max.max(0.));
1138+ if max - min <= 0. || !(max - min).is_finite() {
1139+ return (0., 1., 5);
1140+ }
1141+ let step = nice_ceiling((max - min) / 4.);
1142+ let low = (min / step).floor() * step;
1143+ let high = (max / step).ceil() * step;
1144+ (low, high, ((high - low) / step).round() as usize + 1)
1145+ }
1146+
1147+ /// `1200` as `1.2k`, for axis ticks.
1148+ fn short_number(value: f64) -> String {
1149+ let (scaled, unit) = match value.abs() {
1150+ v if v >= 1e9 => (value / 1e9, "B"),
1151+ v if v >= 1e6 => (value / 1e6, "M"),
1152+ v if v >= 1e4 => (value / 1e3, "k"),
1153+ _ => (value, ""),
1154+ };
1155+ format!("{}{unit}", eval::format_number((scaled * 10.).round() / 10.))
1156+ }
1157+
1158+ /// Changes a block's state from an event handler, then redraws it.
1159+ #[derive(Clone)]
1160+ struct Setter {
1161+ key: Key,
1162+ icx: InlineCx,
1163+ }
1164+
1165+ impl Setter {
1166+ fn update<R>(&self, cx: &mut App, change: impl FnOnce(&mut UiState) -> R) -> R {
1167+ let result = change(&mut cx.default_global::<Store>().blocks.entry(self.key).or_default().ui);
1168+ self.icx.remeasure(cx);
1169+ cx.refresh_windows();
1170+ result
1171+ }
1172+
1173+ fn set(&self, binding: &Binding, value: Value, cx: &mut App) {
1174+ self.update(cx, |ui| {
1175+ ui.errors.remove(&binding.key());
1176+ match binding {
1177+ Binding::Var(name) => {
1178+ ui.state.insert(name.clone(), value);
1179+ }
1180+ Binding::Field(form, name) => {
1181+ ui.fields.insert((form.clone(), name.clone()), value);
1182+ }
1183+ }
1184+ });
1185+ }
1186+ }
1187+
1188+ /// What a button does when clicked.
1189+ struct ActionHandler {
1190+ setter: Setter,
1191+ program: Arc<Program>,
1192+ /// The form the button sits in, for a bare `"submit"`.
1193+ form: Option<String>,
1194+ /// The fields of every form rendered before the button.
1195+ forms: HashMap<String, Vec<FormField>>,
1196+ }
1197+
1198+ impl ActionHandler {
1199+ fn run(&self, action: &Value, _: &mut Window, cx: &mut App) {
1200+ match action {
1201+ Value::Action(action) => {
1202+ let program = self.program.clone();
1203+ let effects = self.setter.update(cx, |ui| {
1204+ ui.notice = None;
1205+ eval::run(&program, &mut ui.state, action)
1206+ });
1207+ for effect in effects {
1208+ match effect {
1209+ Effect::ToAssistant(text) if !text.trim().is_empty() => self.send(text, cx),
1210+ Effect::ToAssistant(_) => {}
1211+ Effect::OpenUrl(url) if url.starts_with("https://") || url.starts_with("http://") => {
1212+ crate::ui::workspace::open_url(&url)
1213+ }
1214+ Effect::OpenUrl(url) => self.notice(true, format!("Only web links open from here, not {url:?}"), cx),
1215+ Effect::Unsupported(step) => self.notice(true, format!("{step} isn't supported in Vectrum Code"), cx),
1216+ }
1217+ }
1218+ }
1219+ Value::String(action) => {
1220+ let action = action.trim();
1221+ if let Some(form) = action.strip_prefix("submit:").or((action == "submit").then_some("")) {
1222+ let form = if form.is_empty() { self.form.clone().unwrap_or_default() } else { form.to_string() };
1223+ self.submit(&form, cx);
1224+ } else if !action.is_empty() {
1225+ let name = action.strip_prefix("action:").unwrap_or(action);
1226+ self.send(format!("[UI] action: {name}"), cx);
1227+ }
1228+ }
1229+ _ => {}
1230+ }
1231+ }
1232+
1233+ /// Checks a form's rules, then sends its values to the agent.
1234+ fn submit(&self, form: &str, cx: &mut App) {
1235+ let fields = self.forms.get(form).cloned().unwrap_or_default();
1236+ let program = self.program.clone();
1237+ let result = self.setter.update(cx, |ui| {
1238+ let mut values = serde_json::Map::new();
1239+ ui.errors.clear();
1240+ ui.notice = None;
1241+ for field in &fields {
1242+ let value = match &field.binding {
1243+ Binding::Var(name) => eval::state_value(&program, &ui.state, name),
1244+ Binding::Field(form, name) => ui.fields.get(&(form.clone(), name.clone())).cloned().unwrap_or(Value::Null),
1245+ };
1246+ let text = value.text();
1247+ for rule in &field.rules {
1248+ let problem = match rule.as_str() {
1249+ "required" if text.trim().is_empty() || value.get() == &Value::Bool(false) => Some("Required"),
1250+ "email" if !text.is_empty() && !(text.contains('@') && text.contains('.')) => Some("Enter an email address"),
1251+ "number" | "numeric" if !text.is_empty() && value.as_number().is_none() => Some("Enter a number"),
1252+ _ => None,
1253+ };
1254+ if let Some(problem) = problem {
1255+ ui.errors.entry(field.binding.key()).or_insert_with(|| problem.to_string());
1256+ }
1257+ }
1258+ values.insert(field.binding.name().to_string(), value.to_json());
1259+ }
1260+ ui.errors.is_empty().then_some(values)
1261+ });
1262+ if let Some(values) = result {
1263+ let json = serde_json::Value::Object(values);
1264+ self.send(format!("[UI] Submitted {}: {json}", serde_json::Value::String(form.to_string())), cx);
1265+ }
1266+ }
1267+
1268+ fn notice(&self, error: bool, text: String, cx: &mut App) {
1269+ self.setter.update(cx, |ui| ui.notice = Some((error, text)));
1270+ }
1271+
1272+ /// Sends text to the thread's agent as the user, like the composer: while
1273+ /// it works, the follow-up setting decides between steering and queueing.
1274+ fn send(&self, text: String, cx: &mut App) {
1275+ let Some(session) = self.setter.icx.session.upgrade() else { return };
1276+ let queue = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
1277+ let queued = session.update(cx, |session, cx| {
1278+ if (session.is_working() || session.preparing) && queue {
1279+ session.queue_message(text, Vec::new(), cx);
1280+ true
1281+ } else {
1282+ session.send_message(text, Vec::new(), cx);
1283+ false
1284+ }
1285+ });
1286+ self.notice(false, if queued { "Queued for the agent".into() } else { "Sent to the agent".into() }, cx);
1287+ if let Some(scroller) = self.setter.icx.scroller.upgrade() {
1288+ scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
1289+ }
1290+ }
1291+ }
1292+
1293+ /// Images in blocks: data URIs decoded once, and remote images fetched
1294+ /// after the user asks.
1295+ mod images {
1296+ use super::*;
1297+
1298+ pub(super) enum Remote {
1299+ Loading,
1300+ Ready(Arc<Image>),
1301+ Failed(String),
1302+ }
1303+
1304+ #[derive(Default)]
1305+ struct Images {
1306+ entries: HashMap<String, Remote>,
1307+ }
1308+
1309+ impl Global for Images {}
1310+
1311+ fn format(bytes: &[u8]) -> Option<ImageFormat> {
1312+ Some(match bytes {
1313+ [0x89, b'P', b'N', b'G', ..] => ImageFormat::Png,
1314+ [0xff, 0xd8, ..] => ImageFormat::Jpeg,
1315+ [b'G', b'I', b'F', b'8', ..] => ImageFormat::Gif,
1316+ [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => ImageFormat::Webp,
1317+ _ if String::from_utf8_lossy(&bytes[..bytes.len().min(512)]).contains("<svg") => ImageFormat::Svg,
1318+ _ => return None,
1319+ })
1320+ }
1321+
1322+ pub(super) fn inline(src: &str, cx: &mut App) -> Result<Arc<Image>, String> {
1323+ let key = format!("{:x}", super::super::hash(src));
1324+ if let Some(Remote::Ready(image)) = cx.default_global::<Images>().entries.get(&key) {
1325+ return Ok(image.clone());
1326+ }
1327+ let (_, data) = src.split_once(',').ok_or("not a data URI")?;
1328+ if data.len() as u64 > MAX_IMAGE_BYTES * 4 / 3 {
1329+ return Err("too large".into());
1330+ }
1331+ use base64::Engine as _;
1332+ let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|_| "not base64")?;
1333+ let format = format(&bytes).ok_or("unknown format")?;
1334+ let image = Arc::new(Image::from_bytes(format, bytes));
1335+ cx.default_global::<Images>().entries.insert(key, Remote::Ready(image.clone()));
1336+ Ok(image)
1337+ }
1338+
1339+ pub(super) fn remote(src: &str, icx: InlineCx, cx: &mut App) -> Remote {
1340+ match cx.default_global::<Images>().entries.get(src) {
1341+ Some(Remote::Ready(image)) => return Remote::Ready(image.clone()),
1342+ Some(Remote::Failed(error)) => return Remote::Failed(error.clone()),
1343+ Some(Remote::Loading) => return Remote::Loading,
1344+ None => {}
1345+ }
1346+ cx.default_global::<Images>().entries.insert(src.to_string(), Remote::Loading);
1347+ let url = src.to_string();
1348+ let fetch = cx.background_executor().spawn(async move {
1349+ let response = ureq::get(&url).timeout(Duration::from_secs(20)).call().map_err(|err| err.to_string())?;
1350+ let mut bytes = Vec::new();
1351+ response.into_reader().take(MAX_IMAGE_BYTES).read_to_end(&mut bytes).map_err(|err| err.to_string())?;
1352+ let format = format(&bytes).ok_or("not an image")?;
1353+ Ok::<_, String>(Image::from_bytes(format, bytes))
1354+ });
1355+ let src = src.to_string();
1356+ cx.spawn(async move |cx| {
1357+ let result = fetch.await;
1358+ cx.update(|cx| {
1359+ let entry = match result {
1360+ Ok(image) => Remote::Ready(Arc::new(image)),
1361+ Err(error) => Remote::Failed(error),
1362+ };
1363+ cx.default_global::<Images>().entries.insert(src, entry);
1364+ icx.remeasure(cx);
1365+ cx.refresh_windows();
1366+ });
1367+ })
1368+ .detach();
1369+ Remote::Loading
1370+ }
1371+ }
1372+
1373+ #[cfg(test)]
1374+ mod tests {
1375+ use super::{nice_ceiling, short_number, typed};
1376+ use crate::inline_ui::eval::Value;
1377+
1378+ #[::core::prelude::v1::test]
1379+ fn chart_scales() {
1380+ assert_eq!(nice_ceiling(87.), 100.);
1381+ assert_eq!(nice_ceiling(120.), 200.);
1382+ assert_eq!(nice_ceiling(2.2), 2.5);
1383+ assert_eq!(nice_ceiling(0.), 1.);
1384+ assert_eq!(short_number(12_500.), "12.5k");
1385+ assert_eq!(short_number(2_000_000.), "2M");
1386+ assert_eq!(short_number(250.), "250");
1387+ }
1388+
1389+ #[::core::prelude::v1::test]
1390+ fn scales() {
1391+ assert_eq!(super::nice_scale(0., 170.), (0., 200., 5));
1392+ assert_eq!(super::nice_scale(-2., 3.), (-2., 4., 4));
1393+ assert_eq!(super::nice_scale(0., 8.), (0., 8., 5));
1394+ assert_eq!(super::nice_scale(0., 0.), (0., 1., 5));
1395+ }
1396+
1397+ #[::core::prelude::v1::test]
1398+ fn typed_input() {
1399+ assert_eq!(typed("12".into()), Value::Number(12.));
1400+ assert_eq!(typed("1.".into()), Value::String("1.".into()));
1401+ assert_eq!(typed("007".into()), Value::String("007".into()));
1402+ assert_eq!(typed("Ada".into()), Value::String("Ada".into()));
1403+ }
1404+ }