Skip to main content

a2ui_tui/components/
text_field.rs

1//! TextField component — renders a labeled text input display.
2
3use ratatui::{
4    Frame,
5    layout::Rect,
6    style::{Color, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Paragraph},
9};
10
11use a2ui_base::model::component_context::ComponentContext;
12use a2ui_base::protocol::common_types::DynamicString;
13use crate::component_impl::TuiComponent;
14
15/// TextField component implementation.
16///
17/// Renders a bordered paragraph with a label title and the current value
18/// followed by a cursor block character. Supports variants:
19/// - "shortText" (default): plain text display
20/// - "longText": plain text display
21/// - "number": plain text display
22/// - "obscured": shows `*` for each character instead of actual text
23///
24/// Actual keyboard input handling is done at the Gallery App level;
25/// this component only displays the current resolved value.
26/// Applies a default 1-cell margin.
27pub struct TextFieldComponent;
28
29impl TuiComponent for TextFieldComponent {
30    fn name(&self) -> &'static str {
31        "TextField"
32    }
33
34    fn render(
35        &self,
36        ctx: &ComponentContext,
37        area: Rect,
38        frame: &mut Frame,
39        _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
40        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
41    ) {
42        let comp_model = match ctx.components.get(&ctx.component_id) {
43            Some(m) => m,
44            None => return,
45        };
46
47        // Apply default 1-cell margin on all sides (never collapses to zero).
48        let inner = crate::layout_engine::padded_content(area);
49
50        if inner.width == 0 || inner.height == 0 {
51            return;
52        }
53
54        // Resolve the label.
55        let label = match comp_model.get_property::<DynamicString>("label") {
56            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
57            None => String::new(),
58        };
59
60        // Resolve the current value.
61        let raw_value = match comp_model.get_property::<DynamicString>("value") {
62            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
63            None => String::new(),
64        };
65
66        // Determine variant and mask the value if obscured.
67        let variant: Option<String> = comp_model.get_property("variant");
68        let display_value = match variant.as_deref() {
69            Some("obscured") => obscure_value(&raw_value),
70            _ => raw_value.clone(),
71        };
72
73        // Resolve placeholder.
74        let placeholder = comp_model
75            .get_property::<DynamicString>("placeholder")
76            .map(|ds| ctx.data_context.resolve_dynamic_string(&ds));
77
78        // Build the display text: value followed by a cursor block character.
79        // When value is empty and placeholder is set, show placeholder in dim color.
80        let (display_text, is_placeholder) = if raw_value.is_empty() {
81            match &placeholder {
82                Some(p) if !p.is_empty() => (p.clone(), true),
83                _ => ("\u{2588}".to_string(), false), // cursor block only
84            }
85        } else {
86            (format!("{}\u{2588}", display_value), false)
87        };
88
89        // Determine if this text field has keyboard focus.
90        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
91
92        // Build the bordered block with the label as title.
93        // When focused, use yellow border to indicate focus.
94        let block_style = if is_focused {
95            Style::default().fg(Color::Yellow)
96        } else {
97            Style::default()
98        };
99        let block = Block::default()
100            .borders(Borders::ALL)
101            .title(label)
102            .style(block_style);
103
104        let content_area = block.inner(inner);
105
106        // Render the block.
107        frame.render_widget(block, inner);
108
109        if content_area.width == 0 || content_area.height == 0 {
110            return;
111        }
112
113        // Render the paragraph with the display text.
114        let paragraph_style = if is_placeholder {
115            Style::default().fg(Color::DarkGray)
116        } else {
117            Style::default()
118        };
119        let paragraph = Paragraph::new(Line::from(Span::styled(display_text, paragraph_style)));
120        frame.render_widget(paragraph, content_area);
121    }
122
123    fn natural_height(
124        &self,
125        _ctx: &ComponentContext,
126        _available_width: u16,
127        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
128    ) -> Option<u16> {
129        // 1 input line + 2-cell margin + 2-cell border = 5. The render does
130        // `inner = area.shrink(1)` (margin) then `Block::bordered()` (border), so a
131        // single content line needs area.height - 4 >= 1 → minimum 5 rows.
132        Some(5)
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::text_field::handle_event(ctx, event)
141    }
142}
143
144/// Replace each character with a bullet character for obscured display.
145fn obscure_value(value: &str) -> String {
146    value.chars().map(|_| '\u{2022}').collect()
147}