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}
44
45impl TextListState {
46 pub fn new(label: impl Into<String>) -> Self {
48 Self {
49 items: Vec::new(),
50 focused_item: None,
51 cursor: 0,
52 new_item_text: String::new(),
53 label: label.into(),
54 focus: FocusState::Normal,
55 }
56 }
57
58 pub fn with_items(mut self, items: Vec<String>) -> Self {
60 self.items = items;
61 self
62 }
63
64 pub fn with_focus(mut self, focus: FocusState) -> Self {
66 self.focus = focus;
67 self
68 }
69
70 pub fn is_enabled(&self) -> bool {
72 self.focus != FocusState::Disabled
73 }
74
75 pub fn add_item(&mut self) {
77 if !self.is_enabled() || self.new_item_text.is_empty() {
78 return;
79 }
80 self.items.push(std::mem::take(&mut self.new_item_text));
81 self.cursor = 0;
82 }
83
84 pub fn insert_str(&mut self, s: &str) {
86 if !self.is_enabled() {
87 return;
88 }
89 if let Some(index) = self.focused_item {
90 if let Some(item) = self.items.get_mut(index) {
91 if self.cursor <= item.len() {
92 item.insert_str(self.cursor, s);
93 self.cursor += s.len();
94 }
95 }
96 } else if self.cursor <= self.new_item_text.len() {
97 self.new_item_text.insert_str(self.cursor, s);
98 self.cursor += s.len();
99 }
100 }
101
102 pub fn remove_item(&mut self, index: usize) {
104 if !self.is_enabled() || index >= self.items.len() {
105 return;
106 }
107 self.items.remove(index);
108 if let Some(focused) = self.focused_item {
109 if focused >= self.items.len() {
110 self.focused_item = if self.items.is_empty() {
111 None
112 } else {
113 Some(self.items.len() - 1)
114 };
115 }
116 }
117 }
118
119 pub fn focus_item(&mut self, index: usize) {
121 if index < self.items.len() {
122 self.focused_item = Some(index);
123 self.cursor = self.items[index].len();
124 }
125 }
126
127 pub fn focus_new_item(&mut self) {
129 self.focused_item = None;
130 self.cursor = self.new_item_text.len();
131 }
132
133 pub fn insert(&mut self, c: char) {
135 if !self.is_enabled() {
136 return;
137 }
138 match self.focused_item {
139 Some(idx) if idx < self.items.len() => {
140 self.items[idx].insert(self.cursor, c);
141 self.cursor += 1;
142 }
143 None => {
144 self.new_item_text.insert(self.cursor, c);
145 self.cursor += 1;
146 }
147 _ => {}
148 }
149 }
150
151 pub fn backspace(&mut self) {
153 if !self.is_enabled() || self.cursor == 0 {
154 return;
155 }
156 self.cursor -= 1;
157 match self.focused_item {
158 Some(idx) if idx < self.items.len() => {
159 self.items[idx].remove(self.cursor);
160 }
161 None => {
162 self.new_item_text.remove(self.cursor);
163 }
164 _ => {}
165 }
166 }
167
168 pub fn move_left(&mut self) {
170 if self.cursor > 0 {
171 self.cursor -= 1;
172 }
173 }
174
175 pub fn move_right(&mut self) {
177 let max = match self.focused_item {
178 Some(idx) if idx < self.items.len() => self.items[idx].len(),
179 None => self.new_item_text.len(),
180 _ => 0,
181 };
182 if self.cursor < max {
183 self.cursor += 1;
184 }
185 }
186
187 pub fn move_home(&mut self) {
189 self.cursor = 0;
190 }
191
192 pub fn move_end(&mut self) {
194 self.cursor = match self.focused_item {
195 Some(idx) if idx < self.items.len() => self.items[idx].len(),
196 None => self.new_item_text.len(),
197 _ => 0,
198 };
199 }
200
201 pub fn delete(&mut self) {
203 if !self.is_enabled() {
204 return;
205 }
206 let max = match self.focused_item {
207 Some(idx) if idx < self.items.len() => self.items[idx].len(),
208 None => self.new_item_text.len(),
209 _ => return,
210 };
211 if self.cursor >= max {
212 return;
213 }
214 match self.focused_item {
215 Some(idx) if idx < self.items.len() => {
216 self.items[idx].remove(self.cursor);
217 }
218 None => {
219 self.new_item_text.remove(self.cursor);
220 }
221 _ => {}
222 }
223 }
224
225 pub fn focus_prev(&mut self) {
227 match self.focused_item {
228 Some(0) => {}
229 Some(idx) => {
230 self.focused_item = Some(idx - 1);
231 self.cursor = self.items[idx - 1].len();
232 }
233 None if !self.items.is_empty() => {
234 self.focused_item = Some(self.items.len() - 1);
235 self.cursor = self.items.last().map(|s| s.len()).unwrap_or(0);
236 }
237 None => {}
238 }
239 }
240
241 pub fn focus_next(&mut self) {
243 match self.focused_item {
244 Some(idx) if idx + 1 < self.items.len() => {
245 self.focused_item = Some(idx + 1);
246 self.cursor = self.items[idx + 1].len();
247 }
248 Some(_) => {
249 self.focused_item = None;
250 self.cursor = self.new_item_text.len();
251 }
252 None => {}
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy)]
259pub struct TextListColors {
260 pub label: Color,
262 pub text: Color,
264 pub border: Color,
266 pub remove_button: Color,
268 pub add_button: Color,
270 pub focused: Color,
272 pub cursor: Color,
274 pub disabled: Color,
276}
277
278impl Default for TextListColors {
279 fn default() -> Self {
280 Self {
281 label: Color::White,
282 text: Color::White,
283 border: Color::Gray,
284 remove_button: Color::Red,
285 add_button: Color::Green,
286 focused: Color::Cyan,
287 cursor: Color::Yellow,
288 disabled: Color::DarkGray,
289 }
290 }
291}
292
293impl TextListColors {
294 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
296 Self {
297 label: theme.editor_fg,
298 text: theme.editor_fg,
299 border: theme.line_number_fg,
300 remove_button: theme.diagnostic_error_fg,
301 add_button: theme.diagnostic_info_fg,
302 focused: theme.selection_bg,
303 cursor: theme.cursor,
304 disabled: theme.line_number_fg,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Copy)]
311pub struct TextListRowLayout {
312 pub text_area: Rect,
314 pub button_area: Rect,
316 pub index: Option<usize>,
318}
319
320#[derive(Debug, Clone, Default)]
322pub struct TextListLayout {
323 pub rows: Vec<TextListRowLayout>,
325 pub full_area: Rect,
327}
328
329impl TextListLayout {
330 pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
332 for row in &self.rows {
333 if y >= row.text_area.y
334 && y < row.text_area.y + row.text_area.height
335 && x >= row.button_area.x
336 && x < row.button_area.x + row.button_area.width
337 {
338 return Some(TextListHit::Button(row.index));
339 }
340 if y >= row.text_area.y
341 && y < row.text_area.y + row.text_area.height
342 && x >= row.text_area.x
343 && x < row.text_area.x + row.text_area.width
344 {
345 return Some(TextListHit::TextField(row.index));
346 }
347 }
348 None
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum TextListHit {
355 TextField(Option<usize>),
357 Button(Option<usize>),
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use ratatui::backend::TestBackend;
365 use ratatui::Terminal;
366
367 fn test_frame<F>(width: u16, height: u16, f: F)
368 where
369 F: FnOnce(&mut ratatui::Frame, Rect),
370 {
371 let backend = TestBackend::new(width, height);
372 let mut terminal = Terminal::new(backend).unwrap();
373 terminal
374 .draw(|frame| {
375 let area = Rect::new(0, 0, width, height);
376 f(frame, area);
377 })
378 .unwrap();
379 }
380
381 #[test]
382 fn test_text_list_empty() {
383 test_frame(40, 5, |frame, area| {
384 let state = TextListState::new("Items");
385 let colors = TextListColors::default();
386 let layout = render_text_list(frame, area, &state, &colors, 20);
387
388 assert_eq!(layout.rows.len(), 1);
389 assert!(layout.rows[0].index.is_none());
390 });
391 }
392
393 #[test]
394 fn test_text_list_with_items() {
395 test_frame(40, 5, |frame, area| {
396 let state =
397 TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
398 let colors = TextListColors::default();
399 let layout = render_text_list(frame, area, &state, &colors, 20);
400
401 assert_eq!(layout.rows.len(), 3);
402 assert_eq!(layout.rows[0].index, Some(0));
403 assert_eq!(layout.rows[1].index, Some(1));
404 assert!(layout.rows[2].index.is_none());
405 });
406 }
407
408 #[test]
409 fn test_text_list_add_item() {
410 let mut state = TextListState::new("Items");
411 state.new_item_text = "new item".to_string();
412 state.add_item();
413
414 assert_eq!(state.items.len(), 1);
415 assert_eq!(state.items[0], "new item");
416 assert!(state.new_item_text.is_empty());
417 }
418
419 #[test]
420 fn test_text_list_remove_item() {
421 let mut state =
422 TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
423 state.remove_item(0);
424
425 assert_eq!(state.items.len(), 1);
426 assert_eq!(state.items[0], "b");
427 }
428
429 #[test]
430 fn test_text_list_edit_item() {
431 let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
432 state.focus_item(0);
433 state.insert('!');
434
435 assert_eq!(state.items[0], "hello!");
436 }
437
438 #[test]
439 fn test_text_list_navigation() {
440 let mut state = TextListState::new("Items")
441 .with_items(vec!["a".to_string(), "b".to_string()])
442 .with_focus(FocusState::Focused);
443
444 assert!(state.focused_item.is_none());
445
446 state.focus_prev();
447 assert_eq!(state.focused_item, Some(1));
448
449 state.focus_prev();
450 assert_eq!(state.focused_item, Some(0));
451
452 state.focus_prev();
453 assert_eq!(state.focused_item, Some(0));
454
455 state.focus_next();
456 assert_eq!(state.focused_item, Some(1));
457
458 state.focus_next();
459 assert!(state.focused_item.is_none());
460 }
461
462 #[test]
463 fn test_text_list_hit_test() {
464 test_frame(40, 5, |frame, area| {
465 let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
466 let colors = TextListColors::default();
467 let layout = render_text_list(frame, area, &state, &colors, 20);
468
469 let btn = &layout.rows[0].button_area;
470 let hit = layout.hit_test(btn.x, btn.y);
471 assert_eq!(hit, Some(TextListHit::Button(Some(0))));
472
473 let add_btn = &layout.rows[1].button_area;
474 let hit = layout.hit_test(add_btn.x, add_btn.y);
475 assert_eq!(hit, Some(TextListHit::Button(None)));
476 });
477 }
478}