use super::*;
use row::{clip, fit, skip_cols};
pub(super) const HL_MAX_BYTES: usize = 1 << 20;
pub(crate) fn chip(hex: &str) -> Style {
let n = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0x6e7681);
let (r, g, b) = ((n >> 16) as u8, (n >> 8) as u8, n as u8);
let light = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32 > 150.0;
let fg = if light { Color::Black } else { Color::White };
Style::new().bg(Color::Rgb(r, g, b)).fg(fg).add_modifier(ratatui::style::Modifier::BOLD)
}
#[derive(Clone, Copy)]
struct Cols {
full: Rect,
area: Rect,
sw: usize,
pin: usize,
h: usize,
lw: usize,
gw: usize,
ghw: usize,
relw: usize,
tw: usize,
mw: usize,
mtw: usize,
margin: bool,
float: bool,
ctw: usize,
htw_l: usize,
htw_r: usize,
mx: usize,
code_tw: usize,
hd: usize,
}
impl App {
pub(super) fn draw(&mut self, f: &mut Frame) {
let c = self.frame_cols(f.area());
self.scroll_to_cursor(&c);
self.draw_body(f, &c);
let Cols { full, area, sw, pin, h, .. } = c;
let keys = match self.pending {
Some(p) => App::which(p),
None if self.files_focus && self.files_w > 0 && self.picker.is_none() && self.popup.is_none() => Some(self.list_keys()),
None if self.picker.is_none() && self.popup.is_none() && self.prompt.is_none() => self.context_keys(),
None => None,
};
if sw > 0 {
self.draw_files(f, Rect { x: full.x, y: full.y, width: sw as u16, height: (h + pin) as u16 }, keys);
}
self.draw_title(f, &c);
self.draw_link_tip(f, &c);
self.draw_status(f, Rect { y: area.y + (h + pin) as u16, height: 1, ..full });
self.draw_picker(f, full);
if let Some((title, keys)) = keys.filter(|_| sw == 0) {
draw_hints(f, &self.ui, Rect { y: area.y + pin as u16, height: h as u16, ..area }, title, keys, Some(self.keep_clear(&c)));
}
self.draw_popup(f, full);
}
pub(super) fn relayout(&mut self) {
self.frame_cols(self.full);
}
fn frame_cols(&mut self, full: Rect) -> Cols {
self.full = full;
let left = match self.zoom {
Some(false) => self.files_focus || self.rows[self.head.row].thread.is_some(),
Some(true) => self.files_focus || !self.margin_side(),
None => false,
};
if left {
self.zoom = None;
}
let sw = if self.zoom.is_some() { 0 } else { self.files_width(full.width as usize) };
self.files_w = sw;
let area = Rect { x: full.x + sw as u16, width: full.width - sw as u16, ..full };
self.pin = self.rows.first().is_some_and(|r| r.file == INFO && r.line.kind == Kind::File)
&& matches!(self.drows.first(), Some(DRow::Full(0)))
&& area.height > 4;
let pin = self.pin as usize;
let h = (area.height as usize).saturating_sub(1 + pin);
self.height = h.max(1);
self.width = full.width; let maxno = self.rows.iter().filter_map(|r| r.line.old.max(r.line.new)).max().unwrap_or(0);
let lw = maxno.to_string().len().max(3);
let gw = lw * 2 + 4;
let relw = if self.settings.relative { 4 } else { 0 };
let width = (area.width as usize).saturating_sub(relw);
let tw = width.saturating_sub(gw).max(1);
let ghw = lw + 3;
let mut mw = (width / 3).clamp(MARGIN_MIN, MARGIN_MAX).max(width.saturating_sub(1 + MARGIN_CODE_MAX));
let room = match self.split {
true => width >= 2 * (ghw + MARGIN_SPLIT_MIN) + 1 + 1 + mw,
false => width >= gw + MARGIN_CODE_MIN + 1 + mw,
};
if !room {
mw = (width / 2).clamp(MARGIN_NARROW, MARGIN_MIN);
}
mw = self.margin_w_pref.unwrap_or(mw).min(width.saturating_sub(gw + 20)).max(MARGIN_NARROW);
let mut shown = self.margin_pref.unwrap_or(room || self.view != View::All);
if !shown && self.in_margin(self.head.row) && !self.rows[self.head.row].attached {
(self.margin_pref, shown) = (Some(true), true);
}
shown &= self.zoom != Some(false);
let tiny = width <= mw + gw + 10 || self.zoom == Some(true);
if tiny {
mw = width.saturating_sub(gw + 1).max(12);
}
let margin = shown && width > gw + 12;
let float = margin && (tiny || self.margin_float_pref.unwrap_or(!room));
let code_w = if margin && !float { width.saturating_sub(1 + mw) } else { width };
let ctw = code_w.saturating_sub(gw).max(1);
let half = code_w.saturating_sub(1) / 2;
let (htw_l, htw_r) = (half.saturating_sub(ghw).max(1), code_w.saturating_sub(1 + half).saturating_sub(ghw).max(1));
let ww = if margin { ctw } else { tw }.saturating_sub(1).clamp(40, 100);
if ww != self.wrap_w || margin != self.margin_active || (margin && mw != self.margin_w) || float != self.margin_float {
(self.wrap_w, self.margin_active, self.margin_w, self.margin_float) = (ww, margin, mw, float);
let keep = self.cursor_key();
self.rebuild(keep);
}
let mtw = mw - self.pane_gw();
if !self.margin_side() {
self.code_head = self.head;
}
let mx = width.saturating_sub(mw).saturating_sub(gw + 1);
let code_tw = if float { mx } else { ctw };
self.geom = mouse::Geom { relw, gw, ghw, half, ctw: code_tw, picker: None };
self.bar_counts = self.pin && self.title_fits(code_tw);
let hd = self.hd();
Cols { full, area, sw, pin, h, lw, gw, ghw, relw, tw, mw, mtw, margin, float, ctw, htw_l, htw_r, mx, code_tw, hd }
}
fn scroll_to_cursor(&mut self, c: &Cols) {
let Cols { pin, h, gw, relw, tw, mtw, ctw, htw_l, htw_r, hd, float, mx, .. } = *c;
let so = scrolloff(h);
if hd < self.top + so {
self.top = hd.saturating_sub(so);
}
if hd + so + 1 > self.top + h {
self.top = hd + so + 1 - h;
}
self.top = self.top.max(pin); let code_line = self.rows[self.head.row].line.kind.is_code();
let cur_w = match self.drows[hd] {
DRow::Pair(..) => htw_l.min(htw_r),
DRow::Margin(..) if self.margin_side() => mtw - self.pane_pad(),
DRow::Margin(..) if self.split && code_line => htw_l.min(htw_r),
DRow::Margin(..) => ctw,
DRow::Full(_) => tw,
};
let cur_w = match self.drows[hd] {
DRow::Margin(_, _, Some(_)) if float && !self.margin_side() => match (relw + gw + mx).saturating_sub(self.text_x(c)) {
0 => cur_w,
seen => cur_w.min(seen),
},
_ => cur_w,
};
let cur_w = if code_line && !self.margin_side() { cur_w.saturating_sub(1) } else { cur_w };
let scroller = self.scroller(self.head.row);
let block = if let Some(Scroller::Block(b)) = scroller { Some(b) } else { None };
if block != self.code_at {
(self.code_at, self.code_left) = (block, 0);
}
let nudge = std::mem::take(&mut self.hnudge);
let len = self.len(self.head.row);
let w = self.text_w(self.head.row, cur_w).max(1);
let Some(scroller) = scroller else { return };
let (row, mut left) = (self.head.row, self.hscroll(self.head.row));
if nudge != 0 {
left = (left as isize + nudge).clamp(0, self.col_of(row, len).saturating_sub(1) as isize) as usize;
let first = self.char_from(row, left);
let last = self.char_at(row, left + w).saturating_sub(1).max(first);
self.head.col = self.head.col.clamp(first, last).min(len.saturating_sub(1));
(self.anchor, self.want_col) = (self.head, self.head.col); }
let (start, end) = (self.col_of(row, self.head.col), self.col_of(row, self.head.col + 1));
left = left.min(start);
if end > left + w {
left = end.saturating_sub(w).min(start);
}
*match scroller {
Scroller::Block(_) => &mut self.code_left,
Scroller::Diff => &mut self.left,
} = left;
}
fn draw_body(&mut self, f: &mut Frame, c: &Cols) {
let Cols { full, area, pin, h, lw, gw, ghw, relw, tw, mw, mtw, margin, float, ctw, htw_l, htw_r, mx, code_tw, hd, .. } = *c;
let (s, e) = self.ordered();
let overlay = self.label_overlay();
self.highlight_visible();
let divider = Span::styled("│", self.ui.linenr);
let lit_code = self.lit_code();
let pair = |l: Option<usize>, rr: Option<usize>, cur: bool| {
let half = |row: Option<usize>, right: bool, w: usize| match row {
Some(row) => self.render_row(row, s, e, (lw, ghw, w), Some(right), cur, &overlay),
None => vec![Span::styled("╱".repeat(ghw + w), self.ui.linenr)],
};
let mut spans = half(l, false, htw_l);
spans.push(divider.clone());
spans.extend(half(rr, true, htw_r));
spans
};
let raised = self.ui.popup.bg.or(self.ui.bar);
let sep = self.pane_sep(float);
let pane_cur = self.margin_side();
let lit_box = (!pane_cur && self.settings.cursorline).then(|| self.lit_thread()).flatten();
let head_tint = |r: usize, spans: Vec<Span<'static>>| match lit_box.is_some() && self.rows[r].thread == lit_box && (r == 0 || self.rows[r - 1].thread != lit_box) {
true => self.related_tint(spans),
false => spans,
};
let base_bg = self.ui.base.bg;
let raise = |v: Vec<Span<'static>>| match (float, raised) {
(true, Some(bg)) => v
.into_iter()
.map(|mut sp| {
if sp.style.bg.is_none() || sp.style.bg == base_bg {
sp.style.bg = Some(bg);
}
sp
})
.collect(),
_ => v,
};
let pad = self.pane_pad();
let ptw = mtw - pad;
let sticky = (|| {
let d = self.top.min(self.drows.len().checked_sub(1)?);
let r = match self.drows[d] {
DRow::Full(r) => r,
DRow::Pair(l, r) | DRow::Margin(l, r, _) => l.or(r)?,
};
let file = self.rows[r].file;
(file < self.files.len()).then_some(())?;
let head = self.rows.iter().position(|x| x.file == file && x.line.kind == Kind::File)?;
(self.d_of[head] < self.top).then_some(head)
})();
let code_cols = relw + gw + code_tw;
let rows_shown = (self.top + h).min(self.drows.len()).saturating_sub(self.top);
let lines: Vec<Line> = (0..rows_shown)
.map(|y| {
let d = self.top + y;
let cur = d == hd;
let tint = cur && !self.files_focus;
let rel = match (relw, cur) {
(0, _) => vec![],
(_, true) => vec![Span::styled(format!("{:>w$} ", "", w = relw - 1), if tint && !self.margin_side() { self.ui.linenr_selected } else { self.ui.linenr })],
_ => vec![Span::styled(format!("{:>w$} ", d.abs_diff(hd), w = relw - 1), self.ui.linenr)],
};
let mut spans = rel;
let column = margin && !float;
let fw = if column { ctw } else { tw };
match self.drows[d] {
DRow::Full(r) => {
spans.extend(self.render_row(r, s, e, (lw, gw, fw), None, tint, &overlay));
if column {
spans = clip(spans, code_cols);
spans.push(sep.clone());
spans.push(Span::raw(" ".repeat(mw)));
}
}
DRow::Pair(l, rr) => spans.extend(pair(l, rr, tint)),
DRow::Margin(l, rr, comment) => {
let lit = tint && !pane_cur;
let related = |r: usize| lit_code.is_some_and(|(a, b)| (a..=b).contains(&r));
let diff_line = |r: usize| self.rows[r].line.kind.is_code();
let cw = if float { tw } else { ctw };
let code = match (l, rr) {
(None, None) => vec![],
(l, rr) if self.split && l.or(rr).is_some_and(diff_line) => pair(l, rr, lit),
(Some(r), _) | (None, Some(r)) => self.render_row(r, s, e, (lw, gw, cw), None, lit, &overlay),
};
let code = match l.or(rr).is_some_and(related) && self.settings.cursorline {
true => self.related_tint(code),
false => code,
};
match (comment, float) {
(None, true) => spans.extend(code),
(c, _) => {
spans.extend(clip(code, gw + mx));
spans.push(sep.clone());
if let Some(r) = c {
let (gutter, w) = match (float, self.box_part(r)) {
(false, Some(p)) if p != BoxPart::Body => {
spans.pop();
spans.push(Span::styled(format!("├{}", "─".repeat(pad)), self.ui.linenr.bg(self.ui.base.bg.unwrap_or(Color::Reset))));
(self.pane_gw(), mw - pad)
}
(false, None) => (pad + self.pane_gw(), mw),
_ => {
spans.push(Span::raw(" ".repeat(pad)));
(self.pane_gw(), mw - pad)
}
};
spans.extend(raise(clip(head_tint(r, self.render_row(r, s, e, (lw, gutter, ptw), None, tint && pane_cur, &overlay)), w)));
} else {
spans.push(Span::raw(" ".repeat(mw)));
}
}
}
}
}
if let (0, Some(hr)) = (y, sticky) {
let mut head = vec![Span::styled(" ".repeat(relw), self.ui.header)];
head.extend(self.render_row(hr, s, e, (lw, gw, code_cols - relw - gw), None, false, &overlay));
let mut out = clip(head, code_cols);
out.extend(skip_cols(spans, code_cols));
spans = out;
}
Line::from(spans)
})
.collect();
f.render_widget(Block::new().style(self.ui.base), full);
f.render_widget(Paragraph::new(lines), Rect { y: area.y + pin as u16, height: h as u16, ..area });
if rows_shown < h {
let column = margin && !float;
let code_w = if column { mx } else { tw };
let note = self.empty_note();
let filler: Vec<Line> = (rows_shown..h)
.map(|y| {
let say = note.filter(|_| y == rows_shown + 1);
let code: String = say.map_or("", |n| n.0).chars().take(code_w).collect();
let mut spans = vec![Span::raw(" ".repeat(relw + gw)), Span::styled(format!("{code:<code_w$}"), self.ui.stale)];
if column {
spans.push(sep.clone());
let pane: String = say.map_or("", |n| n.1).chars().take(mw.saturating_sub(1)).collect();
spans.push(Span::styled(format!(" {pane}"), self.ui.stale));
}
Line::from(spans)
})
.collect();
let at = area.y + (pin + rows_shown) as u16;
f.render_widget(Paragraph::new(filler), Rect { y: at, height: (h - rows_shown) as u16, ..area });
}
}
fn title_fits(&self, w: usize) -> bool {
let badges = (!self.sync.is_empty()).then(|| self.sync.chars().count() + 2);
let state = self.header_tag(INFO).map(|t| t.0.chars().count()).into_iter().chain(badges).map(|n| n + 2).sum::<usize>();
let line = self.rows.first().map_or(0, |r| r.line.text.chars().count());
line + 2 + self.pr_counts().chars().count() + state <= w
}
fn pane_sep(&self, float: bool) -> Span<'static> {
match float {
false => Span::styled("│", Style { fg: self.ui.linenr.fg, ..Style::new() }),
true => Span::styled(" ", Style { bg: self.ui.popup.bg.or(self.ui.bar), ..Style::new() }),
}
}
fn draw_title(&mut self, f: &mut Frame, c: &Cols) {
let Cols { area, lw, gw, relw, margin, float, code_tw, .. } = *c;
if !self.pin {
return;
}
let (s, e) = self.ordered();
let overlay = self.label_overlay();
let sep = self.pane_sep(float);
let bar = Style { bg: self.ui.bar, ..self.ui.header };
let mut spans = vec![Span::styled(" ".repeat(relw), bar)];
spans.extend(self.render_row(0, s, e, (lw, gw, code_tw), None, self.head.row == 0, &overlay));
if margin {
let dim = bar.remove_modifier(ratatui::style::Modifier::BOLD).patch(Style { fg: self.ui.linenr.fg, ..Style::new() });
let mut used = 0;
let lit = self.bar();
for (tab, label, cols) in self.pane_tabs() {
if used == 0 && !float {
spans.push(Span::styled(sep.content.clone(), dim.patch(Style { fg: sep.style.fg, ..Style::new() })));
used = 1;
}
spans.push(Span::styled(" ".repeat(cols.start - used), dim));
let on = tab == lit;
spans.push(Span::styled(label, if on { self.ui.menu_selected } else { dim }));
used = cols.end;
}
let rest = (area.width as usize).saturating_sub(relw + gw + code_tw + used);
spans.push(Span::styled(format!("{:>rest$}", "gn ", rest = rest), dim));
}
f.render_widget(Paragraph::new(Line::from(spans)), Rect { height: 1, ..area });
}
fn text_x(&self, c: &Cols) -> usize {
let Cols { gw, ghw, relw, hd, .. } = *c;
match self.drows[hd] {
_ if self.margin_side() => self.pane_x(),
DRow::Pair(..) | DRow::Margin(Some(_), Some(_), _) if self.right => self.right_x(),
DRow::Pair(..) => relw + ghw,
DRow::Margin(Some(_), Some(_), _) if self.split => relw + ghw,
DRow::Margin(Some(l), None, _) if self.split && self.rows[l].line.kind == Kind::Del => relw + ghw,
DRow::Margin(None, Some(_), _) if self.split => self.right_x(),
_ => relw + gw,
}
}
fn draw_link_tip(&self, f: &mut Frame, c: &Cols) {
let Cols { area, pin, h, hd, .. } = *c;
let header = self.rows[self.head.row].line.kind == Kind::File;
if let (Some(url), None, None, false) = (self.link_at_cursor(), &self.picker, &self.popup, header) {
let y = (hd - self.top) as u16 + pin as u16;
let x = ((self.text_x(c) + self.rows[self.head.row].guide_w() + self.col_of(self.head.row, self.head.col).saturating_sub(self.hscroll(self.head.row))) as u16).min(area.width.saturating_sub(20));
let text = format!(" ↗ {url} ");
let w = (text.chars().count() as u16).min(area.width - x);
let ty = if y + 1 < (h + pin) as u16 { y + 1 } else { y.saturating_sub(1) };
let r = Rect { x: area.x + x, y: area.y + ty, width: w, height: 1 };
f.render_widget(Clear, r);
f.render_widget(Paragraph::new(text).style(self.ui.base.patch(self.ui.popup)), r);
}
}
fn draw_status(&self, f: &mut Frame, status: Rect) {
let left = match &self.prompt {
Some(q) => Line::from(format!("/{q}")),
None => {
let l = &self.rows[self.head.row].line;
let pos = match (l.new, l.old) {
(Some(n), _) => format!("{n}:{}", self.head.col + 1),
(None, Some(o)) => format!("-{o}:{}", self.head.col + 1),
_ => String::new(),
};
let (mode, mst) = if self.select { (" SEL ", self.ui.mode_select) } else { (" NOR ", self.ui.mode_normal) };
Line::from(vec![Span::styled(mode, mst), Span::raw(format!(" {} {pos} {}", self.file_path().unwrap_or(""), self.msg))])
}
};
let pend = match self.pending {
Some(CTRL_W) => "C-w".into(),
p => p.map(String::from).unwrap_or_default(),
};
let count = if self.count > 0 { self.count.to_string() } else { String::new() };
let ra = match self.progress.values().next() {
Some((t, m)) => format!("ra: {t} {m} "),
None if self.lsp.is_some() && !self.lsp_ready => "ra: starting ".into(),
None => String::new(),
};
let pr = match self.pr_counts() {
c if c.is_empty() || self.bar_counts => String::new(), c => format!("{c} "),
};
let view = format!(
"{}{}{}",
match self.view {
View::Claude => "claude view ",
View::Pending => "pending view ",
View::All => "",
},
if self.uncommitted() { "uncommitted ".into() } else { self.commits_label().map(|l| format!("{l} ")).unwrap_or_default() },
if self.split { "split " } else { "" }
);
let essential = format!("{view}{count}{pend} {}/{} ", self.head.row + 1, self.rows.len());
let extras = format!("{ra}{pr}");
let width = status.width as usize;
let cols = |s: &str| Span::raw(s.to_owned()).width();
let gap = if self.claude_doing().is_some() { " " } else { "" };
let room = width.saturating_sub(cols(&essential) + cols(gap) + left.width().min(width / 2) + 1);
let doing = self.claude_doing().map(|d| format!(" {} ", super::cut(&d, room.saturating_sub(2).max(9)))).unwrap_or_default();
let essentials = cols(&doing) + cols(gap) + cols(&essential);
let extras = if left.width() + cols(&extras) + essentials < width { extras } else { String::new() };
let right = Line::from(vec![Span::raw(extras), Span::styled(doing, badge(&self.ui, self.ui.pending.fg)), Span::raw(format!("{gap}{essential}"))]);
let left = fit(left, width.saturating_sub(right.width() + 1));
let bar = self.ui.base.patch(self.ui.status);
f.render_widget(Paragraph::new(left).style(bar), status);
f.render_widget(Paragraph::new(right).alignment(Alignment::Right).style(bar), status);
}
fn draw_picker(&mut self, f: &mut Frame, full: Rect) {
let Some(p) = &self.picker else { return };
let m = self.matches();
let sel = p.sel.min(m.len().saturating_sub(1));
let ui = &self.ui;
let icons = crate::theme::icons();
let per = 1 + p.items.iter().map(|i| i.detail.len()).max().unwrap_or(0);
let headed = p.items.iter().map(|i| i.section).collect::<HashSet<_>>().len() > 1;
let q = p.query.to_lowercase();
let heading = ui.heading.add_modifier(ratatui::style::Modifier::BOLD);
let mut lines: Vec<(Line<'static>, Option<usize>)> = vec![];
for (i, &it) in m.iter().enumerate() {
let item = &p.items[it];
let first_of = i == 0 || p.items[m[i - 1]].section != item.section;
if headed && first_of && !item.section.is_empty() {
if i > 0 {
lines.push((Line::default(), None));
}
lines.push((Line::from(Span::styled(format!(" {}", item.section), heading)), None));
} else if per > 1 && i > 0 {
lines.push((Line::default(), None)); }
let st = if i == sel { ui.menu_selected } else { Style::new() };
let (icon, tone, _) = item.target.look();
let color = match tone {
Tone::Plain => ui.key.fg,
Tone::Claude => ui.pending.fg,
Tone::Good => ui.resolved.fg,
Tone::Danger => ui.error.fg,
Tone::Warn => ui.warning.fg,
};
let st = match item.target {
Target::Warn => st.patch(ui.warning),
_ => st,
};
let mut spans = vec![Span::styled(if i == sel { "▌" } else { " " }, st.patch(ui.key))];
if icons {
let icon_st = if i == sel { st } else { st.fg(color.unwrap_or(Color::Reset)) };
spans.push(Span::styled(format!("{icon} "), icon_st));
}
let label: Vec<char> = item.label.chars().collect();
let lower: Vec<char> = item.label.to_lowercase().chars().collect();
let mut hit = vec![false; label.len()];
for w in q.split_whitespace().map(|w| w.chars().collect::<Vec<_>>()) {
for k in (0..lower.len().saturating_sub(w.len() - 1)).filter(|&k| lower.len() == label.len() && lower[k..].starts_with(&w)) {
hit[k..k + w.len()].iter_mut().for_each(|h| *h = true);
}
}
let note = aside(&item.label);
spans.extend(label.iter().enumerate().map(|(k, c)| {
let base = if k >= note && i != sel { st.patch(ui.stale) } else { st };
Span::styled(c.to_string(), if hit[k] { base.patch(ui.key) } else { base })
}));
lines.push((Line::from(spans).style(st), Some(i)));
for k in 0..per - 1 {
let text = item.detail.get(k).map_or(String::new(), |d| format!(" {d}"));
lines.push((Line::from(Span::styled(text, st.patch(ui.stale))).style(st), Some(i)));
}
}
let widest = lines.iter().map(|(l, _)| l.width()).max().unwrap_or(0).max(p.title.trim().chars().count() + 12);
let w = (widest as u16 + 5).clamp(40.min(full.width), (full.width * 3 / 4).max(40).min(full.width));
let ph = (lines.len() as u16 + 4).min(full.height.saturating_sub(4)).max(5);
let r = centered(full, w, ph);
let vis = (ph as usize).saturating_sub(4).max(1);
let at = |i: usize| lines.iter().position(|l| l.1 == Some(i)).unwrap_or(0);
let (top, end) = (at(sel), at(sel) + per);
let top = if headed && top > 0 && lines[top - 1].1.is_none() { top - 1 } else { top };
let off = end.saturating_sub(vis).min(top);
let pos = if m.is_empty() { "0 / 0".into() } else { format!("{} / {}", sel + 1, m.len()) };
let inner = self.popup_frame(f, r, p.title, &pos);
let prompt = if icons { "\u{f422} " } else { "> " };
let query = Line::from(vec![Span::styled(prompt, self.ui.key), Span::raw(p.query.clone()), Span::styled(" ", self.cursor_style())]);
let rule = Line::from(Span::styled("─".repeat(inner.width as usize), self.ui.linenr));
let shown: Vec<Line> = [query, rule].into_iter().chain(lines.iter().skip(off).take(vis).map(|l| l.0.clone())).collect();
f.render_widget(Paragraph::new(shown), inner);
let list = Rect { y: inner.y + 2, height: inner.height.saturating_sub(2), ..inner };
self.scrollbar(f, list, lines.len(), vis, off);
self.geom.picker = Some(mouse::PickerGeom { list, off });
let lines = lines.into_iter().map(|l| l.1).collect();
if let Some(p) = &mut self.picker {
p.lines = lines;
}
}
fn keep_clear(&self, c: &Cols) -> Rect {
if self.margin_side() {
let x = c.relw + c.gw + c.code_tw;
return Rect { x: c.area.x + x as u16, y: c.area.y + c.pin as u16, width: c.area.width.saturating_sub(x as u16), height: c.h as u16 };
}
let row = self.head.row;
let x = self.text_x(c) + self.rows[row].guide_w() + self.col_of(row, self.head.col).saturating_sub(self.hscroll(row));
let y = c.pin + c.hd.saturating_sub(self.top);
Rect { x: c.area.x + x as u16, y: c.area.y + y as u16, width: 1, height: 1 }
}
fn draw_popup(&mut self, f: &mut Frame, full: Rect) {
let Some((title, text, scroll)) = self.popup.clone() else { return };
let text_w = text.iter().map(Line::width).max().unwrap_or(0).max(title.chars().count() + 1);
let w = (text_w as u16 + 5).max(30).min(full.width.saturating_sub(4));
let cw = w.saturating_sub(5).max(1) as usize;
let total: usize = text.iter().map(|l| l.width().div_ceil(cw).max(1)).sum();
let h = (total as u16 + 2).min(full.height.saturating_sub(4)).max(3);
let r = centered(full, w, h);
let vis = h.saturating_sub(2) as usize;
let scroll = scroll.min(total.saturating_sub(vis) as u16);
if let Some(p) = &mut self.popup {
p.2 = scroll;
}
let first = scroll as usize + 1;
let pos = if total > vis { format!("{first}–{} / {total}", (first + vis - 1).min(total)) } else { String::new() };
let inner = self.popup_frame(f, r, &title, &pos);
let text_area = Rect { width: inner.width.saturating_sub(1), ..inner }; let text = code_to_edge(text, text_area.width as usize, self.ui.md.code_bg);
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }).scroll((scroll, 0)), text_area);
self.scrollbar(f, inner, total, vis, scroll as usize);
}
pub(super) fn scrollbar(&self, f: &mut Frame, area: Rect, total: usize, vis: usize, pos: usize) {
if total <= vis {
return;
}
let mut state = ScrollbarState::new(total.saturating_sub(vis)).viewport_content_length(vis).position(pos);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.track_style(self.ui.linenr)
.thumb_symbol("┃")
.thumb_style(self.ui.linenr_selected);
f.render_stateful_widget(bar, area, &mut state);
}
pub(super) fn popup_frame(&self, f: &mut Frame, r: Rect, title: &str, pos: &str) -> Rect {
popup_frame(&self.ui, f, r, title, pos)
}
}
pub(crate) fn draw_hints(f: &mut Frame, ui: &Ui, above: Rect, title: &str, keys: &[(&str, &str)], avoid: Option<Rect>) {
let kw = keys.iter().map(|k| k.0.chars().count()).max().unwrap_or(1);
let lines: Vec<Line> = keys.iter().map(|(k, d)| Line::from(vec![Span::styled(format!("{k:<kw$} "), ui.key), Span::raw(d.to_string())])).collect();
let w = (lines.iter().map(Line::width).max().unwrap_or(10) as u16 + 4).max(title.chars().count() as u16 + 6).min(above.width);
let h = (lines.len() as u16 + 2).min(above.height);
let (right, bottom) = (above.x + above.width - w, above.y + above.height - h);
let corners = [(right, bottom), (above.x, bottom), (right, above.y), (above.x, above.y)].map(|(x, y)| Rect { x, y, width: w, height: h });
let buf = f.buffer_mut();
let covered = |r: &Rect| r.positions().filter(|&p| buf.cell(p).is_some_and(|c| !c.symbol().trim().is_empty())).count();
let r = corners.into_iter().min_by_key(|r| (avoid.is_some_and(|a| r.intersects(a)), covered(r))).unwrap_or(corners[0]);
let inner = popup_frame(ui, f, r, title, "");
f.render_widget(Paragraph::new(lines), inner);
}
pub(super) fn badge(ui: &Ui, color: Option<Color>) -> Style {
Style::new().fg(ui.base.bg.unwrap_or(Color::Black)).bg(color.unwrap_or(Color::Yellow)).add_modifier(ratatui::style::Modifier::BOLD)
}
pub(crate) fn popup_frame(ui: &Ui, f: &mut Frame, r: Rect, title: &str, pos: &str) -> Rect {
f.render_widget(Clear, r);
let room = (r.width as usize).saturating_sub(pos.chars().count() + 8);
let title = title.trim();
let title = match title.chars().count() > room {
true => format!("{}…", title.chars().take(room.saturating_sub(1)).collect::<String>()),
false => title.to_owned(),
};
let edge = Style::new().fg(ui.key.fg.unwrap_or(Color::Reset));
let mut block = Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(edge)
.style(ui.base)
.title(Span::styled(format!(" {title} "), ui.key));
if !pos.is_empty() {
block = block.title(ratatui::text::Line::from(Span::styled(format!(" {pos} "), ui.linenr)).right_aligned());
}
let inner = block.inner(r);
f.render_widget(block, r);
Rect { x: inner.x + 1, width: inner.width.saturating_sub(2), ..inner }
}
pub(super) fn code_to_edge(text: Vec<Line<'static>>, w: usize, code_bg: Option<Color>) -> Vec<Line<'static>> {
let Some(bg) = code_bg else { return text };
text.into_iter()
.map(|mut l| {
if l.style.bg == Some(bg) {
let pad = w.saturating_sub(l.width());
l.spans.push(Span::styled(" ".repeat(pad), Style::new().bg(bg)));
}
l
})
.collect()
}
fn aside(label: &str) -> usize {
let chars: Vec<char> = label.trim_end_matches('…').chars().collect();
if chars.last() != Some(&')') {
return label.chars().count();
}
let mut depth = 0;
for (k, &c) in chars.iter().enumerate().rev() {
depth += match c {
')' => 1,
'(' => -1,
_ => 0,
};
if depth == 0 {
return if k > 0 { k } else { label.chars().count() };
}
}
label.chars().count()
}
fn centered(full: Rect, w: u16, h: u16) -> Rect {
Rect { x: full.x + (full.width - w) / 2, y: 2.min(full.height), width: w, height: h.min(full.height) }
}
pub(super) fn scrolloff(h: usize) -> usize {
3.min(h / 2)
}
impl App {
fn label_overlay(&self) -> HashMap<Pos, char> {
self.labels
.iter()
.filter(|l| self.label_first.is_none_or(|f| l.2[0] == f))
.flat_map(|&(p, _, [a, b])| match self.label_first {
None => vec![(p, a), (Pos { col: p.col + 1, ..p }, b)],
Some(_) => vec![(p, b)],
})
.collect()
}
fn highlight_visible(&mut self) {
for r in self.visible_rows() {
if let Some(key) = self.hl_key(r).filter(|k| !self.hl_cache.contains_key(k)) {
let (rev, path) = &key;
let text = match rev.as_str() {
"" => std::fs::read_to_string(path).unwrap_or_default(),
rev => diff::git(&["show", &format!("{rev}:{path}")]).unwrap_or_default(),
};
let colors = if text.len() > HL_MAX_BYTES { vec![] } else { self.hl.file(path, &text) };
self.hl_cache.insert(key, colors);
}
}
}
pub(super) fn pr_counts(&self) -> String {
let pending = match self.pending_count() {
0 if self.review.is_none() => String::new(),
n => format!(" · {n} pending (S submits)"),
};
let claude = self.threads.iter().filter(|t| t.by_claude()).count();
let review_file = self.threads.iter().filter(|t| t.claude.is_some()).count();
let seen = self.files.iter().filter(|f| self.viewed.get(&f.path).is_some_and(|s| s == "VIEWED")).count();
let on_github = self.files.iter().filter(|f| f.meta != "untracked").count(); let threads = match (self.threads.len() - review_file, claude) {
(n, 0) => format!("{n} threads"),
(n, c) => format!("{n} threads + {c} Claude"),
};
let tabs = self.pin && self.margin_active;
match &self.pr {
Some(_) if tabs => format!("{seen}/{on_github} viewed{pending}"),
Some(_) => format!("{threads} · {seen}/{on_github} viewed{pending}"),
None if claude > 0 && !tabs => threads,
None => String::new(),
}
}
}
impl App {
pub(super) fn visible_rows(&self) -> Vec<usize> {
let mut out = vec![];
for d in &self.drows[self.top.min(self.drows.len())..(self.top + self.height).min(self.drows.len())] {
match *d {
DRow::Full(r) => out.push(r),
DRow::Pair(l, r) => out.extend(l.into_iter().chain(r.filter(|&r| Some(r) != l))),
DRow::Margin(l, r, c) => out.extend(l.into_iter().chain(r.filter(|&r| Some(r) != l)).chain(c)),
}
}
out
}
pub(super) fn hl_key(&self, r: usize) -> Option<(String, String)> {
let row = &self.rows[r];
let f = self.files.get(row.file).filter(|f| f.meta != "binary")?;
match row.line.kind {
Kind::Del => Some((self.base.clone(), f.old_path.clone())),
Kind::Add | Kind::Ctx => Some((self.to.clone().unwrap_or_default(), f.path.clone())),
_ => None,
}
}
pub(super) fn forget_tree_colors(&mut self) {
self.hl_cache.retain(|k, _| !k.0.is_empty() && k.0 != "HEAD");
}
}