fresh/view/controls/text_list/
mod.rs1mod input;
18mod render;
19
20use ratatui::layout::Rect;
21use ratatui::style::Color;
22
23pub use input::TextListEvent;
24pub use render::render_text_list;
25
26use super::FocusState;
27
28#[derive(Debug, Clone)]
30pub struct TextListState {
31 pub items: Vec<String>,
33 pub focused_item: Option<usize>,
35 pub cursor: usize,
37 pub new_item_text: String,
39 pub label: String,
41 pub focus: FocusState,
43 pub is_integer: bool,
45 pub pending_active: bool,
54}
55
56impl TextListState {
57 pub fn new(label: impl Into<String>) -> Self {
59 Self {
60 items: Vec::new(),
61 focused_item: None,
62 cursor: 0,
63 new_item_text: String::new(),
64 label: label.into(),
65 focus: FocusState::Normal,
66 is_integer: false,
67 pending_active: false,
68 }
69 }
70
71 pub fn with_items(mut self, items: Vec<String>) -> Self {
73 self.items = items;
74 self
75 }
76
77 pub fn with_integer_mode(mut self) -> Self {
79 self.is_integer = true;
80 self
81 }
82
83 pub fn with_focus(mut self, focus: FocusState) -> Self {
85 self.focus = focus;
86 self
87 }
88
89 pub fn is_enabled(&self) -> bool {
91 self.focus != FocusState::Disabled
92 }
93
94 pub fn add_item(&mut self) {
96 if !self.is_enabled() || self.new_item_text.is_empty() {
97 return;
98 }
99 self.items.push(std::mem::take(&mut self.new_item_text));
100 self.cursor = 0;
101 self.pending_active = false;
105 }
106
107 pub fn activate_pending(&mut self) {
112 if !self.is_enabled() {
113 return;
114 }
115 self.focused_item = None;
116 self.cursor = self.new_item_text.len();
117 self.pending_active = true;
118 }
119
120 pub fn cancel_pending(&mut self) {
123 self.new_item_text.clear();
124 self.cursor = 0;
125 self.pending_active = false;
126 }
127
128 pub fn insert_str(&mut self, s: &str) {
130 if !self.is_enabled() {
131 return;
132 }
133 if let Some(index) = self.focused_item {
134 if let Some(item) = self.items.get_mut(index) {
135 if self.cursor <= item.len() {
136 item.insert_str(self.cursor, s);
137 self.cursor += s.len();
138 }
139 }
140 } else if self.cursor <= self.new_item_text.len() {
141 self.new_item_text.insert_str(self.cursor, s);
142 self.cursor += s.len();
143 self.pending_active = true;
144 }
145 }
146
147 pub fn remove_item(&mut self, index: usize) {
149 if !self.is_enabled() || index >= self.items.len() {
150 return;
151 }
152 self.items.remove(index);
153 if let Some(focused) = self.focused_item {
154 if focused >= self.items.len() {
155 self.focused_item = if self.items.is_empty() {
156 None
157 } else {
158 Some(self.items.len() - 1)
159 };
160 }
161 }
162 }
163
164 pub fn focus_item(&mut self, index: usize) {
166 if index < self.items.len() {
167 self.focused_item = Some(index);
168 self.cursor = self.items[index].len();
169 self.pending_active = false;
173 }
174 }
175
176 pub fn focus_new_item(&mut self) {
178 self.focused_item = None;
179 self.cursor = self.new_item_text.len();
180 self.pending_active = false;
183 }
184
185 pub fn insert(&mut self, c: char) {
187 if !self.is_enabled() {
188 return;
189 }
190 match self.focused_item {
191 Some(idx) if idx < self.items.len() => {
192 self.items[idx].insert(self.cursor, c);
193 self.cursor += 1;
194 }
195 None => {
196 self.new_item_text.insert(self.cursor, c);
197 self.cursor += 1;
198 self.pending_active = true;
202 }
203 _ => {}
204 }
205 }
206
207 pub fn backspace(&mut self) {
209 if !self.is_enabled() || self.cursor == 0 {
210 return;
211 }
212 self.cursor -= 1;
213 match self.focused_item {
214 Some(idx) if idx < self.items.len() => {
215 self.items[idx].remove(self.cursor);
216 }
217 None => {
218 self.new_item_text.remove(self.cursor);
219 }
220 _ => {}
221 }
222 }
223
224 pub fn move_left(&mut self) {
226 if self.cursor > 0 {
227 self.cursor -= 1;
228 }
229 }
230
231 pub fn move_right(&mut self) {
233 let max = match self.focused_item {
234 Some(idx) if idx < self.items.len() => self.items[idx].len(),
235 None => self.new_item_text.len(),
236 _ => 0,
237 };
238 if self.cursor < max {
239 self.cursor += 1;
240 }
241 }
242
243 pub fn move_home(&mut self) {
245 self.cursor = 0;
246 }
247
248 pub fn move_end(&mut self) {
250 self.cursor = match self.focused_item {
251 Some(idx) if idx < self.items.len() => self.items[idx].len(),
252 None => self.new_item_text.len(),
253 _ => 0,
254 };
255 }
256
257 pub fn delete(&mut self) {
259 if !self.is_enabled() {
260 return;
261 }
262 let max = match self.focused_item {
263 Some(idx) if idx < self.items.len() => self.items[idx].len(),
264 None => self.new_item_text.len(),
265 _ => return,
266 };
267 if self.cursor >= max {
268 return;
269 }
270 match self.focused_item {
271 Some(idx) if idx < self.items.len() => {
272 self.items[idx].remove(self.cursor);
273 }
274 None => {
275 self.new_item_text.remove(self.cursor);
276 }
277 _ => {}
278 }
279 }
280
281 pub fn focus_prev(&mut self) {
283 match self.focused_item {
284 Some(0) => {}
285 Some(idx) => {
286 self.focused_item = Some(idx - 1);
287 self.cursor = self.items[idx - 1].len();
288 }
289 None if !self.items.is_empty() => {
290 self.focused_item = Some(self.items.len() - 1);
291 self.cursor = self.items.last().map(|s| s.len()).unwrap_or(0);
292 self.pending_active = false;
294 }
295 None => {}
296 }
297 }
298
299 pub fn focus_next(&mut self) {
301 match self.focused_item {
302 Some(idx) if idx + 1 < self.items.len() => {
303 self.focused_item = Some(idx + 1);
304 self.cursor = self.items[idx + 1].len();
305 }
306 Some(_) => {
307 self.focused_item = None;
308 self.cursor = self.new_item_text.len();
309 self.pending_active = false;
310 }
311 None => {}
312 }
313 }
314}
315
316#[derive(Debug, Clone, Copy)]
318pub struct TextListColors {
319 pub label: Color,
321 pub text: Color,
323 pub border: Color,
325 pub remove_button: Color,
327 pub add_button: Color,
329 pub focused: Color,
331 pub focused_fg: Color,
333 pub cursor: Color,
335 pub disabled: Color,
337}
338
339impl Default for TextListColors {
340 fn default() -> Self {
341 Self {
342 label: Color::White,
343 text: Color::White,
344 border: Color::Gray,
345 remove_button: Color::Red,
346 add_button: Color::Green,
347 focused: Color::Cyan,
348 focused_fg: Color::Black,
349 cursor: Color::Yellow,
350 disabled: Color::DarkGray,
351 }
352 }
353}
354
355impl TextListColors {
356 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
358 Self {
359 label: theme.editor_fg,
360 text: theme.editor_fg,
361 border: theme.line_number_fg,
362 remove_button: theme.diagnostic_error_fg,
363 add_button: theme.diagnostic_info_fg,
364 focused: theme.settings_selected_bg,
365 focused_fg: theme.settings_selected_fg,
366 cursor: theme.cursor,
367 disabled: theme.line_number_fg,
368 }
369 }
370}
371
372#[derive(Debug, Clone, Copy)]
374pub struct TextListRowLayout {
375 pub text_area: Rect,
377 pub button_area: Rect,
379 pub index: Option<usize>,
381}
382
383#[derive(Debug, Clone, Default)]
385pub struct TextListLayout {
386 pub rows: Vec<TextListRowLayout>,
388 pub full_area: Rect,
390}
391
392impl TextListLayout {
393 pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
395 for row in &self.rows {
396 if y >= row.text_area.y
397 && y < row.text_area.y + row.text_area.height
398 && x >= row.button_area.x
399 && x < row.button_area.x + row.button_area.width
400 {
401 return Some(TextListHit::Button(row.index));
402 }
403 if y >= row.text_area.y
404 && y < row.text_area.y + row.text_area.height
405 && x >= row.text_area.x
406 && x < row.text_area.x + row.text_area.width
407 {
408 return Some(TextListHit::TextField(row.index));
409 }
410 }
411 None
412 }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum TextListHit {
418 TextField(Option<usize>),
420 Button(Option<usize>),
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use ratatui::backend::TestBackend;
428 use ratatui::Terminal;
429
430 fn test_frame<F>(width: u16, height: u16, f: F)
431 where
432 F: FnOnce(&mut ratatui::Frame, Rect),
433 {
434 let backend = TestBackend::new(width, height);
435 let mut terminal = Terminal::new(backend).unwrap();
436 terminal
437 .draw(|frame| {
438 let area = Rect::new(0, 0, width, height);
439 f(frame, area);
440 })
441 .unwrap();
442 }
443
444 #[test]
445 fn test_text_list_empty() {
446 test_frame(40, 5, |frame, area| {
447 let state = TextListState::new("Items");
448 let colors = TextListColors::default();
449 let layout = render_text_list(frame, area, &state, &colors, 20);
450
451 assert_eq!(layout.rows.len(), 1);
452 assert!(layout.rows[0].index.is_none());
453 });
454 }
455
456 #[test]
457 fn test_text_list_with_items() {
458 test_frame(40, 5, |frame, area| {
459 let state =
460 TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
461 let colors = TextListColors::default();
462 let layout = render_text_list(frame, area, &state, &colors, 20);
463
464 assert_eq!(layout.rows.len(), 3);
465 assert_eq!(layout.rows[0].index, Some(0));
466 assert_eq!(layout.rows[1].index, Some(1));
467 assert!(layout.rows[2].index.is_none());
468 });
469 }
470
471 #[test]
472 fn test_text_list_add_item() {
473 let mut state = TextListState::new("Items");
474 state.new_item_text = "new item".to_string();
475 state.add_item();
476
477 assert_eq!(state.items.len(), 1);
478 assert_eq!(state.items[0], "new item");
479 assert!(state.new_item_text.is_empty());
480 }
481
482 #[test]
483 fn test_text_list_remove_item() {
484 let mut state =
485 TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
486 state.remove_item(0);
487
488 assert_eq!(state.items.len(), 1);
489 assert_eq!(state.items[0], "b");
490 }
491
492 #[test]
493 fn test_text_list_edit_item() {
494 let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
495 state.focus_item(0);
496 state.insert('!');
497
498 assert_eq!(state.items[0], "hello!");
499 }
500
501 #[test]
502 fn test_text_list_navigation() {
503 let mut state = TextListState::new("Items")
504 .with_items(vec!["a".to_string(), "b".to_string()])
505 .with_focus(FocusState::Focused);
506
507 assert!(state.focused_item.is_none());
508
509 state.focus_prev();
510 assert_eq!(state.focused_item, Some(1));
511
512 state.focus_prev();
513 assert_eq!(state.focused_item, Some(0));
514
515 state.focus_prev();
516 assert_eq!(state.focused_item, Some(0));
517
518 state.focus_next();
519 assert_eq!(state.focused_item, Some(1));
520
521 state.focus_next();
522 assert!(state.focused_item.is_none());
523 }
524
525 #[test]
526 fn test_text_list_hit_test() {
527 test_frame(40, 5, |frame, area| {
528 let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
529 let colors = TextListColors::default();
530 let layout = render_text_list(frame, area, &state, &colors, 20);
531
532 let btn = &layout.rows[0].button_area;
533 let hit = layout.hit_test(btn.x, btn.y);
534 assert_eq!(hit, Some(TextListHit::Button(Some(0))));
535
536 let add_btn = &layout.rows[1].button_area;
537 let hit = layout.hit_test(add_btn.x, add_btn.y);
538 assert_eq!(hit, Some(TextListHit::Button(None)));
539 });
540 }
541}