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