pub mod diff;
pub mod diff_render;
pub mod log;
pub mod popup;
pub mod status;
use diffler_core::model::FileStatus;
use ratatui::Frame;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use crate::app::{App, BranchAction, Modal, Screen, Severity};
use crate::keymap::{Action, render_chord};
use crate::theme::Theme;
use crate::transient::TransientKind;
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
match app.screen() {
Screen::Status => {
app.enrich_status_expanded();
app.ensure_status_highlights(diff::ensure_file_highlights);
status::draw(frame, app);
}
Screen::Log => log::draw(frame, app),
Screen::Diff => diff::draw(frame, app),
}
match &app.modal {
Some(Modal::Confirm { message, .. }) => {
popup::ConfirmDialog {
message: message.clone(),
}
.render(frame, &app.theme);
}
Some(Modal::Input {
title,
buffer,
cursor,
..
}) => {
popup::InputModal {
title: title.clone(),
buffer: buffer.clone(),
cursor: *cursor,
}
.render(frame, &app.theme);
}
Some(Modal::Help) => {
let screen = match app.screen() {
Screen::Status => "status",
Screen::Diff => "diff",
Screen::Log => "log",
};
popup::Popup {
title: format!("Help — {screen} keys"),
entries: help_entries(app),
}
.render(frame, &app.theme);
}
Some(Modal::BranchList {
branches,
cursor,
action,
}) => {
let title = match action {
BranchAction::Checkout => "Checkout branch",
BranchAction::Delete => "Delete branch",
};
popup::ListModal {
title: title.to_owned(),
items: branches
.iter()
.map(|b| format!("{} {}", if b.is_head { "*" } else { " " }, b.name))
.collect(),
cursor: *cursor,
}
.render(frame, &app.theme);
}
None => {}
}
if app.modal.is_none()
&& let Some(transient) = app.which_key_panel()
{
popup::WhichKeyPanel { transient }.render(frame, &app.theme);
}
}
fn help_entries(app: &App) -> Vec<(String, String)> {
let keymap = app.active_keymap();
let mut entries: Vec<(String, String)> = keymap
.bindings()
.iter()
.map(|(chord, action)| (render_chord(chord), action.name().to_owned()))
.collect();
if app.screen() == Screen::Status {
for kind in TransientKind::ALL {
let Some(prefix) = keymap.prefix_chord(kind) else {
continue;
};
entries.push((prefix, format!("{} …", kind.title())));
for (key, label) in app.transient(kind).flat_entries() {
entries.push((format!(" {key}"), label.to_owned()));
}
}
}
entries
}
pub(super) fn status_color(theme: &Theme, status: FileStatus) -> Color {
match status {
FileStatus::Added | FileStatus::Untracked => theme.added,
FileStatus::Deleted => theme.error_fg,
FileStatus::Modified | FileStatus::Renamed => theme.warn_fg,
}
}
pub(super) fn diffstat_spans(
theme: &Theme,
added: usize,
deleted: usize,
bg: Color,
) -> Vec<Span<'static>> {
if added == 0 && deleted == 0 {
return Vec::new();
}
let side = |count: usize, color: Color| {
let fg = if count == 0 { theme.dim } else { color };
Style::new().fg(fg).bg(bg)
};
vec![
Span::styled(format!(" +{added}"), side(added, theme.added)),
Span::styled(format!(" -{deleted}"), side(deleted, theme.error_fg)),
]
}
pub(super) fn proportion_bar(
theme: &Theme,
added: usize,
deleted: usize,
bg: Color,
) -> Vec<Span<'static>> {
const CELLS: usize = 5;
let total = added + deleted;
if total == 0 {
return Vec::new();
}
let mut add_cells = (added * CELLS).div_ceil(total).min(CELLS);
if added > 0 && add_cells == 0 {
add_cells = 1;
}
if deleted > 0 && add_cells == CELLS {
add_cells = CELLS - 1;
}
let del_cells = CELLS - add_cells;
let mut spans = Vec::new();
if add_cells > 0 {
spans.push(Span::styled(
"█".repeat(add_cells),
Style::new().fg(theme.added).bg(bg),
));
}
if del_cells > 0 {
spans.push(Span::styled(
"█".repeat(del_cells),
Style::new().fg(theme.error_fg).bg(bg),
));
}
spans
}
pub(super) enum Hint {
Leaf(&'static [Action], &'static str),
Prefix(TransientKind, &'static str),
}
pub(super) fn hint_line(app: &App, items: &[Hint]) -> Line<'static> {
let keymap = app.active_keymap();
let mut parts = Vec::new();
for item in items {
match item {
Hint::Leaf(actions, label) => {
let chords: Vec<String> = actions
.iter()
.filter_map(|action| keymap.chord_for(*action))
.collect();
if chords.len() == actions.len() {
parts.push(format!("{} {label}", chords.join("/")));
}
}
Hint::Prefix(kind, label) => {
if let Some(chord) = keymap.prefix_chord(*kind) {
parts.push(format!("{chord} {label}"));
}
}
}
}
Line::styled(
format!(" Hint: {}", parts.join(" ")),
app.theme.dim_style(),
)
}
pub(super) fn relative_time(now: i64, then: i64) -> String {
let secs = (now - then).max(0);
let (n, unit) = match secs {
s if s < 60 => (s, "s"),
s if s < 3600 => (s / 60, "m"),
s if s < 86_400 => (s / 3600, "h"),
s if s < 86_400 * 7 => (s / 86_400, "d"),
s if s < 86_400 * 30 => (s / (86_400 * 7), "w"),
s if s < 86_400 * 365 => (s / (86_400 * 30), "mo"),
s => (s / (86_400 * 365), "y"),
};
format!("{n}{unit}")
}
pub(super) fn commit_meta_spans(
theme: &Theme,
author: &str,
time_unix: i64,
now: i64,
used: usize,
width: usize,
) -> Vec<Span<'static>> {
let age = relative_time(now, time_unix);
let meta_width = author.chars().count() + 2 + age.chars().count() + 1;
if used + meta_width >= width {
return Vec::new();
}
let pad = width - used - meta_width;
vec![
Span::styled(" ".repeat(pad), Style::new().bg(theme.bg)),
Span::styled(
author.to_owned(),
Style::new().fg(theme.accent).bg(theme.bg),
),
Span::styled(" ", Style::new().bg(theme.bg)),
Span::styled(age, theme.dim_style()),
Span::styled(" ", Style::new().bg(theme.bg)),
]
}
pub(super) fn cursor_line(line: Line<'static>, theme: &Theme, width: u16) -> Line<'static> {
let pad = (width as usize).saturating_sub(line.width());
let mut spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| {
let style = span.style.bg(theme.cursor_line);
Span::styled(span.content, style)
})
.collect();
if pad > 0 {
spans.push(Span::styled(
" ".repeat(pad),
Style::new().bg(theme.cursor_line),
));
}
Line::from(spans)
}
pub(super) fn status_bar(app: &App, width: u16) -> Line<'static> {
let theme = &app.theme;
let on_panel = |fg| Style::new().fg(fg).bg(theme.panel);
let chip = match app.screen() {
Screen::Status => " STATUS ",
Screen::Diff => " DIFF ",
Screen::Log => " LOG ",
};
let repo = app
.review
.repo_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let branch = app.head.branch.clone().unwrap_or_else(|| "?".to_owned());
let mut spans = vec![
Span::styled(chip, theme.chip),
Span::styled(format!(" {repo}@{branch}"), on_panel(theme.fg)),
];
if let Some(port) = app.mcp_port {
spans.push(Span::styled(format!(" mcp :{port}"), on_panel(theme.dim)));
} else if app.config.mcp.enabled {
spans.push(Span::styled(" mcp off", on_panel(theme.dim)));
}
if app.refresh_flash > 0 {
spans.push(Span::styled(" ↻", on_panel(theme.dim)));
}
let (files, viewed) = app.viewed_counts();
if files > 0 {
let text = if app.screen() == Screen::Diff {
format!(" viewed {viewed}/{files} files")
} else {
let noun = if files == 1 { "file" } else { "files" };
format!(" {files} {noun}, {viewed} viewed")
};
spans.push(Span::styled(text, on_panel(theme.dim)));
}
if let Some(message) = &app.message {
let fg = match message.severity {
Severity::Info => theme.dim,
Severity::Warning => theme.warn_fg,
Severity::Error => theme.error_fg,
};
let used: usize = spans.iter().map(Span::width).sum();
let text = format!("{} ", message.text);
let pad = (width as usize).saturating_sub(used + text.len());
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), on_panel(theme.fg)));
} else {
spans.push(Span::styled(" ", on_panel(theme.fg)));
}
spans.push(Span::styled(text, on_panel(fg)));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::relative_time;
#[test]
fn relative_time_picks_a_compact_unit() {
let now = 1_000_000;
assert_eq!(relative_time(now, now), "0s");
assert_eq!(relative_time(now, now - 49), "49s");
assert_eq!(relative_time(now, now - 6 * 60), "6m");
assert_eq!(relative_time(now, now - 21 * 3600), "21h");
assert_eq!(relative_time(now, now - 3 * 86_400), "3d");
assert_eq!(relative_time(now, now - 2 * 7 * 86_400), "2w");
assert_eq!(relative_time(now, now - 90 * 86_400), "3mo");
assert_eq!(relative_time(now, now - 800 * 86_400), "2y");
assert_eq!(relative_time(now, now + 500), "0s");
}
}