Color TUI commit graph lanes

This commit is contained in:
Georg Bauer
2026-08-03 22:20:38 +02:00
parent 95526f8956
commit c7c44676fb
2 changed files with 82 additions and 12 deletions

View File

@@ -101,6 +101,7 @@ pub enum Target {
pub struct Item {
pub key: String,
pub title: String,
pub graph_lane: Option<usize>,
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<Item> {
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<Item> {
"{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<Item
.unwrap_or("Commit");
Some(Item {
key: sha.clone(),
title: format!(
"{}{}{}",
"".repeat(node_lane.unwrap_or_default()),
if node_lane.is_some() { "" } else { "" },
message.lines().next().unwrap_or("Commit")
),
title: message.lines().next().unwrap_or("Commit").into(),
graph_lane: node_lane,
meta: format!(
"{} · {}{}",
short_sha(&sha),
@@ -1801,6 +1808,7 @@ fn diff_files(diff: &str) -> Vec<Item> {
.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)),

View File

@@ -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<usize>) -> 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::<Vec<_>>();
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));
}
}