Skip to main content

gw_render/browser/
view.rs

1//! Renders [`App`] into a ratatui frame. Pure: no IO.
2
3use ratatui::{
4    Frame,
5    layout::{Constraint, Layout, Rect},
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Clear, Paragraph},
9};
10use unicode_width::UnicodeWidthStr;
11
12use super::{App, Focus, NameKind, Overlay, Picker};
13use crate::style::{sanitize, truncate};
14
15const LANES: [Color; 6] = [
16    Color::Indexed(33),
17    Color::Indexed(70),
18    Color::Indexed(172),
19    Color::Indexed(135),
20    Color::Indexed(37),
21    Color::Indexed(167),
22];
23pub(crate) const SEL_BG: Color = Color::Indexed(237);
24const SEL_BG_DIM: Color = Color::Indexed(235);
25pub(crate) const FOCUS: Color = Color::Indexed(75);
26pub(crate) const DIM: Style = Style::new().fg(Color::Indexed(244));
27
28pub(crate) fn fg(c: Color) -> Style {
29    Style::new().fg(c)
30}
31
32/// `2h`, `3d`, `5mo`, `2y` from a unix time.
33pub fn age(now: i64, t: i64) -> String {
34    let s = (now - t).max(0);
35    match s {
36        0..60 => "now".into(),
37        60..3600 => format!("{}m", s / 60),
38        3600..86_400 => format!("{}h", s / 3600),
39        86_400..2_592_000 => format!("{}d", s / 86_400),
40        2_592_000..31_536_000 => format!("{}mo", s / 2_592_000),
41        _ => format!("{}y", s / 31_536_000),
42    }
43}
44
45fn graph_spans(graph: &str) -> Vec<Span<'static>> {
46    graph
47        .chars()
48        .enumerate()
49        .map(|(i, c)| {
50            let (sym, bold) = match c {
51                '*' => ('●', true),
52                '|' => ('│', false),
53                '/' => ('╱', false),
54                '\\' => ('╲', false),
55                '-' => ('─', false),
56                '.' => ('╮', false),
57                c => (c, false),
58            };
59            let mut st = fg(LANES[(i / 2) % LANES.len()]);
60            if bold {
61                st = st.add_modifier(Modifier::BOLD);
62            }
63            Span::styled(sym.to_string(), st)
64        })
65        .collect()
66}
67
68/// Text with search-matched char positions highlighted.
69fn marked(text: &str, marks: &[usize], base: Style) -> Vec<Span<'static>> {
70    if marks.is_empty() {
71        return vec![Span::styled(text.to_string(), base)];
72    }
73    let hl = base.fg(Color::Indexed(220)).add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
74    text.chars().enumerate().map(|(i, c)| Span::styled(c.to_string(), if marks.binary_search(&i).is_ok() { hl } else { base })).collect()
75}
76
77fn ref_chip(r: &str, remotes: &[String]) -> Span<'static> {
78    let r = sanitize(r);
79    let (text, style) = if let Some(b) = r.strip_prefix("HEAD -> ") {
80        (format!("@{b}"), fg(Color::Indexed(77)).add_modifier(Modifier::BOLD))
81    } else if r == "HEAD" {
82        ("HEAD".into(), fg(Color::Indexed(77)).add_modifier(Modifier::BOLD))
83    } else if let Some(t) = r.strip_prefix("tag: ") {
84        (format!("#{t}"), fg(Color::Indexed(178)))
85    } else if remotes.iter().any(|rm| r.starts_with(&format!("{rm}/"))) {
86        (r.clone(), fg(Color::Indexed(167)))
87    } else {
88        (r.clone(), fg(Color::Indexed(77)))
89    };
90    Span::styled(format!("({text})"), style)
91}
92
93fn commit_line(app: &App, pos: usize, width: usize) -> Line<'static> {
94    let row = &app.rows[app.view[pos]];
95    let marks = app.marks.get(pos).cloned().unwrap_or_default();
96    let field = |i: usize| marks.get(i).cloned().unwrap_or_default();
97    // While searching, the graph has no meaning (rows are reordered): drop it.
98    let mut spans = if app.search.is_empty() { graph_spans(&row.graph) } else { Vec::new() };
99    if let Some(c) = &row.commit {
100        if !spans.is_empty() {
101            spans.push(Span::raw(" "));
102        }
103        let short_marks: Vec<usize> = field(0).into_iter().filter(|&i| i < c.short.len()).collect();
104        spans.extend(marked(&c.short, &short_marks, fg(Color::Indexed(178))));
105        spans.push(Span::styled(format!(" {:>4} ", age(app.now, c.time)), DIM));
106        let author = truncate(&sanitize(&c.author), 14);
107        let pad = 14usize.saturating_sub(author.width());
108        spans.extend(marked(&author, &field(1), fg(Color::Indexed(110))));
109        spans.push(Span::raw(" ".repeat(pad + 1)));
110        for r in &c.refs {
111            spans.push(ref_chip(r, &app.remotes));
112            spans.push(Span::raw(" "));
113        }
114        spans.extend(marked(&sanitize(&c.subject), &field(2), Style::new()));
115    }
116    let _ = width;
117    Line::from(spans)
118}
119
120pub(crate) fn pane_block(title: String, focused: bool) -> Block<'static> {
121    let st = if focused { fg(FOCUS).add_modifier(Modifier::BOLD) } else { DIM };
122    Block::new().borders(Borders::ALL).border_style(st).title(Span::styled(title, st))
123}
124
125fn draw_list(f: &mut Frame, app: &App, area: Rect) {
126    let focused = app.focus == Focus::List;
127    let block = pane_block(" Commits ".into(), focused);
128    let inner = block.inner(area);
129    f.render_widget(block, area);
130    let h = inner.height as usize;
131    let lines: Vec<Line> = (app.scroll..(app.scroll + h).min(app.view.len()))
132        .map(|pos| {
133            let mut l = commit_line(app, pos, inner.width as usize);
134            if pos == app.sel {
135                l = l.style(Style::new().bg(if focused { SEL_BG } else { SEL_BG_DIM }));
136            }
137            l
138        })
139        .collect();
140    let empty = app.view.is_empty();
141    let mut p = Paragraph::new(lines);
142    if empty {
143        p = Paragraph::new(Line::styled(if app.loading { "loading…" } else { "no commits match" }, DIM));
144    }
145    f.render_widget(p, inner);
146}
147
148fn stat_spans(stat: Option<Option<(u64, u64)>>) -> Vec<Span<'static>> {
149    match stat {
150        Some(Some((a, d))) => vec![
151            Span::styled(format!("+{a}"), if a > 0 { fg(Color::Green) } else { DIM }),
152            Span::raw(" "),
153            Span::styled(format!("-{d}"), if d > 0 { fg(Color::Red) } else { DIM }),
154        ],
155        Some(None) => vec![Span::styled("bin", DIM)],
156        None => vec![],
157    }
158}
159
160fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
161    let focused = app.focus == Focus::Sidebar;
162    let files = app.files.as_ref().filter(|(s, _)| Some(s.as_str()) == app.selected_hash()).map(|f| &f.1);
163    let title = match files {
164        Some(fl) => format!(" Files ({}) ", fl.len()),
165        None => " Files ".into(),
166    };
167    let block = pane_block(title, focused);
168    let inner = block.inner(area);
169    f.render_widget(block, area);
170    let Some(files) = files else {
171        let msg = if app.selected_hash().is_some() { "loading…" } else { "no commit selected" };
172        f.render_widget(Paragraph::new(Line::styled(msg, DIM)), inner);
173        return;
174    };
175    let w = inner.width as usize;
176    let rows = (inner.height as usize).saturating_sub(1);
177    let mut lines: Vec<Line> = Vec::new();
178    for (i, fe) in files.iter().enumerate().skip(app.file_scroll).take(rows) {
179        let code_st = match fe.code {
180            'A' => fg(Color::Green),
181            'D' => fg(Color::Red),
182            'R' | 'C' => fg(Color::Cyan),
183            _ => fg(Color::Yellow),
184        };
185        let stat = stat_spans(fe.stat);
186        let stat_w: usize = stat.iter().map(|s| s.content.width()).sum();
187        let name = match &fe.orig {
188            Some(o) => format!("{} -> {}", sanitize(o), sanitize(&fe.path)),
189            None => sanitize(&fe.path),
190        };
191        let room = w.saturating_sub(2 + stat_w + 1);
192        // Keep the end of long paths: the file name matters most.
193        let name = if name.width() > room {
194            let tail: String = name.chars().rev().take(room.saturating_sub(1)).collect::<Vec<_>>().into_iter().rev().collect();
195            format!("…{tail}")
196        } else {
197            name
198        };
199        let pad = room.saturating_sub(name.width());
200        let mut spans = vec![Span::styled(format!("{} ", fe.code), code_st), Span::raw(name), Span::raw(" ".repeat(pad + 1))];
201        spans.extend(stat);
202        let mut l = Line::from(spans);
203        if i == app.file_sel && focused {
204            l = l.style(Style::new().bg(SEL_BG));
205        }
206        lines.push(l);
207    }
208    if files.is_empty() {
209        lines.push(Line::styled("no file changes", DIM));
210    }
211    f.render_widget(Paragraph::new(lines), inner);
212    let (a, d) = files.iter().filter_map(|fe| fe.stat.flatten()).fold((0, 0), |t, (a, d)| (t.0 + a, t.1 + d));
213    let mut total = vec![Span::styled("Total ", DIM)];
214    total.extend(stat_spans(Some(Some((a, d)))));
215    let last = Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner };
216    f.render_widget(Paragraph::new(Line::from(total)), last);
217}
218
219fn draw_diff(f: &mut Frame, app: &App, area: Rect) {
220    let Some(d) = &app.diff else { return };
221    let short: String = d.sha.chars().take(7).collect();
222    let block = pane_block(format!(" {} @ {short} ", sanitize(&d.path)), true);
223    let inner = block.inner(area);
224    f.render_widget(block, area);
225    let lines: Vec<Line> = d
226        .lines
227        .iter()
228        .skip(d.scroll)
229        .take(inner.height as usize)
230        .map(|l| diff_line(l))
231        .collect();
232    f.render_widget(Paragraph::new(lines), inner);
233}
234
235/// One diff line, sanitized and colored: added green, removed red, hunk headers teal, file headers dim.
236pub(crate) fn diff_line(l: &str) -> Line<'static> {
237    let text = sanitize(&l.replace('\t', "    "));
238    let st = if l.starts_with("+++") || l.starts_with("---") || l.starts_with("diff ") || l.starts_with("index ") {
239        DIM.add_modifier(Modifier::BOLD)
240    } else if l.starts_with('+') {
241        fg(Color::Green)
242    } else if l.starts_with('-') {
243        fg(Color::Red)
244    } else if l.starts_with("@@") {
245        fg(Color::Indexed(73))
246    } else {
247        Style::new()
248    };
249    Line::styled(text, st)
250}
251
252fn draw_hotkeys(f: &mut Frame, app: &App, area: Rect) {
253    draw_keys(f, &app.hotkeys(), area);
254}
255
256/// Key chips in reverse video with labels; keys that don't fit are dropped from the right.
257pub(crate) fn draw_keys(f: &mut Frame, keys: &[(&str, &str)], area: Rect) {
258    let mut spans = Vec::new();
259    let mut used = 0;
260    for &(k, label) in keys {
261        let w = k.width() + label.width() + 3;
262        if used + w > area.width as usize {
263            break; // drop the rest from the right
264        }
265        used += w;
266        spans.push(Span::styled(format!(" {k} "), Style::new().add_modifier(Modifier::REVERSED)));
267        spans.push(Span::raw(format!("{label} ")));
268    }
269    f.render_widget(Paragraph::new(Line::from(spans)), area);
270}
271
272fn draw_status(f: &mut Frame, app: &App, area: Rect) {
273    let line = if app.overlay == Overlay::Search {
274        Line::from(vec![Span::styled("/", fg(FOCUS).add_modifier(Modifier::BOLD)), Span::raw(sanitize(&app.search)), Span::styled("▌", fg(FOCUS))])
275    } else if let Some((msg, err)) = &app.status {
276        Line::styled(sanitize(msg), if *err { fg(Color::Red) } else { fg(Color::Green) })
277    } else {
278        Line::default()
279    };
280    f.render_widget(Paragraph::new(line), area);
281}
282
283fn draw_panel(f: &mut Frame, app: &App, area: Rect) {
284    let short = app.selected_short().unwrap_or_default();
285    let (title, items): (String, &[(&str, &str)]) = if app.overlay == Overlay::ResetMode {
286        (
287            format!(" Reset current branch to {short} "),
288            &[("s", "Soft: keep index and files"), ("m", "Mixed: keep files"), ("h", "Hard: discard changes"), ("Esc", "Back")],
289        )
290    } else {
291        (
292            format!(" Actions on {short} "),
293            &[("p", "Cherry-pick"), ("v", "Revert"), ("o", "Checkout"), ("n", "New branch"), ("r", "Reset"), ("y", "Copy hash"), ("t", "Tag")],
294        )
295    };
296    let block = pane_block(title, true);
297    let inner = block.inner(area);
298    f.render_widget(Clear, area);
299    f.render_widget(block, area);
300    let mut spans = Vec::new();
301    for (k, l) in items {
302        spans.push(Span::styled(format!(" {k} "), Style::new().add_modifier(Modifier::REVERSED)));
303        spans.push(Span::raw(format!(" {l}  ")));
304    }
305    f.render_widget(Paragraph::new(Line::from(spans)), inner);
306}
307
308pub(crate) fn centered(area: Rect, w: u16, h: u16) -> Rect {
309    let w = w.min(area.width.saturating_sub(2));
310    let h = h.min(area.height.saturating_sub(2));
311    Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h }
312}
313
314fn input_line(label: &str, value: &str, active: bool) -> Line<'static> {
315    let st = if active { Style::new().bg(SEL_BG) } else { Style::new() };
316    let mut spans = vec![Span::styled(label.to_string(), DIM), Span::styled(sanitize(value), st)];
317    if active {
318        spans.push(Span::styled("▌", fg(FOCUS)));
319    }
320    Line::from(spans)
321}
322
323pub(crate) fn draw_modal(f: &mut Frame, title: &str, lines: Vec<Line<'static>>, area: Rect, danger: bool) {
324    let h = lines.len() as u16 + 2;
325    let w = lines.iter().map(|l| l.width() as u16).max().unwrap_or(20).max(title.width() as u16 + 4).clamp(40, 80) + 2;
326    let r = centered(area, w, h);
327    let st = if danger { fg(Color::Red).add_modifier(Modifier::BOLD) } else { fg(FOCUS).add_modifier(Modifier::BOLD) };
328    let block = Block::new().borders(Borders::ALL).border_style(st).title(Span::styled(format!(" {title} "), st));
329    f.render_widget(Clear, r);
330    let inner = block.inner(r);
331    f.render_widget(block, r);
332    f.render_widget(Paragraph::new(lines), inner);
333}
334
335fn picker_lines(p: &Picker, label: &str) -> Vec<Line<'static>> {
336    let mut lines = vec![input_line(label, &p.input, true), Line::default()];
337    let start = p.sel.saturating_sub(9);
338    for (k, &i) in p.shown.iter().enumerate().skip(start).take(10) {
339        let mut l = Line::raw(format!(" {}", sanitize(&p.items[i])));
340        if k == p.sel {
341            l = l.style(Style::new().bg(SEL_BG).add_modifier(Modifier::BOLD));
342        }
343        lines.push(l);
344    }
345    if p.shown.is_empty() {
346        lines.push(Line::styled(" no match", DIM));
347    }
348    lines
349}
350
351pub fn draw(f: &mut Frame, app: &App) {
352    let area = f.area();
353    let panel = if matches!(app.overlay, Overlay::Actions | Overlay::ResetMode) { 3 } else { 0 };
354    let [header, body, panel_area, status, keys] =
355        Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(panel), Constraint::Length(1), Constraint::Length(1)]).areas(area);
356
357    f.render_widget(Paragraph::new(Line::styled(truncate(&app.header(), header.width as usize), Style::new().add_modifier(Modifier::BOLD))), header);
358
359    if app.focus == Focus::Diff {
360        draw_diff(f, app, body);
361    } else if app.wide() {
362        let side = (body.width * 35 / 100).clamp(30, 60);
363        let [list, sidebar] = Layout::horizontal([Constraint::Min(1), Constraint::Length(side)]).areas(body);
364        draw_list(f, app, list);
365        draw_sidebar(f, app, sidebar);
366    } else if app.focus == Focus::Sidebar {
367        draw_sidebar(f, app, body);
368    } else {
369        draw_list(f, app, body);
370    }
371    if panel > 0 {
372        draw_panel(f, app, panel_area);
373    }
374    draw_status(f, app, status);
375    draw_hotkeys(f, app, keys);
376
377    match &app.overlay {
378        Overlay::Branch(p) => draw_modal(f, "Branch", picker_lines(p, "Branch: "), body, false),
379        Overlay::Author(p) => draw_modal(f, "Author", picker_lines(p, "Author: "), body, false),
380        Overlay::Date { since, until, field, error } => {
381            let mut lines = vec![input_line("Since: ", since, *field == 0), input_line("Until: ", until, *field == 1), Line::default()];
382            lines.push(match error {
383                Some(e) => Line::styled(sanitize(e), fg(Color::Red)),
384                None => Line::styled("e.g. 2 weeks ago, 2026-10-01, yesterday", DIM),
385            });
386            draw_modal(f, "Date range", lines, body, false);
387        }
388        Overlay::Name { kind, input, error } => {
389            let (title, label) = match kind {
390                NameKind::Branch => ("New branch", "Name: "),
391                NameKind::Tag => ("New tag", "Name: "),
392            };
393            let short = app.selected_short().unwrap_or_default();
394            let mut lines = vec![Line::styled(format!("at {short}"), DIM), input_line(label, input, true)];
395            if let Some(e) = error {
396                lines.push(Line::styled(sanitize(e), fg(Color::Red)));
397            }
398            draw_modal(f, title, lines, body, false);
399        }
400        Overlay::Confirm { lines, need_y, .. } => {
401            let ls = lines.iter().map(|l| Line::raw(l.clone())).collect();
402            draw_modal(f, "Confirm", ls, body, *need_y);
403        }
404        _ => {}
405    }
406}