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 focused_fg: Color,
274 pub cursor: Color,
276 pub disabled: Color,
278}
279
280impl Default for TextListColors {
281 fn default() -> Self {
282 Self {
283 label: Color::White,
284 text: Color::White,
285 border: Color::Gray,
286 remove_button: Color::Red,
287 add_button: Color::Green,
288 focused: Color::Cyan,
289 focused_fg: Color::Black,
290 cursor: Color::Yellow,
291 disabled: Color::DarkGray,
292 }
293 }
294}
295
296impl TextListColors {
297 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
299 Self {
300 label: theme.editor_fg,
301 text: theme.editor_fg,
302 border: theme.line_number_fg,
303 remove_button: theme.diagnostic_error_fg,
304 add_button: theme.diagnostic_info_fg,
305 focused: theme.settings_selected_bg,
306 focused_fg: theme.settings_selected_fg,
307 cursor: theme.cursor,
308 disabled: theme.line_number_fg,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy)]
315pub struct TextListRowLayout {
316 pub text_area: Rect,
318 pub button_area: Rect,
320 pub index: Option<usize>,
322}
323
324#[derive(Debug, Clone, Default)]
326pub struct TextListLayout {
327 pub rows: Vec<TextListRowLayout>,
329 pub full_area: Rect,
331}
332
333impl TextListLayout {
334 pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
336 for row in &self.rows {
337 if y >= row.text_area.y
338 && y < row.text_area.y + row.text_area.height
339 && x >= row.button_area.x
340 && x < row.button_area.x + row.button_area.width
341 {
342 return Some(TextListHit::Button(row.index));
343 }
344 if y >= row.text_area.y
345 && y < row.text_area.y + row.text_area.height
346 && x >= row.text_area.x
347 && x < row.text_area.x + row.text_area.width
348 {
349 return Some(TextListHit::TextField(row.index));
350 }
351 }
352 None
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum TextListHit {
359 TextField(Option<usize>),
361 Button(Option<usize>),
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use ratatui::backend::TestBackend;
369 use ratatui::Terminal;
370
371 fn test_frame<F>(width: u16, height: u16, f: F)
372 where
373 F: FnOnce(&mut ratatui::Frame, Rect),
374 {
375 let backend = TestBackend::new(width, height);
376 let mut terminal = Terminal::new(backend).unwrap();
377 terminal
378 .draw(|frame| {
379 let area = Rect::new(0, 0, width, height);
380 f(frame, area);
381 })
382 .unwrap();
383 }
384
385 #[test]
386 fn test_text_list_empty() {
387 test_frame(40, 5, |frame, area| {
388 let state = TextListState::new("Items");
389 let colors = TextListColors::default();
390 let layout = render_text_list(frame, area, &state, &colors, 20);
391
392 assert_eq!(layout.rows.len(), 1);
393 assert!(layout.rows[0].index.is_none());
394 });
395 }
396
397 #[test]
398 fn test_text_list_with_items() {
399 test_frame(40, 5, |frame, area| {
400 let state =
401 TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
402 let colors = TextListColors::default();
403 let layout = render_text_list(frame, area, &state, &colors, 20);
404
405 assert_eq!(layout.rows.len(), 3);
406 assert_eq!(layout.rows[0].index, Some(0));
407 assert_eq!(layout.rows[1].index, Some(1));
408 assert!(layout.rows[2].index.is_none());
409 });
410 }
411
412 #[test]
413 fn test_text_list_add_item() {
414 let mut state = TextListState::new("Items");
415 state.new_item_text = "new item".to_string();
416 state.add_item();
417
418 assert_eq!(state.items.len(), 1);
419 assert_eq!(state.items[0], "new item");
420 assert!(state.new_item_text.is_empty());
421 }
422
423 #[test]
424 fn test_text_list_remove_item() {
425 let mut state =
426 TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
427 state.remove_item(0);
428
429 assert_eq!(state.items.len(), 1);
430 assert_eq!(state.items[0], "b");
431 }
432
433 #[test]
434 fn test_text_list_edit_item() {
435 let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
436 state.focus_item(0);
437 state.insert('!');
438
439 assert_eq!(state.items[0], "hello!");
440 }
441
442 #[test]
443 fn test_text_list_navigation() {
444 let mut state = TextListState::new("Items")
445 .with_items(vec!["a".to_string(), "b".to_string()])
446 .with_focus(FocusState::Focused);
447
448 assert!(state.focused_item.is_none());
449
450 state.focus_prev();
451 assert_eq!(state.focused_item, Some(1));
452
453 state.focus_prev();
454 assert_eq!(state.focused_item, Some(0));
455
456 state.focus_prev();
457 assert_eq!(state.focused_item, Some(0));
458
459 state.focus_next();
460 assert_eq!(state.focused_item, Some(1));
461
462 state.focus_next();
463 assert!(state.focused_item.is_none());
464 }
465
466 #[test]
467 fn test_text_list_hit_test() {
468 test_frame(40, 5, |frame, area| {
469 let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
470 let colors = TextListColors::default();
471 let layout = render_text_list(frame, area, &state, &colors, 20);
472
473 let btn = &layout.rows[0].button_area;
474 let hit = layout.hit_test(btn.x, btn.y);
475 assert_eq!(hit, Some(TextListHit::Button(Some(0))));
476
477 let add_btn = &layout.rows[1].button_area;
478 let hit = layout.hit_test(add_btn.x, add_btn.y);
479 assert_eq!(hit, Some(TextListHit::Button(None)));
480 });
481 }
482}