use crate::git::load::{Commit, FileEntry, RefKind, RefLabel, fit_refs};
use crate::tui::clamp_scroll;
use crate::tui::input::{X_MOVE, char_to_byte, is_back, is_down, is_up};
use ratatui::Frame;
use ratatui::crossterm::event::KeyCode;
use ratatui::layout::{Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Clear, ListItem, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
};
use std::time::Duration;
pub fn commit_item(c: &Commit, unpushed: bool, width: usize) -> ListItem<'static> {
let mark = if unpushed {
Span::styled(
"↑ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let mut spans = vec![
mark,
Span::styled(
format!("{:<7} ", c.short),
Style::default().fg(Color::Yellow),
),
Span::styled(format!("{} ", c.date), Style::default().fg(Color::Green)),
Span::styled(
format!("<{}> ", c.committer),
Style::default().fg(Color::Blue),
),
];
let left: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let room = width.saturating_sub(left + MIN_SUBJECT);
spans.extend(ref_spans(&c.refs, room));
spans.push(Span::raw(c.subject.clone()));
ListItem::new(Line::from(spans))
}
pub const MIN_SUBJECT: usize = 20;
fn ref_spans(refs: &[RefLabel], room: usize) -> Vec<Span<'static>> {
if refs.is_empty() {
return Vec::new();
}
let (kept, hidden) = fit_refs(refs, room);
let mut spans = vec![Span::raw("(")];
for (i, (kind, text)) in kept.into_iter().enumerate() {
if i > 0 {
spans.push(Span::raw(", "));
}
let colour = match kind {
RefKind::Head | RefKind::Detached => Color::Cyan,
RefKind::Branch => Color::Green,
RefKind::Remote => Color::Red,
RefKind::Tag => Color::Yellow,
};
spans.push(Span::styled(
text,
Style::default().fg(colour).add_modifier(Modifier::BOLD),
));
}
if hidden > 0 {
spans.push(Span::raw(", "));
spans.push(Span::styled(
format!("+{hidden}"),
Style::default().fg(Color::DarkGray),
));
}
spans.push(Span::raw(") "));
spans
}
pub fn file_item(f: &FileEntry) -> ListItem<'static> {
let (color, ch) = status_glyph(f.status);
ListItem::new(Line::from(vec![
Span::styled(format!("{ch} "), Style::default().fg(color)),
Span::raw(f.path.clone()),
]))
}
fn status_glyph(status: char) -> (Color, char) {
match status {
'A' => (Color::Green, 'A'),
'M' => (Color::Yellow, 'M'),
'D' => (Color::Red, 'D'),
'R' => (Color::Cyan, 'R'),
c => (Color::Gray, c),
}
}
pub fn pane_block(title: impl Into<Line<'static>>, active: bool) -> Block<'static> {
let color = if active { Color::Cyan } else { Color::DarkGray };
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.title(title)
}
pub const NOTE: Duration = Duration::from_secs(3);
pub enum Command {
Help,
Quit,
Unknown(String),
}
pub enum Typed {
Open,
Cancel,
Run(Command),
}
#[derive(Default)]
pub struct CommandLine {
text: String,
}
impl CommandLine {
pub fn on_key(&mut self, code: KeyCode) -> Typed {
match code {
KeyCode::Esc => Typed::Cancel,
KeyCode::Enter => Typed::Run(match self.text.trim() {
"" | "help" | "h" | "keys" => Command::Help,
"q" | "quit" => Command::Quit,
other => Command::Unknown(other.to_string()),
}),
KeyCode::Backspace if self.text.is_empty() => Typed::Cancel,
KeyCode::Backspace => {
self.text.pop();
Typed::Open
}
KeyCode::Char(c) => {
self.text.push(c);
Typed::Open
}
_ => Typed::Open,
}
}
}
#[derive(Default)]
pub struct Query {
pub text: String,
pub caret: usize,
}
impl Query {
pub fn keeps(&self, row: &str) -> bool {
if self.text.trim().is_empty() {
return true;
}
let row = row.to_lowercase();
self.text
.split_whitespace()
.all(|term| row.contains(&term.to_lowercase()))
}
pub fn open(&mut self) {
self.caret = self.text.chars().count();
}
pub fn on_key(&mut self, code: KeyCode) -> bool {
let end = self.text.chars().count();
match code {
KeyCode::Char(c) => {
self.text.insert(char_to_byte(&self.text, self.caret), c);
self.caret += 1;
return true;
}
KeyCode::Backspace if self.caret > 0 => {
self.text.remove(char_to_byte(&self.text, self.caret - 1));
self.caret -= 1;
return true;
}
KeyCode::Delete if self.caret < end => {
self.text.remove(char_to_byte(&self.text, self.caret));
return true;
}
KeyCode::Left => self.caret = self.caret.saturating_sub(1),
KeyCode::Right if self.caret < end => self.caret += 1,
KeyCode::Home => self.caret = 0,
KeyCode::End => self.caret = end,
_ => {}
}
false
}
pub fn clear(&mut self) {
self.text.clear();
self.caret = 0;
}
pub fn title_span(&self, key: char, editing: bool, filterable: bool) -> Option<Span<'static>> {
let text = &self.text;
if editing {
let at = char_to_byte(text, self.caret);
Some(Span::raw(format!(
" {key}{}▏{}",
&text[..at],
&text[at..]
)))
} else if !text.is_empty() {
Some(Span::raw(format!(" {key}{text}")))
} else if filterable {
Some(Span::styled(
format!(" {key} filter"),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
))
} else {
None
}
}
}
pub fn key_footer(
actions: &[String],
note: Option<&(bool, String)>,
command: Option<&CommandLine>,
help: bool,
width: u16,
) -> Paragraph<'static> {
let dim = Style::default().fg(Color::DarkGray);
let line = match (command, note) {
(Some(cmd), _) => {
let mut spans = vec![Span::raw(format!(" :{}▏", cmd.text))];
if cmd.text.is_empty() {
spans.push(Span::styled("help", dim.add_modifier(Modifier::DIM)));
}
Line::from(spans)
}
(None, Some((true, text))) => Line::from(Span::styled(
format!(" ✓ {text}"),
Style::default().fg(Color::Green),
)),
(None, Some((false, text))) => Line::from(Span::styled(
format!(" ✗ {text}"),
Style::default().fg(Color::Yellow),
)),
(None, None) if !help => Line::from(Span::styled(fit(actions, width), dim)),
(None, None) => {
const HELP: &str = ":help ";
let room = (width as usize).saturating_sub(HELP.len() + 2);
let left = fit(actions, room as u16);
let pad = (width as usize).saturating_sub(left.chars().count() + HELP.len());
Line::from(vec![
Span::styled(left, dim),
Span::raw(" ".repeat(pad)),
Span::styled(HELP, dim),
])
}
};
Paragraph::new(line)
}
fn fit(keys: &[String], width: u16) -> String {
let mut line = String::new();
for key in keys {
let next = if line.is_empty() {
format!(" {key}")
} else {
format!("{line} · {key}")
};
if next.chars().count() > width as usize {
break;
}
line = next;
}
line
}
const MAX_TABS: usize = 5;
pub fn scope_tabs(labels: &[&str], picked: usize) -> Vec<Span<'static>> {
let first = picked
.saturating_sub(MAX_TABS / 2)
.min(labels.len().saturating_sub(MAX_TABS));
let last = (first + MAX_TABS).min(labels.len());
let dim = Style::default().fg(Color::DarkGray);
let mut spans = Vec::new();
if first > 0 {
spans.push(Span::styled("‹", dim));
}
for (i, label) in labels.iter().enumerate().take(last).skip(first) {
if i > first {
spans.push(Span::styled("│", dim));
}
let style = if i == picked {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Reset)
};
spans.push(Span::styled(format!(" {label} "), style));
}
if last < labels.len() {
spans.push(Span::styled("›", dim));
}
spans
}
fn render_vscrollbar(frame: &mut Frame, area: Rect, total: usize, top: usize) {
let viewport = area.height.saturating_sub(2) as usize;
if total <= viewport {
return; }
let mut state = ScrollbarState::new(total - viewport).position(top);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(bar, area.inner(Margin::new(0, 1)), &mut state);
}
pub fn diff_scrollbar(frame: &mut Frame, area: Rect, total_lines: usize, scroll: u16) {
render_vscrollbar(frame, area, total_lines, scroll as usize);
}
pub fn list_scrollbar(frame: &mut Frame, area: Rect, total: usize, offset: usize) {
render_vscrollbar(frame, area, total, offset);
}
pub fn diff_hscrollbar(frame: &mut Frame, area: Rect, max_line: usize, cell_w: u16, hscroll: u16) {
let cell = cell_w as usize;
if max_line <= cell {
return;
}
let mut state = ScrollbarState::new(max_line - cell).position(hscroll as usize);
let bar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(None)
.end_symbol(None)
.thumb_symbol("■");
frame.render_stateful_widget(bar, area.inner(Margin::new(1, 0)), &mut state);
}
const TAB_WIDTH: usize = 4;
fn printable(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut col = 0usize;
for c in text.chars() {
match c {
'\n' => {
out.push('\n');
col = 0;
}
'\t' => {
let pad = TAB_WIDTH - col % TAB_WIDTH;
out.extend(std::iter::repeat_n(' ', pad));
col += pad;
}
c if c.is_ascii_control() => {
out.push('^');
out.push(char::from(c as u8 ^ 0x40));
col += 2;
}
c if c.is_control() => {
out.push(char::REPLACEMENT_CHARACTER);
col += 1;
}
c => {
out.push(c);
col += 1;
}
}
}
out
}
pub struct Modal {
title: String,
body: String,
scroll: u16,
colour: Color,
keys: &'static str,
}
impl Modal {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Modal {
Modal {
title: title.into(),
body: printable(&body.into()),
scroll: 0,
colour: Color::Yellow,
keys: "j/k ↑↓ scroll · esc dismiss",
}
}
pub fn reader(title: impl Into<String>, rows: &[(String, String)]) -> Modal {
let width = rows
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
+ 3;
let body = rows
.iter()
.map(|(key, does)| format!("{key:<width$}{does}"))
.collect::<Vec<_>>()
.join("\n");
Modal {
title: title.into(),
body: printable(&body),
scroll: 0,
colour: Color::Cyan,
keys: "j/k ↑↓ scroll · esc close",
}
}
pub fn with_text(mut self, text: &str) -> Modal {
if !text.is_empty() {
self.body = format!("{}\n\n{}", self.body, printable(text));
}
self
}
pub fn on_key(&mut self, code: KeyCode) -> bool {
if is_down(code) {
self.scroll = self.scroll.saturating_add(1);
} else if is_up(code) {
self.scroll = self.scroll.saturating_sub(1);
} else if is_back(code) || matches!(code, KeyCode::Enter | KeyCode::Char('q' | ' ')) {
return true;
}
false
}
pub fn draw(&mut self, frame: &mut Frame) {
let full = frame.area();
let width = box_width(full.width);
let body_h = wrapped_height(&self.body, box_inner_width(width)) as u16;
let area = popup_area(full, width, box_height(body_h + 2, full.height));
let viewport = area.height.saturating_sub(BOX_CHROME_H);
self.scroll = clamp_scroll(self.scroll, body_h as usize, viewport);
frame.render_widget(Clear, area); let mut lines: Vec<Line> = self
.body
.lines()
.map(|l| Line::raw(l.to_string()))
.collect();
lines.push(Line::raw(""));
lines.push(box_hint(self.keys));
let body = Paragraph::new(lines)
.block(box_block(self.colour, &self.title))
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));
frame.render_widget(body, area);
}
}
pub const BOX_MIN_W: u16 = 24;
pub const BOX_MAX_W: u16 = 88;
pub const BOX_CHROME_H: u16 = 3;
pub const BOX_CHROME_W: u16 = 6;
pub fn box_width(screen_w: u16) -> u16 {
screen_w.saturating_sub(4).clamp(BOX_MIN_W, BOX_MAX_W)
}
pub fn box_inner_width(width: u16) -> usize {
width.saturating_sub(BOX_CHROME_W).max(1) as usize
}
pub fn box_height(body_rows: u16, screen_h: u16) -> u16 {
let floor = BOX_CHROME_H + 1;
body_rows
.saturating_add(BOX_CHROME_H)
.clamp(floor, screen_h.max(floor))
}
pub fn box_block(colour: Color, title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colour))
.padding(Padding::new(2, 2, 1, 0))
.title(format!(" {} ", title.trim()))
}
pub fn box_hint(keys: &str) -> Line<'static> {
Line::from(Span::styled(
keys.to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
))
}
pub fn box_buttons(colour: Color, yes: bool) -> Line<'static> {
let button = |label: &str, picked: bool| {
let style = if picked {
Style::default()
.fg(Color::Black)
.bg(colour)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::DIM)
};
Span::styled(format!(" {label} "), style)
};
Line::from(vec![
button("Yes (y)", yes),
Span::raw(" "),
button("No (n)", !yes),
])
}
pub fn confirm_popup(
frame: &mut Frame,
colour: Color,
title: &str,
name: &str,
note: Option<&str>,
yes: bool,
) {
let full = frame.area();
let width = box_width(full.width);
let mut lines = vec![Line::from(Span::styled(
name.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))];
for note in note.into_iter().flat_map(str::lines) {
lines.push(Line::from(Span::styled(
note.to_string(),
Style::default().add_modifier(Modifier::DIM),
)));
}
lines.push(Line::from(""));
lines.push(box_buttons(colour, yes));
lines.push(Line::from(""));
lines.push(box_hint(&format!("{X_MOVE} move · enter select · y/n")));
let inner = box_inner_width(width);
let rows: usize = lines.iter().map(|l| l.width().div_ceil(inner).max(1)).sum();
let area = popup_area(full, width, box_height(rows as u16, full.height));
frame.render_widget(Clear, area);
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(box_block(colour, title));
frame.render_widget(body, area);
}
pub fn typed_popup(
frame: &mut Frame,
title: &str,
note: Option<&str>,
name: &str,
typed: &str,
verb: &str,
) {
let full = frame.area();
let width = box_width(full.width);
let mut lines: Vec<Line<'static>> = note
.into_iter()
.flat_map(str::lines)
.map(|l| Line::from(l.to_string()))
.collect();
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw("type "),
Span::styled(
name.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" to confirm:"),
]));
let armed = typed == name;
let field = if armed {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(format!("{typed}▏"), field)));
lines.push(Line::from(""));
let label = format!(" enter {verb} ");
let enter = if armed {
Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
label,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)
};
let esc = Span::styled(
" esc cancel",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
);
lines.push(Line::from(vec![enter, esc]));
let inner = box_inner_width(width);
let rows: usize = lines.iter().map(|l| l.width().div_ceil(inner).max(1)).sum();
let area = popup_area(full, width, box_height(rows as u16, full.height));
frame.render_widget(Clear, area);
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(box_block(Color::Red, title));
frame.render_widget(body, area);
}
pub fn wrapped_height(text: &str, width: usize) -> usize {
text.lines()
.map(|l| l.chars().count().div_ceil(width).max(1))
.sum()
}
pub fn popup_area(area: Rect, w: u16, h: u16) -> Rect {
let (w, h) = (w.min(area.width), h.min(area.height));
Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::{Modal, Query};
#[test]
fn a_box_never_hands_a_tab_or_control_character_to_the_terminal() {
let said = "error: overwritten by merge:\n\tdocs/notes.md\nbell\x07 and\r return";
let bodies = [
Modal::new("t", said).body,
Modal::reader("t", &[("k".to_string(), said.to_string())]).body,
Modal::reader("t", &[]).with_text(said).body,
];
for body in bodies {
assert!(
!body.chars().any(|c| c != '\n' && c.is_control()),
"a control character reached the box: {body:?}"
);
assert!(
body.contains("docs/notes.md"),
"the words around it survive: {body:?}"
);
}
}
fn query(text: &str) -> Query {
Query {
text: text.to_string(),
caret: 0,
}
}
#[test]
fn a_filter_keeps_only_rows_every_term_is_in() {
assert!(
query("").keeps("anything at all"),
"an empty filter keeps everything"
);
let q = query("pablo fix");
assert!(q.keeps("a1b2c3 2026-09-03 pablo fix: the thing"));
assert!(!q.keeps("a1b2c3 2026-09-03 pablo feat: the thing"));
assert!(!q.keeps("a1b2c3 2026-09-03 marta fix: the thing"));
}
#[test]
fn a_filter_ignores_case_on_both_sides() {
assert!(query("FIX Pablo").keeps("pablo fix: lowercase row"));
assert!(query("fix").keeps("PABLO FIX: UPPERCASE ROW"));
}
#[test]
fn a_filter_of_only_spaces_is_no_filter() {
assert!(query(" ").keeps("anything"));
}
}