use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use crate::Theme;
#[derive(Debug, Clone)]
pub struct PickerState {
pub search: String,
pub selected: usize,
pub scroll_offset: usize,
}
impl PickerState {
pub fn new() -> Self {
Self { search: String::new(), selected: 0, scroll_offset: 0 }
}
pub fn select_next(&mut self, item_count: usize) {
if item_count == 0 { return; }
if self.selected + 1 < item_count {
self.selected += 1;
}
}
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn reset_selection(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
pub fn clamp_scroll(&mut self, visible_height: usize) {
if visible_height == 0 { return; }
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible_height {
self.scroll_offset = self.selected - visible_height + 1;
}
}
}
impl Default for PickerState {
fn default() -> Self { Self::new() }
}
#[derive(Debug, Clone)]
pub struct PickerItem {
pub label: String,
pub tag: Option<String>,
pub tag_style: Option<Style>,
}
impl PickerItem {
pub fn new(label: impl Into<String>) -> Self {
Self { label: label.into(), tag: None, tag_style: None }
}
pub fn with_tag(label: impl Into<String>, tag: impl Into<String>) -> Self {
Self { label: label.into(), tag: Some(tag.into()), tag_style: None }
}
pub fn with_tag_styled(label: impl Into<String>, tag: impl Into<String>, style: Style) -> Self {
Self { label: label.into(), tag: Some(tag.into()), tag_style: Some(style) }
}
}
pub fn render_picker<'a>(
f: &mut Frame,
area: Rect,
title: &str,
items: &[PickerItem],
state: &PickerState,
detail: Vec<Line<'a>>,
total_count: usize,
theme: &Theme,
) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(area);
let left_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(cols[0]);
let search_block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_focused)
.title(format!(" {} ", title))
.title_style(theme.tab_active);
f.render_widget(
Paragraph::new(state.search.as_str()).block(search_block),
left_rows[0],
);
let visible_height = left_rows[1].height.saturating_sub(2) as usize;
let scroll = state.scroll_offset.min(items.len().saturating_sub(1));
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.skip(scroll)
.take(visible_height)
.map(|(idx, item)| {
let selected = idx == state.selected;
let row_style = if selected { theme.selection } else { theme.body };
let prefix = if selected { "▶ " } else { " " };
let line = match &item.tag {
Some(tag) => {
let tag_style = if selected {
row_style
} else {
item.tag_style.unwrap_or(row_style)
};
Line::from(vec![
Span::styled(prefix.to_string(), row_style),
Span::styled(format!("{} ", tag), tag_style),
Span::styled(item.label.clone(), row_style),
])
}
None => Line::from(vec![
Span::styled(prefix.to_string(), row_style),
Span::styled(item.label.clone(), row_style),
]),
};
ListItem::new(line)
})
.collect();
let count_title = format!(" {}/{} ", items.len(), total_count);
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_unfocused)
.title(count_title)
.title_style(theme.hint);
f.render_widget(List::new(list_items).block(list_block), left_rows[1]);
let detail_content = if detail.is_empty() {
vec![Line::from(Span::styled(
"(no selection)",
theme.hint.add_modifier(Modifier::ITALIC),
))]
} else {
detail
};
let detail_block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_unfocused)
.title(" Detail ")
.title_style(theme.hint);
f.render_widget(
Paragraph::new(Text::from(detail_content))
.block(detail_block)
.wrap(Wrap { trim: false }),
cols[1],
);
}