use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, 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)>,
pub summary: Vec<String>,
}
const POPUP_COLUMN_GAP: usize = 2;
impl Popup {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let area = frame.area();
let body_rows = area.height.saturating_sub(1) as usize;
let lines = self.lines(theme, body_rows.max(1));
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),
));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
popup_area,
);
}
fn lines(&self, theme: &Theme, body_rows: usize) -> Vec<Line<'static>> {
let key_style = Style::new().fg(theme.purple).bg(theme.panel);
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<'static>> = self
.summary
.iter()
.map(|text| Line::styled(format!(" {text}"), fg))
.collect();
lines.push(Line::styled("Actions", dim));
let rows = body_rows.saturating_sub(lines.len()).max(1);
if self.entries.len() <= rows {
for (key, description) in &self.entries {
lines.push(Line::from(vec![
Span::styled(format!(" {key}"), key_style),
Span::styled(format!(" {description}"), fg),
]));
}
return lines;
}
let columns = self.entries.len().div_ceil(rows);
let per_column = self.entries.len().div_ceil(columns);
let cell_width =
|entry: &(String, String)| 1 + entry.0.chars().count() + 2 + entry.1.chars().count();
let widths: Vec<usize> = (0..columns)
.map(|column| {
(0..per_column)
.filter_map(|row| self.entries.get(column * per_column + row))
.map(cell_width)
.max()
.unwrap_or(0)
})
.collect();
for row in 0..per_column {
let mut spans = Vec::new();
for column in 0..columns {
let Some(entry) = self.entries.get(column * per_column + row) else {
continue;
};
let width = widths
.get(column)
.copied()
.unwrap_or_else(|| cell_width(entry));
let pad = width.saturating_sub(cell_width(entry)) + POPUP_COLUMN_GAP;
spans.push(Span::styled(format!(" {}", entry.0), key_style));
spans.push(Span::styled(
format!(" {}{}", entry.1, " ".repeat(pad)),
fg,
));
}
lines.push(Line::from(spans));
}
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),
));
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);
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)]
pub struct CreatePrForm<'a> {
pub draft: &'a crate::app::pr_create::PrDraft,
}
const CREATE_PR_WIDTH: u16 = 76;
impl CreatePrForm<'_> {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) -> ListHits {
use crate::app::pr_create::PrField;
let draft = self.draft;
let body_lines = draft.body.lines().count();
let head = if draft.needs_push {
format!(
"{} ({} commits, pushes on create)",
draft.head, draft.commits
)
} else {
format!("{} ({} commits)", draft.head, draft.commits)
};
let rows = [
(PrField::Base, "base ", draft.base.clone()),
(PrField::Title, "title", draft.title.clone()),
(
PrField::Body,
"body ",
match body_lines {
0 => "empty ⏎ edit in $EDITOR".to_owned(),
n => format!("{n} lines ⏎ edit in $EDITOR"),
},
),
(
PrField::Draft,
"draft",
if draft.draft { "yes" } else { "no" }.to_owned(),
),
];
let fg = Style::new().fg(theme.fg).bg(theme.panel);
let dim = Style::new().fg(theme.dim).bg(theme.panel);
let label = Style::new().fg(theme.purple).bg(theme.panel);
let mut lines = vec![
Line::from(vec![
Span::styled(" head ".to_owned(), label),
Span::styled(head, dim),
]),
Line::styled(String::new(), dim),
];
for (field, name, value) in rows {
let picked = field == draft.field;
let marker = if picked { "▌" } else { " " };
let value_style = if picked {
Style::new().fg(theme.fg).bg(theme.panel)
} else {
fg
};
lines.push(Line::from(vec![
Span::styled(marker.to_owned(), Style::new().fg(theme.accent)),
Span::styled(format!("{name} "), label),
Span::styled(value, value_style),
]));
}
lines.push(Line::styled(String::new(), dim));
lines.push(Line::styled(
" j/k move ⏎ edit d draft c create esc cancel".to_owned(),
dim,
));
let width = CREATE_PR_WIDTH.min(frame.area().width);
let height = (lines.len() as u16 + 1).min(frame.area().height);
let area = floating(frame, width, height);
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(dialog_block(theme, " Create pull request ", Borders::TOP)),
area,
);
ListHits {
first_row: area.y + 3,
rows: PrField::ORDER.len() as u16,
first_index: 0,
}
}
}
#[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 = floating(frame, width, 4);
let block = dialog_block(theme, " Confirm ", Borders::ALL);
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;
const INPUT_WIDTH: u16 = 72;
impl InputModal {
pub fn render(&self, frame: &mut Frame<'_>, theme: &Theme) {
let box_w = INPUT_WIDTH.min(frame.area().width.max(8));
let inner = (box_w as usize).saturating_sub(2).max(1);
let mut lines = self.wrapped_lines(theme, inner);
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 + 1;
let area = floating(frame, box_w, height);
let block = dialog_block(theme, &format!(" {} ", self.title), Borders::TOP);
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(block),
area,
);
}
fn wrapped_lines(&self, theme: &Theme, width: usize) -> 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 logical in self.buffer.split('\n') {
let chars: Vec<char> = logical.chars().collect();
let len = chars.len();
let cursor_here = (offset..=offset + len).contains(&self.cursor);
let cursor_col = self.cursor.saturating_sub(offset);
for (start, end) in wrap_ranges(&chars, width) {
let last = end >= len;
let owns = cursor_here
&& cursor_col >= start
&& (cursor_col < end || (last && cursor_col == len));
if owns {
let col = cursor_col - start;
let before: String = chars
.get(start..start + col)
.unwrap_or(&[])
.iter()
.collect();
let at = chars
.get(start + col)
.map_or_else(|| " ".to_owned(), char::to_string);
let rest = (start + col + 1).min(end);
let after: String = chars.get(rest..end).unwrap_or(&[]).iter().collect();
lines.push(Line::from(vec![
Span::styled(before, fg),
Span::styled(at, cursor_cell),
Span::styled(after, fg),
]));
} else {
let seg: String = chars.get(start..end).unwrap_or(&[]).iter().collect();
lines.push(Line::styled(seg, fg));
}
}
offset += len + 1;
}
lines
}
}
fn wrap_ranges(chars: &[char], width: usize) -> Vec<(usize, usize)> {
let len = chars.len();
if len == 0 {
return vec![(0, 0)];
}
let mut ranges = Vec::new();
let mut start = 0;
while start < len {
let mut end = (start + width).min(len);
if end < len
&& let Some(space) = (start..end).rev().find(|&i| chars.get(i) == Some(&' '))
&& space > start
{
end = space + 1; }
ranges.push((start, end));
start = end;
}
ranges
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FuzzyModal {
pub title: String,
pub query: String,
pub cursor: usize,
pub typing: bool,
pub items: Vec<(String, String)>,
pub selected: usize,
pub footer: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ListHits {
pub first_row: u16,
pub rows: u16,
pub first_index: usize,
}
impl ListHits {
pub fn index_at(&self, row: u16) -> Option<usize> {
let offset = row.checked_sub(self.first_row)?;
(offset < self.rows).then(|| self.first_index + offset as usize)
}
}
impl FuzzyModal {
pub(crate) fn render(&self, frame: &mut Frame<'_>, theme: &Theme) -> ListHits {
let width = self
.items
.iter()
.map(|(left, right)| left.chars().count() + right.chars().count() + 3)
.chain([
self.footer.len(),
self.title.chars().count() + 2,
self.query.chars().count() + 4,
44,
])
.max()
.unwrap_or(0) as u16
+ 4;
let max_rows = (frame.area().height.saturating_sub(6) as usize).max(1);
let visible = self.items.len().min(max_rows);
let height = visible as u16 + 3;
let width = width.min(frame.area().width);
let area = floating(frame, width, height);
let inner = width.saturating_sub(2) as usize;
let split = self
.query
.char_indices()
.nth(self.cursor)
.map_or(self.query.len(), |(at, _)| at);
let (before, after) = self.query.split_at(split);
let mut rest = after.chars();
let under = rest.next().unwrap_or(' ');
let fg = Style::new().fg(theme.fg).bg(theme.panel);
let cursor_style = if self.typing {
Style::new().fg(theme.panel).bg(theme.fg)
} else {
fg
};
let mut lines = vec![Line::from(vec![
Span::styled(
" > ".to_owned(),
Style::new().fg(theme.accent).bg(theme.panel),
),
Span::styled(before.to_owned(), fg),
Span::styled(under.to_string(), cursor_style),
Span::styled(rest.as_str().to_owned(), fg),
])];
let top = self.selected.saturating_sub(visible.saturating_sub(1));
for (index, (left, right)) in self.items.iter().enumerate().skip(top).take(visible) {
let bg = if index == self.selected {
theme.cursor_line
} else {
theme.panel
};
let gap = inner
.saturating_sub(left.chars().count() + right.chars().count() + 2)
.max(1);
lines.push(Line::from(vec![
Span::styled(format!(" {left}"), Style::new().fg(theme.fg).bg(bg)),
Span::styled(" ".repeat(gap), Style::new().bg(bg)),
Span::styled(format!("{right} "), Style::new().fg(theme.dim).bg(bg)),
]));
}
lines.push(Line::styled(
self.footer.clone(),
Style::new().fg(theme.dim).bg(theme.panel),
));
frame.render_widget(
Paragraph::new(lines)
.style(Style::new().fg(theme.fg).bg(theme.panel))
.block(dialog_block(
theme,
&format!(" {} ", self.title),
Borders::TOP,
)),
area,
);
ListHits {
first_row: area.y + 2,
rows: visible as u16,
first_index: top,
}
}
}
fn dialog_block(theme: &Theme, title: &str, borders: Borders) -> Block<'static> {
Block::new()
.borders(borders)
.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),
))
}
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,
}
}
const SCRIM: u16 = 74;
fn floating(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
let full = frame.area();
let buffer = frame.buffer_mut();
for y in full.top()..full.bottom() {
for x in full.left()..full.right() {
let Some(cell) = buffer.cell_mut((x, y)) else {
continue;
};
cell.set_fg(darkened(cell.fg));
cell.set_bg(darkened(cell.bg));
}
}
let area = centered(full, width, height);
frame.render_widget(Clear, area);
area
}
fn darkened(color: Color) -> Color {
crate::theme::blend(color, Color::Rgb(0, 0, 0), SCRIM)
}
#[cfg(test)]
pub(super) mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::Block;
use super::*;
pub(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 {
summary: Vec::new(),
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 popup_wraps_into_columns_when_too_tall_to_fit() {
let entries: Vec<(String, String)> = (0..60)
.map(|i| (format!("k{i}"), format!("action_{i}")))
.collect();
let popup = Popup {
summary: Vec::new(),
title: "Many keys".to_owned(),
entries,
};
let terminal = render(|frame, theme| popup.render(frame, theme));
let content = terminal.backend().to_string();
assert!(content.contains("action_0"), "{content}");
assert!(content.contains("action_59"), "{content}");
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn a_centered_dialog_dims_every_corner_of_the_screen_behind_it() {
let dialog = ConfirmDialog {
message: "Discard changes to src/lib.rs?".to_owned(),
};
let terminal = render(|frame, theme| dialog.render(frame, theme));
let buffer = terminal.backend().buffer();
let theme = Theme::github_dark();
let bg = |x: u16, y: u16| buffer.cell((x, y)).expect("in bounds").bg;
for (x, y) in [(0, 0), (119, 0), (0, 39), (119, 39)] {
assert_eq!(bg(x, y), darkened(theme.bg), "({x},{y}) behind the dialog");
}
assert_eq!(bg(60, 20), theme.panel, "the dialog itself stays lit");
}
#[test]
fn the_scrim_darkens_the_text_behind_it_as_well_as_the_ground() {
let dialog = ConfirmDialog {
message: "Discard changes to src/lib.rs?".to_owned(),
};
let terminal = render(|frame, theme| dialog.render(frame, theme));
let cell = terminal
.backend()
.buffer()
.cell((0, 0))
.expect("in bounds")
.clone();
assert_eq!(cell.symbol(), "s");
assert_eq!(cell.fg, darkened(Theme::github_dark().fg));
}
#[test]
fn a_dialog_on_a_tiny_terminal_draws_without_panicking() {
let modal = InputModal {
title: "Comment".to_owned(),
buffer: "hi".to_owned(),
cursor: 2,
};
let backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend).expect("terminal");
let theme = Theme::github_dark();
terminal
.draw(|frame| modal.render(frame, &theme))
.expect("draw");
assert_eq!(terminal.backend().buffer().area.height, 4);
}
#[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_wraps_a_long_line_onto_multiple_rows() {
let long = "the quick brown fox jumps over the lazy dog and then keeps on \
running well past the right edge of the comment box";
let modal = InputModal {
title: "Comment".to_owned(),
buffer: long.to_owned(),
cursor: long.chars().count(),
};
let terminal = render(|frame, theme| modal.render(frame, theme));
let content = terminal.backend().to_string();
assert!(content.contains("keeps on running"), "{content}");
assert!(content.contains("well past the right edge"), "{content}");
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}"
);
}
}
#[cfg(test)]
mod create_pr_tests {
use super::tests::render;
use super::*;
use crate::app::pr_create::{PrDraft, PrField};
#[test]
fn create_pr_form_renders() {
let draft = PrDraft {
base: "main".to_owned(),
head: "feat/pr-create".to_owned(),
title: "pr create".to_owned(),
body: "- first\n- second\n".to_owned(),
draft: false,
commits: 2,
needs_push: true,
field: PrField::Title,
};
let terminal = render(|frame, theme| {
CreatePrForm { draft: &draft }.render(frame, theme);
});
insta::assert_snapshot!(terminal.backend());
}
}