Skip to main content

a2ui_tui/components/
tabs.rs

1//! Tabs component — renders a horizontal tab bar with content area.
2
3use 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/// Tab entry deserialized from the `tabs` property.
17#[derive(Debug, Clone, serde::Deserialize)]
18struct TabEntry {
19    title: DynamicString,
20    child: String,
21}
22
23/// Tabs component implementation.
24///
25/// Renders a horizontal row of tab titles with the active tab highlighted,
26/// and the active tab's child content below the tab bar.
27///
28/// The active tab index is read from the `activeTab` property (a `DynamicNumber`).
29/// Arrow keys cycle through tabs and write the new index back via `EventResult::DataUpdate`.
30pub 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        // Resolve active tab index from the `activeTab` property.
60        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        // Split area: 3 rows for tab bar, rest for content.
67        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        // Build tab title spans.
77        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        // Render tab bar.
99        let tab_bar = Paragraph::new(Line::from(spans));
100        frame.render_widget(tab_bar, tab_bar_area);
101
102        // Render the active tab's child.
103        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        // Resolve active tab index from the `activeTab` property.
121        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}