| @@ -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, ¤t)) |
| 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, ¤t)).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 | + } |