use ratatui::{
buffer::Buffer,
layout::{Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, StatefulWidget, Widget},
};
use crate::theme::UiColors;
use super::render_help_overlays;
pub struct SelectList<'a> {
pub title: String,
pub items: &'a [String],
pub descriptions: &'a [Option<String>],
pub selected: Option<usize>,
pub hovered: Option<usize>,
pub show_cursor: bool,
pub borders: Borders,
pub item_color: Color,
pub selected_color: Color,
pub colors: &'a UiColors,
}
impl<'a> SelectList<'a> {
pub fn new(
title: String,
items: &'a [String],
selected: Option<usize>,
item_color: Color,
selected_color: Color,
colors: &'a UiColors,
) -> Self {
Self {
title,
items,
descriptions: &[],
selected,
hovered: None,
show_cursor: false,
borders: Borders::ALL,
item_color,
selected_color,
colors,
}
}
pub fn with_descriptions(mut self, descriptions: &'a [Option<String>]) -> Self {
self.descriptions = descriptions;
self
}
pub fn with_cursor(mut self) -> Self {
self.show_cursor = true;
self
}
pub fn with_borders(mut self, borders: Borders) -> Self {
self.borders = borders;
self
}
pub fn with_hovered(mut self, hovered: Option<usize>) -> Self {
self.hovered = hovered;
self
}
}
impl Widget for SelectList<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = SelectListScrollState::default();
StatefulWidget::render(self, area, buf, &mut state);
}
}
#[derive(Debug, Clone, Default)]
pub struct SelectListScrollState {
pub scroll_offset: usize,
pub visible_items: usize,
}
impl StatefulWidget for SelectList<'_> {
type State = SelectListScrollState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
ratatui::widgets::Clear.render(area, buf);
let mut block = Block::default()
.borders(self.borders)
.border_style(Style::default().fg(self.colors.active_border));
if !self.title.is_empty() {
block = block
.title(self.title)
.title_style(
Style::default()
.fg(self.colors.active_border)
.add_modifier(Modifier::BOLD),
);
}
let items: Vec<ratatui::widgets::ListItem> = if self.items.is_empty() {
vec![ratatui::widgets::ListItem::new(Line::from(Span::styled(
"(no matches)",
Style::default().fg(self.colors.help).italic(),
)))]
} else {
self.items
.iter()
.enumerate()
.map(|(i, label)| {
let is_selected = self.selected == Some(i);
let style = if is_selected {
Style::default()
.fg(self.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.item_color)
};
let mut spans = Vec::new();
if self.show_cursor {
let prefix = if is_selected { "▶ " } else { " " };
spans.push(Span::styled(
prefix,
if is_selected {
Style::default()
.fg(self.colors.active_border)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
}
spans.push(Span::styled(label.clone(), style));
let mut item = ratatui::widgets::ListItem::new(Line::from(spans));
if is_selected {
item = item.style(Style::default().bg(self.colors.selected_bg));
} else if self.hovered == Some(i) {
item = item.style(Style::default().bg(self.colors.hover_bg));
}
item
})
.collect()
};
let border_height = if self.borders.contains(Borders::TOP) { 1 } else { 0 }
+ if self.borders.contains(Borders::BOTTOM) { 1 } else { 0 };
let visible_items = area.height.saturating_sub(border_height) as usize;
state.visible_items = visible_items;
let mut list_state = ratatui::widgets::ListState::default().with_selected(
if self.items.is_empty() {
None
} else {
self.selected
},
);
let scroll_offset = if let Some(sel) = self.selected {
if visible_items > 0 && sel >= visible_items {
sel.saturating_sub(visible_items - 1)
} else {
0
}
} else {
0
};
list_state = list_state.with_offset(scroll_offset);
state.scroll_offset = scroll_offset;
let list = ratatui::widgets::List::new(items).block(block);
ratatui::widgets::StatefulWidget::render(list, area, buf, &mut list_state);
if !self.descriptions.is_empty() {
let top_border = if self.borders.contains(Borders::TOP) { 1 } else { 0 };
let bottom_border = if self.borders.contains(Borders::BOTTOM) { 1 } else { 0 };
let inner = area.inner(Margin {
horizontal: 1,
vertical: top_border,
});
let inner = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(bottom_border),
);
let help_entries: Vec<(usize, Line<'static>)> = self
.descriptions
.iter()
.enumerate()
.filter_map(|(i, d)| {
d.as_deref().map(|desc| {
(
i,
Line::from(Span::styled(
desc.to_string(),
Style::default().fg(self.colors.help),
)),
)
})
})
.collect();
render_help_overlays(buf, &help_entries, scroll_offset, inner);
}
let total_items = self.items.len();
if total_items > visible_items && visible_items > 0 {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 0,
vertical: if self.borders.contains(Borders::TOP) { 1 } else { 0 },
});
let scrollbar_area = if self.borders.contains(Borders::BOTTOM) {
Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(
if self.borders.contains(Borders::BOTTOM) { 1 } else { 0 }
))
} else {
inner
};
let mut scrollbar_state = ScrollbarState::new(total_items.saturating_sub(visible_items))
.position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("┃")
.track_style(Style::default().fg(self.colors.inactive_border))
.thumb_style(Style::default().fg(self.colors.active_border));
StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
}
}
}