Skip to main content

a2ui_tui/components/
button.rs

1//! Button component — renders a clickable button with variant styling.
2
3use ratatui::{
4    Frame,
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    widgets::{Block, Borders},
8};
9
10use a2ui_base::model::component_context::ComponentContext;
11use crate::component_impl::TuiComponent;
12
13/// Button component implementation.
14///
15/// Renders a bordered or styled block with optional child content inside.
16/// Supports variants: "primary", "borderless", "default".
17/// If `checks` conditions resolve to false, the button is dimmed.
18/// Applies a default 1-cell margin.
19pub struct ButtonComponent;
20
21impl TuiComponent for ButtonComponent {
22    fn name(&self) -> &'static str {
23        "Button"
24    }
25
26    fn render(
27        &self,
28        ctx: &ComponentContext,
29        area: Rect,
30        frame: &mut Frame,
31        render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
32        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
33    ) {
34        let comp_model = match ctx.components.get(&ctx.component_id) {
35            Some(m) => m,
36            None => return,
37        };
38
39        // Apply default 1-cell margin on all sides (never collapses to zero).
40        let inner = crate::layout_engine::padded_content(area);
41
42        if inner.width == 0 || inner.height == 0 {
43            return;
44        }
45
46        // Determine variant.
47        let variant: Option<String> = comp_model.get_property("variant");
48
49        // Determine if this button has keyboard focus.
50        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
51
52        // Evaluate checks — if any condition resolves to false, dim the button.
53        let checks_pass = evaluate_checks(ctx, comp_model);
54
55        // Build the block and style based on variant.
56        let (block, base_style) = match variant.as_deref() {
57            Some("primary") => {
58                let style = Style::default()
59                    .bg(Color::Blue)
60                    .fg(Color::White)
61                    .add_modifier(Modifier::BOLD);
62                let block = Block::default().style(style).borders(Borders::NONE);
63                (block, style)
64            }
65            Some("borderless") => {
66                let style = Style::default().add_modifier(Modifier::UNDERLINED);
67                let block = Block::default().style(style).borders(Borders::NONE);
68                (block, style)
69            }
70            _ => {
71                // "default" variant: plain bordered block.
72                let style = Style::default();
73                let block = Block::default().style(style).borders(Borders::ALL);
74                (block, style)
75            }
76        };
77
78        // If checks fail, apply DIM modifier. If focused, add REVERSED highlight.
79        let final_style = if !checks_pass {
80            base_style.add_modifier(Modifier::DIM)
81        } else if is_focused {
82            base_style.add_modifier(Modifier::REVERSED)
83        } else {
84            base_style
85        };
86
87        let block = block.style(final_style);
88
89        // Compute the inner area for the child (inside the block's borders).
90        let child_area = block.inner(inner);
91
92        // Render the block itself.
93        frame.render_widget(block, inner);
94
95        // If the button has a child, render it inside the block's inner area.
96        if let Some(child_id) = comp_model.child() {
97            if child_area.width > 0 && child_area.height > 0 {
98                render_child(&child_id, child_area, frame, "");
99            }
100        } else if let Some(a11y) = comp_model.accessibility() {
101            // When no child is present, use the accessibility label as visible text.
102            let a11y_text = a11y.label
103                .as_ref()
104                .map(|ds| ctx.data_context.resolve_dynamic_string(ds))
105                .unwrap_or_default();
106            if !a11y_text.is_empty() && child_area.width > 0 && child_area.height > 0 {
107                let text = ratatui::text::Line::from(a11y_text);
108                let widget = ratatui::widgets::Paragraph::new(text).style(final_style);
109                frame.render_widget(widget, child_area);
110            }
111        }
112    }
113
114    fn natural_height(
115        &self,
116        ctx: &ComponentContext,
117        _available_width: u16,
118        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
119    ) -> Option<u16> {
120        // The render does `inner = area.shrink(1)` (2-cell margin). The default
121        // variant additionally draws `Block::bordered()` (2-cell border), so its
122        // single content line needs area.height - 4 >= 1 → 5. The primary /
123        // borderless variants use `Borders::NONE`, so they only need margin → 3.
124        let comp_model = match ctx.components.get(&ctx.component_id) {
125            Some(m) => m,
126            None => return Some(3),
127        };
128        let variant: Option<String> = comp_model.get_property("variant");
129        match variant.as_deref() {
130            Some("primary") | Some("borderless") => Some(3),
131            _ => Some(5),
132        }
133    }
134
135    fn handle_event(
136        &self,
137        ctx: &ComponentContext,
138        event: &a2ui_base::event::InputEvent,
139    ) -> Option<a2ui_base::event::EventResult> {
140        a2ui_base::components::button::handle_event(ctx, event)
141    }
142}
143
144/// Evaluate all `checks` on the component. Returns `true` if all pass (or none exist).
145fn evaluate_checks(ctx: &ComponentContext, comp_model: &a2ui_base::model::component_model::ComponentModel) -> bool {
146    match comp_model.checks() {
147        Some(checks) => checks.iter().all(|rule| {
148            ctx.data_context.resolve_dynamic_boolean_condition(&rule.condition)
149        }),
150        None => true,
151    }
152}