use super::*; use crate::a2ui::{Surface, binding_path, bound_value_at, display_value}; use iced::widget::{Column, Row, column, radio, responsive, text_editor}; use serde_json::Value; use std::collections::{BTreeSet, HashMap, HashSet}; use time::{Month, OffsetDateTime}; impl App { pub(super) fn a2ui_detail(&self) -> Element<'_, Message> { let content = self.displayed_a2ui_surface().unwrap_or_else(|| { container( column![ text("No A2UI surface").size(24), text("Interactive surfaces from DS4 will appear here.") .size(14) .color(muted_text()), ] .spacing(8) .align_x(Alignment::Center), ) .center_x(Length::Fill) .center_y(Length::Fill) .into() }); container(content) .center_x(Length::Fill) .height(Length::Fill) .padding(24) .into() } pub(crate) fn sync_a2ui_renderer_state(&mut self) { let mut markdown = HashMap::new(); let mut editors = HashMap::new(); let mut filters = HashSet::new(); if let Some(surface) = self.displayed_a2ui_store().active_surface() { collect_renderer_state( surface, "root", &surface.data, None, BTreeSet::new(), &mut markdown, &mut editors, &mut filters, ); } self.a2ui_markdown = markdown .into_iter() .map(|(key, value)| (key, markdown::Content::parse(&value))) .collect(); self.a2ui_editors.retain(|key, _| editors.contains_key(key)); for (key, value) in editors { match self.a2ui_editors.entry(key) { std::collections::hash_map::Entry::Occupied(mut entry) if entry.get().text() != value => { entry.insert(iced::widget::text_editor::Content::with_text(&value)); } std::collections::hash_map::Entry::Vacant(entry) => { entry.insert(iced::widget::text_editor::Content::with_text(&value)); } _ => {} } } self.a2ui_choice_filters .retain(|key, _| filters.contains(key)); } pub(super) fn displayed_a2ui_surface(&self) -> Option> { self.displayed_a2ui_store() .active_surface() .map(|surface| self.a2ui_surface(surface)) } fn a2ui_surface<'a>(&'a self, surface: &'a Surface) -> Element<'a, Message> { let agent = surface .surface_properties .get("agentDisplayName") .and_then(Value::as_str) .unwrap_or("A2UI"); let content: Element<'_, Message> = if surface.components.contains_key("root") { responsive(move |size| { scrollable(self.a2ui_component( surface, "root", &surface.data, None, BTreeSet::new(), size.height, )) .height(Length::Fill) .into() }) .into() } else { text("Building interactive surface…") .size(13) .color(muted_text()) .into() }; let dismiss = action_button(text("Dismiss").size(11)).padding([5, 9]); let dismiss = if self.generating || self.a2ui_history_index.is_some() { dismiss } else { dismiss.on_press(Message::RequestA2uiDismiss(surface.id.clone())) }; let previous = action_button(icon(ICON_ARROW_LEFT, 15)).padding(5); let previous = if self.has_previous_a2ui_surface() { previous.on_press(Message::A2uiPreviousSurface) } else { previous }; let next = action_button(icon(ICON_ARROW_RIGHT, 15)).padding(5); let next = if self.has_next_a2ui_surface() { next.on_press(Message::A2uiNextSurface) } else { next }; container( column![ row![ previous, next, text(agent).size(10).color(muted_text()), Space::new().width(Length::Fill), text(&surface.id).size(10).color(muted_text()), dismiss, ] .spacing(8) .align_y(Alignment::Center), content, ] .height(Length::Fill) .spacing(10), ) .padding(14) .width(Length::Fill) .height(Length::Fill) .style(preference_group_style) .into() } fn a2ui_component<'a>( &'a self, surface: &'a Surface, id: &str, context: &'a Value, context_path: Option, mut ancestors: BTreeSet, surface_height: f32, ) -> Element<'a, Message> { if !ancestors.insert(id.to_owned()) { return text(format!("Cyclic component reference: {id}")) .style(iced::widget::text::danger) .into(); } let Some(component) = surface.components.get(id).and_then(Value::as_object) else { return text(format!("Waiting for component `{id}`…")) .size(12) .color(muted_text()) .into(); }; let kind = component .get("component") .and_then(Value::as_str) .unwrap_or("Unknown"); match kind { "Text" => { let value = display_value(&bound_value_at( component.get("text"), &surface.data, context, )); let size = match component.get("variant").and_then(Value::as_str) { Some("caption") => 11, _ => 14, }; let key = ( surface.id.clone(), id.to_owned(), context_path.clone().unwrap_or_default(), ); self.a2ui_markdown.get(&key).map_or_else( || text(value).size(size).into(), |content| { markdown::view( content.items(), markdown::Settings::with_text_size( size, markdown::Style::from_palette(app_theme().palette()), ), ) .map(Message::OpenLink) }, ) } "Image" => { let url = display_value(&bound_value_at( component.get("url"), &surface.data, context, )); if let Some(handle) = self.a2ui_images.get(&url) { let description = display_value(&bound_value_at( component.get("description"), &surface.data, context, )); let variant = component.get("variant").and_then(Value::as_str); let size = match variant { Some("icon") => 24, Some("avatar") => 40, Some("smallFeature") => 100, Some("largeFeature") => 300, Some("header") => 200, _ => 200, }; let mut rendered = image(handle.clone()) .width( if matches!(variant, Some("icon" | "avatar" | "smallFeature")) { Length::Fixed(size as f32) } else { Length::Fill }, ) .height(size) .content_fit(match component.get("fit").and_then(Value::as_str) { Some("cover") => iced::ContentFit::Cover, Some("fill") => iced::ContentFit::Fill, Some("none") => iced::ContentFit::None, Some("scaleDown") => iced::ContentFit::ScaleDown, _ => iced::ContentFit::Contain, }); if variant == Some("avatar") { rendered = rendered.border_radius(20); } let mut content = Column::new().push(rendered); if !description.is_empty() { content = content.push(text(description).size(11).color(muted_text())); } content.spacing(5).into() } else { image_link(component, &surface.data, context) } } "Video" | "AudioPlayer" => self.media_player(component, kind, &surface.data, context), "Icon" => text(icon_glyph(&display_value(&bound_value_at( component.get("name"), &surface.data, context, )))) .size(19) .into(), "Divider" => { if component.get("axis").and_then(Value::as_str) == Some("vertical") { rule::vertical(1).into() } else { rule::horizontal(1).into() } } "Row" | "Column" => self.a2ui_children( surface, component.get("children"), context, context_path, ancestors, Some((component, kind == "Row")), surface_height, ), "List" => { let horizontal = component.get("direction").and_then(Value::as_str) == Some("horizontal"); let content = self.a2ui_children( surface, component.get("children"), context, context_path, ancestors, Some((component, horizontal)), surface_height, ); let list = scrollable(content).height(280); if horizontal { list.direction(iced::widget::scrollable::Direction::Horizontal( Default::default(), )) .into() } else { list.into() } } "Card" => { let child = component.get("child").and_then(Value::as_str).unwrap_or(""); container(self.a2ui_component( surface, child, context, context_path, ancestors, surface_height, )) .padding(14) .width(Length::Fill) .style(overview_style) .into() } "Tabs" => { let tabs = component .get("tabs") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or(&[]); let selected = self .a2ui_tabs .get(&(surface.id.clone(), id.to_owned())) .copied() .unwrap_or(0) .min(tabs.len().saturating_sub(1)); let mut labels = Row::new().spacing(6); for (index, tab) in tabs.iter().enumerate() { let title = display_value(&bound_value_at(tab.get("title"), &surface.data, context)); let mut tab_button = action_button(text(title).size(12)); if index != selected { tab_button = tab_button.on_press(Message::A2uiSelectTab( surface.id.clone(), id.to_owned(), index, )); } labels = labels.push(tab_button); } let child = tabs .get(selected) .and_then(|tab| tab.get("child")) .and_then(Value::as_str) .unwrap_or(""); column![ labels, self.a2ui_component( surface, child, context, context_path, ancestors, surface_height, ) ] .spacing(10) .into() } "Modal" => { let trigger = component .get("trigger") .and_then(Value::as_str) .unwrap_or(""); let content = component .get("content") .and_then(Value::as_str) .unwrap_or(""); let key = (surface.id.clone(), id.to_owned()); let trigger = mouse_area(self.a2ui_component( surface, trigger, context, context_path.clone(), ancestors.clone(), surface_height, )) .on_press(Message::A2uiToggleModal(key.0.clone(), key.1.clone())); let _ = (content, context_path, ancestors); trigger.into() } "Button" => { let child = component.get("child").and_then(Value::as_str).unwrap_or(""); let content = self.a2ui_component( surface, child, context, context_path.clone(), ancestors, surface_height, ); let button = if component.get("variant").and_then(Value::as_str) == Some("borderless") { button(content).style(button::text) } else { action_button(content) }; if crate::a2ui::first_failed_check(component, &surface.data).is_some() { button.into() } else if let Some(url) = component .get("action") .and_then(|action| action.get("functionCall")) .and_then(|call| call.get("args")) .and_then(|args| args.get("url")) .map(|url| display_value(&bound_value_at(Some(url), &surface.data, context))) .filter(|url| { url::Url::parse(url) .is_ok_and(|url| matches!(url.scheme(), "http" | "https")) }) { button.on_press(Message::OpenLink(url)).into() } else { button .on_press(Message::A2uiAction( surface.id.clone(), id.to_owned(), context_path, )) .into() } } "TextField" => { let binding = component.get("value"); let value = display_value(&bound_value_at(binding, &surface.data, context)); let label = display_value(&bound_value_at( component.get("label"), &surface.data, context, )); let placeholder = component .get("placeholder") .map(|value| { display_value(&bound_value_at(Some(value), &surface.data, context)) }) .filter(|value| !value.is_empty()) .unwrap_or_else(|| label.clone()); let path = local_path(binding_path(binding), context_path.as_deref()); let variant = component.get("variant").and_then(Value::as_str); let input: Element<'_, Message> = if variant == Some("longText") { let key = path .as_ref() .map(|path| (surface.id.clone(), id.to_owned(), path.clone())); if let Some((key, content)) = key .as_ref() .and_then(|key| self.a2ui_editors.get(key).map(|content| (key, content))) { let mut editor = text_editor(content) .placeholder(placeholder.clone()) .height(120) .padding(9); if path.is_some() { let surface_id = surface.id.clone(); let component_id = id.to_owned(); let path = key.2.clone(); editor = editor.on_action(move |action| { Message::A2uiEditorAction( surface_id.clone(), component_id.clone(), path.clone(), action, ) }); } editor.into() } else { text_input(&placeholder, &value).padding(9).into() } } else { let mut input = text_input(&placeholder, &value) .secure(variant == Some("obscured")) .padding(9); if let Some(path) = path { let surface_id = surface.id.clone(); input = input.on_input(move |value| { if variant == Some("number") && !valid_number_edit(&value) { Message::Noop } else { Message::A2uiDataChanged( surface_id.clone(), path.clone(), Value::String(value), ) } }); } input.into() }; let mut field = column![text(label).size(12), input].spacing(5); if let Some(error) = crate::a2ui::first_failed_check(component, &surface.data) { field = field.push(text(error).size(11).style(iced::widget::text::danger)); } field.into() } "DateTimeInput" => date_time_input(component, surface, context, context_path), "CheckBox" => { let binding = component.get("value"); let checked = bound_value_at(binding, &surface.data, context) .as_bool() .unwrap_or(false); let label = display_value(&bound_value_at( component.get("label"), &surface.data, context, )); let control = toggle(checked).label(label); if let Some(path) = local_path(binding_path(binding), context_path.as_deref()) { let surface_id = surface.id.clone(); control .on_toggle(move |value| { Message::A2uiDataChanged( surface_id.clone(), path.clone(), Value::Bool(value), ) }) .into() } else { control.into() } } "Slider" => { let binding = component.get("value"); let current = bound_value_at(binding, &surface.data, context) .as_f64() .unwrap_or(0.0) as f32; let min = component.get("min").and_then(Value::as_f64).unwrap_or(0.0) as f32; let max = component .get("max") .and_then(Value::as_f64) .unwrap_or(100.0) as f32; if let Some(path) = local_path(binding_path(binding), context_path.as_deref()) { let surface_id = surface.id.clone(); let mut control = slider(min..=max, current.clamp(min, max), move |value| { Message::A2uiDataChanged( surface_id.clone(), path.clone(), serde_json::Number::from_f64(value as f64) .map(Value::Number) .unwrap_or(Value::Null), ) }); if let Some(steps) = component.get("steps").and_then(Value::as_u64) && steps > 0 { control = control.step((max - min) / steps as f32); } column![ text(display_value(&bound_value_at( component.get("label"), &surface.data, context, ))) .size(12), row![control, text(format!("{current}"))] .spacing(8) .align_y(Alignment::Center) ] .spacing(5) .into() } else { progress_bar(min..=max, current.clamp(min, max)).into() } } "ChoicePicker" => { let binding = component.get("value"); let selected = bound_value_at(binding, &surface.data, context); let multiple = component.get("variant").and_then(Value::as_str) == Some("multipleSelection"); let path = local_path(binding_path(binding), context_path.as_deref()); let filter_key = ( surface.id.clone(), id.to_owned(), context_path.clone().unwrap_or_default(), ); let filter = self .a2ui_choice_filters .get(&filter_key) .map_or("", String::as_str); let normalized_filter = filter.to_lowercase(); let options = component .get("options") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or(&[]); let selected_index = options.iter().position(|option| { option.get("value").is_some_and(|value| { selected .as_array() .is_some_and(|values| values.contains(value)) }) }); let display_style = component .get("displayStyle") .and_then(Value::as_str) .unwrap_or("checkbox"); let mut choices: Column<'_, Message> = Column::new().spacing(6); let mut chips: Row<'_, Message> = Row::new().spacing(6); for (option_index, option) in options.iter().enumerate() { let label = display_value(&bound_value_at(option.get("label"), &surface.data, context)); if !normalized_filter.is_empty() && !label.to_lowercase().contains(&normalized_filter) { continue; } let option_value = option.get("value").cloned().unwrap_or(Value::Null); let is_selected = selected .as_array() .is_some_and(|values| values.contains(&option_value)); let next_value = choice_selection(&selected, &option_value, multiple, !is_selected); if display_style == "chips" { let mut choice = action_button(text(if is_selected { format!("✓ {label}") } else { label })); if let Some(path) = path.clone() { choice = choice.on_press(Message::A2uiDataChanged( surface.id.clone(), path, next_value, )); } chips = chips.push(choice); } else if multiple { let mut choice = toggle(is_selected).label(label); if let Some(path) = path.clone() { let surface_id = surface.id.clone(); let option_value = option_value.clone(); let selected = selected.clone(); choice = choice.on_toggle(move |checked| { Message::A2uiDataChanged( surface_id.clone(), path.clone(), choice_selection(&selected, &option_value, true, checked), ) }); } choices = choices.push(choice); } else { let surface_id = surface.id.clone(); let message_path = path.clone(); choices = choices.push(radio(label, option_index, selected_index, move |_| { message_path.clone().map_or(Message::Noop, |path| { Message::A2uiDataChanged( surface_id.clone(), path, next_value.clone(), ) }) })); } } let mut picker = Column::new().spacing(5).push( text(display_value(&bound_value_at( component.get("label"), &surface.data, context, ))) .size(12), ); if component.get("filterable") == Some(&Value::Bool(true)) { let key = filter_key.clone(); picker = picker.push(text_input("Filter choices…", filter).padding(7).on_input( move |value| { Message::A2uiChoiceFilterChanged( key.0.clone(), key.1.clone(), key.2.clone(), value, ) }, )); } let choices: Element<'_, Message> = if display_style == "chips" { chips.wrap().into() } else { choices.into() }; picker.push(choices).into() } "Chart" => research_chart(component, &surface.data, context, surface_height), "Table" => research_table(component, &surface.data, context), "Metric" => research_metric(component, &surface.data, context), "Timeline" => research_timeline(component, &surface.data, context), "Map" => research_map(component, &surface.data, context), "MindMap" => research_mind_map(component, &surface.data, context), "Form" => { let children = self.a2ui_children( surface, component.get("children"), context, context_path.clone(), ancestors, None, surface_height, ); let label = component .get("submitLabel") .and_then(Value::as_str) .unwrap_or("Submit"); let button = action_button(label); let button = if component.get("action").is_some() { button.on_press(Message::A2uiAction( surface.id.clone(), id.to_owned(), context_path, )) } else { button }; column![ component .get("title") .and_then(Value::as_str) .map(|title| text(title.to_owned()).size(18)) .unwrap_or_else(|| text("")), children, button, ] .spacing(10) .into() } _ => text(format!("Unsupported component `{kind}`")) .style(iced::widget::text::danger) .into(), } } #[allow(clippy::too_many_arguments)] fn a2ui_children<'a>( &'a self, surface: &'a Surface, children: Option<&'a Value>, context: &'a Value, context_path: Option, ancestors: BTreeSet, layout: Option<(&serde_json::Map, bool)>, surface_height: f32, ) -> Element<'a, Message> { let horizontal = layout.is_some_and(|(_, horizontal)| horizontal); let layout = layout.map(|(component, _)| component); let mut elements = Vec::new(); if let Some(children) = children.and_then(Value::as_array) { for child in children.iter().filter_map(Value::as_str) { elements.push(( self.a2ui_component( surface, child, context, context_path.clone(), ancestors.clone(), surface_height, ), component_weight(surface, child), )); } } else if let Some(template) = children.and_then(Value::as_object) { let component_id = template .get("componentId") .and_then(Value::as_str) .unwrap_or(""); let path = template.get("path").and_then(Value::as_str).unwrap_or("/"); if let Some(items) = surface.data.pointer(path).and_then(Value::as_array) { for (index, item) in items.iter().enumerate() { elements.push(( crate::a2ui::with_template_index(index, || { self.a2ui_component( surface, component_id, item, Some(format!("{}/{index}", path.trim_end_matches('/'))), ancestors.clone(), surface_height, ) }), component_weight(surface, component_id), )); } } } let align_name = layout .and_then(|component| component.get("align")) .and_then(Value::as_str); let align = match align_name { Some("center") => Alignment::Center, Some("end") => Alignment::End, _ => Alignment::Start, }; let justify = layout .and_then(|component| component.get("justify")) .and_then(Value::as_str); let expands_main_axis = elements.iter().any(|(_, weight)| weight.is_some()) || matches!( justify, Some("center" | "end" | "spaceBetween" | "spaceAround" | "spaceEvenly" | "stretch") ); if horizontal { let mut row = Row::new().spacing(10).align_y(align); if matches!(justify, Some("spaceAround" | "spaceEvenly")) { row = row.push(Space::new().width(Length::FillPortion(1))); } let count = elements.len(); for (index, (element, weight)) in elements.into_iter().enumerate() { if index > 0 && matches!( justify, Some("spaceBetween" | "spaceAround" | "spaceEvenly") ) { row = row.push(Space::new().width(Length::FillPortion( if justify == Some("spaceAround") { 2 } else { 1 }, ))); } let width = weight .or((justify == Some("stretch")).then_some(1)) .map_or(Length::Shrink, Length::FillPortion); let mut child = container(element).width(width); if align_name == Some("stretch") { child = child.height(Length::Fill); } row = row.push(child); } if count > 0 && matches!(justify, Some("spaceAround" | "spaceEvenly")) { row = row.push(Space::new().width(Length::FillPortion(1))); } if align_name == Some("stretch") { row = row.height(Length::Fill); } let row = container(row).width(Length::Fill); match justify { Some("center") => row.align_x(Alignment::Center).into(), Some("end") => row.align_x(Alignment::End).into(), _ => row.into(), } } else { let mut column = Column::new().spacing(10).align_x(align); if matches!(justify, Some("spaceAround" | "spaceEvenly")) { column = column.push(Space::new().height(Length::FillPortion(1))); } let count = elements.len(); for (index, (element, weight)) in elements.into_iter().enumerate() { if index > 0 && matches!( justify, Some("spaceBetween" | "spaceAround" | "spaceEvenly") ) { column = column.push(Space::new().height(Length::FillPortion( if justify == Some("spaceAround") { 2 } else { 1 }, ))); } let height = weight .or((justify == Some("stretch")).then_some(1)) .map_or(Length::Shrink, Length::FillPortion); let mut child = container(element).height(height); if align_name == Some("stretch") { child = child.width(Length::Fill); } column = column.push(child); } if count > 0 && matches!(justify, Some("spaceAround" | "spaceEvenly")) { column = column.push(Space::new().height(Length::FillPortion(1))); } if align_name == Some("stretch") { column = column.width(Length::Fill); } let mut column = container(column); if expands_main_axis { column = column.height(Length::Fill); } match justify { Some("center") => column.align_y(Alignment::Center).into(), Some("end") => column.align_y(Alignment::End).into(), _ => column.into(), } } } fn media_player<'a>( &'a self, component: &'a serde_json::Map, kind: &str, data: &Value, context: &Value, ) -> Element<'a, Message> { let url = display_value(&bound_value_at(component.get("url"), data, context)); let description = display_value(&bound_value_at(component.get("description"), data, context)); let title = if description.is_empty() { url::Url::parse(&url) .ok() .and_then(|url| { url.path_segments() .and_then(Iterator::last) .filter(|name| !name.is_empty()) .map(str::to_owned) }) .unwrap_or_else(|| kind.to_owned()) } else { description.clone() }; let video = kind == "Video"; let mut content = Column::new().spacing(10); if video { let poster = display_value(&bound_value_at(component.get("posterUrl"), data, context)); if let Some(handle) = self.a2ui_images.get(&poster) { content = content.push( image(handle.clone()) .width(Length::Fill) .height(240) .content_fit(iced::ContentFit::Cover), ); } else if !poster.is_empty() { content = content.push( container(text("Loading video poster…").color(muted_text())) .center(Length::Fill) .height(180), ); } } let label = if video { "▶ Play video" } else { "▶ Play audio" }; let mut play = action_button(text(label).size(12)); if url::Url::parse(&url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { play = play.on_press(Message::A2uiPlayMedia(url.clone(), title.clone(), video)); } content = content.push( row![ column![text(title).size(13), text(url).size(10).color(muted_text())] .spacing(3) .width(Length::Fill), play, ] .spacing(10) .align_y(Alignment::Center), ); container(content) .padding(12) .width(Length::Fill) .style(overview_style) .into() } pub(super) fn a2ui_modal_panel(&self) -> Option> { let (surface_id, component_id) = self.a2ui_modals.iter().next()?; let surface = self.displayed_a2ui_store().surface(surface_id)?; let component = surface.components.get(component_id)?.as_object()?; let content_id = component.get("content")?.as_str()?; let content = self.a2ui_component( surface, content_id, &surface.data, None, BTreeSet::new(), 480.0, ); let dialog = container( column![ row![ text("Dialog").size(12), Space::new().width(Length::Fill), action_button("Close").on_press(Message::A2uiToggleModal( surface_id.clone(), component_id.clone(), )), ], content, ] .spacing(12), ) .padding(22) .width(560) .style(overview_style); Some(opaque( container(dialog) .center_x(Length::Fill) .center_y(Length::Fill) .style(|_| { container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) }), )) } } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] struct DateTimeParts { year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8, } fn component_weight(surface: &Surface, id: &str) -> Option { let weight = surface.components.get(id)?.get("weight")?.as_f64()?; (weight > 0.0).then(|| (weight * 1_000.0).round().clamp(1.0, u16::MAX as f64) as u16) } #[derive(Clone, Copy)] enum DatePart { Year, Month, Day, Hour, Minute, } #[derive(Clone)] struct DateControl { surface_id: String, path: String, value: DateTimeParts, enable_date: bool, enable_time: bool, min: Option, max: Option, } impl DateControl { fn message(&self, part: DatePart, value: String) -> Message { let mut parts = self.value; match part { DatePart::Year => { let Ok(value) = value.parse() else { return Message::Noop; }; parts.year = value; } DatePart::Month => parts.month = value.parse().unwrap_or(parts.month), DatePart::Day => parts.day = value.parse().unwrap_or(parts.day), DatePart::Hour => parts.hour = value.parse().unwrap_or(parts.hour), DatePart::Minute => parts.minute = value.parse().unwrap_or(parts.minute), } parts.day = parts.day.min(days_in_month(parts.year, parts.month)); if let Some(min) = self.min { parts = parts.max(min); } if let Some(max) = self.max { parts = parts.min(max); } Message::A2uiDataChanged( self.surface_id.clone(), self.path.clone(), Value::String(format_date_time(parts, self.enable_date, self.enable_time)), ) } } fn date_time_input<'a>( component: &serde_json::Map, surface: &Surface, context: &Value, context_path: Option, ) -> Element<'a, Message> { let label = display_value(&bound_value_at( component.get("label"), &surface.data, context, )); let binding = component.get("value"); let enable_date = component .get("enableDate") .and_then(Value::as_bool) .unwrap_or(false); let enable_time = component .get("enableTime") .and_then(Value::as_bool) .unwrap_or(false); let now = OffsetDateTime::now_utc(); let fallback = DateTimeParts { year: now.year(), month: now.month() as u8, day: now.day(), hour: now.hour(), minute: now.minute(), second: now.second(), }; let raw = display_value(&bound_value_at(binding, &surface.data, context)); let mut value = parse_date_time(&raw, enable_date, enable_time, fallback).unwrap_or(fallback); let min = component .get("min") .map(|value| display_value(&bound_value_at(Some(value), &surface.data, context))) .and_then(|value| parse_date_time(&value, enable_date, enable_time, fallback)); let max = component .get("max") .map(|value| display_value(&bound_value_at(Some(value), &surface.data, context))) .and_then(|value| parse_date_time(&value, enable_date, enable_time, fallback)); if let Some(min) = min { value = value.max(min); } if let Some(max) = max { value = value.min(max); } let Some(path) = local_path(binding_path(binding), context_path.as_deref()) else { return column![text(label).size(12), text(raw)].spacing(5).into(); }; if !enable_date && !enable_time { return column![text(label).size(12), text(raw)].spacing(5).into(); } let control = DateControl { surface_id: surface.id.clone(), path, value, enable_date, enable_time, min, max, }; let mut inputs = Row::new().spacing(6).align_y(Alignment::Center); if enable_date { let first_year = min .map_or(value.year.saturating_sub(100), |parts| parts.year) .clamp(0, 9999); let last_year = max .map_or(value.year.saturating_add(100), |parts| parts.year) .clamp(0, 9999); let years = (first_year.min(last_year)..=first_year.max(last_year)) .map(|year| format!("{year:04}")) .collect::>(); let update = control.clone(); inputs = inputs .push(pick_list( years, Some(format!("{:04}", value.year)), move |value| update.message(DatePart::Year, value), )) .push(text("–").color(muted_text())); let update = control.clone(); inputs = inputs .push(pick_list( (1..=12) .map(|value| format!("{value:02}")) .collect::>(), Some(format!("{:02}", value.month)), move |value| update.message(DatePart::Month, value), )) .push(text("–").color(muted_text())); let update = control.clone(); inputs = inputs.push(pick_list( (1..=days_in_month(value.year, value.month)) .map(|value| format!("{value:02}")) .collect::>(), Some(format!("{:02}", value.day)), move |value| update.message(DatePart::Day, value), )); } if enable_date && enable_time { inputs = inputs.push(text("at").size(12).color(muted_text())); } if enable_time { let update = control.clone(); inputs = inputs .push(pick_list( (0..24) .map(|value| format!("{value:02}")) .collect::>(), Some(format!("{:02}", value.hour)), move |value| update.message(DatePart::Hour, value), )) .push(text(":").color(muted_text())); inputs = inputs.push(pick_list( (0..60) .map(|value| format!("{value:02}")) .collect::>(), Some(format!("{:02}", value.minute)), move |value| control.message(DatePart::Minute, value), )); } let mut field = column![text(label).size(12), inputs].spacing(5); if let Some(error) = crate::a2ui::first_failed_check(component, &surface.data) { field = field.push(text(error).size(11).style(iced::widget::text::danger)); } field.into() } fn parse_date_time( value: &str, enable_date: bool, enable_time: bool, mut parts: DateTimeParts, ) -> Option { let (date, time) = match (enable_date, enable_time) { (true, true) => value.split_once('T')?, (true, false) => (value, ""), (false, true) => ("", value), (false, false) => return Some(parts), }; if enable_date { let mut values = date.split('-'); parts.year = values.next()?.parse().ok()?; parts.month = values.next()?.parse().ok()?; parts.day = values.next()?.parse().ok()?; if values.next().is_some() || !(0..=9999).contains(&parts.year) || !(1..=12).contains(&parts.month) || parts.day == 0 || parts.day > days_in_month(parts.year, parts.month) { return None; } } if enable_time { let time = time .trim_end_matches('Z') .split(['+', '-']) .next() .unwrap_or(time) .split('.') .next() .unwrap_or(time); let mut values = time.split(':'); parts.hour = values.next()?.parse().ok()?; parts.minute = values.next()?.parse().ok()?; parts.second = values.next().unwrap_or("0").parse().ok()?; if values.next().is_some() || parts.hour > 23 || parts.minute > 59 || parts.second > 59 { return None; } } Some(parts) } fn format_date_time(parts: DateTimeParts, enable_date: bool, enable_time: bool) -> String { match (enable_date, enable_time) { (true, true) => format!( "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second ), (true, false) => format!("{:04}-{:02}-{:02}", parts.year, parts.month, parts.day), (false, true) => format!("{:02}:{:02}:{:02}", parts.hour, parts.minute, parts.second), (false, false) => String::new(), } } fn days_in_month(year: i32, month: u8) -> u8 { Month::try_from(month) .map(|month| month.length(year)) .unwrap_or(31) } fn valid_number_edit(value: &str) -> bool { matches!(value, "" | "+" | "-" | "." | "+." | "-.") || value.parse::().is_ok() } fn choice_selection(selected: &Value, option: &Value, multiple: bool, checked: bool) -> Value { let mut values = selected.as_array().cloned().unwrap_or_default(); if multiple { values.retain(|value| value != option); if checked { values.push(option.clone()); } } else { values = vec![option.clone()]; } Value::Array(values) } #[allow(clippy::too_many_arguments)] fn collect_renderer_state( surface: &Surface, id: &str, context: &Value, context_path: Option, mut ancestors: BTreeSet, markdown: &mut HashMap<(String, String, String), String>, editors: &mut HashMap<(String, String, String), String>, filters: &mut HashSet<(String, String, String)>, ) { if !ancestors.insert(id.to_owned()) { return; } let Some(component) = surface.components.get(id).and_then(Value::as_object) else { return; }; let context_key = context_path.clone().unwrap_or_default(); match component.get("component").and_then(Value::as_str) { Some("Text") => { markdown.insert( (surface.id.clone(), id.to_owned(), context_key), display_value(&bound_value_at( component.get("text"), &surface.data, context, )), ); } Some("TextField") if component.get("variant").and_then(Value::as_str) == Some("longText") => { if let Some(path) = local_path( binding_path(component.get("value")), context_path.as_deref(), ) { editors.insert( (surface.id.clone(), id.to_owned(), path), display_value(&bound_value_at( component.get("value"), &surface.data, context, )), ); } } Some("ChoicePicker") if component.get("filterable") == Some(&Value::Bool(true)) => { filters.insert((surface.id.clone(), id.to_owned(), context_key)); } _ => {} } match component.get("component").and_then(Value::as_str) { Some("Row" | "Column" | "List" | "Form") => collect_children_state( surface, component.get("children"), context, context_path, ancestors, markdown, editors, filters, ), Some("Card" | "Button") => { if let Some(child) = component.get("child").and_then(Value::as_str) { collect_renderer_state( surface, child, context, context_path, ancestors, markdown, editors, filters, ); } } Some("Tabs") => { for child in component .get("tabs") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|tab| tab.get("child").and_then(Value::as_str)) { collect_renderer_state( surface, child, context, context_path.clone(), ancestors.clone(), markdown, editors, filters, ); } } Some("Modal") => { for child in ["trigger", "content"] .into_iter() .filter_map(|field| component.get(field).and_then(Value::as_str)) { collect_renderer_state( surface, child, context, context_path.clone(), ancestors.clone(), markdown, editors, filters, ); } } _ => {} } } #[allow(clippy::too_many_arguments)] fn collect_children_state( surface: &Surface, children: Option<&Value>, context: &Value, context_path: Option, ancestors: BTreeSet, markdown: &mut HashMap<(String, String, String), String>, editors: &mut HashMap<(String, String, String), String>, filters: &mut HashSet<(String, String, String)>, ) { if let Some(children) = children.and_then(Value::as_array) { for child in children.iter().filter_map(Value::as_str) { collect_renderer_state( surface, child, context, context_path.clone(), ancestors.clone(), markdown, editors, filters, ); } } else if let Some(template) = children.and_then(Value::as_object) { let component_id = template .get("componentId") .and_then(Value::as_str) .unwrap_or(""); let path = template.get("path").and_then(Value::as_str).unwrap_or("/"); for (index, item) in surface .data .pointer(path) .and_then(Value::as_array) .into_iter() .flatten() .enumerate() { collect_renderer_state( surface, component_id, item, Some(format!("{}/{index}", path.trim_end_matches('/'))), ancestors.clone(), markdown, editors, filters, ); } } } fn title<'a>(component: &serde_json::Map) -> Option> { component .get("title") .and_then(Value::as_str) .map(|title| text(title.to_owned()).size(17)) } fn image_link<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { let url = display_value(&bound_value_at(component.get("url"), data, context)); let button = action_button(text("Open image").size(12)); if url::Url::parse(&url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { button.on_press(Message::OpenLink(url)).into() } else { column![button, text(url).size(11).color(muted_text())] .spacing(5) .into() } } fn research_chart<'a>( component: &serde_json::Map, data: &Value, context: &Value, surface_height: f32, ) -> Element<'a, Message> { let series = bound_value_at(component.get("series"), data, context); let series = series.as_array().map(Vec::as_slice).unwrap_or(&[]); let chart_type = component .get("chartType") .and_then(Value::as_str) .unwrap_or("bar"); if matches!(chart_type, "pie" | "donut") { return research_pie_chart(component, series, chart_type == "donut", surface_height); } if chart_type == "heatmap" { return research_heatmap(component, series); } let max = series .iter() .filter_map(|point| point.get("value").and_then(Value::as_f64)) .fold(0.0_f64, f64::max) .max(1.0) as f32; let mut rows = Column::new().spacing(7); if let Some(title) = title(component) { rows = rows.push(title); } rows = rows.push(text(chart_type.to_uppercase()).size(10).color(muted_text())); for point in series { let label = point.get("label").map(display_value).unwrap_or_default(); let value = point.get("value").and_then(Value::as_f64).unwrap_or(0.0) as f32; rows = rows.push( row![ text(label).size(11).width(110), progress_bar(0.0..=max, value.clamp(0.0, max)), text(format!("{value}")).size(11).width(55), ] .spacing(8) .align_y(Alignment::Center), ); if let Some(segments) = point.get("segments").and_then(Value::as_array) { rows = rows.push( text( segments .iter() .map(|segment| { format!( "{} {}", segment.get("label").map(display_value).unwrap_or_default(), segment.get("value").map(display_value).unwrap_or_default() ) }) .collect::>() .join(" · "), ) .size(10) .color(muted_text()), ); } } rows.into() } #[derive(Debug, PartialEq)] struct HeatmapRow { label: String, cells: Vec, } #[derive(Debug, PartialEq)] struct HeatmapData { columns: Vec, rows: Vec, max: f64, } fn heatmap_data(series: &[Value]) -> HeatmapData { let rows = series .iter() .filter(|entry| { entry .get("segments") .and_then(Value::as_array) .is_some_and(|segments| !segments.is_empty()) }) .collect::>(); let mut columns = Vec::new(); let mut max = 0.0_f64; for entry in &rows { for segment in entry["segments"].as_array().into_iter().flatten() { if let Some(label) = segment.get("label").and_then(Value::as_str) && !columns.iter().any(|column| column == label) { columns.push(label.to_owned()); } if let Some(value) = segment.get("value").and_then(Value::as_f64) { max = max.max(value); } } } let rows = rows .into_iter() .map(|entry| { let mut values = HashMap::new(); for segment in entry["segments"].as_array().into_iter().flatten() { if let (Some(label), Some(value)) = ( segment.get("label").and_then(Value::as_str), segment.get("value").and_then(Value::as_f64), ) { values.insert(label, value); } } HeatmapRow { label: entry.get("label").map(display_value).unwrap_or_default(), cells: columns .iter() .map(|column| values.get(column.as_str()).copied().unwrap_or(0.0)) .collect(), } }) .collect(); HeatmapData { columns, rows, max } } fn research_heatmap<'a>( component: &serde_json::Map, series: &[Value], ) -> Element<'a, Message> { let heatmap = heatmap_data(series); let mut chart = Column::new().spacing(8).width(Length::Fill); if let Some(title) = title(component) { chart = chart.push(title); } chart = chart.push(text("HEATMAP").size(10).color(muted_text())); if heatmap.rows.is_empty() || heatmap.columns.is_empty() { return chart.into(); } chart .push(responsive(move |size| { let row_label_width = size.width.min(80.0); let gap = 2.0; let cell_side = ((size.width - row_label_width - gap * heatmap.columns.len() as f32) / heatmap.columns.len() as f32) .max(14.0); let mut grid = Column::new().spacing(gap); let mut header = Row::new() .spacing(gap) .push(Space::new().width(row_label_width).height(14)); for label in &heatmap.columns { header = header.push( container(text(label.clone()).size(11).color(muted_text())) .width(cell_side) .align_x(Alignment::Center), ); } grid = grid.push(header); for row in &heatmap.rows { let mut cells = Row::new().spacing(gap).align_y(Alignment::Center).push( container(text(row.label.clone()).size(11).color(muted_text())) .width(row_label_width) .padding([0, 4]) .align_x(Alignment::End), ); for value in &row.cells { let value = *value; let (background, foreground) = heatmap_cell_colors(value, heatmap.max); cells = cells.push( container( text(if value > 0.0 { format_chart_value(value) } else { String::new() }) .size(10), ) .width(cell_side) .height(cell_side) .center_x(Length::Fill) .center_y(Length::Fill) .style(move |_| container::Style { text_color: Some(foreground), background: Some(background.into()), border: Border { radius: 2.0.into(), ..Border::default() }, ..container::Style::default() }), ); } grid = grid.push(cells); } grid.into() })) .into() } fn heatmap_cell_colors(value: f64, max: f64) -> (Color, Color) { let intensity = if max > 0.0 { value / max } else { 0.0 }; if intensity <= 0.0 { return (Color::TRANSPARENT, app_theme().palette().text); } let intensity = intensity as f32; let red = (53.0 + (183.0 - 53.0) * intensity).round(); let green = (117.0 + (72.0 - 117.0) * intensity).round(); let blue = (56.0 + (72.0 - 56.0) * intensity).round(); let opacity = 0.25 + intensity * 0.75; let effective_red = red * opacity + 30.0 * (1.0 - opacity); let effective_green = green * opacity + 30.0 * (1.0 - opacity); let effective_blue = blue * opacity + 30.0 * (1.0 - opacity); let foreground = if 0.299 * effective_red + 0.587 * effective_green + 0.114 * effective_blue > 140.0 { Color::BLACK } else { Color::WHITE }; ( Color::from_rgba8(red as u8, green as u8, blue as u8, opacity), foreground, ) } fn research_pie_chart<'a>( component: &serde_json::Map, series: &[Value], donut: bool, surface_height: f32, ) -> Element<'a, Message> { let slices = series .iter() .filter_map(|point| { let value = point.get("value")?.as_f64()?; (value > 0.0).then(|| { ( point.get("label").map(display_value).unwrap_or_default(), value, point.get("value").map(display_value).unwrap_or_default(), ) }) }) .collect::>(); let values = slices .iter() .map(|(_, value, _)| *value) .collect::>(); let mut chart = Column::new().spacing(8).width(Length::Fill); if let Some(title) = title(component) { chart = chart.push(title); } chart = chart.push( text(if donut { "DONUT" } else { "PIE" }) .size(10) .color(muted_text()), ); if slices.is_empty() { return chart .push(text("No positive values").size(11).color(muted_text())) .into(); } let handle = svg::Handle::from_memory(pie_chart_svg(&values, donut).into_bytes()); let total = values.iter().sum::(); chart = chart.push( responsive(move |size| { let (body_height, side, rows_per_column) = pie_chart_layout(size.width, surface_height); let mut legend = Row::new().spacing(14); for (column_index, entries) in slices.chunks(rows_per_column).enumerate() { let mut column = Column::new().spacing(6); for (row_index, (label, _, value)) in entries.iter().enumerate() { let index = column_index * rows_per_column + row_index; let color = chart_color(index); column = column.push( row![ container(Space::new().width(10).height(10)) .style(move |_| { container::Style::default().background(color) }), text(format!( "{label} {value} · {:.0}%", values[index] / total * 100.0 )) .size(11), ] .spacing(5) .align_y(Alignment::Center), ); } legend = legend.push(column); } row![ container(svg(handle.clone()).width(side).height(side)) .width(side) .align_x(Alignment::Center), container(legend).width(Length::Fill), ] .height(body_height) .spacing(18) .align_y(Alignment::Center) .into() }) .height(Length::Shrink), ); chart.into() } fn pie_chart_layout(width: f32, surface_height: f32) -> (f32, f32, usize) { let body_height = (surface_height - 80.0).max(0.0); ( body_height, (width * 0.68).min(body_height).min(560.0), ((body_height + 6.0) / 20.0).max(1.0) as usize, ) } fn pie_chart_svg(values: &[f64], donut: bool) -> String { let total = values .iter() .copied() .filter(|value| *value > 0.0) .sum::(); if total <= 0.0 { return String::new(); } let mut current = 0.0; let mut slices = String::new(); for (index, value) in values.iter().copied().enumerate() { if value <= 0.0 { continue; } let fraction = value / total; let color = hex(chart_color(index)); if donut { let circumference = 2.0 * std::f64::consts::PI * 46.0; slices.push_str(&format!( r#""#, fraction * circumference, (1.0 - fraction) * circumference, -current * circumference, )); } else if fraction >= 0.999_999 { slices.push_str(&format!( r#""# )); } else { let start = current * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2; let end = (current + fraction) * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2; let (x1, y1) = (70.0 + 56.0 * start.cos(), 70.0 + 56.0 * start.sin()); let (x2, y2) = (70.0 + 56.0 * end.cos(), 70.0 + 56.0 * end.sin()); let large = u8::from(fraction > 0.5); slices.push_str(&format!( r##""## )); } current += fraction; } let center = donut.then(|| { format!( r#"{}"#, hex(app_theme().palette().text), format_chart_value(total), ) }); format!( r#"{slices}{}"#, center.unwrap_or_default() ) } fn chart_color(index: usize) -> Color { [ Color::from_rgb8(117, 190, 255), Color::from_rgb8(137, 209, 133), Color::from_rgb8(209, 134, 22), Color::from_rgb8(241, 76, 76), Color::from_rgb8(177, 128, 215), Color::from_rgb8(226, 196, 64), ][index % 6] } fn format_chart_value(value: f64) -> String { if value.fract() == 0.0 { format!("{value:.0}") } else { format!("{value:.2}") } } fn research_table<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { let columns = bound_value_at(component.get("columns"), data, context); let rows = bound_value_at(component.get("rows"), data, context); let mut table = Column::new().spacing(0); if let Some(title) = title(component) { table = table.push(container(title).padding(8)); } if let Some(columns) = columns.as_array() { table = table .push(table_row(columns, true)) .push(rule::horizontal(1)); } if let Some(rows) = rows.as_array() { for (index, row) in rows.iter().filter_map(Value::as_array).enumerate() { if index > 0 { table = table.push(rule::horizontal(1)); } table = table.push(table_row(row, false)); } } container(table) .width(Length::Fill) .style(overview_style) .into() } fn table_row<'a>(values: &[Value], header: bool) -> Element<'a, Message> { values .iter() .fold(Row::new().spacing(8), |row, value| { row.push( text(display_value(value)) .size(if header { 12 } else { 11 }) .width(Length::Fill), ) }) .padding(8) .into() } fn research_metric<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { column![ text(display_value(&bound_value_at( component.get("label"), data, context ))) .size(11) .color(muted_text()), text(display_value(&bound_value_at( component.get("value"), data, context ))) .size(28), text(display_value(&bound_value_at( component.get("detail"), data, context ))) .size(11) .color(muted_text()), ] .spacing(4) .into() } fn research_timeline<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { let events = bound_value_at(component.get("events"), data, context); let mut timeline = Column::new().spacing(8); if let Some(title) = title(component) { timeline = timeline.push(title); } for event in events.as_array().map(Vec::as_slice).unwrap_or(&[]) { timeline = timeline.push( row![ text("●").size(12), column![ text(event.get("title").map(display_value).unwrap_or_default()).size(13), text(format!( "{}{}", event.get("time").map(display_value).unwrap_or_default(), event .get("description") .map(|value| format!(" · {}", display_value(value))) .unwrap_or_default() )) .size(11) .color(muted_text()), ] .spacing(2), ] .spacing(8), ); } timeline.into() } fn research_map<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { let locations = bound_value_at(component.get("locations"), data, context); let mut map = Column::new().spacing(7); if let Some(title) = title(component) { map = map.push(title); } for location in locations.as_array().map(Vec::as_slice).unwrap_or(&[]) { let label = location.get("label").map(display_value).unwrap_or_default(); let latitude = location .get("latitude") .map(display_value) .unwrap_or_default(); let longitude = location .get("longitude") .map(display_value) .unwrap_or_default(); map = map.push( row![ text("⌖").size(18), column![ text(label).size(13), text(format!("{latitude}, {longitude}")) .size(11) .color(muted_text()), ] ] .spacing(8), ); } container(map) .padding(10) .width(Length::Fill) .style(overview_style) .into() } fn research_mind_map<'a>( component: &serde_json::Map, data: &Value, context: &Value, ) -> Element<'a, Message> { let nodes = bound_value_at(component.get("nodes"), data, context); let nodes = nodes.as_array().map(Vec::as_slice).unwrap_or(&[]); let mut map = Column::new().spacing(6); if let Some(title) = title(component) { map = map.push(title); } if let Some(root) = nodes.first() { map = map.push(mind_node(nodes, root, 0, BTreeSet::new())); } map.into() } fn mind_node<'a>( nodes: &[Value], node: &Value, depth: usize, mut seen: BTreeSet, ) -> Element<'a, Message> { let id = node.get("id").map(display_value).unwrap_or_default(); if !seen.insert(id) { return text("Cycle").size(11).color(muted_text()).into(); } let mut branch = Column::new().spacing(4).push( row![ Space::new().width((depth * 18) as f32), text(if depth == 0 { "◆" } else { "↳" }).size(12), text(node.get("label").map(display_value).unwrap_or_default()).size(13), ] .spacing(6), ); for child in node .get("children") .and_then(Value::as_array) .into_iter() .flatten() { if let Some(child) = nodes.iter().find(|node| node.get("id") == Some(child)) { branch = branch.push(mind_node(nodes, child, depth + 1, seen.clone())); } } branch.into() } fn local_path(path: Option<&str>, context_path: Option<&str>) -> Option { let path = path?; if path.starts_with('/') { Some(path.to_owned()) } else { Some(format!( "{}/{}", context_path.unwrap_or("").trim_end_matches('/'), path )) } } fn icon_glyph(name: &str) -> &'static str { match name { "accountCircle" | "person" => "●", "add" => "+", "arrowBack" => "←", "arrowForward" => "→", "attachFile" => "⌕", "calendarToday" | "event" => "▣", "call" | "phone" => "☎", "camera" => "◉", "check" => "✓", "close" => "×", "delete" => "⌫", "download" => "⇩", "edit" => "✎", "error" => "⊗", "fastForward" => "≫", "favorite" => "♥", "favoriteOff" => "♡", "folder" => "▰", "help" => "?", "home" => "⌂", "warning" => "⚠", "info" => "ⓘ", "locationOn" => "⌖", "lock" => "▣", "lockOpen" => "□", "search" => "⌕", "mail" => "✉", "menu" => "☰", "moreVert" => "⋮", "moreHoriz" => "…", "notifications" => "◈", "notificationsOff" => "◇", "pause" => "Ⅱ", "payment" => "¤", "photo" => "▧", "play" => "▶", "print" => "▤", "refresh" => "↻", "rewind" => "≪", "send" => "➤", "settings" => "⚙", "share" => "↗", "shoppingCart" => "⌑", "skipNext" => "▸|", "skipPrevious" => "|◂", "star" => "★", "starHalf" => "☆", "starOff" => "☆", "stop" => "■", "upload" => "⇧", "visibility" => "◉", "visibilityOff" => "○", "volumeDown" => "◖", "volumeMute" => "◁", "volumeOff" => "×", "volumeUp" => "◀", _ => "•", } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn parts() -> DateTimeParts { DateTimeParts { year: 2024, month: 1, day: 31, hour: 12, minute: 30, second: 45, } } #[test] fn renderer_input_helpers_cover_dates_numbers_choices_and_weights() { let fallback = parts(); let leap = parse_date_time("2024-02-29T23:59:58Z", true, true, fallback).unwrap(); assert_eq!(format_date_time(leap, true, true), "2024-02-29T23:59:58"); assert!(parse_date_time("2023-02-29", true, false, fallback).is_none()); assert_eq!( format_date_time( parse_date_time("08:09+02:00", false, true, fallback).unwrap(), false, true ), "08:09:00" ); assert!(valid_number_edit("-.")); assert!(valid_number_edit("1.25e-3")); assert!(!valid_number_edit("one")); assert_eq!( choice_selection(&json!(["a"]), &json!("b"), true, true), json!(["a", "b"]) ); assert_eq!( choice_selection(&json!(["a", "b"]), &json!("a"), true, false), json!(["b"]) ); assert_eq!( choice_selection(&json!(["a"]), &json!("b"), false, true), json!(["b"]) ); let surface = Surface { id: "surface".into(), catalog_id: crate::a2ui::CATALOG_ID.into(), surface_properties: json!({}), send_data_model: true, components: [("child".into(), json!({"weight": 1.5}))].into(), data: json!({}), owner_message_id: 1, }; assert_eq!(component_weight(&surface, "child"), Some(1_500)); let control = DateControl { surface_id: "surface".into(), path: "/when".into(), value: parts(), enable_date: true, enable_time: false, min: None, max: None, }; let Message::A2uiDataChanged(_, _, Value::String(value)) = control.message(DatePart::Month, "2".into()) else { panic!("date control did not update its binding"); }; assert_eq!(value, "2024-02-29"); let pie = pie_chart_svg(&[60.0, 25.0, 15.0], false); assert_eq!(pie.matches("41")); assert_eq!(pie_chart_layout(1_000.0, 400.0), (320.0, 320.0, 16)); assert_eq!(pie_chart_layout(1_000.0, 700.0), (620.0, 560.0, 31)); let series = json!([ {"label":"2024","value":99,"segments":[{"label":"Jan","value":2},{"label":"Feb","value":4}]}, {"label":"plain","value":50}, {"label":"2025","segments":[{"label":"Feb","value":8},{"label":"Mar","value":1}]} ]); let heatmap = heatmap_data(series.as_array().unwrap()); assert_eq!(heatmap.columns, ["Jan", "Feb", "Mar"]); assert_eq!(heatmap.max, 8.0); assert_eq!( heatmap.rows, [ HeatmapRow { label: "2024".into(), cells: vec![2.0, 4.0, 0.0], }, HeatmapRow { label: "2025".into(), cells: vec![0.0, 8.0, 1.0], }, ] ); assert_eq!(heatmap_cell_colors(0.0, heatmap.max).0, Color::TRANSPARENT); assert_eq!( heatmap_cell_colors(heatmap.max, heatmap.max), (Color::from_rgb8(183, 72, 72), Color::WHITE) ); } }