a2ui_tui/components/
tabs.rs1use ratatui::{
4 Frame,
5 layout::{Constraint, Layout, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::Paragraph,
9};
10
11use a2ui_base::event::{EventResult, InputEvent, InputKey};
12use a2ui_base::model::component_context::ComponentContext;
13use a2ui_base::protocol::common_types::{DynamicNumber, DynamicString};
14use crate::component_impl::TuiComponent;
15
16#[derive(Debug, Clone, serde::Deserialize)]
18struct TabEntry {
19 title: DynamicString,
20 child: String,
21}
22
23pub struct TabsComponent;
31
32impl TuiComponent for TabsComponent {
33 fn name(&self) -> &'static str {
34 "Tabs"
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 tabs: Vec<TabEntry> = match comp_model.get_property("tabs") {
51 Some(t) => t,
52 None => return,
53 };
54
55 if tabs.is_empty() {
56 return;
57 }
58
59 let active_tab: usize = comp_model
61 .get_property::<DynamicNumber>("activeTab")
62 .map(|dn| ctx.data_context.resolve_dynamic_number(&dn) as usize)
63 .unwrap_or(0)
64 .min(tabs.len() - 1);
65
66 let chunks = Layout::vertical([
68 Constraint::Length(3),
69 Constraint::Min(0),
70 ])
71 .split(area);
72
73 let tab_bar_area = chunks[0];
74 let content_area = chunks[1];
75
76 let spans: Vec<Span> = tabs
78 .iter()
79 .enumerate()
80 .flat_map(|(i, tab)| {
81 let title = ctx.data_context.resolve_dynamic_string(&tab.title);
82 let style = if i == active_tab {
83 Style::default()
84 .fg(Color::Cyan)
85 .add_modifier(Modifier::BOLD)
86 } else {
87 Style::default().fg(Color::DarkGray)
88 };
89 let separator = if i < tabs.len() - 1 {
90 Span::raw(" | ")
91 } else {
92 Span::raw("")
93 };
94 vec![Span::styled(format!(" {} ", title), style), separator]
95 })
96 .collect();
97
98 let tab_bar = Paragraph::new(Line::from(spans));
100 frame.render_widget(tab_bar, tab_bar_area);
101
102 if content_area.width > 0 && content_area.height > 0 {
104 render_child(&tabs[active_tab].child, content_area, frame, "");
105 }
106 }
107
108 fn natural_height(
109 &self,
110 ctx: &ComponentContext,
111 available_width: u16,
112 measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
113 ) -> Option<u16> {
114 let comp_model = ctx.components.get(&ctx.component_id)?;
115 let tabs: Vec<TabEntry> = comp_model.get_property("tabs")?;
116 if tabs.is_empty() {
117 return None;
118 }
119
120 let active_tab: usize = comp_model
122 .get_property::<DynamicNumber>("activeTab")
123 .map(|dn| ctx.data_context.resolve_dynamic_number(&dn) as usize)
124 .unwrap_or(0)
125 .min(tabs.len() - 1);
126
127 let child_h = measure_child(&tabs[active_tab].child, "", available_width)?;
128 Some(child_h.saturating_add(3))
129 }
130
131 fn handle_event(
132 &self,
133 ctx: &ComponentContext,
134 event: &a2ui_base::event::InputEvent,
135 ) -> Option<a2ui_base::event::EventResult> {
136 let comp_model = ctx.components.get(&ctx.component_id)?;
137 let tabs: Vec<TabEntry> = comp_model.get_property("tabs")?;
138 if tabs.is_empty() {
139 return None;
140 }
141
142 let active_tab_dn = comp_model.get_property::<DynamicNumber>("activeTab")?;
143 let binding = match &active_tab_dn {
144 DynamicNumber::Binding(b) => b.clone(),
145 _ => return None,
146 };
147
148 let current = ctx
149 .data_context
150 .resolve_dynamic_number(&active_tab_dn) as usize;
151 let current = current.min(tabs.len() - 1);
152
153 let new_idx = match event {
154 InputEvent::KeyPress {
155 key: InputKey::Right,
156 } => (current + 1) % tabs.len(),
157 InputEvent::KeyPress {
158 key: InputKey::Left,
159 } => {
160 if current == 0 {
161 tabs.len() - 1
162 } else {
163 current - 1
164 }
165 }
166 _ => return None,
167 };
168
169 Some(EventResult::DataUpdate {
170 path: binding.path.clone(),
171 value: serde_json::json!(new_idx),
172 })
173 }
174}