2217 lines
81 KiB
Rust
2217 lines
81 KiB
Rust
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)
|
||
);
|
||
}
|
||
}
|