1use crate::i18n::{tr, tr_f};
4use ratatui::Frame;
5use ratatui::layout::{Constraint, Layout, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
9
10use crate::app::{FileEntry, Session, Side};
11
12use super::UiState;
13use super::panes::draw_columns;
14use super::theme::Theme;
15
16pub fn draw(frame: &mut Frame, session: &mut Session, ui: &mut UiState) {
21 let [header, body, hints, message] = Layout::vertical([
22 Constraint::Length(1),
23 Constraint::Min(0),
24 Constraint::Length(1),
25 Constraint::Length(1),
26 ])
27 .areas(frame.area());
28
29 draw_header(frame, header, session, &ui.theme);
30 let folded = session.folded;
31 let file_idx = session.current;
32 match session.current_file_mut() {
33 FileEntry::Text(merge) => {
34 let highlight = ui.cache.get(file_idx, merge, ui.revision, ui.theme.light);
35 let rows = ui.rows.get(file_idx, merge, ui.revision, folded);
36 draw_columns(
37 frame,
38 body,
39 merge,
40 &ui.theme,
41 highlight,
42 rows,
43 &mut ui.scroll_request,
44 );
45 }
46 FileEntry::Binary {
47 path,
48 ours,
49 theirs,
50 choice,
51 } => draw_binary(frame, body, path, ours, theirs, *choice, &ui.theme),
52 }
53 ui.scroll_request = 0;
55 draw_hints(frame, hints, &ui.theme);
56 frame.render_widget(
57 Paragraph::new(ui.message.as_str()).style(Style::new().fg(ui.theme.amber)),
58 message,
59 );
60 if ui.show_help {
61 draw_help(frame, &ui.theme);
62 }
63}
64
65fn draw_header(frame: &mut Frame, area: Rect, session: &Session, theme: &Theme) {
67 let entry = session.current_file();
68 let pending = match entry {
69 FileEntry::Text(m) => m.pending_conflicts(),
70 FileEntry::Binary { choice, .. } => usize::from(choice.is_none()),
71 };
72 let done = session.written.iter().filter(|&&w| w).count();
73 let total = session.files.len();
74
75 const BAR_WIDTH: usize = 10;
77 let filled = (done * BAR_WIDTH).checked_div(total).unwrap_or(0);
78 let sep = Span::styled("▏", Style::new().fg(theme.border));
79
80 let mut spans = vec![
81 Span::styled(
82 format!(" ⚑ {} ", session.op_label.to_uppercase()),
83 Style::new()
84 .fg(theme.badge_fg)
85 .bg(theme.blue)
86 .add_modifier(Modifier::BOLD),
87 ),
88 Span::raw(" "),
89 Span::styled("█".repeat(filled), Style::new().fg(theme.blue)),
90 Span::styled(
91 "░".repeat(BAR_WIDTH - filled),
92 Style::new().fg(theme.border),
93 ),
94 Span::styled(
95 format!(
96 " {} ",
97 tr_f(
98 "ui.files_badge",
99 &[
100 ("i", &(session.current + 1).to_string()),
101 ("n", &total.to_string()),
102 ],
103 )
104 ),
105 Style::new().fg(theme.fg_bright),
106 ),
107 sep.clone(),
108 Span::raw(format!(" {} ", entry.path())),
109 sep,
110 ];
111 if pending > 0 {
112 spans.push(Span::styled(
113 format!(
114 " {}",
115 tr_f("ui.conflicts_pending", &[("n", &pending.to_string())])
116 ),
117 Style::new().fg(theme.red).add_modifier(Modifier::BOLD),
118 ));
119 } else {
120 spans.push(Span::styled(
121 format!(" {}", tr("ui.file_ready")),
122 Style::new().fg(theme.green),
123 ));
124 }
125 frame.render_widget(Paragraph::new(Line::from(spans)), area);
126}
127
128fn draw_binary(
130 frame: &mut Frame,
131 area: Rect,
132 path: &str,
133 ours: &[u8],
134 theirs: &[u8],
135 choice: Option<Side>,
136 theme: &Theme,
137) {
138 let panel = centered_rect(area, 64, 11);
139 let [notice, _, cards, _, hint] = Layout::vertical([
140 Constraint::Length(2),
141 Constraint::Length(1),
142 Constraint::Length(5),
143 Constraint::Length(1),
144 Constraint::Length(1),
145 ])
146 .areas(panel);
147
148 frame.render_widget(
149 Paragraph::new(vec![
150 Line::styled(path.to_owned(), Style::new().add_modifier(Modifier::BOLD)).centered(),
151 Line::styled(tr("ui.binary_notice"), Style::new().fg(theme.amber)).centered(),
152 ]),
153 notice,
154 );
155
156 let [left, _, right] = Layout::horizontal([
157 Constraint::Fill(1),
158 Constraint::Length(2),
159 Constraint::Fill(1),
160 ])
161 .areas(cards);
162 binary_card(
163 frame,
164 left,
165 " LOCAL ",
166 "h",
167 ours,
168 choice == Some(Side::Ours),
169 theme,
170 );
171 binary_card(
172 frame,
173 right,
174 " REMOTE ",
175 "l",
176 theirs,
177 choice == Some(Side::Theirs),
178 theme,
179 );
180
181 frame.render_widget(
182 Paragraph::new(Line::styled(
183 tr("ui.binary_keys"),
184 Style::new().fg(theme.fg_dim),
185 ))
186 .centered(),
187 hint,
188 );
189}
190
191fn binary_card(
193 frame: &mut Frame,
194 area: Rect,
195 title: &str,
196 key: &str,
197 bytes: &[u8],
198 selected: bool,
199 theme: &Theme,
200) {
201 let (border, title_text) = if selected {
202 (theme.green, format!("{title}✓ "))
203 } else {
204 (theme.border, title.to_owned())
205 };
206 let block = Block::bordered()
207 .border_type(BorderType::Rounded)
208 .border_style(Style::new().fg(border))
209 .title(Span::styled(
210 title_text,
211 Style::new()
212 .fg(theme.fg_bright)
213 .add_modifier(Modifier::BOLD),
214 ));
215 let inner = block.inner(area);
216 frame.render_widget(block, area);
217 frame.render_widget(
218 Paragraph::new(vec![
219 Line::styled(
220 tr_f("ui.bytes", &[("n", &bytes.len().to_string())]),
221 Style::new().fg(theme.fg_bright),
222 )
223 .centered(),
224 Line::styled(
225 tr_f("ui.press_pick", &[("key", key)]),
226 Style::new().fg(theme.fg_dim),
227 )
228 .centered(),
229 ]),
230 inner,
231 );
232}
233
234fn keycap(key: &str, theme: &Theme) -> Span<'static> {
236 Span::styled(
237 format!(" {key} "),
238 Style::new().fg(theme.keycap_fg).bg(theme.keycap_bg),
239 )
240}
241
242fn draw_hints(frame: &mut Frame, area: Rect, theme: &Theme) {
244 let mut spans = vec![Span::raw(" ")];
245 for (key, desc) in super::keymap::hint_entries() {
246 spans.push(keycap(key, theme));
247 spans.push(Span::styled(
248 format!(" {} ", tr(desc)),
249 Style::new().fg(theme.hint_fg),
250 ));
251 }
252 frame.render_widget(Paragraph::new(Line::from(spans)), area);
253}
254
255fn draw_help(frame: &mut Frame, theme: &Theme) {
257 let (left_items, right_items) = super::keymap::help_columns();
258 let column = |entries: &[(&'static str, &'static str)]| -> Vec<Line<'static>> {
259 entries
260 .iter()
261 .map(|(key, desc)| {
262 Line::from(vec![
263 Span::styled(
264 format!(" {key:<7}"),
265 Style::new().fg(theme.blue).add_modifier(Modifier::BOLD),
266 ),
267 Span::raw(tr(desc).to_owned()),
268 ])
269 })
270 .collect()
271 };
272
273 let overlay_h = left_items.len().max(right_items.len()) as u16 + 2;
275 let area = centered_rect(frame.area(), 78, overlay_h);
276 frame.render_widget(Clear, area);
277 let block = Block::bordered()
278 .border_type(BorderType::Rounded)
279 .border_style(Style::new().fg(theme.border))
280 .title(Span::styled(
281 format!(" {} ", tr("ui.help_title")),
282 Style::new()
283 .fg(theme.fg_bright)
284 .add_modifier(Modifier::BOLD),
285 ))
286 .title_bottom(
287 Line::from(Span::styled(
288 format!(" {} ", tr("common.close_any_key")),
289 Style::new().fg(theme.fg_dim),
290 ))
291 .centered(),
292 );
293 let inner = block.inner(area);
294 frame.render_widget(block, area);
295 let [left, right] =
296 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(inner);
297 frame.render_widget(Paragraph::new(column(&left_items)), left);
298 frame.render_widget(Paragraph::new(column(&right_items)), right);
299}
300
301fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
303 let w = width.min(area.width);
304 let h = height.min(area.height);
305 Rect {
306 x: area.x + (area.width - w) / 2,
307 y: area.y + (area.height - h) / 2,
308 width: w,
309 height: h,
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::app::FileMerge;
317 use crate::ui::rows::build_rows;
318 use ratatui::Terminal;
319 use ratatui::backend::TestBackend;
320
321 #[test]
323 fn draw_smoke_renders_key_elements() {
324 let merge =
325 FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
326 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
327 let mut ui = UiState::default();
328
329 let backend = TestBackend::new(120, 24);
330 let mut terminal = Terminal::new(backend).unwrap();
331 terminal
332 .draw(|frame| draw(frame, &mut session, &mut ui))
333 .unwrap();
334
335 let content = format!("{:?}", terminal.backend().buffer());
336 assert!(content.contains("demo.txt"));
337 assert!(content.contains("MERGE"));
339 assert!(content.contains("░"));
340 assert!(content.contains("╭"));
342 assert!(content.contains("LOCAL"));
343 assert!(content.contains("RESULT"));
344 assert!(content.contains("REMOTE"));
345 assert!(content.contains("pending"));
347 assert!(content.contains("»"));
348 assert!(content.contains("«"));
349 }
350
351 #[test]
353 fn draw_narrow_terminal_smoke() {
354 let merge =
355 FileMerge::from_three_way("demo.txt".to_owned(), "a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
356 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
357 let mut ui = UiState::default();
358 for width in [60u16, 30, 12] {
359 let backend = TestBackend::new(width, 16);
360 let mut terminal = Terminal::new(backend).unwrap();
361 terminal
362 .draw(|frame| draw(frame, &mut session, &mut ui))
363 .unwrap();
364 }
365 }
366
367 #[test]
369 fn scroll_keeps_selected_chunk_in_view() {
370 let stable: String = (1..=40).map(|i| format!("s{i}\n")).collect();
372 let base = format!("a\n{stable}b\n{stable}c\n");
373 let ours = format!("A\n{stable}B\n{stable}C\n");
374 let theirs = format!("X\n{stable}Y\n{stable}Z\n");
375 let merge = FileMerge::from_three_way("demo.txt".to_owned(), &base, &ours, &theirs);
376 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
377 session.folded = false;
378 let mut ui = UiState::default();
379 let backend = TestBackend::new(100, 24);
380 let mut terminal = Terminal::new(backend).unwrap();
381 let body_height = 19usize;
383
384 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
386 for step in ["next", "next", "prev"] {
387 let FileEntry::Text(m) = session.current_file_mut() else {
388 unreachable!()
389 };
390 if step == "next" {
391 m.next_conflict();
392 } else {
393 m.prev_conflict();
394 }
395 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
396 }
397
398 let FileEntry::Text(m) = session.current_file() else {
399 unreachable!()
400 };
401 let (_, starts) = build_rows(m, false);
402 let target = starts[m.cursor];
403 assert!(target >= m.scroll, "选中块滚出了视口上方");
404 assert!(target < m.scroll + body_height, "选中块滚出了视口下方");
405 assert!(
407 target + 2 < m.scroll + body_height,
408 "选中块贴在视口底部(target={target}, scroll={})",
409 m.scroll
410 );
411 }
412
413 #[test]
415 fn manual_scroll_detaches_and_actions_reattach() {
416 let stable: String = (1..=200).map(|i| format!("s{i}\n")).collect();
417 let base = format!("a\n{stable}");
418 let ours = format!("A\n{stable}");
419 let theirs = format!("X\n{stable}");
420 let merge = FileMerge::from_three_way("demo.txt".to_owned(), &base, &ours, &theirs);
421 let mut session = Session::new(vec![FileEntry::Text(merge)], "merge".to_owned());
422 session.folded = false;
423 let mut ui = UiState::default();
424 let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
425 let scroll_of = |s: &Session| match s.current_file() {
426 FileEntry::Text(m) => (m.scroll, m.follow),
427 FileEntry::Binary { .. } => unreachable!(),
428 };
429
430 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
431 assert_eq!(scroll_of(&session), (0, true));
432
433 ui.scroll_request = 2;
435 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
436 let (scrolled, follow) = scroll_of(&session);
437 assert!(scrolled > 0);
438 assert!(!follow);
439 assert_eq!(ui.scroll_request, 0);
440
441 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
443 assert_eq!(scroll_of(&session), (scrolled, false));
444
445 super::super::handle_file_key(
447 &mut session,
448 super::super::keymap::Action::NextConflict,
449 &mut ui,
450 );
451 terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
452 let (snapped, follow) = scroll_of(&session);
453 assert!(follow);
454 assert!(snapped < scrolled, "视口应吸附回顶部的冲突块");
455 }
456
457 #[test]
459 fn draw_binary_entry_smoke() {
460 let mut session = Session::new(
461 vec![FileEntry::Binary {
462 path: "logo.png".to_owned(),
463 ours: vec![0, 1],
464 theirs: vec![2, 3],
465 choice: None,
466 }],
467 "merge".to_owned(),
468 );
469 let backend = TestBackend::new(100, 20);
470 let mut terminal = Terminal::new(backend).unwrap();
471 terminal
472 .draw(|frame| draw(frame, &mut session, &mut UiState::default()))
473 .unwrap();
474 let content = format!("{:?}", terminal.backend().buffer());
475 assert!(content.contains("logo.png"));
476 }
477}