use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use super::truncate_to_cells;
use crate::render::theme::Theme;
use mermaid_domain::RewindCandidate;
pub struct RewindPickerWidget<'a> {
pub theme: &'a Theme,
pub candidates: &'a [RewindCandidate],
pub cursor: usize,
}
impl<'a> Widget for RewindPickerWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = "Rewind — fork at an earlier message · ↑↓ navigate · Enter fork · Esc cancel";
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(self.theme.colors.border.to_color()));
let inner_height = area.height.saturating_sub(2) as usize;
let visible = inner_height.min(10);
let start = if self.cursor >= visible {
self.cursor + 1 - visible
} else {
0
};
let rows: Vec<Line<'_>> = self
.candidates
.iter()
.enumerate()
.skip(start)
.take(visible)
.map(|(i, candidate)| {
let highlighted = i == self.cursor;
let prefix = if highlighted { " > " } else { " " };
let colors = &self.theme.colors;
let (row_style, meta_color) = if highlighted {
(
Style::default()
.bg(colors.user_message_background.to_color())
.add_modifier(Modifier::BOLD),
colors.text_primary.to_color(),
)
} else {
(Style::default(), colors.text_disabled.to_color())
};
let excerpt = truncate_to_cells(&candidate.excerpt, 64);
let meta = format!(" (#{} back)", i + 1);
Line::from(vec![
Span::raw(prefix),
Span::styled(excerpt, row_style.fg(colors.text_primary.to_color())),
Span::styled(meta, row_style.fg(meta_color)),
])
})
.collect();
Paragraph::new(rows).block(block).render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidates(excerpts: &[&str]) -> Vec<RewindCandidate> {
excerpts
.iter()
.enumerate()
.map(|(i, e)| RewindCandidate {
message_index: i,
excerpt: e.to_string(),
})
.collect()
}
fn render_text(widget: RewindPickerWidget<'_>, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn renders_excerpts_with_recency_labels() {
let theme = Theme::dark();
let list = candidates(&["fix the resolver", "add tests"]);
let text = render_text(
RewindPickerWidget {
theme: &theme,
candidates: &list,
cursor: 0,
},
90,
6,
);
assert!(text.contains("Rewind"), "{text}");
assert!(text.contains("fix the resolver"), "{text}");
assert!(text.contains("(#2 back)"), "{text}");
}
#[test]
fn highlighted_row_meta_is_readable_on_its_band() {
let list = candidates(&["fix the resolver", "add tests"]);
for make_theme in [Theme::dark, Theme::light] {
let theme = make_theme();
let area = Rect::new(0, 0, 90, 5);
let mut buf = Buffer::empty(area);
RewindPickerWidget {
theme: &theme,
candidates: &list,
cursor: 0,
}
.render(area, &mut buf);
let x = (0..area.width)
.find(|&x| buf[(x, 1)].symbol() == "(")
.expect("the recency label is drawn on the first row");
let cell = &buf[(x, 1)];
let colors = &theme.colors;
assert_eq!(
cell.bg,
colors.user_message_background.to_color(),
"{}",
theme.name
);
assert_eq!(cell.fg, colors.text_primary.to_color(), "{}", theme.name);
assert_ne!(
cell.fg, cell.bg,
"{}: meta drawn in its band's colour",
theme.name
);
}
}
#[test]
fn out_of_range_cursor_does_not_panic() {
let theme = Theme::dark();
let list = candidates(&["only one"]);
let text = render_text(
RewindPickerWidget {
theme: &theme,
candidates: &list,
cursor: 42,
},
60,
5,
);
assert!(text.contains("Rewind"), "{text}");
}
}