a2ui_tui/components/
choice_picker.rs1use 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#[derive(Debug, Clone, serde::Deserialize)]
18struct ChoiceOption {
19 label: String,
20 #[serde(default)]
21 value: String,
22}
23
24pub 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 let inner = crate::layout_engine::padded_content(area);
52
53 if inner.width == 0 || inner.height == 0 {
54 return;
55 }
56
57 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 let options: Vec<ChoiceOption> = match comp_model.get_property("options") {
65 Some(opts) => opts,
66 None => return,
67 };
68
69 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 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 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 let variant: Option<String> = comp_model.get_property("variant");
103 let is_exclusive = variant.as_deref() == Some("mutuallyExclusive");
104
105 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 let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
112
113 let mut lines: Vec<Line> = Vec::new();
115
116 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 if is_chips {
128 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 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} " } else {
157 "\u{25cb} " }
159 } else {
160 if is_selected {
161 "\u{2611} " } else {
163 "\u{2610} " }
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 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 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 let options: Vec<ChoiceOption> = comp_model.get_property("options")?;
211
212 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 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 } None
290 }
291 _ => None,
292 }
293 }
294}