use crate::git::load::{Commit, FileEntry, RefKind, RefLabel, fit_refs};
use crate::tui::clamp_scroll;
use crate::tui::input::{char_to_byte, is_back, is_down, is_up};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, 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;
#[derive(Clone, Copy)]
pub enum CommitMark {
Unpushed,
Incoming,
}
impl CommitMark {
pub fn glyph(self) -> &'static str {
match self {
CommitMark::Unpushed => "↑",
CommitMark::Incoming => "↓",
}
}
pub fn style(self) -> Style {
match self {
CommitMark::Unpushed => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
CommitMark::Incoming => Style::default().fg(Color::Magenta),
}
}
}
pub fn commit_item(c: &Commit, mark: Option<CommitMark>, width: usize) -> ListItem<'static> {
let mark = match mark {
Some(m) => Span::styled(format!("{} ", m.glyph()), m.style()),
None => 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 const CREATE: &str = "c create";
pub const EDIT: &str = "e edit";
pub const DEL: &str = "d del";
pub const FIND: &str = "/ find";
pub const REFRESH: &str = "r refresh";
pub const QUIT: &str = "q quit";
pub const HELP: &str = "? help";
pub const KEEP: &str = "↵ keep";
pub const SELECT: &str = "↵ select";
pub const BACK: &str = "esc back";
pub const CANCEL: &str = "esc cancel";
pub const CLOSE: &str = "esc close";
pub const SEP: &str = " · ";
pub const GATE_KEYS: &[&str] = &[SELECT, CANCEL];
pub const READER_KEYS: &[&str] = &[CLOSE];
pub const FILTER_KEYS: &[&str] = &[KEEP, BACK];
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) -> bool {
let had = !self.text.is_empty();
self.text.clear();
self.caret = 0;
had
}
pub fn on_key(&mut self, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let mut chars: Vec<char> = self.text.chars().collect();
let len = chars.len();
let mut at = self.caret.min(len);
match key.code {
KeyCode::Left => at = at.saturating_sub(1),
KeyCode::Right => at = (at + 1).min(len),
KeyCode::Home => at = 0,
KeyCode::End => at = len,
KeyCode::Backspace if alt || ctrl => {
let from = word_start(&chars, at, char::is_alphanumeric);
chars.drain(from..at);
at = from;
}
KeyCode::Backspace if at > 0 => {
chars.remove(at - 1);
at -= 1;
}
KeyCode::Delete if at < len => {
chars.remove(at);
}
KeyCode::Char(c) if ctrl => match c {
'a' => at = 0,
'e' => at = len,
'b' => at = at.saturating_sub(1),
'f' => at = (at + 1).min(len),
'h' if at > 0 => {
chars.remove(at - 1);
at -= 1;
}
'd' if at < len => {
chars.remove(at);
}
'w' => {
let from = word_start(&chars, at, |c| !c.is_whitespace());
chars.drain(from..at);
at = from;
}
'u' => {
chars.drain(..at);
at = 0;
}
_ => {}
},
KeyCode::Char(c) if alt => match c {
'b' => at = word_start(&chars, at, char::is_alphanumeric),
'f' => at = word_end(&chars, at),
'd' => {
let to = word_end(&chars, at);
chars.drain(at..to);
}
_ => {}
},
KeyCode::Char(c) => {
chars.insert(at, c);
at += 1;
}
_ => {}
}
self.caret = at;
let text: String = chars.into_iter().collect();
let changed = text != self.text;
self.text = text;
changed
}
pub fn split(&self) -> (&str, &str) {
self.text.split_at(char_to_byte(&self.text, self.caret))
}
pub fn clear(&mut self) {
self.text.clear();
self.caret = 0;
}
pub fn title_span(&self, key: &str, editing: bool, filterable: bool) -> Option<Span<'static>> {
let lead = match key.chars().count() {
1 => key.to_string(),
_ => format!("{key} "),
};
if editing {
let (before, after) = self.split();
Some(Span::raw(format!(" {lead}{before}▏{after}")))
} else if !self.text.is_empty() {
Some(Span::raw(format!(" {lead}{}", self.text)))
} else if filterable {
Some(Span::styled(
format!(" {key} find"),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
))
} else {
None
}
}
}
fn word_start(chars: &[char], mut at: usize, in_word: fn(char) -> bool) -> usize {
while at > 0 && !in_word(chars[at - 1]) {
at -= 1;
}
while at > 0 && in_word(chars[at - 1]) {
at -= 1;
}
at
}
fn word_end(chars: &[char], mut at: usize) -> usize {
while at < chars.len() && !chars[at].is_alphanumeric() {
at += 1;
}
while at < chars.len() && chars[at].is_alphanumeric() {
at += 1;
}
at
}
pub const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
pub fn key_footer(
actions: &[&str],
note: Option<&(bool, String)>,
command: Option<&CommandLine>,
help: Option<&str>,
width: u16,
) -> Paragraph<'static> {
let dim = Style::default().fg(Color::DarkGray);
let line = match (command, note, help) {
(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, None) => Line::from(Span::styled(fit(actions, width), dim)),
(None, None, Some(help)) => {
let pin = format!("{help} ");
let pin_w = pin.chars().count();
let room = (width as usize).saturating_sub(pin_w + SEP.chars().count());
let left = fit(actions, room as u16);
let pad = (width as usize).saturating_sub(left.chars().count() + pin_w);
Line::from(vec![
Span::styled(left, dim),
Span::raw(" ".repeat(pad)),
Span::styled(pin, dim),
])
}
};
Paragraph::new(line)
}
fn fit(keys: &[&str], width: u16) -> String {
let mut line = String::new();
for key in keys {
let next = if line.is_empty() {
format!(" {key}")
} else {
format!("{line}{SEP}{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
}
pub fn vscrollbar(frame: &mut Frame, area: Rect, total: usize, top: usize, view: usize) {
if total <= view {
return;
}
let mut state = ScrollbarState::new(total - view).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 hscrollbar(frame: &mut Frame, area: Rect, total: usize, left: usize, view: usize) {
if total <= view {
return;
}
let mut state = ScrollbarState::new(total - view).position(left);
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 fn diff_scrollbar(frame: &mut Frame, area: Rect, total_lines: usize, scroll: u16) {
let view = area.height.saturating_sub(2) as usize;
vscrollbar(frame, area, total_lines, scroll as usize, view);
}
pub fn list_scrollbar(frame: &mut Frame, area: Rect, total: usize, offset: usize) {
let view = area.height.saturating_sub(2) as usize;
vscrollbar(frame, area, total, offset, view);
}
pub fn diff_hscrollbar(frame: &mut Frame, area: Rect, max_line: usize, cell_w: u16, hscroll: u16) {
hscrollbar(frame, area, max_line, hscroll as usize, cell_w as usize);
}
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,
help: Option<Vec<Line<'static>>>,
scroll: u16,
colour: Color,
}
pub type HelpSection = (&'static str, &'static [(&'static str, &'static str)]);
const HELP_KEYS: usize = 16;
fn help_lines(sections: &[HelpSection]) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (section, entries) in sections {
if !lines.is_empty() {
lines.push(Line::raw(""));
}
lines.push(Line::styled(
*section,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
for (keys, what) in *entries {
if keys.is_empty() {
lines.push(Line::styled(
format!(" {what}"),
Style::default().add_modifier(Modifier::DIM),
));
continue;
}
lines.push(Line::from(vec![
Span::styled(
format!(" {keys:<HELP_KEYS$}"),
Style::default().fg(Color::Yellow),
),
Span::raw(*what),
]));
}
}
lines
}
impl Modal {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Modal {
Modal {
title: title.into(),
body: printable(&body.into()),
help: None,
scroll: 0,
colour: Color::Yellow,
}
}
pub fn help(sections: &[HelpSection]) -> Modal {
Modal {
help: Some(help_lines(sections)),
colour: Color::Cyan,
..Modal::new("help", "")
}
}
pub fn reader(title: impl Into<String>, rows: &[(String, String)]) -> Modal {
let width = rows
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
+ 2;
let body = rows
.iter()
.map(|(key, does)| format!("{key:<width$}{does}"))
.collect::<Vec<_>>()
.join("\n");
Modal {
title: title.into(),
body: printable(&body),
help: None,
scroll: 0,
colour: Color::Cyan,
}
}
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, ctrl: bool) -> bool {
if self.help.is_some() {
return self.help_key(code, ctrl);
}
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
}
fn help_key(&mut self, code: KeyCode, ctrl: bool) -> bool {
let half = 10;
match code {
KeyCode::Char('d') if ctrl => self.scroll = self.scroll.saturating_add(half),
KeyCode::Char('u') if ctrl => self.scroll = self.scroll.saturating_sub(half),
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(half),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(half),
KeyCode::Char('g') | KeyCode::Home => self.scroll = 0,
KeyCode::Char('G') | KeyCode::End => self.scroll = u16::MAX,
_ if is_down(code) => self.scroll = self.scroll.saturating_add(1),
_ if is_up(code) => self.scroll = self.scroll.saturating_sub(1),
_ if is_back(code) => return true,
KeyCode::Enter | KeyCode::Char('q' | ' ' | '?') => return true,
_ => {}
}
false
}
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
if let Some(lines) = &self.help {
let lines = lines.clone();
return self.draw_help(frame, area, lines);
}
let width = box_width(area.width);
let body_h = wrapped_height(&self.body, box_inner_width(width)) as u16;
let rect = popup_area(area, width, box_height(body_h + 2, area.height));
let block = box_block(self.colour, &self.title);
let inner = block.inner(rect);
let [text_area, _, keys_area] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
self.scroll = clamp_scroll(self.scroll, body_h as usize, text_area.height);
frame.render_widget(Clear, rect); frame.render_widget(block, rect);
let lines: Vec<Line> = self
.body
.lines()
.map(|l| Line::raw(l.to_string()))
.collect();
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((self.scroll, 0));
frame.render_widget(body, text_area);
frame.render_widget(Paragraph::new(box_hint(READER_KEYS)), keys_area);
vscrollbar(
frame,
rect,
body_h as usize,
self.scroll as usize,
text_area.height as usize,
);
}
fn draw_help(&mut self, frame: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
let width = box_width(area.width);
let rect = popup_area(area, width, box_height(lines.len() as u16 + 2, area.height));
let block = box_block(self.colour, &self.title);
let inner = block.inner(rect);
let [text_area, _, keys_area] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
self.scroll = clamp_scroll(self.scroll, lines.len(), text_area.height);
frame.render_widget(Clear, rect);
frame.render_widget(block, rect);
let shown = text_area.height as usize;
let total = lines.len();
frame.render_widget(Paragraph::new(lines).scroll((self.scroll, 0)), text_area);
frame.render_widget(Paragraph::new(box_hint(READER_KEYS)), keys_area);
vscrollbar(frame, rect, total, self.scroll as usize, shown);
}
}
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.join(SEP),
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,
area: Rect,
colour: Color,
title: &str,
name: &str,
note: Option<&str>,
yes: bool,
) {
let width = box_width(area.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(GATE_KEYS));
draw_box(frame, area, width, lines, colour, title);
}
fn draw_box(
frame: &mut Frame,
area: Rect,
width: u16,
lines: Vec<Line<'static>>,
colour: Color,
title: &str,
) {
let inner = box_inner_width(width);
let rows: usize = lines
.iter()
.map(|l| {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
wrapped_rows(&text, inner)
})
.sum();
let rect = popup_area(area, width, box_height(rows as u16, area.height));
frame.render_widget(Clear, rect);
let body = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(box_block(colour, title));
frame.render_widget(body, rect);
}
pub fn typed_popup(
frame: &mut Frame,
area: Rect,
title: &str,
note: Option<&str>,
name: &str,
typed: &Query,
verb: &str,
) {
let width = box_width(area.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.text == name;
let field = if armed {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let (before, after) = typed.split();
lines.push(Line::from(Span::styled(format!("{before}▏{after}"), field)));
lines.push(Line::from(""));
let dim = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM);
let enter = Span::styled(
format!("↵ {verb}"),
if armed {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
dim
},
);
lines.push(Line::from(vec![
enter,
Span::styled(format!("{SEP}{CANCEL}"), dim),
]));
draw_box(frame, area, width, lines, Color::Red, title);
}
pub struct Field {
pub label: &'static str,
pub value: Query,
pub required: bool,
pub placeholder: &'static str,
}
impl Field {
pub fn new(label: &'static str, placeholder: &'static str, required: bool) -> Field {
Field {
label,
value: Query::default(),
required,
placeholder,
}
}
pub fn with(mut self, text: &str) -> Field {
self.value.text = text.to_string();
self.value.caret = text.chars().count();
self
}
}
pub enum FormKey {
Open,
Cancel,
Submit,
}
pub struct Form {
pub title: String,
pub fields: Vec<Field>,
pub at: usize,
}
const FORM_VALUES: usize = 14;
const FORM_VALUES_MAX: usize = 22;
impl Form {
pub fn new(title: impl Into<String>, fields: Vec<Field>) -> Form {
Form {
title: title.into(),
fields,
at: 0,
}
}
pub fn value(&self, i: usize) -> &str {
self.fields.get(i).map_or("", |f| f.value.text.trim())
}
pub fn on_key(&mut self, code: KeyCode, key: KeyEvent) -> FormKey {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let last = self.fields.len().saturating_sub(1);
match code {
KeyCode::Esc => return FormKey::Cancel,
KeyCode::Enter => {
let blank = self
.fields
.iter()
.position(|f| f.required && f.value.text.trim().is_empty());
match blank {
Some(i) if i <= self.at || self.at == last => self.at = i,
_ if self.at < last => self.at += 1,
_ => return FormKey::Submit,
}
}
KeyCode::Tab => self.at = if self.at == last { 0 } else { self.at + 1 },
KeyCode::BackTab => self.at = if self.at == 0 { last } else { self.at - 1 },
KeyCode::Down => self.at = (self.at + 1).min(last),
KeyCode::Up => self.at = self.at.saturating_sub(1),
KeyCode::Char('j') if ctrl => self.at = (self.at + 1).min(last),
KeyCode::Char('k') if ctrl => self.at = self.at.saturating_sub(1),
_ => {
if let Some(field) = self.fields.get_mut(self.at) {
field.value.on_key(key);
}
}
}
FormKey::Open
}
pub fn draw(&self, frame: &mut Frame, area: Rect) {
let width = box_width(area.width);
let longest = self
.fields
.iter()
.map(|f| f.label.chars().count() + 3)
.max()
.unwrap_or(0);
let col = longest.clamp(FORM_VALUES, FORM_VALUES_MAX);
let dim = Style::default().add_modifier(Modifier::DIM);
let mut lines = Vec::new();
for (i, f) in self.fields.iter().enumerate() {
let focused = i == self.at;
let label = if focused { Style::default() } else { dim };
let mut spans = vec![Span::styled(f.label.to_string(), label)];
let mut used = f.label.chars().count() + 1;
if f.required {
spans.push(Span::styled("*", Style::default().fg(Color::Red)));
used += 1;
}
spans.push(Span::styled(":", label));
spans.push(Span::raw(" ".repeat(col.saturating_sub(used).max(1))));
let placeholder = Span::styled(
f.placeholder.to_string(),
Style::default().fg(Color::DarkGray),
);
match (focused, f.value.text.is_empty()) {
(true, true) => spans.extend([Span::raw("▏"), placeholder]),
(true, false) => {
let (before, after) = f.value.split();
spans.push(Span::raw(format!("{before}▏{after}")));
}
(false, true) => spans.push(placeholder),
(false, false) => spans.push(Span::styled(f.value.text.clone(), dim)),
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let enter = if self.at + 1 == self.fields.len() {
"↵ submit"
} else {
"↵ next"
};
let mut keys = vec![enter, CANCEL];
if self.fields.iter().any(|f| f.required) {
keys.push("* required");
}
lines.push(box_hint(&keys));
draw_box(frame, area, width, lines, Color::Cyan, &self.title);
}
}
pub fn wrapped_height(text: &str, width: usize) -> usize {
text.lines().map(|l| wrapped_rows(l, width)).sum()
}
fn wrapped_rows(text: &str, width: usize) -> usize {
if width == 0 {
return 1;
}
let mut rows = 1usize;
let mut col = 0usize;
for (i, word) in text.split(' ').enumerate() {
let w = word.chars().count();
let need = if i == 0 { w } else { col + 1 + w };
if need <= width {
col = need;
} else {
rows += 1;
col = w;
}
while col > width {
rows += 1;
col -= width;
}
}
rows
}
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"));
}
}