mermaid_cli/render/widgets/
rewind_picker.rs1use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Paragraph, Widget};
13
14use super::truncate_to_cells;
15use crate::domain::RewindCandidate;
16use crate::render::theme::Theme;
17
18pub struct RewindPickerWidget<'a> {
19 pub theme: &'a Theme,
20 pub candidates: &'a [RewindCandidate],
21 pub cursor: usize,
22}
23
24impl<'a> Widget for RewindPickerWidget<'a> {
25 fn render(self, area: Rect, buf: &mut Buffer) {
26 let title = "Rewind — fork at an earlier message · ↑↓ navigate · Enter fork · Esc cancel";
27 let block = Block::default()
28 .borders(Borders::ALL)
29 .title(title)
30 .border_style(Style::default().fg(self.theme.colors.border.to_color()));
31
32 let inner_height = area.height.saturating_sub(2) as usize;
35 let visible = inner_height.min(10);
36 let start = if self.cursor >= visible {
37 self.cursor + 1 - visible
38 } else {
39 0
40 };
41
42 let rows: Vec<Line<'_>> = self
43 .candidates
44 .iter()
45 .enumerate()
46 .skip(start)
47 .take(visible)
48 .map(|(i, candidate)| {
49 let highlighted = i == self.cursor;
50 let prefix = if highlighted { " > " } else { " " };
51 let row_style = if highlighted {
52 Style::default()
53 .bg(self.theme.colors.text_disabled.to_color())
54 .add_modifier(Modifier::BOLD)
55 } else {
56 Style::default()
57 };
58 let excerpt = truncate_to_cells(&candidate.excerpt, 64);
59 let meta = format!(" (#{} back)", i + 1);
61 Line::from(vec![
62 Span::raw(prefix),
63 Span::styled(
64 excerpt,
65 row_style.fg(self.theme.colors.text_primary.to_color()),
66 ),
67 Span::styled(
68 meta,
69 row_style.fg(self.theme.colors.text_disabled.to_color()),
70 ),
71 ])
72 })
73 .collect();
74
75 Paragraph::new(rows).block(block).render(area, buf);
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82
83 fn candidates(excerpts: &[&str]) -> Vec<RewindCandidate> {
84 excerpts
85 .iter()
86 .enumerate()
87 .map(|(i, e)| RewindCandidate {
88 message_index: i,
89 excerpt: e.to_string(),
90 })
91 .collect()
92 }
93
94 fn render_text(widget: RewindPickerWidget<'_>, width: u16, height: u16) -> String {
95 let area = Rect::new(0, 0, width, height);
96 let mut buf = Buffer::empty(area);
97 widget.render(area, &mut buf);
98 (0..height)
99 .map(|y| {
100 (0..width)
101 .map(|x| buf[(x, y)].symbol().to_string())
102 .collect::<String>()
103 })
104 .collect::<Vec<_>>()
105 .join("\n")
106 }
107
108 #[test]
109 fn renders_excerpts_with_recency_labels() {
110 let theme = Theme::dark();
111 let list = candidates(&["fix the resolver", "add tests"]);
112 let text = render_text(
113 RewindPickerWidget {
114 theme: &theme,
115 candidates: &list,
116 cursor: 0,
117 },
118 90,
119 6,
120 );
121 assert!(text.contains("Rewind"), "{text}");
122 assert!(text.contains("fix the resolver"), "{text}");
123 assert!(text.contains("(#2 back)"), "{text}");
124 }
125
126 #[test]
127 fn out_of_range_cursor_does_not_panic() {
128 let theme = Theme::dark();
129 let list = candidates(&["only one"]);
130 let text = render_text(
131 RewindPickerWidget {
132 theme: &theme,
133 candidates: &list,
134 cursor: 42,
135 },
136 60,
137 5,
138 );
139 assert!(text.contains("Rewind"), "{text}");
142 }
143}