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