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 struct Item {
pub key: String, pub key: String,
pub title: String, pub title: String,
pub graph_lane: Option<usize>,
pub meta: String, pub meta: String,
pub detail: String, pub detail: String,
pub target: Target, pub target: Target,
@@ -1104,6 +1105,7 @@ impl App {
} else { } else {
name.clone() name.clone()
}, },
graph_lane: None,
meta: format!("{} · {}", server.provider, server.url), meta: format!("{} · {}", server.provider, server.url),
detail: format!( detail: format!(
"Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••", "Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••",
@@ -1179,6 +1181,7 @@ impl App {
activity.created.as_deref().unwrap_or_default() activity.created.as_deref().unwrap_or_default()
), ),
title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()), title: format!("{:?} · {repository}", activity.op_type.unwrap_or_default()),
graph_lane: None,
meta: activity.created.unwrap_or_default(), meta: activity.created.unwrap_or_default(),
detail, detail,
target, target,
@@ -1231,6 +1234,7 @@ impl App {
Item { Item {
key: format!("{}/{}", id.owner, id.repository), key: format!("{}/{}", id.owner, id.repository),
title: format!("{} {name}", if favorite { "" } else { " " }), title: format!("{} {name}", if favorite { "" } else { " " }),
graph_lane: None,
meta: format!( meta: format!(
"{} · {} open · {}", "{} · {} open · {}",
repository.language.as_deref().unwrap_or(""), repository.language.as_deref().unwrap_or(""),
@@ -1330,6 +1334,7 @@ impl App {
.and_then(|user| user.login.as_deref()) .and_then(|user| user.login.as_deref())
.unwrap_or("unknown") .unwrap_or("unknown")
), ),
graph_lane: None,
meta: comment.updated_at.unwrap_or_default(), meta: comment.updated_at.unwrap_or_default(),
detail: body.clone(), detail: body.clone(),
target: id.map_or(Target::None, |id| { target: id.map_or(Target::None, |id| {
@@ -1441,6 +1446,7 @@ impl App {
Some(Item { Some(Item {
key: path.clone(), key: path.clone(),
title: format!("{} {name}", if directory { "" } else { " " }), title: format!("{} {name}", if directory { "" } else { " " }),
graph_lane: None,
meta: if directory { meta: if directory {
"directory".into() "directory".into()
} else { } else {
@@ -1544,6 +1550,7 @@ impl App {
Some(Item { Some(Item {
key: path.clone(), key: path.clone(),
title: path.clone(), title: path.clone(),
graph_lane: None,
meta: file.status.unwrap_or_else(|| "modified".into()), meta: file.status.unwrap_or_else(|| "modified".into()),
detail: format!( detail: format!(
"+{} -{}", "+{} -{}",
@@ -1568,6 +1575,7 @@ impl App {
.and_then(|user| user.login.as_deref()) .and_then(|user| user.login.as_deref())
.unwrap_or("unknown") .unwrap_or("unknown")
), ),
graph_lane: None,
meta: comment.updated_at.unwrap_or_default(), meta: comment.updated_at.unwrap_or_default(),
detail: comment.body.unwrap_or_default(), detail: comment.body.unwrap_or_default(),
target: Target::None, target: Target::None,
@@ -1697,6 +1705,7 @@ fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option<Item> {
Some(Item { Some(Item {
key: number.to_string(), key: number.to_string(),
title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")), title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")),
graph_lane: None,
meta: format!( meta: format!(
"{} · {} · {} comments", "{} · {} · {} comments",
state, state,
@@ -1719,6 +1728,7 @@ fn pull_item(issue: models::Issue) -> Option<Item> {
"{name} #{number} {}", "{name} #{number} {}",
issue.title.as_deref().unwrap_or("Untitled") issue.title.as_deref().unwrap_or("Untitled")
), ),
graph_lane: None,
meta: format!( meta: format!(
"{} · {} comments", "{} · {} comments",
issue.state.as_deref().unwrap_or("unknown"), issue.state.as_deref().unwrap_or("unknown"),
@@ -1743,6 +1753,7 @@ fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Op
title: milestone title: milestone
.title .title
.unwrap_or_else(|| "Untitled milestone".into()), .unwrap_or_else(|| "Untitled milestone".into()),
graph_lane: None,
meta: format!( meta: format!(
"{} · {} open · {} closed", "{} · {} open · {} closed",
state, state,
@@ -1770,12 +1781,8 @@ fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option<Item
.unwrap_or("Commit"); .unwrap_or("Commit");
Some(Item { Some(Item {
key: sha.clone(), key: sha.clone(),
title: format!( title: message.lines().next().unwrap_or("Commit").into(),
"{}{}{}", graph_lane: node_lane,
"".repeat(node_lane.unwrap_or_default()),
if node_lane.is_some() { "" } else { "" },
message.lines().next().unwrap_or("Commit")
),
meta: format!( meta: format!(
"{} · {}{}", "{} · {}{}",
short_sha(&sha), short_sha(&sha),
@@ -1801,6 +1808,7 @@ fn diff_files(diff: &str) -> Vec<Item> {
.map(|(path, _)| Item { .map(|(path, _)| Item {
key: path.into(), key: path.into(),
title: path.into(), title: path.into(),
graph_lane: None,
meta: "changed file".into(), meta: "changed file".into(),
detail: diff_for_file(diff, path), detail: diff_for_file(diff, path),
target: Target::Text(path.into(), 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() .iter()
.map(|item| { .map(|item| {
ListItem::new(vec![ ListItem::new(vec![
Line::from(Span::styled( item_title(&item.title, item.graph_lane),
item.title.clone(),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled( Line::from(Span::styled(
item.meta.clone(), item.meta.clone(),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
@@ -125,14 +122,50 @@ fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.borders(Borders::ALL) .borders(Borders::ALL)
.title(format!(" {} ", app.screen.title)), .title(format!(" {} ", app.screen.title)),
) )
.highlight_symbol("") .highlight_symbol(Line::from(Span::styled(
.highlight_style(Style::default().fg(ACCENT)); "",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)))
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
let mut state = ListState::default() let mut state = ListState::default()
.with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected)); .with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected));
frame.render_stateful_widget(list, area, &mut state); frame.render_stateful_widget(list, area, &mut state);
app.list_offset = state.offset(); 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) { fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
let mut detail = app.screen.detail.clone(); let mut detail = app.screen.detail.clone();
if let Some(item) = app.screen.selected_item() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use ratatui::{Terminal, backend::TestBackend, widgets::ListItem};
#[test] #[test]
fn responsive_popup_stays_inside_small_terminals() { fn responsive_popup_stays_inside_small_terminals() {
@@ -295,4 +329,32 @@ mod tests {
assert!(!areas[0].contains((9, 1).into())); assert!(!areas[0].contains((9, 1).into()));
assert!(!areas[4].contains((80, 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));
}
} }