1use std::{borrow::Cow, collections::HashSet};
2
3use ratatui::{
4 backend::FromCrossterm,
5 buffer::Buffer,
6 layout::{Rect, Size},
7 prelude::StatefulWidget,
8 style::{Style, Styled},
9 widgets::{Block, Borders, Widget},
10};
11use tui_widget_list::{ListBuilder, ListState, ListView, ScrollAxis};
12use unicode_width::UnicodeWidthStr;
13
14use crate::config::Theme;
15
16pub trait CustomListItem {
18 type Widget<'w>: Widget + 'w
19 where
20 Self: 'w;
21
22 fn as_widget<'a>(
24 &'a self,
25 theme: &Theme,
26 inline: bool,
27 is_highlighted: bool,
28 is_discarded: bool,
29 ) -> (Self::Widget<'a>, Size);
30}
31
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34pub enum HighlightSymbolMode {
35 First,
37 Repeat,
39 Last,
41}
42
43pub struct CustomList<'a, T: CustomListItem> {
45 theme: Theme,
47 inline: bool,
49 block: Option<Block<'a>>,
51 axis: ScrollAxis,
53 items: Vec<T>,
55 focus: bool,
57 state: ListState,
59 discarded_indices: HashSet<usize>,
61 highlight_symbol: Option<String>,
63 highlight_symbol_mode: HighlightSymbolMode,
65 highlight_symbol_style: Style,
67 highlight_symbol_style_focused: Style,
69}
70
71impl<'a, T: CustomListItem> CustomList<'a, T> {
72 pub fn new(theme: Theme, inline: bool, items: Vec<T>) -> Self {
74 let mut state = ListState::default();
75 if !items.is_empty() {
76 state.select(Some(0));
77 }
78 Self {
79 block: (!inline)
80 .then(|| Block::default().borders(Borders::ALL).style(Style::from_crossterm(theme.primary))),
81 axis: ScrollAxis::Vertical,
82 items,
83 focus: true,
84 state,
85 discarded_indices: HashSet::new(),
86 highlight_symbol_style: Style::from_crossterm(theme.primary),
87 highlight_symbol_style_focused: Style::from_crossterm(theme.highlight_primary_full()),
88 highlight_symbol: Some(theme.highlight_symbol.clone()).filter(|s| !s.trim().is_empty()),
89 highlight_symbol_mode: HighlightSymbolMode::Last,
90 theme,
91 inline,
92 }
93 }
94
95 pub fn horizontal(mut self) -> Self {
97 self.axis = ScrollAxis::Horizontal;
98 self
99 }
100
101 pub fn vertical(mut self) -> Self {
103 self.axis = ScrollAxis::Vertical;
104 self
105 }
106
107 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
109 self.set_title(title);
110 self
111 }
112
113 pub fn highlight_symbol(mut self, highlight_symbol: String) -> Self {
115 self.highlight_symbol = Some(highlight_symbol).filter(|s| !s.is_empty());
116 self
117 }
118
119 pub fn highlight_symbol_mode(mut self, highlight_symbol_mode: HighlightSymbolMode) -> Self {
121 self.highlight_symbol_mode = highlight_symbol_mode;
122 self
123 }
124
125 pub fn highlight_symbol_style(mut self, highlight_symbol_style: Style) -> Self {
127 self.highlight_symbol_style = highlight_symbol_style;
128 self
129 }
130
131 pub fn highlight_symbol_style_focused(mut self, highlight_symbol_style_focused: Style) -> Self {
133 self.highlight_symbol_style_focused = highlight_symbol_style_focused;
134 self
135 }
136
137 pub fn set_title(&mut self, new_title: impl Into<Cow<'a, str>>) {
139 if let Some(ref mut block) = self.block {
140 *block = Block::default()
141 .borders(Borders::ALL)
142 .style(Styled::style(block))
143 .title(new_title.into());
144 }
145 }
146
147 pub fn set_focus(&mut self, focus: bool) {
149 self.focus = focus;
150 }
151
152 pub fn is_focused(&self) -> bool {
154 self.focus
155 }
156
157 pub fn len(&self) -> usize {
159 self.items.len()
160 }
161
162 #[must_use]
164 pub fn is_empty(&self) -> bool {
165 self.items.is_empty()
166 }
167
168 pub fn items(&self) -> &Vec<T> {
170 &self.items
171 }
172
173 pub fn items_mut(&mut self) -> &mut Vec<T> {
175 &mut self.items
176 }
177
178 pub fn non_discarded_items(&self) -> impl Iterator<Item = &T> {
180 self.items
181 .iter()
182 .enumerate()
183 .filter_map(|(index, item)| if self.is_discarded(index) { None } else { Some(item) })
184 }
185
186 pub fn is_discarded(&self, index: usize) -> bool {
188 self.discarded_indices.contains(&index)
189 }
190
191 pub fn update_items(&mut self, items: Vec<T>, keep_selection: bool) {
195 self.items = items;
196 self.discarded_indices.clear();
197
198 if keep_selection {
199 if self.items.is_empty() {
200 self.state.select(None);
201 } else if let Some(selected) = self.state.selected {
202 if selected > self.items.len() - 1 {
203 self.state.select(Some(self.items.len() - 1));
204 }
205 } else {
206 self.state.select(Some(0));
207 }
208 } else {
209 self.state = ListState::default();
210 if !self.items.is_empty() {
211 self.state.select(Some(0));
212 }
213 }
214 }
215
216 pub fn select_next(&mut self) {
218 if self.focus
219 && let Some(selected) = self.state.selected
220 {
221 if self.items.is_empty() {
222 self.state.select(None);
223 } else {
224 let i = if selected >= self.items.len() - 1 {
225 0
226 } else {
227 selected + 1
228 };
229 self.state.select(Some(i));
230 }
231 }
232 }
233
234 pub fn select_prev(&mut self) {
236 if self.focus
237 && let Some(selected) = self.state.selected
238 {
239 if self.items.is_empty() {
240 self.state.select(None);
241 } else {
242 let i = if selected == 0 {
243 self.items.len() - 1
244 } else {
245 selected - 1
246 };
247 self.state.select(Some(i));
248 }
249 }
250 }
251
252 pub fn select_first(&mut self) {
254 if self.focus && !self.items.is_empty() {
255 self.state.select(Some(0));
256 }
257 }
258
259 pub fn select_last(&mut self) {
261 if self.focus && !self.items.is_empty() {
262 let i = self.items.len() - 1;
263 self.state.select(Some(i));
264 }
265 }
266
267 pub fn select_matching<F>(&mut self, predicate: F) -> bool
269 where
270 F: FnMut(&T) -> bool,
271 {
272 if !self.items.is_empty()
273 && let Some(index) = self.items.iter().position(predicate)
274 {
275 self.state.select(Some(index));
276 true
277 } else {
278 false
279 }
280 }
281
282 pub fn select(&mut self, index: usize) {
284 if self.focus && index < self.items.len() {
285 self.state.select(Some(index));
286 }
287 }
288
289 pub fn selected_index(&self) -> Option<usize> {
291 self.state.selected
292 }
293
294 pub fn selected_mut(&mut self) -> Option<&mut T> {
296 if let Some(selected) = self.state.selected {
297 self.items.get_mut(selected)
298 } else {
299 None
300 }
301 }
302
303 pub fn selected(&self) -> Option<&T> {
305 if let Some(selected) = self.state.selected {
306 self.items.get(selected)
307 } else {
308 None
309 }
310 }
311
312 pub fn selected_with_index(&self) -> Option<(usize, &T)> {
314 if let Some(selected) = self.state.selected {
315 self.items.get(selected).map(|i| (selected, i))
316 } else {
317 None
318 }
319 }
320
321 pub fn delete_selected(&mut self) -> Option<(usize, T)> {
323 if self.focus {
324 let selected = self.state.selected?;
325 let deleted = self.items.remove(selected);
326
327 self.discarded_indices = self
329 .discarded_indices
330 .iter()
331 .filter_map(|&idx| {
332 if idx < selected {
333 Some(idx)
335 } else if idx > selected {
336 Some(idx - 1)
338 } else {
339 None
341 }
342 })
343 .collect();
344
345 if self.items.is_empty() {
346 self.state.select(None);
347 } else if selected >= self.items.len() {
348 self.state.select(Some(self.items.len() - 1));
349 }
350
351 Some((selected, deleted))
352 } else {
353 None
354 }
355 }
356
357 pub fn toggle_discard_selected(&mut self) {
359 if let Some(selected) = self.state.selected
360 && !self.discarded_indices.remove(&selected)
361 {
362 self.discarded_indices.insert(selected);
363 }
364 }
365
366 pub fn toggle_discard_all(&mut self) {
368 if self.items.is_empty() {
369 return;
370 }
371 if self.discarded_indices.len() == self.items.len() {
373 self.discarded_indices.clear();
375 } else {
376 self.discarded_indices.extend(0..self.items.len());
378 }
379 }
380}
381
382impl<'a, T: CustomListItem> Widget for &mut CustomList<'a, T> {
383 fn render(self, area: Rect, buf: &mut Buffer)
384 where
385 Self: Sized,
386 {
387 if let Some(ref highlight_symbol) = self.highlight_symbol {
388 render_list_view(
390 ListBuilder::new(|ctx| {
391 let is_highlighted = self.focus && ctx.is_selected;
392 let is_discarded = self.discarded_indices.contains(&ctx.index);
393 let (item_widget, item_size) =
395 self.items[ctx.index].as_widget(&self.theme, self.inline, is_highlighted, is_discarded);
396
397 let item = SymbolAndWidget {
399 content: item_widget,
400 content_height: item_size.height,
401 symbol: if ctx.is_selected { highlight_symbol.as_str() } else { "" },
402 symbol_width: highlight_symbol.width() as u16,
403 symbol_mode: self.highlight_symbol_mode,
404 symbol_style: if self.focus {
405 self.highlight_symbol_style_focused
406 } else {
407 self.highlight_symbol_style
408 },
409 };
410
411 let main_axis_size = match ctx.scroll_axis {
412 ScrollAxis::Vertical => item_size.height,
413 ScrollAxis::Horizontal => item_size.width + 1,
414 };
415
416 (item, main_axis_size)
417 }),
418 self.axis,
419 self.block.is_none(),
420 self.items.len(),
421 self.block.clone(),
422 &mut self.state,
423 area,
424 buf,
425 );
426 } else {
427 render_list_view(
429 ListBuilder::new(|ctx| {
430 let is_highlighted = ctx.is_selected;
431 let is_discarded = self.discarded_indices.contains(&ctx.index);
432 let (item_widget, item_size) =
433 self.items[ctx.index].as_widget(&self.theme, self.inline, is_highlighted, is_discarded);
434 let main_axis_size = match ctx.scroll_axis {
435 ScrollAxis::Vertical => item_size.height,
436 ScrollAxis::Horizontal => item_size.width + 1,
437 };
438 (item_widget, main_axis_size)
439 }),
440 self.axis,
441 self.block.is_none(),
442 self.items.len(),
443 self.block.clone(),
444 &mut self.state,
445 area,
446 buf,
447 );
448 }
449 }
450}
451
452#[allow(clippy::too_many_arguments)]
454fn render_list_view<'a, W: Widget>(
455 builder: ListBuilder<'a, W>,
456 axis: ScrollAxis,
457 inline: bool,
458 item_count: usize,
459 block: Option<Block<'a>>,
460 state: &mut ListState,
461 area: Rect,
462 buf: &mut Buffer,
463) {
464 let mut view = ListView::new(builder, item_count)
465 .scroll_axis(axis)
466 .infinite_scrolling(false)
467 .scroll_padding(1 + (!inline as u16));
468 if let Some(block) = block {
469 view = view.block(block);
470 }
471 view.render(area, buf, state)
472}
473
474struct SymbolAndWidget<'a, W: Widget> {
476 content: W,
478 content_height: u16,
480 symbol: &'a str,
482 symbol_width: u16,
484 symbol_mode: HighlightSymbolMode,
486 symbol_style: Style,
488}
489
490impl<'a, W: Widget> Widget for SymbolAndWidget<'a, W> {
491 fn render(self, area: Rect, buf: &mut Buffer) {
492 let mut content_area = area;
493 let mut symbol_area = Rect::default();
494
495 if self.symbol_width > 0 && area.width > 0 {
497 symbol_area = Rect {
498 x: area.x,
499 y: area.y,
500 width: self.symbol_width.min(area.width),
501 height: area.height,
502 };
503
504 content_area.x = area.x.saturating_add(symbol_area.width);
506 content_area.width = area.width.saturating_sub(symbol_area.width);
507 }
508
509 if content_area.width > 0 && content_area.height > 0 {
511 self.content.render(content_area, buf);
512 }
513
514 if !self.symbol.is_empty() && symbol_area.width > 0 && symbol_area.height > 0 {
516 if let Some(bg_color) = self.symbol_style.bg {
518 for y_coord in symbol_area.top()..symbol_area.bottom() {
519 for x_coord in symbol_area.left()..symbol_area.right() {
520 if let Some(cell) = buf.cell_mut((x_coord, y_coord)) {
521 cell.set_bg(bg_color);
522 }
523 }
524 }
525 }
526 match self.symbol_mode {
528 HighlightSymbolMode::First => {
529 buf.set_stringn(
531 symbol_area.x,
532 symbol_area.y,
533 self.symbol,
534 symbol_area.width as usize,
535 self.symbol_style,
536 );
537 }
538 HighlightSymbolMode::Repeat => {
539 for i in 0..self.content_height {
541 let y_pos = symbol_area.y + i;
542 if y_pos < symbol_area.bottom() && i < symbol_area.height {
544 buf.set_stringn(
545 symbol_area.x,
546 y_pos,
547 self.symbol,
548 symbol_area.width as usize,
549 self.symbol_style,
550 );
551 } else {
552 break;
554 }
555 }
556 }
557 HighlightSymbolMode::Last => {
558 if self.content_height > 0 {
560 let y_pos = symbol_area.y + self.content_height - 1;
561 if y_pos < symbol_area.bottom() && (self.content_height - 1) < symbol_area.height {
563 buf.set_stringn(
564 symbol_area.x,
565 y_pos,
566 self.symbol,
567 symbol_area.width as usize,
568 self.symbol_style,
569 );
570 }
571 }
572 }
573 }
574 }
575 }
576}