use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use crate::theme::Theme;
use crate::transient::Transient;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Popup {
pub title: String,
pub entries: Vec<(String, String)>,
}
impl Popup {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let area = frame.area();
let lines = self.lines(theme);
let height = (lines.len() as u16 + 1).min(area.height);
let popup_area = Rect {
x: area.x,
y: area.y + area.height - height,
width: area.width,
height,
};
frame.render_widget(Clear, popup_area);
let block = Block::new()
.borders(Borders::TOP)
.border_style(Style::new().fg(theme.border).bg(theme.panel))
.title(Span::styled(
format!(" {} ", self.title),
Style::new()
.fg(theme.accent)
.bg(theme.panel)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
popup_area,
);
}
fn lines(&self, theme: &Theme) -> Vec<Line<'static>> {
let key_style = Style::new()
.fg(theme.purple)
.bg(theme.panel)
.add_modifier(Modifier::BOLD);
let dim = Style::new().fg(theme.dim).bg(theme.panel);
let fg = Style::new().fg(theme.fg).bg(theme.panel);
let mut lines = vec![Line::styled("Actions", dim)];
for (key, description) in &self.entries {
lines.push(Line::from(vec![
Span::styled(format!(" {key}"), key_style),
Span::styled(format!(" {description}"), fg),
]));
}
lines
}
}
const WHICH_KEY_COL_SPACING: usize = 2;
const WHICH_KEY_KEY_SEP: usize = 2;
const WHICH_KEY_MAX_HEIGHT: u16 = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
struct WhichKeyColumn {
heading: String,
entries: Vec<(String, String)>,
width: usize,
}
impl WhichKeyColumn {
fn new(heading: String, entries: Vec<(String, String)>) -> Self {
let body = entries
.iter()
.map(|(key, label)| key.chars().count() + WHICH_KEY_KEY_SEP + label.chars().count())
.max()
.unwrap_or(0);
let width = body.max(heading.chars().count());
Self {
heading,
entries,
width,
}
}
}
fn pack_columns(widths: &[usize], available: usize) -> Vec<Vec<usize>> {
let mut bands: Vec<Vec<usize>> = Vec::new();
let mut used = 0usize;
for (index, &width) in widths.iter().enumerate() {
let needs = if bands.last().is_some_and(|b| !b.is_empty()) {
WHICH_KEY_COL_SPACING + width
} else {
width
};
match bands.last_mut() {
Some(band) if !band.is_empty() && used + needs <= available => {
band.push(index);
used += needs;
}
_ => {
bands.push(vec![index]);
used = width;
}
}
}
bands
}
#[derive(Debug, Clone)]
pub struct WhichKeyPanel<'a> {
pub transient: &'a Transient,
}
impl WhichKeyPanel<'_> {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let area = frame.area();
let columns = self.columns();
let widths: Vec<usize> = columns.iter().map(|c| c.width).collect();
let available = (area.width as usize).saturating_sub(2).max(1);
let bands = pack_columns(&widths, available);
let lines = render_bands(&columns, &bands, theme);
let height = (lines.len() as u16 + 1)
.min(WHICH_KEY_MAX_HEIGHT)
.min(area.height);
let panel_area = Rect {
x: area.x,
y: area.y + area.height - height,
width: area.width,
height,
};
frame.render_widget(Clear, panel_area);
let block = Block::new()
.borders(Borders::TOP)
.border_style(Style::new().fg(theme.border).bg(theme.panel))
.title(Span::styled(
format!(" {} ", self.transient.kind.title()),
Style::new()
.fg(theme.accent)
.bg(theme.panel)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
panel_area,
);
}
fn columns(&self) -> Vec<WhichKeyColumn> {
self.transient
.groups
.iter()
.map(|group| {
let entries = group
.entries
.iter()
.map(|entry| {
(
crate::keymap::render_chord(std::slice::from_ref(&entry.key)),
entry.label.to_owned(),
)
})
.collect();
WhichKeyColumn::new(group.heading.to_owned(), entries)
})
.collect()
}
}
fn render_bands(
columns: &[WhichKeyColumn],
bands: &[Vec<usize>],
theme: &Theme,
) -> Vec<Line<'static>> {
let key_style = Style::new()
.fg(theme.purple)
.bg(theme.panel)
.add_modifier(Modifier::BOLD);
let dim = Style::new().fg(theme.dim).bg(theme.panel);
let fg = Style::new().fg(theme.fg).bg(theme.panel);
let sep = " ".repeat(WHICH_KEY_COL_SPACING);
let mut lines = Vec::new();
for band in bands {
let band_columns: Vec<&WhichKeyColumn> =
band.iter().filter_map(|&col| columns.get(col)).collect();
let mut heading = vec![Span::styled(" ".to_owned(), dim)];
for (slot, column) in band_columns.iter().enumerate() {
if slot > 0 {
heading.push(Span::styled(sep.clone(), dim));
}
heading.push(Span::styled(pad(&column.heading, column.width), dim));
}
lines.push(Line::from(heading));
let rows = band_columns
.iter()
.map(|column| column.entries.len())
.max()
.unwrap_or(0);
for row in 0..rows {
let mut spans = vec![Span::styled(" ".to_owned(), fg)];
for (slot, column) in band_columns.iter().enumerate() {
if slot > 0 {
spans.push(Span::styled(sep.clone(), fg));
}
match column.entries.get(row) {
Some((key, label)) => {
let used = key.chars().count() + WHICH_KEY_KEY_SEP + label.chars().count();
let pad = column.width.saturating_sub(used);
spans.push(Span::styled(key.clone(), key_style));
spans.push(Span::styled(" ".repeat(WHICH_KEY_KEY_SEP), fg));
spans.push(Span::styled(format!("{label}{}", " ".repeat(pad)), fg));
}
None => spans.push(Span::styled(" ".repeat(column.width), fg)),
}
}
lines.push(Line::from(spans));
}
}
lines
}
fn pad(text: &str, width: usize) -> String {
let pad = width.saturating_sub(text.chars().count());
format!("{text}{}", " ".repeat(pad))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmDialog {
pub message: String,
}
impl ConfirmDialog {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let width = (self.message.len() as u16 + 4).clamp(24, frame.area().width);
let area = centered(frame.area(), width, 4);
frame.render_widget(Clear, area);
let block = bordered_block(theme, " Confirm ");
let body = vec![
Line::styled(
self.message.clone(),
Style::new().fg(theme.fg).bg(theme.panel),
),
Line::styled(
"y confirm n cancel",
Style::new().fg(theme.dim).bg(theme.panel),
),
];
frame.render_widget(
Paragraph::new(body)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
area,
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputModal {
pub title: String,
pub buffer: String,
pub cursor: usize,
}
const INPUT_MAX_LINES: usize = 8;
impl InputModal {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let mut lines = self.input_lines(theme);
let overflow = lines.len().saturating_sub(INPUT_MAX_LINES);
lines.drain(..overflow);
lines.push(Line::styled(
"enter submit a-enter newline esc cancel",
Style::new().fg(theme.dim).bg(theme.panel),
));
let height = lines.len() as u16 + 2;
let area = centered(frame.area(), frame.area().width.min(60), height);
frame.render_widget(Clear, area);
let block = bordered_block(theme, &format!(" {} ", self.title));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
area,
);
}
fn input_lines(&self, theme: &Theme) -> Vec<Line<'static>> {
let fg = Style::new().fg(theme.fg).bg(theme.panel);
let cursor_cell = Style::new().fg(theme.bg).bg(theme.accent);
let mut lines = Vec::new();
let mut offset = 0usize;
for text in self.buffer.split('\n') {
let len = text.chars().count();
if (offset..=offset + len).contains(&self.cursor) {
let column = self.cursor - offset;
let before: String = text.chars().take(column).collect();
let at: String = text
.chars()
.nth(column)
.map_or_else(|| " ".to_owned(), |c| c.to_string());
let after: String = text.chars().skip(column + 1).collect();
lines.push(Line::from(vec![
Span::styled(before, fg),
Span::styled(at, cursor_cell),
Span::styled(after, fg),
]));
} else {
lines.push(Line::styled(text.to_owned(), fg));
}
offset += len + 1;
}
lines
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModal {
pub title: String,
pub items: Vec<String>,
pub cursor: usize,
}
impl ListModal {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let hint = "j/k move enter select esc back";
let width = self
.items
.iter()
.map(|item| item.chars().count())
.chain([hint.len(), self.title.chars().count() + 2])
.max()
.unwrap_or(0) as u16
+ 4;
let height = self.items.len() as u16 + 3;
let area = centered(frame.area(), width.min(frame.area().width), height);
frame.render_widget(Clear, area);
let mut lines: Vec<Line<'static>> = self
.items
.iter()
.enumerate()
.map(|(index, item)| {
let style = if index == self.cursor {
Style::new().fg(theme.fg).bg(theme.cursor_line)
} else {
Style::new().fg(theme.fg).bg(theme.panel)
};
Line::styled(format!(" {item} "), style)
})
.collect();
lines.push(Line::styled(
hint,
Style::new().fg(theme.dim).bg(theme.panel),
));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(bordered_block(theme, &format!(" {} ", self.title))),
area,
);
}
}
fn bordered_block(theme: &Theme, title: &str) -> Block<'static> {
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(theme.border).bg(theme.panel))
.title(Span::styled(
title.to_owned(),
Style::new()
.fg(theme.accent)
.bg(theme.panel)
.add_modifier(Modifier::BOLD),
))
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
}
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::Block;
use super::*;
fn render(draw: impl Fn(&mut Frame<'_>, &Theme)) -> Terminal<TestBackend> {
let theme = Theme::github_dark();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| {
frame.render_widget(Block::new().style(theme.base()), frame.area());
frame.render_widget(
ratatui::text::Text::from("status screen content behind the popup"),
frame.area(),
);
draw(frame, &theme);
})
.expect("draw");
terminal
}
#[test]
fn pack_columns_keeps_fitting_columns_on_one_band() {
let bands = pack_columns(&[10, 10, 10], 34);
assert_eq!(bands, vec![vec![0, 1, 2]]);
}
#[test]
fn pack_columns_wraps_when_the_next_column_overflows() {
assert_eq!(pack_columns(&[10, 10, 10], 33), vec![vec![0, 1], vec![2]]);
}
#[test]
fn pack_columns_gives_an_oversized_column_its_own_band() {
assert_eq!(pack_columns(&[40, 5], 20), vec![vec![0], vec![1]]);
}
#[test]
fn which_key_column_width_covers_key_label_and_heading() {
let column = WhichKeyColumn::new(
"Create and switch branches".to_owned(),
vec![("c".to_owned(), "Create".to_owned())],
);
assert_eq!(column.width, "Create and switch branches".chars().count());
let column = WhichKeyColumn::new(
"X".to_owned(),
vec![("D".to_owned(), "Delete branch".to_owned())],
);
assert_eq!(
column.width,
1 + WHICH_KEY_KEY_SEP + "Delete branch".chars().count()
);
}
#[test]
fn which_key_panel_renders_the_commit_transient() {
let (transient, warnings) = crate::transient::Transient::build(
crate::transient::TransientKind::Commit,
&crate::config::KeysConfig::default(),
);
assert!(warnings.is_empty());
let terminal = render(|frame, theme| {
WhichKeyPanel {
transient: &transient,
}
.render(frame, theme);
});
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn which_key_panel_renders_the_stash_transient() {
let (transient, warnings) = crate::transient::Transient::build(
crate::transient::TransientKind::Stash,
&crate::config::KeysConfig::default(),
);
assert!(warnings.is_empty());
let terminal = render(|frame, theme| {
WhichKeyPanel {
transient: &transient,
}
.render(frame, theme);
});
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn popup_renders_as_bottom_split() {
let popup = Popup {
title: "Branch".to_owned(),
entries: vec![
("c".to_owned(), "create and checkout".to_owned()),
("n".to_owned(), "create".to_owned()),
("D".to_owned(), "delete".to_owned()),
],
};
let terminal = render(|frame, theme| popup.render(frame, theme));
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn confirm_dialog_renders_centered() {
let dialog = ConfirmDialog {
message: "Discard changes to src/lib.rs?".to_owned(),
};
let terminal = render(|frame, theme| dialog.render(frame, theme));
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn input_modal_renders_with_cursor() {
let modal = InputModal {
title: "New branch".to_owned(),
buffer: "feat/m1".to_owned(),
cursor: 7,
};
let terminal = render(|frame, theme| modal.render(frame, theme));
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn input_modal_renders_a_two_line_buffer() {
let modal = InputModal {
title: "Comment".to_owned(),
buffer: "first line\nsecond".to_owned(),
cursor: 17,
};
let terminal = render(|frame, theme| modal.render(frame, theme));
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn input_modal_overflow_shows_the_last_lines() {
let buffer = (1..=12)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let modal = InputModal {
title: "Comment".to_owned(),
cursor: buffer.chars().count(),
buffer,
};
let terminal = render(|frame, theme| modal.render(frame, theme));
let content = terminal.backend().to_string();
assert!(content.contains("line 12"), "tail stays visible: {content}");
assert!(content.contains("line 5 "), "8 lines fit: {content}");
assert!(
!content.contains("line 4 "),
"older lines scroll away: {content}"
);
}
}