hefesto_widgets/text_input/
mod.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Style},
5 text::{Line, Span},
6 widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
7};
8
9use crate::BorderType;
10use crate::{BORDER_GRAY, TEXT_PADDING};
11
12#[derive(Clone, Default)]
13pub struct TextInputState {
14 pub content: String,
15 pub cursor: usize,
16}
17
18impl TextInputState {
19 pub fn insert_char(&mut self, c: char) {
20 self.content.insert(self.cursor, c);
21 self.cursor += c.len_utf8();
22 }
23
24 pub fn delete_before(&mut self) {
25 if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
26 self.cursor = prev;
27 self.content.remove(self.cursor);
28 }
29 }
30
31 pub fn delete_at(&mut self) {
32 if self.cursor < self.content.len() {
33 if let Some(c) = self.content[self.cursor..].chars().next() {
34 let end = self.cursor + c.len_utf8();
35 self.content.drain(self.cursor..end);
36 }
37 }
38 }
39
40 pub fn cursor_left(&mut self) {
41 if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
42 self.cursor = prev;
43 }
44 }
45
46 pub fn cursor_right(&mut self) {
47 if self.cursor < self.content.len() {
48 if let Some(c) = self.content[self.cursor..].chars().next() {
49 self.cursor += c.len_utf8();
50 }
51 }
52 }
53
54 pub fn cursor_home(&mut self) {
55 self.cursor = 0;
56 }
57
58 pub fn cursor_end(&mut self) {
59 self.cursor = self.content.len();
60 }
61}
62
63#[derive(Clone)]
64pub struct TextInput<'a> {
65 cursor_style: Style,
66 text_style: Style,
67 border_style: Style,
68 borders: Borders,
69 border_type: BorderType,
70 placeholder: Option<&'a str>,
71 bg_color: Option<Color>,
72 rows: u16,
73 cols: Option<u16>,
74 scroll_padding: u16,
75 scroll_reserve: u16,
76}
77
78impl<'a> TextInput<'a> {
79 pub fn new() -> Self {
80 Self {
81 cursor_style: Style::new().fg(Color::Black).bg(Color::White),
82 text_style: Style::new().fg(Color::White),
83 border_style: Style::new().fg(BORDER_GRAY),
84 borders: Borders::NONE,
85 border_type: BorderType::None,
86 placeholder: None,
87 bg_color: None,
88 rows: 1,
89 cols: None,
90 scroll_padding: 8,
91 scroll_reserve: 8,
92 }
93 }
94
95 pub fn cursor_style(mut self, style: Style) -> Self {
96 self.cursor_style = style;
97 self
98 }
99
100 pub fn text_style(mut self, style: Style) -> Self {
101 self.text_style = style;
102 self
103 }
104
105 pub fn border_style(mut self, style: Style) -> Self {
106 self.border_style = style;
107 self
108 }
109
110 pub fn borders(mut self, borders: Borders) -> Self {
111 self.borders = borders;
112 self
113 }
114
115 pub fn border_type(mut self, border_type: BorderType) -> Self {
116 self.border_type = border_type;
117 self
118 }
119
120 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
121 self.placeholder = Some(placeholder);
122 self
123 }
124
125 pub fn bg_color(mut self, color: Color) -> Self {
126 self.bg_color = Some(color);
127 self
128 }
129
130 pub fn rows(mut self, rows: u16) -> Self {
131 self.rows = rows;
132 self
133 }
134
135 pub fn cols(mut self, cols: u16) -> Self {
136 self.cols = Some(cols);
137 self
138 }
139
140 pub fn scroll_padding(mut self, padding: u16) -> Self {
141 self.scroll_padding = padding;
142 self
143 }
144
145 pub fn scroll_reserve(mut self, reserve: u16) -> Self {
146 self.scroll_reserve = reserve;
147 self
148 }
149
150 pub fn required_height(&self) -> u16 {
151 let has_top = self.borders.contains(Borders::TOP);
152 let has_bottom = self.borders.contains(Borders::BOTTOM);
153 let border_lines = (has_top as u16) + (has_bottom as u16);
154 self.rows + border_lines
155 }
156}
157
158impl Default for TextInput<'_> {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164impl StatefulWidget for TextInput<'_> {
165 type State = TextInputState;
166
167 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
168 let has_border = !matches!(self.border_type, BorderType::None);
169 let effective_borders = if has_border { self.borders } else { Borders::NONE };
170
171 let ratatui_border_type = if has_border {
172 self.border_type.to_ratatui()
173 } else {
174 ratatui::widgets::BorderType::Plain
175 };
176
177 let block = Block::bordered()
178 .borders(effective_borders)
179 .border_type(ratatui_border_type)
180 .border_style(self.border_style)
181 .padding(TEXT_PADDING);
182
183 let inner = block.inner(area);
184 block.render(area, buf);
185
186 if let Some(bg) = self.bg_color {
187 buf.set_style(inner, Style::new().bg(bg));
188 }
189
190 let display = if state.content.is_empty() {
191 self.placeholder.unwrap_or("")
192 } else {
193 &state.content
194 };
195
196 let inner_width = inner.width as usize;
197 let cursor_byte = state.cursor.min(display.len());
198 let cursor_byte = if display.is_char_boundary(cursor_byte) {
199 cursor_byte
200 } else {
201 let mut i = cursor_byte;
202 while i > 0 && !display.is_char_boundary(i) {
203 i -= 1;
204 }
205 i
206 };
207 let cursor_char = display[..cursor_byte].chars().count();
208 let total_chars = display.chars().count();
209
210 let sp = self.scroll_padding as usize;
211
212 let scroll_char = if inner_width > sp {
213 cursor_char
214 .saturating_sub(inner_width.saturating_sub(sp))
215 .min(total_chars.saturating_sub(inner_width.saturating_sub(sp)))
216 } else {
217 0
218 };
219
220 let visible: String = display.chars().skip(scroll_char).take(inner_width).collect();
221 let visible_chars = visible.chars().count();
222
223 let visible_cursor = cursor_char.saturating_sub(scroll_char).min(visible_chars);
224 let before: String = visible.chars().take(visible_cursor).collect();
225 let at = visible.chars().nth(visible_cursor);
226 let after: String = visible.chars().skip(visible_cursor + 1).collect();
227
228 let mut spans: Vec<Span<'_>> = Vec::with_capacity(inner_width + 1);
229
230 if !before.is_empty() {
231 spans.push(Span::styled(before, self.text_style));
232 }
233
234 match at {
235 Some(c) => spans.push(Span::styled(c.to_string(), self.cursor_style)),
236 None => spans.push(Span::styled(" ", self.cursor_style)),
237 }
238
239 if !after.is_empty() {
240 spans.push(Span::styled(after, self.text_style));
241 }
242
243 let text_rect = if self.rows > 1 && inner.height >= self.rows {
244 Rect { x: inner.x, y: inner.y, width: inner.width, height: 1 }
245 } else {
246 inner
247 };
248
249 Paragraph::new(Line::from(spans)).render(text_rect, buf);
250 }
251}
252#[cfg(test)]
253mod tests;