Color TUI commit graph lanes
This commit is contained in:
@@ -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)),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user