diff --git a/src/app/view/a2ui.rs b/src/app/view/a2ui.rs index b2fc57d..41ed435 100644 --- a/src/app/view/a2ui.rs +++ b/src/app/view/a2ui.rs @@ -1,6 +1,6 @@ use super::*; use crate::a2ui::{Surface, binding_path, bound_value_at, display_value}; -use iced::widget::{Column, Row, column, radio, text_editor}; +use iced::widget::{Column, Row, column, radio, responsive, text_editor}; use serde_json::Value; use std::collections::{BTreeSet, HashMap, HashSet}; use time::{Month, OffsetDateTime}; @@ -23,7 +23,6 @@ impl App { .into() }); container(content) - .max_width(860) .center_x(Length::Fill) .height(Length::Fill) .padding(24) @@ -80,8 +79,20 @@ impl App { .get("agentDisplayName") .and_then(Value::as_str) .unwrap_or("A2UI"); - let content = if surface.components.contains_key("root") { - self.a2ui_component(surface, "root", &surface.data, None, BTreeSet::new()) + 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) @@ -118,7 +129,7 @@ impl App { ] .spacing(8) .align_y(Alignment::Center), - scrollable(content).height(Length::Fill), + content, ] .height(Length::Fill) .spacing(10), @@ -137,6 +148,7 @@ impl App { context: &'a Value, context_path: Option, mut ancestors: BTreeSet, + surface_height: f32, ) -> Element<'a, Message> { if !ancestors.insert(id.to_owned()) { return text(format!("Cyclic component reference: {id}")) @@ -254,6 +266,7 @@ impl App { context_path, ancestors, Some((component, kind == "Row")), + surface_height, ), "List" => { let horizontal = @@ -265,6 +278,7 @@ impl App { context_path, ancestors, Some((component, horizontal)), + surface_height, ); let list = scrollable(content).height(280); if horizontal { @@ -278,11 +292,18 @@ impl App { } "Card" => { let child = component.get("child").and_then(Value::as_str).unwrap_or(""); - container(self.a2ui_component(surface, child, context, context_path, ancestors)) - .padding(14) - .width(Length::Fill) - .style(overview_style) - .into() + 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 @@ -317,7 +338,14 @@ impl App { .unwrap_or(""); column![ labels, - self.a2ui_component(surface, child, context, context_path, ancestors,) + self.a2ui_component( + surface, + child, + context, + context_path, + ancestors, + surface_height, + ) ] .spacing(10) .into() @@ -338,6 +366,7 @@ impl App { context, context_path.clone(), ancestors.clone(), + surface_height, )) .on_press(Message::A2uiToggleModal(key.0.clone(), key.1.clone())); let _ = (content, context_path, ancestors); @@ -345,8 +374,14 @@ impl App { } "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); + 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) @@ -640,7 +675,7 @@ impl App { }; picker.push(choices).into() } - "Chart" => research_chart(component, &surface.data, context), + "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), @@ -654,6 +689,7 @@ impl App { context_path.clone(), ancestors, None, + surface_height, ); let label = component .get("submitLabel") @@ -687,6 +723,7 @@ impl App { } } + #[allow(clippy::too_many_arguments)] fn a2ui_children<'a>( &'a self, surface: &'a Surface, @@ -695,6 +732,7 @@ impl App { context_path: Option, ancestors: BTreeSet, layout: Option<(&serde_json::Map, bool)>, + surface_height: f32, ) -> Element<'a, Message> { let horizontal = layout.is_some_and(|(_, horizontal)| horizontal); let layout = layout.map(|(component, _)| component); @@ -708,6 +746,7 @@ impl App { context, context_path.clone(), ancestors.clone(), + surface_height, ), component_weight(surface, child), )); @@ -728,6 +767,7 @@ impl App { item, Some(format!("{}/{index}", path.trim_end_matches('/'))), ancestors.clone(), + surface_height, ) }), component_weight(surface, component_id), @@ -906,8 +946,14 @@ impl App { 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()); + let content = self.a2ui_component( + surface, + content_id, + &surface.data, + None, + BTreeSet::new(), + 480.0, + ); let dialog = container( column![ row![ @@ -1405,6 +1451,7 @@ fn research_chart<'a>( component: &serde_json::Map, data: &Value, context: &Value, + surface_height: f32, ) -> Element<'a, Message> { let series = bound_value_at(component.get("series"), data, context); let series = series.as_array().map(Vec::as_slice).unwrap_or(&[]); @@ -1413,7 +1460,7 @@ fn research_chart<'a>( .and_then(Value::as_str) .unwrap_or("bar"); if matches!(chart_type, "pie" | "donut") { - return research_pie_chart(component, series, chart_type == "donut"); + return research_pie_chart(component, series, chart_type == "donut", surface_height); } let max = series .iter() @@ -1464,6 +1511,7 @@ fn research_pie_chart<'a>( component: &serde_json::Map, series: &[Value], donut: bool, + surface_height: f32, ) -> Element<'a, Message> { let slices = series .iter() @@ -1482,7 +1530,7 @@ fn research_pie_chart<'a>( .iter() .map(|(_, value, _)| *value) .collect::>(); - let mut chart = Column::new().spacing(8); + let mut chart = Column::new().spacing(8).width(Length::Fill); if let Some(title) = title(component) { chart = chart.push(title); } @@ -1496,35 +1544,56 @@ fn research_pie_chart<'a>( .push(text("No positive values").size(11).color(muted_text())) .into(); } - chart = chart.push( - container( - svg(svg::Handle::from_memory( - pie_chart_svg(&values, donut).into_bytes(), - )) - .width(180) - .height(180), - ) - .width(Length::Fill) - .align_x(Alignment::Center), - ); + let handle = svg::Handle::from_memory(pie_chart_svg(&values, donut).into_bytes()); let total = values.iter().sum::(); - let mut legend: Row<'_, Message> = Row::new().spacing(12); - for (index, (label, _, value)) in slices.into_iter().enumerate() { - let color = chart_color(index); - legend = legend.push( + 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(Space::new().width(10).height(10)) - .style(move |_| container::Style::default().background(color)), - text(label).size(11), - text(format!("{value} · {:.0}%", values[index] / total * 100.0)) - .size(10) - .color(muted_text()), + container(svg(handle.clone()).width(side).height(side)) + .width(side) + .align_x(Alignment::Center), + container(legend).width(Length::Fill), ] - .spacing(5) - .align_y(Alignment::Center), - ); - } - chart.push(legend.wrap()).into() + .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 { @@ -1952,5 +2021,7 @@ mod tests { let donut = pie_chart_svg(&[41.0], true); assert!(donut.contains("stroke-dasharray")); assert!(donut.contains(">41")); + assert_eq!(pie_chart_layout(1_000.0, 400.0), (320.0, 320.0, 16)); + assert_eq!(pie_chart_layout(1_000.0, 700.0), (620.0, 560.0, 31)); } }