1use ratatui::{
6 buffer::Buffer,
7 layout::Rect,
8 style::{Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, StatefulWidget, Widget},
11};
12
13use crate::color::{ColorTheme, GraphColorSet};
14use crate::graph::{calc_graph_owned, Edge, EdgeType, OwnedGraph};
15use crate::void_backend::{CommitCid, RefKind, VoidCommit, VoidHead, VoidRef};
16
17#[derive(Debug)]
19pub struct CommitListState {
20 commits: Vec<VoidCommit>,
22 refs: Vec<VoidRef>,
24 head: Option<VoidHead>,
26 selected: usize,
28 offset: usize,
30 graph: OwnedGraph,
32 color_set: GraphColorSet,
34}
35
36impl CommitListState {
37 pub fn new(commits: Vec<VoidCommit>, refs: Vec<VoidRef>, head: Option<VoidHead>) -> Self {
39 let graph = calc_graph_owned(&commits);
40 Self {
41 commits,
42 refs,
43 head,
44 selected: 0,
45 offset: 0,
46 graph,
47 color_set: GraphColorSet::default(),
48 }
49 }
50
51 pub fn len(&self) -> usize {
53 self.commits.len()
54 }
55
56 pub fn is_empty(&self) -> bool {
58 self.commits.is_empty()
59 }
60
61 pub fn selected_commit(&self) -> Option<&VoidCommit> {
63 let idx = self.offset + self.selected;
64 self.commits.get(idx)
65 }
66
67 pub fn selected_index(&self) -> usize {
69 self.offset + self.selected
70 }
71
72 pub fn select_next(&mut self, viewport_height: usize) {
74 let max_idx = self.commits.len().saturating_sub(1);
75 let current_abs = self.offset + self.selected;
76
77 if current_abs < max_idx {
78 if self.selected < viewport_height.saturating_sub(1) {
79 self.selected += 1;
80 } else {
81 self.offset += 1;
82 }
83 }
84 }
85
86 pub fn select_prev(&mut self) {
88 if self.selected > 0 {
89 self.selected -= 1;
90 } else if self.offset > 0 {
91 self.offset -= 1;
92 }
93 }
94
95 pub fn scroll_down_half(&mut self, viewport_height: usize) {
97 let half = viewport_height / 2;
98 for _ in 0..half {
99 self.select_next(viewport_height);
100 }
101 }
102
103 pub fn scroll_up_half(&mut self, viewport_height: usize) {
105 let half = viewport_height / 2;
106 for _ in 0..half {
107 self.select_prev();
108 }
109 }
110
111 pub fn scroll_down_page(&mut self, viewport_height: usize) {
113 for _ in 0..viewport_height {
114 self.select_next(viewport_height);
115 }
116 }
117
118 pub fn scroll_up_page(&mut self, viewport_height: usize) {
120 for _ in 0..viewport_height {
121 self.select_prev();
122 }
123 }
124
125 pub fn select_first(&mut self) {
127 self.selected = 0;
128 self.offset = 0;
129 }
130
131 pub fn select_last(&mut self, viewport_height: usize) {
133 let total = self.commits.len();
134 if total <= viewport_height {
135 self.offset = 0;
136 self.selected = total.saturating_sub(1);
137 } else {
138 self.offset = total - viewport_height;
139 self.selected = viewport_height.saturating_sub(1);
140 }
141 }
142
143 fn refs_at(&self, cid: &CommitCid) -> Vec<&VoidRef> {
145 self.refs
146 .iter()
147 .filter(|r| &r.target == cid)
148 .collect()
149 }
150
151 fn is_head(&self, cid: &CommitCid) -> bool {
153 match &self.head {
154 Some(VoidHead::Detached(head_cid)) => head_cid == cid,
155 Some(VoidHead::Branch(branch_name)) => self
156 .refs
157 .iter()
158 .any(|r| r.kind == RefKind::Branch && &r.name == branch_name && &r.target == cid),
159 None => false,
160 }
161 }
162}
163
164fn edge_type_to_char(edge_type: EdgeType) -> char {
166 match edge_type {
167 EdgeType::Vertical => '│',
168 EdgeType::Horizontal => '─',
169 EdgeType::Up => '╵',
170 EdgeType::Down => '╷',
171 EdgeType::Left => '╴',
172 EdgeType::Right => '╶',
173 EdgeType::RightTop => '╮',
174 EdgeType::RightBottom => '╯',
175 EdgeType::LeftTop => '╭',
176 EdgeType::LeftBottom => '╰',
177 }
178}
179
180fn render_graph_row_full(
184 edges: &[Edge],
185 commit_pos_x: usize,
186 max_pos_x: usize,
187 color_set: &GraphColorSet,
188) -> Vec<(char, ratatui::style::Color)> {
189 use ratatui::style::Color;
190
191 let width = max_pos_x + 1;
192 let mut cells: Vec<(char, Option<usize>)> = vec![(' ', None); width];
193
194 for edge in edges {
196 if edge.pos_x < width {
197 let ch = edge_type_to_char(edge.edge_type);
198 cells[edge.pos_x] = (ch, Some(edge.associated_line_pos_x));
199 }
200 }
201
202 if commit_pos_x < width {
204 cells[commit_pos_x] = ('●', Some(commit_pos_x));
205 }
206
207 cells
209 .into_iter()
210 .map(|(ch, lane)| {
211 let color = lane
212 .map(|l| color_set.get(l).to_ratatui_color())
213 .unwrap_or(Color::DarkGray);
214 (ch, color)
215 })
216 .collect()
217}
218
219pub struct CommitList<'a> {
221 theme: &'a ColorTheme,
222}
223
224impl<'a> CommitList<'a> {
225 pub fn new(theme: &'a ColorTheme) -> Self {
227 Self { theme }
228 }
229}
230
231impl<'a> StatefulWidget for CommitList<'a> {
232 type State = CommitListState;
233
234 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
235 let block = Block::default()
237 .borders(Borders::ALL)
238 .title(" Commits ");
239 let inner = block.inner(area);
240 block.render(area, buf);
241
242 if state.is_empty() {
243 return;
244 }
245
246 let viewport_height = inner.height as usize;
247
248 let graph_width = ((state.graph.max_pos_x + 1).max(1) * 2) as u16;
251 let cid_width = 9_u16; let date_width = 11_u16; let remaining = inner
254 .width
255 .saturating_sub(graph_width + cid_width + date_width);
256 let subject_width = remaining;
257
258 for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
260 if idx >= state.commits.len() {
261 break;
262 }
263
264 let commit = &state.commits[idx];
265 let is_selected = i == state.selected;
266 let y = inner.y + i as u16;
267
268 let base_style = if is_selected {
270 Style::default()
271 .fg(self.theme.list_selected_fg)
272 .bg(self.theme.list_selected_bg)
273 } else {
274 Style::default()
275 };
276
277 let commit_pos_x = state
279 .graph
280 .commit_pos_map
281 .get(&commit.cid)
282 .map(|(x, _)| *x)
283 .unwrap_or(0);
284
285 let edges = state.graph.edges.get(idx).map(|e| e.as_slice()).unwrap_or(&[]);
287
288 let colored_cells = render_graph_row_full(
290 edges,
291 commit_pos_x,
292 state.graph.max_pos_x,
293 &state.color_set,
294 );
295
296 for (x_offset, (ch, color)) in colored_cells.iter().enumerate() {
298 let style = if is_selected {
299 base_style
300 } else {
301 Style::default().fg(*color)
302 };
303 let cell_x = inner.x + (x_offset * 2) as u16;
304 if cell_x < inner.x + graph_width {
305 buf.set_string(cell_x, y, ch.to_string(), style);
306 if cell_x + 1 < inner.x + graph_width {
308 buf.set_string(cell_x + 1, y, " ", Style::default());
309 }
310 }
311 }
312
313 let cid_area = Rect::new(inner.x + graph_width, y, cid_width, 1);
315 buf.set_string(
316 cid_area.x,
317 cid_area.y,
318 format!("{:<8}", commit.cid.short()),
319 if is_selected {
320 base_style
321 } else {
322 Style::default().fg(self.theme.list_hash_fg)
323 },
324 );
325
326 let refs_at = state.refs_at(&commit.cid);
328 let is_head = state.is_head(&commit.cid);
329
330 let mut subject_parts: Vec<Span> = Vec::new();
331
332 if is_head {
334 subject_parts.push(Span::styled(
335 "HEAD ",
336 Style::default()
337 .fg(self.theme.list_head_fg)
338 .add_modifier(Modifier::BOLD),
339 ));
340 }
341
342 if !refs_at.is_empty() {
344 subject_parts.push(Span::styled(
345 "(",
346 Style::default().fg(self.theme.list_ref_paren_fg),
347 ));
348
349 for (j, r) in refs_at.iter().enumerate() {
350 if j > 0 {
351 subject_parts.push(Span::styled(
352 ", ",
353 Style::default().fg(self.theme.list_ref_paren_fg),
354 ));
355 }
356 let color = match r.kind {
357 RefKind::Branch => self.theme.list_ref_branch_fg,
358 RefKind::Tag => self.theme.list_ref_tag_fg,
359 };
360 subject_parts.push(Span::styled(&r.name, Style::default().fg(color)));
361 }
362
363 subject_parts.push(Span::styled(
364 ") ",
365 Style::default().fg(self.theme.list_ref_paren_fg),
366 ));
367 }
368
369 let subject = commit
371 .message
372 .lines()
373 .next()
374 .unwrap_or("")
375 .to_string();
376 subject_parts.push(Span::styled(
377 subject,
378 Style::default().fg(self.theme.list_subject_fg),
379 ));
380
381 let subject_line = Line::from(subject_parts);
383
384 let subj_area = Rect::new(
386 inner.x + graph_width + cid_width,
387 y,
388 subject_width.saturating_sub(date_width),
389 1,
390 );
391 let line_with_style = if is_selected {
392 Line::from(
393 subject_line
394 .spans
395 .into_iter()
396 .map(|s| s.style(base_style))
397 .collect::<Vec<_>>(),
398 )
399 } else {
400 subject_line
401 };
402 buf.set_line(subj_area.x, subj_area.y, &line_with_style, subj_area.width);
403
404 let date_x = inner.x + inner.width.saturating_sub(date_width);
406 buf.set_string(
407 date_x,
408 y,
409 format_timestamp(commit.timestamp_ms),
410 if is_selected {
411 base_style
412 } else {
413 Style::default().fg(self.theme.list_date_fg)
414 },
415 );
416 }
417 }
418}
419
420fn format_timestamp(ts_ms: u64) -> String {
422 use std::time::{Duration, UNIX_EPOCH};
423 let d = UNIX_EPOCH + Duration::from_millis(ts_ms);
424 let datetime: chrono::DateTime<chrono::Utc> = d.into();
425 datetime.format("%Y-%m-%d").to_string()
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn make_commit(cid: &str, parents: Vec<&str>) -> VoidCommit {
433 VoidCommit {
434 cid: CommitCid(cid.to_string()),
435 parents: parents.into_iter().map(|p| CommitCid(p.to_string())).collect(),
436 message: String::new(),
437 timestamp_ms: 0,
438 is_signed: false,
439 signature_valid: None,
440 author: None,
441 }
442 }
443
444 #[test]
445 fn test_calc_graph_owned_empty() {
446 let graph = calc_graph_owned(&[]);
447 assert!(graph.commit_pos_map.is_empty());
448 assert!(graph.edges.is_empty());
449 assert_eq!(graph.max_pos_x, 0);
450 }
451
452 #[test]
453 fn test_calc_graph_owned_linear() {
454 let commits = vec![
455 make_commit("aaa", vec!["bbb"]),
456 make_commit("bbb", vec!["ccc"]),
457 make_commit("ccc", vec![]),
458 ];
459
460 let graph = calc_graph_owned(&commits);
461 assert_eq!(graph.commit_pos_map.len(), 3);
463 for commit in &commits {
464 let (x, _) = graph.commit_pos_map[&commit.cid];
465 assert_eq!(x, 0);
466 }
467 }
468
469 #[test]
470 fn test_edge_type_to_char() {
471 assert_eq!(edge_type_to_char(EdgeType::Vertical), '│');
472 assert_eq!(edge_type_to_char(EdgeType::Horizontal), '─');
473 assert_eq!(edge_type_to_char(EdgeType::Up), '╵');
474 assert_eq!(edge_type_to_char(EdgeType::Down), '╷');
475 assert_eq!(edge_type_to_char(EdgeType::Left), '╴');
476 assert_eq!(edge_type_to_char(EdgeType::Right), '╶');
477 assert_eq!(edge_type_to_char(EdgeType::RightTop), '╮');
478 assert_eq!(edge_type_to_char(EdgeType::RightBottom), '╯');
479 assert_eq!(edge_type_to_char(EdgeType::LeftTop), '╭');
480 assert_eq!(edge_type_to_char(EdgeType::LeftBottom), '╰');
481 }
482
483 #[test]
484 fn test_render_graph_row_full_single_commit() {
485 let color_set = GraphColorSet::default();
486 let edges = vec![Edge::new(EdgeType::Down, 0, 0)];
487 let cells = render_graph_row_full(&edges, 0, 0, &color_set);
488 assert_eq!(cells.len(), 1);
490 assert_eq!(cells[0].0, '●'); }
492
493 #[test]
494 fn test_render_graph_row_full_with_vertical_line() {
495 let color_set = GraphColorSet::default();
496 let edges = vec![
497 Edge::new(EdgeType::Vertical, 1, 1),
498 ];
499 let cells = render_graph_row_full(&edges, 0, 1, &color_set);
500 assert_eq!(cells.len(), 2);
501 assert_eq!(cells[0].0, '●'); assert_eq!(cells[1].0, '│'); }
504}