use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::app::{AlertKind, App, Screen};
fn hex_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Color::Reset;
}
match u32::from_str_radix(hex, 16) {
Ok(rgb) => Color::Rgb(
((rgb >> 16) & 0xFF) as u8,
((rgb >> 8) & 0xFF) as u8,
(rgb & 0xFF) as u8,
),
Err(_) => Color::Reset,
}
}
fn fg(hex: &str) -> Style {
Style::default().fg(hex_color(hex))
}
pub fn render(frame: &mut Frame, app: &App) {
match &app.screen {
Screen::Loading => render_loading(frame),
Screen::List => render_commit_list(frame, app, false),
Screen::Detail => {
render_commit_list(frame, app, true);
render_detail_overlay(frame, app);
}
Screen::Error(msg) => render_error(frame, msg),
Screen::Alert(kind) => {
render_commit_list(frame, app, true);
render_alert_overlay(frame, app, kind);
}
}
if app.notification.is_some() {
render_notification(frame, app);
}
if app.show_help {
render_help_overlay(frame, app);
}
}
fn render_loading(frame: &mut Frame) {
let area = frame.area();
let text = Paragraph::new("Loading commits...")
.alignment(Alignment::Center)
.block(Block::default().title(" tuit ").borders(Borders::ALL));
frame.render_widget(text, area);
}
pub fn render_commit_list(frame: &mut Frame, app: &App, grayed_out: bool) {
let colors = &app.colors;
let area = frame.area();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let header_line = Line::from(vec![
Span::styled(" tuit ", Style::default().bold()),
Span::styled(" q:quit", Style::default().fg(Color::DarkGray)),
]);
let header = Paragraph::new(header_line);
frame.render_widget(header, layout[0]);
let list_area = layout[1];
let list_bg = hex_color(&colors.list_bg);
let muted_fg = Style::default().fg(Color::DarkGray);
let rows: Vec<Line> = app
.commits
.iter()
.enumerate()
.map(|(i, commit)| {
let is_selected = i == app.selected_index;
let cursor = if is_selected { "❯" } else { " " };
let max_msg_width = list_area.width.saturating_sub(35).max(5) as usize;
let msg = if commit.message.len() > max_msg_width {
let mut s: String = commit
.message
.chars()
.take(max_msg_width.saturating_sub(1))
.collect();
s.push('.');
s
} else {
commit.message.clone()
};
let author_pad = 12usize.saturating_sub(commit.author.len());
let author_padded = format!("{}{}", " ".repeat(author_pad), commit.author);
let date_str = if commit.date.len() > 10 {
let mut s: String = commit.date.chars().take(9).collect();
s.push('.');
s
} else {
commit.date.clone()
};
if is_selected {
let bg = if grayed_out {
Color::Gray
} else {
hex_color(&colors.cursor_bg)
};
let fg_color = if grayed_out {
Color::Black
} else {
hex_color(&colors.cursor_fg)
};
let base = Style::default().bg(bg).fg(fg_color);
Line::from(vec![
Span::styled(format!(" {}", cursor), base),
Span::styled(format!(" {} ", commit.hash), base),
Span::styled(format!(" {} ", msg), base),
Span::styled(format!(" {} {}", author_padded, date_str), base),
])
} else {
Line::from(vec![
Span::styled(format!(" {}", cursor), muted_fg),
Span::styled(
format!(" {} ", commit.hash),
if grayed_out {
muted_fg
} else {
fg(&colors.hash)
},
),
Span::styled(
format!(" {} ", msg),
if grayed_out {
muted_fg
} else {
fg(&colors.message)
},
),
Span::styled(
format!(" {}", author_padded),
if grayed_out {
muted_fg
} else {
fg(&colors.author)
},
),
Span::styled(
format!(" {}", date_str),
if grayed_out {
muted_fg
} else {
fg(&colors.date)
},
),
])
}
})
.collect();
let list_block = Block::default().style(Style::default().bg(list_bg));
let list = Paragraph::new(Text::from(rows))
.block(list_block)
.wrap(Wrap { trim: false });
frame.render_widget(list, layout[1]);
let footer_text = " ^/k up | v/j down | Enter detail | q quit ";
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
Style::default().fg(Color::DarkGray),
)))
.alignment(Alignment::Center);
frame.render_widget(footer, layout[2]);
}
pub fn render_detail_overlay(frame: &mut Frame, app: &App) {
let colors = &app.colors;
let area = frame.area();
let commit = match &app.selected_commit {
Some(c) => c,
None => return,
};
let overlay_w = (area.width as f64 * 0.9) as u16;
let overlay_h = (area.height as f64 * 0.8) as u16;
let overlay_x = (area.width - overlay_w) / 2;
let overlay_y = (area.height - overlay_h) / 3;
let overlay_area = Rect {
x: overlay_x,
y: overlay_y,
width: overlay_w,
height: overlay_h,
};
let dim_block = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(dim_block, area);
frame.render_widget(Clear, overlay_area);
let border_color = hex_color(&colors.detail_border);
let bg_color = hex_color(&colors.detail_bg);
let overlay = Block::default()
.title(format!(
" {} | {} | {} ",
commit.hash, commit.author, commit.date,
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg_color));
let inner = overlay.inner(overlay_area);
frame.render_widget(overlay, overlay_area);
let body_lines: Vec<&str> = commit.body.lines().collect();
let body_count = body_lines.len();
let showed_all_body = body_count <= 8;
let wrap_width = inner.width.max(1) as usize;
let wrap_rows = |text: &str| -> u16 {
let w = wrap_width.max(1);
let vis = text.width();
if vis == 0 { 1 } else { vis.div_ceil(w) as u16 }
};
let mut msg_height = wrap_rows(&commit.message); if body_count > 0 {
msg_height += 1; for line in body_lines.iter().take(8) {
msg_height += wrap_rows(line);
}
if !showed_all_body {
msg_height += 1; }
}
let max_msg = (inner.height.saturating_sub(3) * 2 / 3).max(1);
msg_height = msg_height.min(max_msg);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(msg_height),
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let value_style = Style::default().fg(hex_color(&colors.detail_value));
let mut msg_lines: Vec<Line> = Vec::new();
msg_lines.push(Line::from(Span::styled(
commit.message.clone(),
value_style.add_modifier(Modifier::BOLD),
)));
if body_count > 0 {
msg_lines.push(Line::from(Span::styled(String::new(), value_style)));
for line in body_lines.iter().take(8) {
msg_lines.push(Line::from(Span::styled(
line.to_string(),
value_style,
)));
}
if !showed_all_body {
let dim_style = Style::default().fg(Color::DarkGray);
msg_lines.push(Line::from(Span::styled(
format!(" (... and {} more lines)", body_count - 8),
dim_style,
)));
}
}
let msg = Paragraph::new(Text::from(msg_lines))
.wrap(Wrap { trim: false })
.style(Style::default().bg(bg_color));
frame.render_widget(msg, layout[0]);
let sep_style = Style::default().fg(Color::DarkGray);
let sep = Paragraph::new(Line::from(Span::styled(
"-".repeat(layout[1].width.max(1) as usize),
sep_style,
)))
.style(Style::default().bg(bg_color));
frame.render_widget(sep, layout[1]);
let diff_area = layout[2];
let diff_width = diff_area.width.saturating_sub(2) as usize;
let mut diff_lines: Vec<Line> = Vec::new();
for line in commit.diff.lines() {
let style = if line.starts_with('+') {
fg(&colors.diff_add)
} else if line.starts_with('-') {
fg(&colors.diff_del)
} else {
fg(&colors.diff_normal)
};
let display = if line.len() > diff_width {
let mut s: String = line
.chars()
.take(diff_width.saturating_sub(3))
.collect();
s.push_str(" ...");
s
} else {
line.to_string()
};
diff_lines.push(Line::from(Span::styled(display, style)));
}
let diff_height = diff_area.height as usize;
app.detail_content_height.set(diff_height);
let total_diff_lines = diff_lines.len();
let max_scroll = total_diff_lines.saturating_sub(diff_height);
let scroll = app.detail_scroll.get().min(max_scroll);
app.detail_scroll.set(scroll);
let diff_para = Paragraph::new(Text::from(diff_lines))
.style(Style::default().bg(bg_color))
.scroll((scroll as u16, 0));
frame.render_widget(diff_para, diff_area);
let hint_text = if total_diff_lines > diff_height {
let pct = if max_scroll > 0 {
(scroll as f64 / max_scroll as f64 * 100.0) as u8
} else {
0
};
format!(" [Esc] close | ^/v scroll ({pct}%) ")
} else {
" [Esc] close ".to_string()
};
let hint = Paragraph::new(Line::from(Span::styled(
hint_text,
Style::default().fg(Color::DarkGray),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(bg_color));
frame.render_widget(hint, layout[3]);
}
fn render_help_overlay(frame: &mut Frame, app: &App) {
let colors = &app.colors;
let area = frame.area();
let dim_block = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(dim_block, area);
let popup_w = (area.width as f64 * 0.7).min(52.0).max(40.0) as u16;
let popup_h = (area.height as f64 * 0.85).min(28.0) as u16;
let popup_x = (area.width - popup_w) / 2;
let popup_y = (area.height - popup_h) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_w,
height: popup_h,
};
frame.render_widget(Clear, popup_area);
let border_color = hex_color(&colors.detail_border);
let bg_color = hex_color(&colors.detail_bg);
let overlay = Block::default()
.title(" Keybindings ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg_color));
let inner = overlay.inner(popup_area);
frame.render_widget(overlay, popup_area);
let heading_style = Style::default()
.fg(hex_color(&colors.detail_heading))
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(hex_color(&colors.detail_border));
let desc_style = Style::default().fg(hex_color(&colors.detail_value));
let dim_style = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = Vec::new();
let (section_title, section_bindings): (&str, &[(&str, &str)]) = match &app.screen {
Screen::List => (
"Commit List",
&[
("^ / k", "Move cursor up"),
("v / j", "Move cursor down"),
("Enter", "Show commit details"),
("q", "Quit tuit"),
],
),
Screen::Detail => (
"Commit Detail",
&[
("^ / k", "Scroll up"),
("v / j", "Scroll down"),
("Ctrl+f", "Scroll down one page"),
("Ctrl+b", "Scroll up one page"),
("Esc", "Close detail view"),
],
),
Screen::Error(_) => (
"Error",
&[
("Enter", "Quit"),
],
),
Screen::Alert(_) => (
"Alert",
&[
("Enter / Esc", "Dismiss"),
],
),
Screen::Loading => return, };
lines.push(Line::from(Span::styled(
format!(" {} ", section_title),
heading_style,
)));
lines.push(Line::from(Span::styled(String::new(), dim_style)));
for (key_str, desc) in section_bindings {
lines.push(Line::from(vec![
Span::styled(format!(" {:<12}", key_str), key_style),
Span::styled(desc.to_string(), desc_style),
]));
}
lines.push(Line::from(Span::styled(String::new(), dim_style)));
lines.push(Line::from(Span::styled(
" [Esc/?] close ",
dim_style,
)));
let content = Paragraph::new(Text::from(lines))
.style(Style::default().bg(bg_color));
frame.render_widget(content, inner);
}
pub fn render_alert_overlay(frame: &mut Frame, app: &App, kind: &AlertKind) {
let colors = &app.colors;
let area = frame.area();
let dim_block = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(dim_block, area);
let popup_w = (area.width as f64 * 0.6).min(50.0).max(36.0) as u16;
let popup_h = 8;
let popup_x = (area.width - popup_w) / 2;
let popup_y = (area.height - popup_h) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_w,
height: popup_h,
};
frame.render_widget(Clear, popup_area);
let border_color = hex_color(&colors.detail_border);
let bg_color = hex_color(&colors.detail_bg);
let overlay = Block::default()
.title(" ⚠ Commit Deleted ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg_color));
let inner = overlay.inner(popup_area);
frame.render_widget(overlay, popup_area);
let value_style = Style::default().fg(hex_color(&colors.detail_value));
let dim_style = Style::default().fg(Color::DarkGray);
let msg = match kind {
AlertKind::CommitDeleted { oid } => {
let short = oid.chars().take(7).collect::<String>();
format!("Commit {} no longer exists in the repository.", short)
}
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
let msg_para = Paragraph::new(Line::from(Span::styled(msg, value_style)))
.alignment(Alignment::Center);
frame.render_widget(msg_para, layout[0]);
let hint = Paragraph::new(Line::from(Span::styled(
" [Enter / Esc] back to list ",
dim_style,
)))
.alignment(Alignment::Center);
frame.render_widget(hint, layout[1]);
}
pub fn render_notification(frame: &mut Frame, app: &App) {
let colors = &app.colors;
let area = frame.area();
let notif = match &app.notification {
Some(n) => n,
None => return,
};
let bar = Rect {
x: 0,
y: area.height.saturating_sub(1),
width: area.width,
height: 1,
};
let bg_color = hex_color(&colors.detail_border);
let notice = Paragraph::new(Line::from(Span::styled(
format!(" {} ", notif.message),
Style::default().fg(Color::Black).bg(bg_color),
)));
frame.render_widget(notice, bar);
}
pub fn render_error(frame: &mut Frame, msg: &str) {
let area = frame.area();
let block = Block::default().title(" tuit ").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ])
.split(inner);
let icon = Paragraph::new(Line::from(Span::styled(
" ⚠ Error ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
frame.render_widget(icon, layout[0]);
let msg_para = Paragraph::new(Line::from(Span::styled(
msg,
Style::default().fg(Color::White),
)))
.alignment(Alignment::Center);
frame.render_widget(msg_para, layout[1]);
let hint = Paragraph::new(Line::from(Span::styled(
" [Enter] exit ",
Style::default().fg(Color::DarkGray),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, layout[2]);
}