Skip to main content

fresh/view/controls/text_input/
render.rs

1//! Text input rendering functions
2
3use crate::primitives::display_width::{char_width, str_width};
4use ratatui::layout::Rect;
5use ratatui::style::{Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::Paragraph;
8use ratatui::Frame;
9
10use super::{FocusState, TextInputColors, TextInputLayout, TextInputState};
11
12/// Render a text input control
13///
14/// # Arguments
15/// * `frame` - The ratatui frame to render to
16/// * `area` - Rectangle where the control should be rendered
17/// * `state` - The text input state
18/// * `colors` - Colors for rendering
19/// * `field_width` - Width of the input field (not including label)
20///
21/// # Returns
22/// Layout information for hit testing
23pub fn render_text_input(
24    frame: &mut Frame,
25    area: Rect,
26    state: &TextInputState,
27    colors: &TextInputColors,
28    field_width: u16,
29) -> TextInputLayout {
30    render_text_input_aligned(frame, area, state, colors, field_width, None)
31}
32
33/// Render a text input control with optional label width alignment
34///
35/// # Arguments
36/// * `frame` - The ratatui frame to render to
37/// * `area` - Rectangle where the control should be rendered
38/// * `state` - The text input state
39/// * `colors` - Colors for rendering
40/// * `field_width` - Width of the input field (not including label)
41/// * `label_width` - Optional minimum label width for alignment
42///
43/// # Returns
44/// Layout information for hit testing
45pub fn render_text_input_aligned(
46    frame: &mut Frame,
47    area: Rect,
48    state: &TextInputState,
49    colors: &TextInputColors,
50    field_width: u16,
51    label_width: Option<u16>,
52) -> TextInputLayout {
53    if area.height == 0 || area.width < 5 {
54        return TextInputLayout::default();
55    }
56
57    // Distinguish three visual states for the label and brackets:
58    //   - Normal / selected-but-not-editing → normal label; muted border.
59    //     (The row's selection-highlight background already indicates
60    //     keyboard focus; the input box stays visually calm.)
61    //   - Focused + editing → normal label; brackets picked out in the
62    //     accent colour to show this is where typing goes.
63    //   - Hovered → accent-coloured brackets as a hover affordance.
64    //   - Disabled → everything dimmed.
65    let (label_color, text_color, border_color, placeholder_color) = match state.focus {
66        FocusState::Normal => (colors.label, colors.text, colors.border, colors.placeholder),
67        FocusState::Focused => {
68            let border = if state.editing {
69                colors.focused
70            } else {
71                colors.border
72            };
73            (colors.label, colors.text, border, colors.placeholder)
74        }
75        FocusState::Hovered => (
76            colors.label,
77            colors.text,
78            colors.focused,
79            colors.placeholder,
80        ),
81        FocusState::Disabled => (
82            colors.disabled,
83            colors.disabled,
84            colors.disabled,
85            colors.disabled,
86        ),
87    };
88
89    let actual_label_width = label_width.unwrap_or(state.label.len() as u16);
90    let final_label_width = actual_label_width + 2;
91    let actual_field_width = field_width.min(area.width.saturating_sub(final_label_width + 2));
92
93    let (display_text, is_placeholder) = if state.value.is_empty() && !state.placeholder.is_empty()
94    {
95        (&state.placeholder, true)
96    } else {
97        (&state.value, false)
98    };
99
100    let inner_width = actual_field_width.saturating_sub(2) as usize;
101
102    // Calculate visual width of text before cursor for proper scrolling
103    // state.cursor is a byte offset, we need the visual width
104    let text_before_cursor = &state.value[..state.cursor.min(state.value.len())];
105    let cursor_visual_pos = str_width(text_before_cursor);
106
107    // Calculate scroll offset based on visual width
108    let scroll_visual_offset = cursor_visual_pos.saturating_sub(inner_width);
109
110    // Build visible text by iterating chars and tracking visual width
111    let mut visible_text = String::new();
112    let mut current_visual_pos = 0;
113    for ch in display_text.chars() {
114        let ch_width = char_width(ch);
115        // Skip characters before scroll offset
116        if current_visual_pos + ch_width <= scroll_visual_offset {
117            current_visual_pos += ch_width;
118            continue;
119        }
120        // Stop if we've filled the visible area
121        if current_visual_pos - scroll_visual_offset >= inner_width {
122            break;
123        }
124        visible_text.push(ch);
125        current_visual_pos += ch_width;
126    }
127
128    // Pad to fill the field width
129    let visible_width = str_width(&visible_text);
130    let padding = " ".repeat(inner_width.saturating_sub(visible_width));
131    let padded = format!("{}{}", visible_text, padding);
132
133    // When the input is actively being edited, fill the field with a
134    // contrast background and bold the brackets — a web-form-style
135    // "keystrokes go here" signal that's unmissable in a terminal.
136    //
137    // We key on `state.editing` alone (not `focus == Focused && editing`)
138    // because the entry dialog's per-item `state.focus` is not
139    // consistently set to `Focused` on the selected item — the dialog
140    // tracks focus via its own `selected_item` index. `state.editing`
141    // is the authoritative "this is where keystrokes go right now".
142    let is_actively_editing = state.editing;
143    let bracket_style = if is_actively_editing {
144        Style::default()
145            .fg(border_color)
146            .add_modifier(Modifier::BOLD)
147    } else {
148        Style::default().fg(border_color)
149    };
150    let base_fg = if is_placeholder {
151        placeholder_color
152    } else {
153        text_color
154    };
155    let field_text_style = if is_actively_editing {
156        Style::default().fg(base_fg).bg(colors.editing_bg)
157    } else {
158        Style::default().fg(base_fg)
159    };
160
161    let padded_label = format!(
162        "{:width$}",
163        state.label,
164        width = actual_label_width as usize
165    );
166
167    let line = Line::from(vec![
168        Span::styled(padded_label, Style::default().fg(label_color)),
169        Span::styled(": ", Style::default().fg(label_color)),
170        Span::styled("[", bracket_style),
171        Span::styled(padded, field_text_style),
172        Span::styled("]", bracket_style),
173    ]);
174
175    let paragraph = Paragraph::new(line);
176    frame.render_widget(paragraph, area);
177
178    let input_start = area.x + final_label_width;
179    let input_area = Rect::new(input_start, area.y, actual_field_width + 2, 1);
180
181    let cursor_pos = if is_actively_editing && !is_placeholder {
182        // Calculate cursor visual position within the visible area
183        let cursor_visual_in_field = cursor_visual_pos.saturating_sub(scroll_visual_offset);
184        let cursor_x = input_start + 1 + cursor_visual_in_field as u16;
185        if cursor_x < input_start + actual_field_width + 1 {
186            let cursor_area = Rect::new(cursor_x, area.y, 1, 1);
187            // Get the grapheme at cursor position for the highlight
188            let cursor_char = if state.cursor < state.value.len() {
189                crate::primitives::grapheme::grapheme_at(&state.value, state.cursor)
190                    .map(|(g, _, _)| g.chars().next().unwrap_or(' '))
191                    .unwrap_or(' ')
192            } else {
193                ' '
194            };
195            let cursor_span = Span::styled(
196                cursor_char.to_string(),
197                Style::default()
198                    .fg(colors.cursor)
199                    .add_modifier(Modifier::REVERSED),
200            );
201            frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
202            Some((cursor_x, area.y))
203        } else {
204            None
205        }
206    } else {
207        None
208    };
209
210    TextInputLayout {
211        input_area,
212        full_area: Rect::new(
213            area.x,
214            area.y,
215            input_start - area.x + actual_field_width + 2,
216            1,
217        ),
218        cursor_pos,
219    }
220}