From c7c44676fb86635600c1b7b8abfc8caac65b39f8 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 3 Aug 2026 22:20:38 +0200 Subject: [PATCH] Color TUI commit graph lanes --- crates/tui/src/app.rs | 20 ++++++++---- crates/tui/src/ui.rs | 74 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 7e9cf41..abea556 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -101,6 +101,7 @@ pub enum Target { pub struct Item { pub key: String, pub title: String, + pub graph_lane: Option, pub meta: String, pub detail: String, pub target: Target, @@ -1104,6 +1105,7 @@ impl App { } else { name.clone() }, + graph_lane: None, meta: format!("{} · {}", server.provider, server.url), detail: format!( "Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••", @@ -1179,6 +1181,7 @@ impl App { activity.created.as_deref().unwrap_or_default() ), title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()), + graph_lane: None, meta: activity.created.unwrap_or_default(), detail, target, @@ -1231,6 +1234,7 @@ impl App { Item { key: format!("{}/{}", id.owner, id.repository), title: format!("{} {name}", if favorite { "★" } else { " " }), + graph_lane: None, meta: format!( "{} · {} open · {}", repository.language.as_deref().unwrap_or(""), @@ -1330,6 +1334,7 @@ impl App { .and_then(|user| user.login.as_deref()) .unwrap_or("unknown") ), + graph_lane: None, meta: comment.updated_at.unwrap_or_default(), detail: body.clone(), target: id.map_or(Target::None, |id| { @@ -1441,6 +1446,7 @@ impl App { Some(Item { key: path.clone(), title: format!("{} {name}", if directory { "▸" } else { " " }), + graph_lane: None, meta: if directory { "directory".into() } else { @@ -1544,6 +1550,7 @@ impl App { Some(Item { key: path.clone(), title: path.clone(), + graph_lane: None, meta: file.status.unwrap_or_else(|| "modified".into()), detail: format!( "+{} -{}", @@ -1568,6 +1575,7 @@ impl App { .and_then(|user| user.login.as_deref()) .unwrap_or("unknown") ), + graph_lane: None, meta: comment.updated_at.unwrap_or_default(), detail: comment.body.unwrap_or_default(), target: Target::None, @@ -1697,6 +1705,7 @@ fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option { Some(Item { key: number.to_string(), title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")), + graph_lane: None, meta: format!( "{} · {} · {} comments", state, @@ -1719,6 +1728,7 @@ fn pull_item(issue: models::Issue) -> Option { "{name} #{number} {}", issue.title.as_deref().unwrap_or("Untitled") ), + graph_lane: None, meta: format!( "{} · {} comments", issue.state.as_deref().unwrap_or("unknown"), @@ -1743,6 +1753,7 @@ fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Op title: milestone .title .unwrap_or_else(|| "Untitled milestone".into()), + graph_lane: None, meta: format!( "{} · {} open · {} closed", state, @@ -1770,12 +1781,8 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option Vec { .map(|(path, _)| Item { key: path.into(), title: path.into(), + graph_lane: None, meta: "changed file".into(), detail: diff_for_file(diff, path), target: Target::Text(path.into(), diff_for_file(diff, path)), diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 74142a6..63def97 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -107,10 +107,7 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) { .iter() .map(|item| { ListItem::new(vec![ - Line::from(Span::styled( - item.title.clone(), - Style::default().add_modifier(Modifier::BOLD), - )), + item_title(&item.title, item.graph_lane), Line::from(Span::styled( item.meta.clone(), Style::default().fg(Color::DarkGray), @@ -125,14 +122,50 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) { .borders(Borders::ALL) .title(format!(" {} ", app.screen.title)), ) - .highlight_symbol("▸ ") - .highlight_style(Style::default().fg(ACCENT)); + .highlight_symbol(Line::from(Span::styled( + "▸ ", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ))) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); let mut state = ListState::default() .with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected)); frame.render_stateful_widget(list, area, &mut state); app.list_offset = state.offset(); } +fn item_title(title: &str, graph_lane: Option) -> Line<'static> { + let Some(node_lane) = graph_lane else { + return Line::from(Span::styled( + title.to_owned(), + Style::default().add_modifier(Modifier::BOLD), + )); + }; + let mut spans = (0..node_lane) + .map(|lane| Span::styled("│ ", Style::default().fg(lane_color(lane)))) + .collect::>(); + spans.push(Span::styled( + "● ", + Style::default().fg(lane_color(node_lane)), + )); + spans.push(Span::styled( + title.to_owned(), + Style::default().add_modifier(Modifier::BOLD), + )); + Line::from(spans) +} + +fn lane_color(lane: usize) -> Color { + const COLORS: [Color; 6] = [ + Color::LightRed, + Color::LightGreen, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::Yellow, + ]; + COLORS[lane % COLORS.len()] +} + fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { let mut detail = app.screen.detail.clone(); if let Some(item) = app.screen.selected_item() @@ -274,6 +307,7 @@ fn centered(area: Rect, width_percent: u16, height_percent: u16) -> Rect { #[cfg(test)] mod tests { use super::*; + use ratatui::{Terminal, backend::TestBackend, widgets::ListItem}; #[test] fn responsive_popup_stays_inside_small_terminals() { @@ -295,4 +329,32 @@ mod tests { assert!(!areas[0].contains((9, 1).into())); assert!(!areas[4].contains((80, 1).into())); } + + #[test] + fn commit_graph_lanes_use_stable_distinct_colors() { + let title = item_title("Merge feature", Some(2)); + assert_eq!(title.to_string(), "│ │ ● Merge feature"); + assert_eq!(title.spans[0].style.fg, Some(lane_color(0))); + assert_eq!(title.spans[1].style.fg, Some(lane_color(1))); + assert_eq!(title.spans[2].style.fg, Some(lane_color(2))); + assert_ne!(lane_color(0), lane_color(1)); + assert_eq!(lane_color(6), lane_color(0)); + + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let list = List::new([ListItem::new(title)]) + .highlight_symbol(Line::from(Span::styled("▸ ", Style::default().fg(ACCENT)))) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + let mut state = ListState::default().with_selected(Some(0)); + frame.render_stateful_widget(list, frame.area(), &mut state); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].fg, ACCENT); + assert_eq!(buffer[(2, 0)].fg, lane_color(0)); + assert_eq!(buffer[(4, 0)].fg, lane_color(1)); + assert_eq!(buffer[(6, 0)].fg, lane_color(2)); + } }