1use crate::actor::{InputEvent, KeyCode};
7use crate::buffer::{Buffer, Cell, Rgb};
8use crate::layout::Rect;
9use super::traits::Widget;
10
11#[derive(Debug, Clone)]
13pub struct TextInputConfig {
14 pub fg: Rgb,
16 pub bg: Rgb,
18 pub cursor_fg: Rgb,
20 pub placeholder: String,
22 pub placeholder_fg: Rgb,
24 pub prompt: String,
26 pub prompt_fg: Rgb,
28}
29
30impl Default for TextInputConfig {
31 fn default() -> Self {
32 Self {
33 fg: Rgb::WHITE,
34 bg: Rgb::new(30, 30, 30),
35 cursor_fg: Rgb::new(0, 255, 255),
36 placeholder: String::new(),
37 placeholder_fg: Rgb::new(100, 100, 100),
38 prompt: String::from("> "),
39 prompt_fg: Rgb::new(0, 255, 255),
40 }
41 }
42}
43
44#[derive(Debug)]
46pub struct TextInput {
47 content: String,
49 cursor: usize,
51 bounds: Rect,
53 focused: bool,
55 config: TextInputConfig,
57 frame: u64,
59 dirty: bool,
61}
62
63impl TextInput {
64 pub fn new(bounds: Rect) -> Self {
66 Self {
67 content: String::new(),
68 cursor: 0,
69 bounds,
70 focused: true,
71 config: TextInputConfig::default(),
72 frame: 0,
73 dirty: true,
74 }
75 }
76
77 pub const fn with_config(bounds: Rect, config: TextInputConfig) -> Self {
79 Self {
80 content: String::new(),
81 cursor: 0,
82 bounds,
83 focused: true,
84 config,
85 frame: 0,
86 dirty: true,
87 }
88 }
89
90 pub fn content(&self) -> &str {
92 &self.content
93 }
94
95 pub fn set_content(&mut self, content: &str) {
97 self.content = content.to_string();
98 self.cursor = self.content.len();
99 self.dirty = true;
100 }
101
102 pub fn clear(&mut self) {
104 self.content.clear();
105 self.cursor = 0;
106 self.dirty = true;
107 }
108
109 pub const fn is_empty(&self) -> bool {
111 self.content.is_empty()
112 }
113
114 pub const fn set_focused(&mut self, focused: bool) {
116 self.focused = focused;
117 self.dirty = true;
118 }
119
120 pub const fn is_focused(&self) -> bool {
122 self.focused
123 }
124
125 pub const fn tick(&mut self) {
127 self.frame = self.frame.wrapping_add(1);
128 if self.focused && self.frame.is_multiple_of(15) {
130 self.dirty = true;
131 }
132 }
133
134 fn insert_char(&mut self, c: char) {
136 self.content.insert(self.cursor, c);
137 self.cursor += c.len_utf8();
138 self.dirty = true;
139 }
140
141 fn backspace(&mut self) {
143 if self.cursor > 0 {
144 let prev = self.content[..self.cursor]
146 .char_indices()
147 .last()
148 .map_or(0, |(i, _)| i);
149 self.content.remove(prev);
150 self.cursor = prev;
151 self.dirty = true;
152 }
153 }
154
155 fn delete(&mut self) {
157 if self.cursor < self.content.len() {
158 self.content.remove(self.cursor);
159 self.dirty = true;
160 }
161 }
162
163 fn cursor_left(&mut self) {
165 if self.cursor > 0 {
166 self.cursor = self.content[..self.cursor]
168 .char_indices()
169 .last()
170 .map_or(0, |(i, _)| i);
171 self.dirty = true;
172 }
173 }
174
175 fn cursor_right(&mut self) {
177 if self.cursor < self.content.len() {
178 if let Some(c) = self.content[self.cursor..].chars().next() {
180 self.cursor += c.len_utf8();
181 self.dirty = true;
182 }
183 }
184 }
185
186 const fn cursor_home(&mut self) {
188 if self.cursor != 0 {
189 self.cursor = 0;
190 self.dirty = true;
191 }
192 }
193
194 const fn cursor_end(&mut self) {
196 let end = self.content.len();
197 if self.cursor != end {
198 self.cursor = end;
199 self.dirty = true;
200 }
201 }
202}
203
204impl Widget for TextInput {
205 fn bounds(&self) -> Rect {
206 self.bounds
207 }
208
209 fn set_bounds(&mut self, bounds: Rect) {
210 self.bounds = bounds;
211 self.dirty = true;
212 }
213
214 fn render(&self, buffer: &mut Buffer) {
215 let x = self.bounds.x;
216 let y = self.bounds.y;
217 let width = self.bounds.width as usize;
218
219 for i in 0..self.bounds.width {
221 buffer.set(x + i, y, Cell::new(' ').with_bg(self.config.bg));
222 }
223
224 let prompt_len = self.config.prompt.chars().count();
226 for (i, c) in self.config.prompt.chars().enumerate() {
227 #[allow(clippy::cast_possible_truncation)]
228 let px = x + i as u16;
229 if (px as usize) < x as usize + width {
230 buffer.set(px, y, Cell::new(c)
231 .with_fg(self.config.prompt_fg)
232 .with_bg(self.config.bg));
233 }
234 }
235
236 #[allow(clippy::cast_possible_truncation)]
237 let text_start = x + prompt_len as u16;
238 let text_width = width.saturating_sub(prompt_len);
239
240 if self.content.is_empty() && !self.config.placeholder.is_empty() {
241 for (i, c) in self.config.placeholder.chars().take(text_width).enumerate() {
243 #[allow(clippy::cast_possible_truncation)]
244 let px = text_start + i as u16;
245 buffer.set(px, y, Cell::new(c)
246 .with_fg(self.config.placeholder_fg)
247 .with_bg(self.config.bg));
248 }
249 } else {
250 let cursor_char_pos = self.content[..self.cursor].chars().count();
253 let content_chars: Vec<char> = self.content.chars().collect();
254
255 let scroll_offset = if cursor_char_pos >= text_width {
257 cursor_char_pos - text_width + 1
258 } else {
259 0
260 };
261
262 for (i, &c) in content_chars.iter().skip(scroll_offset).take(text_width).enumerate() {
263 #[allow(clippy::cast_possible_truncation)]
264 let px = text_start + i as u16;
265 let is_cursor = self.focused
266 && (i + scroll_offset) == cursor_char_pos
267 && self.frame % 30 < 15;
268
269 if is_cursor {
270 buffer.set(px, y, Cell::new(c)
271 .with_fg(self.config.bg)
272 .with_bg(self.config.cursor_fg));
273 } else {
274 buffer.set(px, y, Cell::new(c)
275 .with_fg(self.config.fg)
276 .with_bg(self.config.bg));
277 }
278 }
279
280 #[allow(clippy::cast_possible_truncation)]
282 let cursor_visual_pos = cursor_char_pos.saturating_sub(scroll_offset) as u16;
283 #[allow(clippy::cast_possible_truncation)]
284 let text_width_u16 = text_width as u16;
285 if self.focused
286 && cursor_char_pos == content_chars.len()
287 && cursor_visual_pos < text_width_u16
288 && self.frame % 30 < 15
289 {
290 let cx = text_start + cursor_visual_pos;
291 buffer.set(cx, y, Cell::new('█')
292 .with_fg(self.config.cursor_fg)
293 .with_bg(self.config.bg));
294 }
295 }
296 }
297
298 fn handle_input(&mut self, event: &InputEvent) -> bool {
299 if !self.focused {
300 return false;
301 }
302
303 if let InputEvent::Key { code, modifiers } = event {
304 match code {
305 KeyCode::Char(c) => {
306 if !modifiers.control && !modifiers.alt {
307 self.insert_char(*c);
308 return true;
309 }
310 }
311 KeyCode::Backspace => {
312 self.backspace();
313 return true;
314 }
315 KeyCode::Delete => {
316 self.delete();
317 return true;
318 }
319 KeyCode::Left => {
320 self.cursor_left();
321 return true;
322 }
323 KeyCode::Right => {
324 self.cursor_right();
325 return true;
326 }
327 KeyCode::Home => {
328 self.cursor_home();
329 return true;
330 }
331 KeyCode::End => {
332 self.cursor_end();
333 return true;
334 }
335 _ => {}
336 }
337 }
338
339 false
340 }
341
342 fn needs_redraw(&self) -> bool {
343 self.dirty
344 }
345
346 fn clear_redraw(&mut self) {
347 self.dirty = false;
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_text_input_basic() {
357 let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
358
359 input.insert_char('H');
361 input.insert_char('i');
362 assert_eq!(input.content(), "Hi");
363 assert_eq!(input.cursor, 2);
364 }
365
366 #[test]
367 fn test_text_input_backspace() {
368 let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
369 input.set_content("Hello");
370
371 input.backspace();
372 assert_eq!(input.content(), "Hell");
373 }
374
375 #[test]
376 fn test_text_input_cursor_movement() {
377 let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
378 input.set_content("Hello");
379
380 input.cursor_left();
381 assert_eq!(input.cursor, 4);
382
383 input.cursor_home();
384 assert_eq!(input.cursor, 0);
385
386 input.cursor_end();
387 assert_eq!(input.cursor, 5);
388 }
389}