fresh/view/controls/text_input/
render.rs1use 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
12pub 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
33pub 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 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 let text_before_cursor = &state.value[..state.cursor.min(state.value.len())];
105 let cursor_visual_pos = str_width(text_before_cursor);
106
107 let scroll_visual_offset = cursor_visual_pos.saturating_sub(inner_width);
109
110 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 if current_visual_pos + ch_width <= scroll_visual_offset {
117 current_visual_pos += ch_width;
118 continue;
119 }
120 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 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 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 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 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}