use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use a2ui_base::event::{EventResult, InputEvent, InputKey};
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::protocol::common_types::{DynamicString, DynamicStringList};
use crate::component_impl::TuiComponent;
#[derive(Debug, Clone, serde::Deserialize)]
struct ChoiceOption {
label: String,
#[serde(default)]
value: String,
}
pub struct ChoicePickerComponent;
impl TuiComponent for ChoicePickerComponent {
fn name(&self) -> &'static str {
"ChoicePicker"
}
fn render(
&self,
ctx: &ComponentContext,
area: Rect,
frame: &mut Frame,
_render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) {
let comp_model = match ctx.components.get(&ctx.component_id) {
Some(m) => m,
None => return,
};
let inner = crate::layout_engine::padded_content(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let label = match comp_model.get_property::<DynamicString>("label") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
let options: Vec<ChoiceOption> = match comp_model.get_property("options") {
Some(opts) => opts,
None => return,
};
let selected_values: Vec<String> = match comp_model.get_property::<DynamicStringList>("value")
{
Some(dsl) => match dsl {
DynamicStringList::Literal(v) => v,
DynamicStringList::Binding(b) => {
match ctx.data_context.get(&b.path) {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
DynamicStringList::Function(fc) => {
let result = ctx.data_context.resolve_dynamic_value(
&a2ui_base::protocol::common_types::DynamicValue::Function(fc),
);
match result {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
},
None => Vec::new(),
};
let variant: Option<String> = comp_model.get_property("variant");
let is_exclusive = variant.as_deref() == Some("mutuallyExclusive");
let display_style: Option<String> = comp_model.get_property("displayStyle");
let _filterable: bool = comp_model.get_property("filterable").unwrap_or(false);
let is_chips = display_style.as_deref() == Some("chips");
let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
let mut lines: Vec<Line> = Vec::new();
if !label.is_empty() {
let label_style = if is_focused {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(label, label_style)));
}
if is_chips {
let mut spans = Vec::new();
for (i, option) in options.iter().enumerate() {
let is_selected = selected_values.iter().any(|v| v == &option.value);
let indicator = if is_exclusive {
if is_selected { "◉ " } else { "○ " }
} else {
if is_selected { "☑ " } else { "☐ " }
};
let style = if is_selected {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("{}{}", indicator, option.label), style));
}
lines.push(Line::from(spans));
} else {
for option in &options {
let is_selected = selected_values.iter().any(|v| v == &option.value);
let indicator = if is_exclusive {
if is_selected {
"\u{25cf} " } else {
"\u{25cb} " }
} else {
if is_selected {
"\u{2611} " } else {
"\u{2610} " }
};
let style = if is_selected {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
lines.push(Line::from(vec![
Span::styled(indicator.to_string(), style),
Span::styled(option.label.clone(), style),
]));
}
}
let paragraph = Paragraph::new(lines);
if is_focused {
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Yellow));
let content_area = block.inner(inner);
frame.render_widget(block, inner);
frame.render_widget(paragraph, content_area);
} else {
frame.render_widget(paragraph, inner);
}
}
fn natural_height(
&self,
ctx: &ComponentContext,
_available_width: u16,
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) -> Option<u16> {
let comp_model = ctx.components.get(&ctx.component_id)?;
let label = match comp_model.get_property::<DynamicString>("label") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
let options: Vec<ChoiceOption> = comp_model.get_property("options")?;
let display_style: Option<String> = comp_model.get_property("displayStyle");
let is_chips = display_style.as_deref() == Some("chips");
let lines = (if !label.is_empty() { 1 } else { 0 })
+ (if is_chips { 1 } else { options.len() });
let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
Some((lines as u16).saturating_add(2).saturating_add(if is_focused { 2 } else { 0 }))
}
fn handle_event(
&self,
ctx: &ComponentContext,
event: &a2ui_base::event::InputEvent,
) -> Option<a2ui_base::event::EventResult> {
let comp_model = ctx.components.get(&ctx.component_id)?;
let options: Vec<ChoiceOption> = comp_model.get_property("options")?;
if options.is_empty() {
return None;
}
let variant: Option<String> = comp_model.get_property("variant");
let is_exclusive = variant.as_deref() == Some("mutuallyExclusive");
let value_dsl = comp_model.get_property::<DynamicStringList>("value")?;
let (binding, selected) = match &value_dsl {
DynamicStringList::Binding(b) => {
let selected = match ctx.data_context.get(&b.path) {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>(),
Some(serde_json::Value::String(s)) => vec![s.clone()],
_ => Vec::new(),
};
(b.clone(), selected)
}
_ => return None,
};
match event {
InputEvent::KeyPress { key: InputKey::Down } | InputEvent::KeyPress { key: InputKey::Up } => {
if !is_exclusive {
return None;
}
let current_idx = selected
.first()
.and_then(|v| options.iter().position(|o| &o.value == v))
.unwrap_or(0);
let new_idx = match event {
InputEvent::KeyPress { key: InputKey::Down } => {
(current_idx + 1) % options.len()
}
InputEvent::KeyPress { key: InputKey::Up } => {
if current_idx == 0 {
options.len() - 1
} else {
current_idx - 1
}
}
_ => current_idx,
};
Some(EventResult::DataUpdate {
path: binding.path.clone(),
value: serde_json::json!([options[new_idx].value]),
})
}
InputEvent::KeyPress { key: InputKey::Enter } | InputEvent::KeyPress { key: InputKey::Space } => {
if is_exclusive {
return None;
} None
}
_ => None,
}
}
}