1use 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
32pub 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
68fn 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 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 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
235pub(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
256pub(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; }
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}