Skip to main content

git_pincer/ui/
chrome.rs

1//! 界面整体绘制:状态栏、提示条、消息条、帮助浮层与二进制视图。
2
3use 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
16/// 绘制整个界面(独立成 pub 函数,便于 TestBackend 冒烟测试)。
17///
18/// 需要可变会话:绘制时会把滚动位置写回当前文件,跨帧保持视口稳定;
19/// 需要可变 UI 状态:高亮缓存按需构建。
20pub 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    // 二进制视图不消费滚动请求,统一清零避免残留到后续文本文件
54    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
65/// 顶部状态条:操作徽章 · 进度条 · 文件路径 · 冲突计数。
66fn 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    // █░ 进度条:已写盘文件数 / 总文件数
76    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
128/// 二进制文件的降级视图:LOCAL / REMOTE 两张卡片整文件二选一。
129fn 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
191/// 单张二进制候选卡片:选中侧边框换绿色并在标题加 ✓。
192fn 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
234/// 键帽样式的按键 Span(底色微凸起)。
235fn 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
242/// 底部按键提示条(键帽 + 描述),条目来自 [`keymap`](super::keymap) 单一绑定表。
243fn 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
255/// 帮助浮层:圆角边框 + 双栏按键布局,条目来自 [`keymap`](super::keymap) 单一绑定表。
256fn 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    // 高度按最长一栏自适应(上下边框各占一行)
274    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
301/// 居中的浮层矩形(不超出屏幕)。
302fn 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    /// 用 TestBackend 渲染一帧,断言关键内容存在且不 panic
322    #[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        // 状态栏操作徽章与进度条
338        assert!(content.contains("MERGE"));
339        assert!(content.contains("░"));
340        // 圆角边框面板与嵌入边框的栏头
341        assert!(content.contains("╭"));
342        assert!(content.contains("LOCAL"));
343        assert!(content.contains("RESULT"));
344        assert!(content.contains("REMOTE"));
345        // 未解决冲突:结果栏占位带 + 两侧 gutter 取用箭头
346        assert!(content.contains("pending"));
347        assert!(content.contains("»"));
348        assert!(content.contains("«"));
349    }
350
351    /// 窄终端(60 列)渲染不 panic,预算逐级降级
352    #[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    /// 回归:跳转冲突后选中块应留在视口内且不贴底(依赖 scroll 跨帧持久化)
368    #[test]
369    fn scroll_keeps_selected_chunk_in_view() {
370        // 三个冲突,彼此隔着 40 行稳定区;展开渲染保证行数远超视口
371        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        // 视口正文高度 = 24 - 状态条/提示/消息各 1 行 - 上下边框 2 行
382        let body_height = 19usize;
383
384        // 跳到最后一个冲突,再跳回中间的冲突
385        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        // 不贴底:块首行下方至少还能看到两行块内容
406        assert!(
407            target + 2 < m.scroll + body_height,
408            "选中块贴在视口底部(target={target}, scroll={})",
409            m.scroll
410        );
411    }
412
413    /// 手动滚动脱离光标跟随且重绘不回拉;作用于光标的动作重新吸附
414    #[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        // 两次半页下滚:请求被消费,视口前进并脱离跟随
434        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        // 光标块已在视口外,重绘不得回拉(脱离跟随的意义所在)
442        terminal.draw(|f| draw(f, &mut session, &mut ui)).unwrap();
443        assert_eq!(scroll_of(&session), (scrolled, false));
444
445        // 作用于光标的动作(如跳冲突)恢复跟随,视口吸附回光标块
446        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    /// 二进制条目渲染不 panic
458    #[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}