use std::io;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use unicode_width::UnicodeWidthStr;
use crate::app::App;
use crate::model::{Focus, LoadState, LogEntry};
pub type Terminal = DefaultTerminal;
const CHANGE_ID_WIDTH: usize = 8;
const DATE_WIDTH: usize = 12;
const BOOKMARK_WIDTH: usize = 20;
const AUTHOR_WIDTH: usize = 6;
const COLUMN_GAP_WIDTH: usize = 2;
const CHANGE_ID_HIGHLIGHT_COLOR: Color = Color::Magenta;
const LOG_LINE_WIDTH: usize =
CHANGE_ID_WIDTH + DATE_WIDTH + BOOKMARK_WIDTH + AUTHOR_WIDTH + (COLUMN_GAP_WIDTH * 3);
pub fn init_terminal() -> io::Result<Terminal> {
let terminal = ratatui::init();
let mut stdout = io::stdout();
crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
Ok(terminal)
}
pub fn restore_terminal() -> io::Result<()> {
let mut stdout = io::stdout();
crossterm::execute!(stdout, crossterm::event::DisableMouseCapture)?;
ratatui::restore();
Ok(())
}
pub fn render(frame: &mut Frame, app: &mut App) {
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(frame.area());
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(areas[1]);
app.set_left_height(areas[0]);
render_log_list(frame, areas[0], app);
render_description(frame, right[0], app);
render_diff(frame, right[1], app);
}
fn render_log_list(frame: &mut Frame, area: Rect, app: &App) {
let block = bordered_block("Log", app.focus == Focus::Log);
match &app.logs_state {
LoadState::Loading => {
frame.render_widget(Paragraph::new("Loading jj log...").block(block), area);
}
LoadState::Error(message) => {
frame.render_widget(
Paragraph::new(message.as_str())
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
LoadState::Ready(logs) => {
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = build_log_lines(
logs,
app.selected,
app.log_top,
inner.width as usize,
inner.height as usize,
);
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
}
}
fn render_description(frame: &mut Frame, area: Rect, app: &App) {
let block = bordered_block("Description", false);
let text = match app.selected_log() {
Some(entry) => format_description(entry),
None => "No commit selected".to_string(),
};
frame.render_widget(
Paragraph::new(text).block(block).wrap(Wrap { trim: false }),
area,
);
}
fn render_diff(frame: &mut Frame, area: Rect, app: &App) {
let block = bordered_block("Diff Stat", app.focus == Focus::Diff);
let body = match app.current_diff_state() {
Some(LoadState::Loading) => Paragraph::new("Loading jj diff --stat..."),
Some(LoadState::Error(message)) => Paragraph::new(message.as_str()),
Some(LoadState::Ready(lines)) => {
Paragraph::new(lines.join("\n")).scroll((app.diff_scroll, 0))
}
None => Paragraph::new("No commit selected"),
};
frame.render_widget(body.block(block).wrap(Wrap { trim: false }), area);
}
fn build_log_lines(
logs: &[LogEntry],
selected: usize,
top: usize,
width: usize,
height: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let max_graph_prefix_width = logs
.iter()
.filter_map(|entry| entry.graph_lines.first())
.map(|line| display_width(line))
.max()
.unwrap_or(0);
for (index, entry) in logs.iter().enumerate().skip(top) {
let row_style = if index == selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
let first_prefix = entry.graph_lines.first().cloned().unwrap_or_default();
let padded_prefix = pad_or_truncate(&first_prefix, max_graph_prefix_width);
let main_spans = format_log_spans(
entry,
row_style,
width.saturating_sub(max_graph_prefix_width),
);
let mut spans = vec![Span::styled(padded_prefix, row_style.fg(Color::Cyan))];
spans.extend(main_spans);
lines.push(Line::from(spans));
for graph_line in entry.graph_lines.iter().skip(1) {
lines.push(Line::from(Span::styled(
graph_line.clone(),
row_style.fg(Color::Cyan),
)));
}
if lines.len() >= height {
lines.truncate(height);
break;
}
}
lines
}
#[cfg(test)]
fn format_log_line(entry: &LogEntry, width: usize) -> String {
let fixed = format_log_line_full(entry);
let rendered = if display_width(&fixed) == LOG_LINE_WIDTH {
fixed
} else {
pad_or_truncate(&fixed, LOG_LINE_WIDTH)
};
truncate_display_width(&rendered, width)
}
fn format_log_spans(entry: &LogEntry, row_style: Style, width: usize) -> Vec<Span<'static>> {
let full_line = format_log_line_full(entry);
let rendered = if display_width(&full_line) == LOG_LINE_WIDTH {
full_line
} else {
pad_or_truncate(&full_line, LOG_LINE_WIDTH)
};
let truncated = truncate_display_width(&rendered, width);
let highlight_width = display_width(&truncate_display_width(
&entry.change_id_prefix,
CHANGE_ID_WIDTH,
));
if width <= CHANGE_ID_WIDTH {
return split_change_id_spans(&truncated, highlight_width, row_style);
}
let change_id = display_change_id(entry);
let change_id_width = display_width(&change_id);
let change_id_end = truncated
.char_indices()
.find_map(|(index, _)| {
(display_width(&truncated[..index]) == change_id_width).then_some(index)
})
.unwrap_or(truncated.len());
let (change_id_part, remainder) = truncated.split_at(change_id_end);
let mut spans = split_change_id_spans(change_id_part, highlight_width, row_style);
if !remainder.is_empty() {
spans.push(Span::styled(remainder.to_string(), row_style));
}
spans
}
fn format_log_line_full(entry: &LogEntry) -> String {
[
display_change_id(entry),
pad_or_truncate(&entry.date, DATE_WIDTH),
pad_or_truncate(
&join_bookmarks(&entry.bookmarks, BOOKMARK_WIDTH),
BOOKMARK_WIDTH,
),
pad_or_truncate(&entry.author, AUTHOR_WIDTH),
]
.join(" ")
}
fn display_change_id(entry: &LogEntry) -> String {
let rendered = format!("{}{}", entry.change_id_prefix, entry.change_id_rest);
let mut truncated = truncate_display_width_plain(&rendered, CHANGE_ID_WIDTH);
let pad = CHANGE_ID_WIDTH.saturating_sub(display_width(&truncated));
truncated.push_str(&" ".repeat(pad));
truncated
}
fn split_change_id_spans(
change_id: &str,
highlight_width: usize,
row_style: Style,
) -> Vec<Span<'static>> {
let highlight_end = byte_index_at_display_width(change_id, highlight_width);
let (highlight, rest) = change_id.split_at(highlight_end);
let mut spans = Vec::new();
if !highlight.is_empty() {
spans.push(Span::styled(
highlight.to_string(),
row_style.fg(CHANGE_ID_HIGHLIGHT_COLOR),
));
}
if !rest.is_empty() {
spans.push(Span::styled(rest.to_string(), row_style));
}
spans
}
fn byte_index_at_display_width(value: &str, target_width: usize) -> usize {
if target_width == 0 {
return 0;
}
let mut width = 0usize;
for (index, ch) in value.char_indices() {
let next = width + display_width(ch.encode_utf8(&mut [0; 4]));
if next > target_width {
return index;
}
width = next;
if width == target_width {
return index + ch.len_utf8();
}
}
value.len()
}
fn join_bookmarks(bookmarks: &[String], max_width: usize) -> String {
let mut rendered = String::new();
let mut hidden = 0usize;
for bookmark in bookmarks {
let candidate = if rendered.is_empty() {
bookmark.clone()
} else {
format!("{rendered}, {bookmark}")
};
if display_width(&candidate) > max_width {
hidden += 1;
continue;
}
rendered = candidate;
}
if hidden > 0 {
if rendered.is_empty() {
format!("+{hidden}")
} else {
format!("{rendered}, +{hidden}")
}
} else {
rendered
}
}
fn format_description(entry: &LogEntry) -> String {
let description = if entry.description.is_empty() {
"(no description)"
} else {
entry.description.as_str()
};
format!("commit ID: {}\n\n{}", entry.commit_id, description)
}
fn pad_or_truncate(value: &str, width: usize) -> String {
let mut truncated = truncate_display_width(value, width);
let pad = width.saturating_sub(display_width(&truncated));
truncated.push_str(&" ".repeat(pad));
truncated
}
fn truncate_display_width(value: &str, max_width: usize) -> String {
if display_width(value) <= max_width {
return value.to_string();
}
let ellipsis = "…";
let ellipsis_width = display_width(ellipsis);
let target = max_width.saturating_sub(ellipsis_width);
let mut result = String::new();
for ch in value.chars() {
let next_width = display_width(&result) + display_width(ch.encode_utf8(&mut [0; 4]));
if next_width > target {
break;
}
result.push(ch);
}
format!("{result}{ellipsis}")
}
fn truncate_display_width_plain(value: &str, max_width: usize) -> String {
if display_width(value) <= max_width {
return value.to_string();
}
let mut result = String::new();
for ch in value.chars() {
let next_width = display_width(&result) + display_width(ch.encode_utf8(&mut [0; 4]));
if next_width > max_width {
break;
}
result.push(ch);
}
result
}
fn display_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
fn bordered_block<'a>(title: &'a str, focused: bool) -> Block<'a> {
let style = if focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Block::default()
.borders(Borders::ALL)
.title(Span::styled(title, style))
}
#[cfg(test)]
mod tests {
use super::{
AUTHOR_WIDTH, BOOKMARK_WIDTH, CHANGE_ID_WIDTH, COLUMN_GAP_WIDTH, DATE_WIDTH,
LOG_LINE_WIDTH, build_log_lines, display_change_id, display_width, format_description,
format_log_line, format_log_spans,
};
use crate::model::LogEntry;
use ratatui::style::Style;
#[test]
fn format_log_line_uses_fixed_width_columns() {
let entry = LogEntry {
change_id_prefix: "abc".to_string(),
change_id_rest: "defghijk".to_string(),
date: "260101T01:01".to_string(),
author: "verylongname".to_string(),
bookmarks: vec![
"main".to_string(),
"release/2026".to_string(),
"feature".to_string(),
],
..LogEntry::default()
};
let line = format_log_line(&entry, LOG_LINE_WIDTH);
assert_eq!(display_width(&line), LOG_LINE_WIDTH);
assert!(line.contains("abcdefgh"));
assert!(line.contains("260101T01:01"));
assert!(line.contains("main"));
assert!(line.contains("veryl…"));
assert!(!line.contains("123456789abc"));
}
#[test]
fn empty_bookmarks_still_reserve_column_width() {
let entry = LogEntry {
change_id_prefix: "abc".to_string(),
change_id_rest: "defghijk".to_string(),
date: "260101T01:01".to_string(),
author: "alice".to_string(),
..LogEntry::default()
};
let line = format_log_line(&entry, LOG_LINE_WIDTH);
let bookmark_start = CHANGE_ID_WIDTH + COLUMN_GAP_WIDTH + DATE_WIDTH + COLUMN_GAP_WIDTH;
let bookmark_end = bookmark_start + BOOKMARK_WIDTH;
let author_start = bookmark_end + COLUMN_GAP_WIDTH;
let author_end = author_start + AUTHOR_WIDTH;
assert_eq!(
&line[bookmark_start..bookmark_end],
" ".repeat(BOOKMARK_WIDTH)
);
assert_eq!(&line[author_start..author_end], "alice ");
}
#[test]
fn format_description_includes_commit_id_header() {
let entry = LogEntry {
commit_id: "1234567890abcdef".to_string(),
description: "subject\nbody".to_string(),
..LogEntry::default()
};
assert_eq!(
format_description(&entry),
"commit ID: 1234567890abcdef\n\nsubject\nbody"
);
}
#[test]
fn build_log_lines_aligns_change_id_column() {
let logs = vec![
LogEntry {
change_id_prefix: "aaaa".to_string(),
change_id_rest: "bbbb".to_string(),
date: "260101T01:01".to_string(),
author: "alice".to_string(),
graph_lines: vec!["@ ".to_string()],
..LogEntry::default()
},
LogEntry {
change_id_prefix: "cccc".to_string(),
change_id_rest: "dddd".to_string(),
date: "260101T01:01".to_string(),
author: "bob".to_string(),
graph_lines: vec!["├─╮ ".to_string()],
..LogEntry::default()
},
];
let lines = build_log_lines(&logs, 0, 0, 80, 10);
let rendered = lines
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>();
let first_pos = rendered[0]
.find("aaaabbbb")
.expect("first change id should exist");
let second_pos = rendered[1]
.find("ccccdddd")
.expect("second change id should exist");
assert_eq!(
display_width(&rendered[0][..first_pos]),
display_width(&rendered[1][..second_pos])
);
}
#[test]
fn display_change_id_keeps_eight_columns() {
let entry = LogEntry {
change_id_prefix: "abc".to_string(),
change_id_rest: "defghijkl".to_string(),
..LogEntry::default()
};
let rendered = display_change_id(&entry);
assert_eq!(display_width(&rendered), CHANGE_ID_WIDTH);
assert_eq!(rendered, "abcdefgh");
}
#[test]
fn format_log_spans_highlights_only_shortest_prefix() {
let entry = LogEntry {
change_id_prefix: "ab".to_string(),
change_id_rest: "cdefgh".to_string(),
date: "260101T01:01".to_string(),
author: "alice".to_string(),
..LogEntry::default()
};
let spans = format_log_spans(&entry, Style::default(), LOG_LINE_WIDTH);
assert_eq!(spans[0].content, "ab");
assert_eq!(spans[1].content, "cdefgh");
assert_eq!(spans[0].style.fg, Some(super::CHANGE_ID_HIGHLIGHT_COLOR));
assert_eq!(spans[1].style.fg, None);
}
}