Files
DS4Server/src/app/view/a2ui.rs
2026-07-28 18:44:45 +02:00

2217 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Element<'_, Message>> {
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<String>,
mut ancestors: BTreeSet<String>,
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<String>,
ancestors: BTreeSet<String>,
layout: Option<(&serde_json::Map<String, Value>, 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<String, Value>,
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<Element<'_, Message>> {
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<u16> {
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<DateTimeParts>,
max: Option<DateTimeParts>,
}
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<String, Value>,
surface: &Surface,
context: &Value,
context_path: Option<String>,
) -> 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::<Vec<_>>();
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::<Vec<_>>(),
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::<Vec<_>>(),
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::<Vec<_>>(),
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::<Vec<_>>(),
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<DateTimeParts> {
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::<f64>().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<String>,
mut ancestors: BTreeSet<String>,
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<String>,
ancestors: BTreeSet<String>,
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<String, Value>) -> Option<iced::widget::Text<'a>> {
component
.get("title")
.and_then(Value::as_str)
.map(|title| text(title.to_owned()).size(17))
}
fn image_link<'a>(
component: &serde_json::Map<String, Value>,
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<String, Value>,
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::<Vec<_>>()
.join(" · "),
)
.size(10)
.color(muted_text()),
);
}
}
rows.into()
}
#[derive(Debug, PartialEq)]
struct HeatmapRow {
label: String,
cells: Vec<f64>,
}
#[derive(Debug, PartialEq)]
struct HeatmapData {
columns: Vec<String>,
rows: Vec<HeatmapRow>,
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::<Vec<_>>();
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<String, Value>,
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<String, Value>,
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::<Vec<_>>();
let values = slices
.iter()
.map(|(_, value, _)| *value)
.collect::<Vec<_>>();
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::<f64>();
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::<f64>();
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#"<circle cx="70" cy="70" r="46" fill="none" stroke="{color}" stroke-width="24" stroke-dasharray="{:.3} {:.3}" stroke-dashoffset="{:.3}" transform="rotate(-90 70 70)"/>"#,
fraction * circumference,
(1.0 - fraction) * circumference,
-current * circumference,
));
} else if fraction >= 0.999_999 {
slices.push_str(&format!(
r#"<circle cx="70" cy="70" r="56" fill="{color}"/>"#
));
} 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##"<path d="M70 70 L{x1:.3} {y1:.3} A56 56 0 {large} 1 {x2:.3} {y2:.3} Z" fill="{color}" stroke="#1f1f21" stroke-width="1"/>"##
));
}
current += fraction;
}
let center = donut.then(|| {
format!(
r#"<text x="70" y="70" text-anchor="middle" dominant-baseline="central" fill="{}" font-family="sans-serif" font-size="16" font-weight="600">{}</text>"#,
hex(app_theme().palette().text),
format_chart_value(total),
)
});
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140">{slices}{}</svg>"#,
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<String, Value>,
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<String, Value>,
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<String, Value>,
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<String, Value>,
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<String, Value>,
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<String>,
) -> 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<String> {
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("<path").count(), 3);
let donut = pie_chart_svg(&[41.0], true);
assert!(donut.contains("stroke-dasharray"));
assert!(donut.contains(">41</text>"));
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)
);
}
}