Skip to main content

a2ui_tui/components/
choice_picker.rs

1//! ChoicePicker component — renders a list of selectable options.
2
3use ratatui::{
4    Frame,
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Paragraph},
9};
10
11use a2ui_base::event::{EventResult, InputEvent, InputKey};
12use a2ui_base::model::component_context::ComponentContext;
13use a2ui_base::protocol::common_types::{DynamicString, DynamicStringList};
14use crate::component_impl::TuiComponent;
15
16/// An option entry in the choice picker.
17#[derive(Debug, Clone, serde::Deserialize)]
18struct ChoiceOption {
19    label: String,
20    #[serde(default)]
21    value: String,
22}
23
24/// ChoicePicker component implementation.
25///
26/// Renders a list of options with radio buttons (mutuallyExclusive) or
27/// checkboxes (multipleSelection). Supports "checkbox" and "chips" display
28/// styles. Selected options are highlighted based on the resolved `value`.
29/// Applies a default 1-cell margin.
30pub struct ChoicePickerComponent;
31
32impl TuiComponent for ChoicePickerComponent {
33    fn name(&self) -> &'static str {
34        "ChoicePicker"
35    }
36
37    fn render(
38        &self,
39        ctx: &ComponentContext,
40        area: Rect,
41        frame: &mut Frame,
42        _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
43        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
44    ) {
45        let comp_model = match ctx.components.get(&ctx.component_id) {
46            Some(m) => m,
47            None => return,
48        };
49
50        // Apply default 1-cell margin on all sides (never collapses to zero).
51        let inner = crate::layout_engine::padded_content(area);
52
53        if inner.width == 0 || inner.height == 0 {
54            return;
55        }
56
57        // Resolve label.
58        let label = match comp_model.get_property::<DynamicString>("label") {
59            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
60            None => String::new(),
61        };
62
63        // Resolve options.
64        let options: Vec<ChoiceOption> = match comp_model.get_property("options") {
65            Some(opts) => opts,
66            None => return,
67        };
68
69        // Resolve current value as a list of selected strings.
70        let selected_values: Vec<String> = match comp_model.get_property::<DynamicStringList>("value")
71        {
72            Some(dsl) => match dsl {
73                DynamicStringList::Literal(v) => v,
74                DynamicStringList::Binding(b) => {
75                    // Try to resolve as an array of strings from data model.
76                    match ctx.data_context.get(&b.path) {
77                        Some(serde_json::Value::Array(arr)) => arr
78                            .iter()
79                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
80                            .collect(),
81                        _ => Vec::new(),
82                    }
83                }
84                DynamicStringList::Function(fc) => {
85                    // Execute function and try to get array of strings.
86                    let result = ctx.data_context.resolve_dynamic_value(
87                        &a2ui_base::protocol::common_types::DynamicValue::Function(fc),
88                    );
89                    match result {
90                        serde_json::Value::Array(arr) => arr
91                            .iter()
92                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
93                            .collect(),
94                        _ => Vec::new(),
95                    }
96                }
97            },
98            None => Vec::new(),
99        };
100
101        // Determine variant.
102        let variant: Option<String> = comp_model.get_property("variant");
103        let is_exclusive = variant.as_deref() == Some("mutuallyExclusive");
104
105        // Determine display style and filterable flag.
106        let display_style: Option<String> = comp_model.get_property("displayStyle");
107        let _filterable: bool = comp_model.get_property("filterable").unwrap_or(false);
108        let is_chips = display_style.as_deref() == Some("chips");
109
110        // Determine if this choice picker has keyboard focus.
111        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
112
113        // Build lines.
114        let mut lines: Vec<Line> = Vec::new();
115
116        // Add label line if present.
117        if !label.is_empty() {
118            let label_style = if is_focused {
119                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
120            } else {
121                Style::default().fg(Color::White)
122            };
123            lines.push(Line::from(Span::styled(label, label_style)));
124        }
125
126        // Add option lines.
127        if is_chips {
128            // Render as inline chips: [✓ Email] [○ Phone] [○ SMS]
129            let mut spans = Vec::new();
130            for (i, option) in options.iter().enumerate() {
131                let is_selected = selected_values.iter().any(|v| v == &option.value);
132                let indicator = if is_exclusive {
133                    if is_selected { "◉ " } else { "○ " }
134                } else {
135                    if is_selected { "☑ " } else { "☐ " }
136                };
137                let style = if is_selected {
138                    Style::default().fg(Color::Cyan)
139                } else {
140                    Style::default().fg(Color::DarkGray)
141                };
142                if i > 0 {
143                    spans.push(Span::raw(" "));
144                }
145                spans.push(Span::styled(format!("{}{}", indicator, option.label), style));
146            }
147            lines.push(Line::from(spans));
148        } else {
149            // Default stacked layout
150            for option in &options {
151                let is_selected = selected_values.iter().any(|v| v == &option.value);
152
153                let indicator = if is_exclusive {
154                    if is_selected {
155                        "\u{25cf} " // ● filled circle
156                    } else {
157                        "\u{25cb} " // ○ empty circle
158                    }
159                } else {
160                    if is_selected {
161                        "\u{2611} " // ☑ checked box
162                    } else {
163                        "\u{2610} " // ☐ empty box
164                    }
165                };
166
167                let style = if is_selected {
168                    Style::default().fg(Color::Cyan)
169                } else {
170                    Style::default().fg(Color::DarkGray)
171                };
172
173                lines.push(Line::from(vec![
174                    Span::styled(indicator.to_string(), style),
175                    Span::styled(option.label.clone(), style),
176                ]));
177            }
178        }
179
180        let paragraph = Paragraph::new(lines);
181
182        // When focused, render with a yellow bordered block.
183        if is_focused {
184            let block = Block::default()
185                .borders(Borders::ALL)
186                .style(Style::default().fg(Color::Yellow));
187            let content_area = block.inner(inner);
188            frame.render_widget(block, inner);
189            frame.render_widget(paragraph, content_area);
190        } else {
191            frame.render_widget(paragraph, inner);
192        }
193    }
194
195    fn natural_height(
196        &self,
197        ctx: &ComponentContext,
198        _available_width: u16,
199        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
200    ) -> Option<u16> {
201        let comp_model = ctx.components.get(&ctx.component_id)?;
202
203        // Resolve label.
204        let label = match comp_model.get_property::<DynamicString>("label") {
205            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
206            None => String::new(),
207        };
208
209        // Resolve options.
210        let options: Vec<ChoiceOption> = comp_model.get_property("options")?;
211
212        // Determine display style.
213        let display_style: Option<String> = comp_model.get_property("displayStyle");
214        let is_chips = display_style.as_deref() == Some("chips");
215
216        let lines = (if !label.is_empty() { 1 } else { 0 })
217            + (if is_chips { 1 } else { options.len() });
218
219        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
220
221        Some((lines as u16).saturating_add(2).saturating_add(if is_focused { 2 } else { 0 }))
222    }
223
224    fn handle_event(
225        &self,
226        ctx: &ComponentContext,
227        event: &a2ui_base::event::InputEvent,
228    ) -> Option<a2ui_base::event::EventResult> {
229        let comp_model = ctx.components.get(&ctx.component_id)?;
230
231        let options: Vec<ChoiceOption> = comp_model.get_property("options")?;
232        if options.is_empty() {
233            return None;
234        }
235
236        let variant: Option<String> = comp_model.get_property("variant");
237        let is_exclusive = variant.as_deref() == Some("mutuallyExclusive");
238
239        let value_dsl = comp_model.get_property::<DynamicStringList>("value")?;
240        let (binding, selected) = match &value_dsl {
241            DynamicStringList::Binding(b) => {
242                let selected = match ctx.data_context.get(&b.path) {
243                    Some(serde_json::Value::Array(arr)) => arr
244                        .iter()
245                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
246                        .collect::<Vec<_>>(),
247                    Some(serde_json::Value::String(s)) => vec![s.clone()],
248                    _ => Vec::new(),
249                };
250                (b.clone(), selected)
251            }
252            _ => return None,
253        };
254
255        match event {
256            InputEvent::KeyPress { key: InputKey::Down } | InputEvent::KeyPress { key: InputKey::Up } => {
257                if !is_exclusive {
258                    return None;
259                }
260                // Find current selection index, move to next/prev.
261                let current_idx = selected
262                    .first()
263                    .and_then(|v| options.iter().position(|o| &o.value == v))
264                    .unwrap_or(0);
265                let new_idx = match event {
266                    InputEvent::KeyPress { key: InputKey::Down } => {
267                        (current_idx + 1) % options.len()
268                    }
269                    InputEvent::KeyPress { key: InputKey::Up } => {
270                        if current_idx == 0 {
271                            options.len() - 1
272                        } else {
273                            current_idx - 1
274                        }
275                    }
276                    _ => current_idx,
277                };
278                Some(EventResult::DataUpdate {
279                    path: binding.path.clone(),
280                    value: serde_json::json!([options[new_idx].value]),
281                })
282            }
283            InputEvent::KeyPress { key: InputKey::Enter } | InputEvent::KeyPress { key: InputKey::Space } => {
284                if is_exclusive {
285                    return None;
286                } // handled by Up/Down for exclusive
287                  // For multiple selection: not enough state to know which option to toggle.
288                  // Skip for now.
289                None
290            }
291            _ => None,
292        }
293    }
294}