use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use zero_commands::RiskDirection;
use crate::app::picker::{PICKER_MAX_VISIBLE, SlashMatch, SlashPicker};
use crate::theme::Theme;
#[must_use]
pub fn picker_rows(picker: &SlashPicker) -> u16 {
let n = picker.matches().len().min(PICKER_MAX_VISIBLE);
u16::try_from(n).unwrap_or(0)
}
#[derive(Debug)]
pub struct PickerWidget<'a> {
pub picker: &'a SlashPicker,
pub theme: Theme,
}
impl Widget for PickerWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 || !self.picker.is_active() {
return;
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
buf[(x, y)].set_char(' ');
}
}
let matches = self.picker.matches();
let visible = usize::from(area.height).min(matches.len());
let sel = self.picker.selected_index();
let start = if sel < visible { 0 } else { sel + 1 - visible };
for (i, m) in matches.iter().enumerate().skip(start).take(visible) {
let visible_row = i - start;
let y = area.top() + u16::try_from(visible_row).unwrap_or(u16::MAX);
if y >= area.bottom() {
break;
}
let row_area = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
render_row(m, i == sel, &self.theme, row_area, buf);
}
}
}
fn render_row(m: &SlashMatch, selected: bool, theme: &Theme, area: Rect, buf: &mut Buffer) {
let base_style = if selected {
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(theme.primary)
};
let dim = Style::default()
.fg(theme.metadata)
.add_modifier(Modifier::DIM);
let bold = base_style.add_modifier(Modifier::BOLD);
let risk_style = match m.info.risk {
RiskDirection::Reduces => Style::default().fg(theme.primary),
RiskDirection::Neutral => dim,
RiskDirection::Increases => Style::default()
.fg(theme.alert)
.add_modifier(Modifier::BOLD),
};
let chevron = if selected { "› " } else { " " };
let mut spans: Vec<Span<'static>> = Vec::with_capacity(m.info.name.chars().count() + 4);
spans.push(Span::styled(chevron.to_string(), base_style));
for (i, c) in m.info.name.chars().enumerate() {
let styled = if m.matched_chars.contains(&i) {
bold
} else {
base_style
};
spans.push(Span::styled(c.to_string(), styled));
}
spans.push(Span::styled(
format!(" [{}] ", risk_label(m.info.risk)),
risk_style,
));
spans.push(Span::styled(m.info.summary.to_string(), dim));
Line::from(spans).render(area, buf);
}
const fn risk_label(r: RiskDirection) -> &'static str {
match r {
RiskDirection::Reduces => "reduce",
RiskDirection::Neutral => "read",
RiskDirection::Increases => "trade",
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render(picker: &SlashPicker, width: u16, height: u16) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).expect("term");
term.draw(|f| {
let w = PickerWidget {
picker,
theme: Theme::default(),
};
f.render_widget(w, f.area());
})
.expect("draw");
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn renders_selected_row_with_chevron() {
let picker = SlashPicker::from_prompt_line("/h").expect("picker");
let rows = picker_rows(&picker);
assert!(rows >= 1);
let lines = render(&picker, 60, rows);
assert!(
lines[0].starts_with('›'),
"selected row should lead with chevron: {:?}",
lines[0]
);
}
#[test]
fn inactive_picker_renders_nothing_visible() {
let picker = SlashPicker::from_prompt_line("/xyzzyq").expect("picker");
assert!(!picker.is_active());
let lines = render(&picker, 60, 3);
for line in &lines {
assert!(
line.chars().all(|c| c == ' '),
"expected all-blank row, got {line:?}"
);
}
}
}