use crate::i18n::{tr, tr_f};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
use crate::app::{FileEntry, Session, Side};
use super::UiState;
use super::panes::draw_columns;
use super::theme::Theme;
pub fn draw(frame: &mut Frame, session: &mut Session, ui: &mut UiState) {
let [header, body, hints, message] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
draw_header(frame, header, session, &ui.theme);
let folded = session.folded;
let file_idx = session.current;
match session.current_file_mut() {
FileEntry::Text(merge) => {
let highlight = ui.cache.get(file_idx, merge, ui.revision, ui.theme.light);
let rows = ui.rows.get(file_idx, merge, ui.revision, folded);
draw_columns(
frame,
body,
merge,
&ui.theme,
highlight,
rows,
&mut ui.scroll_request,
);
}
FileEntry::Binary {
path,
ours,
theirs,
choice,
} => draw_binary(frame, body, path, ours, theirs, *choice, &ui.theme),
}
ui.scroll_request = 0;
draw_hints(frame, hints, &ui.theme);
frame.render_widget(
Paragraph::new(ui.message.as_str()).style(Style::new().fg(ui.theme.amber)),
message,
);
if ui.show_help {
draw_help(frame, &ui.theme);
}
}
fn draw_header(frame: &mut Frame, area: Rect, session: &Session, theme: &Theme) {
let entry = session.current_file();
let pending = match entry {
FileEntry::Text(m) => m.pending_conflicts(),
FileEntry::Binary { choice, .. } => usize::from(choice.is_none()),
};
let done = session.written.iter().filter(|&&w| w).count();
let total = session.files.len();
const BAR_WIDTH: usize = 10;
let filled = (done * BAR_WIDTH).checked_div(total).unwrap_or(0);
let sep = Span::styled("▏", Style::new().fg(theme.border));
let mut spans = vec![
Span::styled(
format!(" ⚑ {} ", session.op_label.to_uppercase()),
Style::new()
.fg(theme.badge_fg)
.bg(theme.blue)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("█".repeat(filled), Style::new().fg(theme.blue)),
Span::styled(
"░".repeat(BAR_WIDTH - filled),
Style::new().fg(theme.border),
),
Span::styled(
format!(
" {} ",
tr_f(
"ui.files_badge",
&[
("i", &(session.current + 1).to_string()),
("n", &total.to_string()),
],
)
),
Style::new().fg(theme.fg_bright),
),
sep.clone(),
Span::raw(format!(" {} ", entry.path())),
sep,
];
if pending > 0 {
spans.push(Span::styled(
format!(
" {}",
tr_f("ui.conflicts_pending", &[("n", &pending.to_string())])
),
Style::new().fg(theme.red).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
format!(" {}", tr("ui.file_ready")),
Style::new().fg(theme.green),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_binary(
frame: &mut Frame,
area: Rect,
path: &str,
ours: &[u8],
theirs: &[u8],
choice: Option<Side>,
theme: &Theme,
) {
let panel = centered_rect(area, 64, 11);
let [notice, _, cards, _, hint] = Layout::vertical([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(panel);
frame.render_widget(
Paragraph::new(vec![
Line::styled(path.to_owned(), Style::new().add_modifier(Modifier::BOLD)).centered(),
Line::styled(tr("ui.binary_notice"), Style::new().fg(theme.amber)).centered(),
]),
notice,
);
let [left, _, right] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Fill(1),
])
.areas(cards);
binary_card(
frame,
left,
" LOCAL ",
"h",
ours,
choice == Some(Side::Ours),
theme,
);
binary_card(
frame,
right,
" REMOTE ",
"l",
theirs,
choice == Some(Side::Theirs),
theme,
);
frame.render_widget(
Paragraph::new(Line::styled(
tr("ui.binary_keys"),
Style::new().fg(theme.fg_dim),
))
.centered(),
hint,
);
}
fn binary_card(
frame: &mut Frame,
area: Rect,
title: &str,
key: &str,
bytes: &[u8],
selected: bool,
theme: &Theme,
) {
let (border, title_text) = if selected {
(theme.green, format!("{title}✓ "))
} else {
(theme.border, title.to_owned())
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(border))
.title(Span::styled(
title_text,
Style::new()
.fg(theme.fg_bright)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(vec![
Line::styled(
tr_f("ui.bytes", &[("n", &bytes.len().to_string())]),
Style::new().fg(theme.fg_bright),
)
.centered(),
Line::styled(
tr_f("ui.press_pick", &[("key", key)]),
Style::new().fg(theme.fg_dim),
)
.centered(),
]),
inner,
);
}
fn keycap(key: &str, theme: &Theme) -> Span<'static> {
Span::styled(
format!(" {key} "),
Style::new().fg(theme.keycap_fg).bg(theme.keycap_bg),
)
}
fn draw_hints(frame: &mut Frame, area: Rect, theme: &Theme) {
let mut spans = vec![Span::raw(" ")];
for (key, desc) in super::keymap::hint_entries() {
spans.push(keycap(key, theme));
spans.push(Span::styled(
format!(" {} ", tr(desc)),
Style::new().fg(theme.hint_fg),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_help(frame: &mut Frame, theme: &Theme) {
let (left_items, right_items) = super::keymap::help_columns();
let column = |entries: &[(&'static str, &'static str)]| -> Vec<Line<'static>> {
entries
.iter()
.map(|(key, desc)| {
Line::from(vec![
Span::styled(
format!(" {key:<7}"),
Style::new().fg(theme.blue).add_modifier(Modifier::BOLD),
),
Span::raw(tr(desc).to_owned()),
])
})
.collect()
};
let overlay_h = left_items.len().max(right_items.len()) as u16 + 2;
let area = centered_rect(frame.area(), 78, overlay_h);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(theme.border))
.title(Span::styled(
format!(" {} ", tr("ui.help_title")),
Style::new()
.fg(theme.fg_bright)
.add_modifier(Modifier::BOLD),
))
.title_bottom(
Line::from(Span::styled(
format!(" {} ", tr("common.close_any_key")),
Style::new().fg(theme.fg_dim),
))
.centered(),
);
let inner = block.inner(area);
frame.render_widget(block, area);
let [left, right] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(inner);
frame.render_widget(Paragraph::new(column(&left_items)), left);
frame.render_widget(Paragraph::new(column(&right_items)), right);
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::FileMerge;
use crate::ui::rows::build_rows;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn draw_smoke_renders_key_elements() {
let merge =
FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
let mut ui = UiState::default();
let backend = TestBackend::new(120, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw(frame, &mut session, &mut ui))
.unwrap();
let content = format!("{:?}", terminal.backend().buffer());
assert!(content.contains("demo.txt"));
assert!(content.contains("MERGE"));
assert!(content.contains("░"));
assert!(content.contains("╭"));
assert!(content.contains("LOCAL"));
assert!(content.contains("RESULT"));
assert!(content.contains("REMOTE"));
assert!(content.contains("pending"));
assert!(content.contains("»"));
assert!(content.contains("«"));
}
#[test]
fn draw_narrow_terminal_smoke() {
let merge =
FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
let mut ui = UiState::default();
for width in [60u16, 30, 12] {
let backend = TestBackend::new(width, 16);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw(frame, &mut session, &mut ui))
.unwrap();
}
}
#[test]
fn scroll_keeps_selected_chunk_in_view() {
let stable: String = (1..=40).map(|i| format!("s{i}\n")).collect();
let base = format!("a\n{stable}b\n{stable}c\n");
let ours = format!("A\n{stable}B\n{stable}C\n");
let theirs = format!("X\n{stable}Y\n{stable}Z\n");
let merge = FileMerge::from_three_way("demo.txt".to_owned(), &base, &ours, &theirs);
let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
session.folded = false;
let mut ui = UiState::default();
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
let body_height = 19usize;
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
for step in ["next", "next", "prev"] {
let FileEntry::Text(m) = session.current_file_mut() else {
unreachable!()
};
if step == "next" {
m.next_conflict();
} else {
m.prev_conflict();
}
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
}
let FileEntry::Text(m) = session.current_file() else {
unreachable!()
};
let (_, starts) = build_rows(m, false);
let target = starts[m.cursor];
assert!(target >= m.scroll, "选中块滚出了视口上方");
assert!(target < m.scroll + body_height, "选中块滚出了视口下方");
assert!(
target + 2 < m.scroll + body_height,
"选中块贴在视口底部(target={target}, scroll={})",
m.scroll
);
}
#[test]
fn manual_scroll_detaches_and_actions_reattach() {
let stable: String = (1..=200).map(|i| format!("s{i}\n")).collect();
let base = format!("a\n{stable}");
let ours = format!("A\n{stable}");
let theirs = format!("X\n{stable}");
let merge = FileMerge::from_three_way("demo.txt".to_owned(), &base, &ours, &theirs);
let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
session.folded = false;
let mut ui = UiState::default();
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
let scroll_of = |s: &Session| match s.current_file() {
FileEntry::Text(m) => (m.scroll, m.follow),
FileEntry::Binary { .. } => unreachable!(),
};
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
assert_eq!(scroll_of(&session), (0, true));
ui.scroll_request = 2;
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
let (scrolled, follow) = scroll_of(&session);
assert!(scrolled > 0);
assert!(!follow);
assert_eq!(ui.scroll_request, 0);
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
assert_eq!(scroll_of(&session), (scrolled, false));
super::super::handle_file_key(
&mut session,
super::super::keymap::Action::NextConflict,
&mut ui,
);
terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
let (snapped, follow) = scroll_of(&session);
assert!(follow);
assert!(snapped < scrolled, "视口应吸附回顶部的冲突块");
}
#[test]
fn draw_binary_entry_smoke() {
let mut session = Session::new(
vec![FileEntry::Binary {
path: "logo.png".to_owned(),
ours: vec![0, 1],
theirs: vec![2, 3],
choice: None,
}],
"merge".to_owned(),
);
let backend = TestBackend::new(100, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| draw(frame, &mut session, &mut UiState::default()))
.unwrap();
let content = format!("{:?}", terminal.backend().buffer());
assert!(content.contains("logo.png"));
}
}