use crate::git::load::{Commit, FileEntry};
use crate::tui::clamp_scroll;
use crate::tui::input::{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,
};
pub fn commit_item(c: &Commit, unpushed: bool) -> ListItem<'static> {
let mark = if unpushed {
Span::styled(
"↑ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
ListItem::new(Line::from(vec![
mark,
Span::styled(
format!("{:<8}", 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),
),
Span::raw(c.subject.clone()),
]))
}
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: String, 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)
}
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);
}
pub struct Modal {
title: String,
body: String,
scroll: u16,
}
impl Modal {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Modal {
Modal {
title: title.into(),
body: body.into(),
scroll: 0,
}
}
pub fn on_key(&mut self, code: KeyCode) -> bool {
if is_down(code) || code == KeyCode::PageDown {
self.scroll = self.scroll.saturating_add(1);
} else if is_up(code) || code == KeyCode::PageUp {
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("j/k ↑↓ scroll · esc dismiss"));
let body = Paragraph::new(lines)
.block(box_block(Color::Yellow, &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 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,
}
}