Lines
91.03 %
Functions
80 %
Branches
100 %
use crate::form::{Field, Form, FormKind};
use crate::widgets::{SelectWidget, Widget};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Text};
const MAX_VISIBLE_OPTIONS: usize = 8;
pub(super) fn render_field(i: usize, f: &Field, focus: usize, width: usize) -> Vec<Line<'static>> {
let focused = i == focus;
let marker = if focused { "▸" } else { " " };
let label = format!("{}:", f.label);
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
match (&f.widget, focused) {
(Widget::Splits(sw), _) => {
let mut lines = vec![Line::styled(format!("{marker} {label}"), style)];
lines.extend(
sw.render_lines()
.into_iter()
.map(|l| Line::from(format!(" {l}"))),
);
lines
}
(Widget::Select(sw), true) => render_select_expanded(marker, &label, sw, style, width),
_ => vec![Line::styled(
format!("{marker} {label:<width$} [{}]", f.widget.display()),
style,
)],
fn render_select_expanded(
marker: &str,
label: &str,
sw: &SelectWidget,
style: Style,
width: usize,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::styled(
format!(
"{marker} {label:<width$} [{}] filter:{}",
sw.display(),
sw.query()
),
)];
let matches = sw.matches();
let focused_idx = sw.focused_in_filtered();
let opts = sw.options();
let total = matches.len();
// Slide a fixed-size window so the focused option is always visible even
// when the filtered list is longer than the cap.
let window = MAX_VISIBLE_OPTIONS;
let start = if total <= window || focused_idx < window {
0
(focused_idx + 1 - window).min(total - window)
let end = (start + window).min(total);
if start > 0 {
lines.push(Line::from(format!(" ↑ ({start} more above)")));
for (local, &idx) in matches[start..end].iter().enumerate() {
let row_style = if start + local == focused_idx {
lines.push(Line::styled(format!(" {}", opts[idx].label), row_style));
if end < total {
lines.push(Line::from(format!(" ↓ (+{} more)", total - end)));
pub(super) fn form_modal_content(form: &Form) -> (&'static str, Text<'static>) {
let (title, footer) = match form.kind {
FormKind::ConfigSet => ("Set config", "\n\nEnter to save, Esc to cancel."),
FormKind::ReportParams { .. } => (
"Report parameters",
"\n\nEnter to fetch, Esc to cancel.\nDates: YYYY-MM-DD or RFC3339. Chart: bar | line | stacked.",
FormKind::CommodityCreate => ("Create Commodity", "\n\nEnter to save, Esc to cancel."),
FormKind::AccountCreate => ("Create Account", "\n\nEnter to save, Esc to cancel."),
FormKind::TransactionCreate => (
"Create Transaction",
"\n\nDate: YYYY-MM-DDTHH:MM Up/Down +/-1 day\
\nTab/BTab cell (yields at edges) Up/Down row or option Enter submit Esc cancel\
\n+/C-n add row - /C-d remove row",
FormKind::TransactionEdit => (
"Edit Transaction",
FormKind::AccountTag => ("Set Account Tag", "\n\nEnter to save, Esc to cancel."),
FormKind::TransactionTag => ("Set Transaction Tag", "\n\nEnter to save, Esc to cancel."),
FormKind::CommodityConvert => (
"Convert Amount",
"\n\nUp/Down select commodity Enter to convert Esc to cancel.",
let width = form.fields.iter().map(|f| f.label.len()).max().unwrap_or(0) + 1;
let mut lines: Vec<Line<'static>> = form
.fields
.iter()
.enumerate()
.flat_map(|(i, f)| render_field(i, f, form.focus, width))
.collect();
// The footer leads with `\n` that, in the old single-string body, served as
// the break after the last field. Strip exactly one so it does not render as
// an extra blank line now that fields are already discrete `Line`s.
let footer_body = if lines.is_empty() {
footer
footer.strip_prefix('\n').unwrap_or(footer)
lines.extend(footer_body.lines().map(Line::from));
(title, Text::from(lines))
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::{EditMode, Editor, SelectOption, SelectWidget};
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
#[test]
fn footer_separated_from_fields_by_single_blank_line() {
let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
let (_title, text) = form_modal_content(&form);
let texts: Vec<String> = text.lines.iter().map(line_text).collect();
let footer = texts
.position(|l| l.contains("Enter to save"))
.expect("footer line present");
assert!(
footer >= 1 && texts[footer - 1].trim().is_empty(),
"exactly one blank line must precede the footer: {texts:?}"
footer < 2 || !texts[footer - 2].trim().is_empty(),
"no double blank line before the footer: {texts:?}"
fn make_select_field(opts: Vec<SelectOption>) -> Field {
let mut sw = SelectWidget::new();
sw.set_options(opts);
Field {
label: "pick",
widget: Widget::Select(sw),
fn focused_select_renders_option_labels() {
let field = make_select_field(vec![
SelectOption {
id: "id-a".to_string(),
label: "Apple".to_string(),
},
id: "id-b".to_string(),
label: "Banana".to_string(),
]);
let lines = render_field(0, &field, 0, 10);
let texts: Vec<String> = lines.iter().map(line_text).collect();
texts.iter().any(|l| l.contains("Apple")),
"Apple must appear in rendered lines: {texts:?}"
texts.iter().any(|l| l.contains("Banana")),
"Banana must appear in rendered lines: {texts:?}"
fn focused_select_focused_option_has_reversed_modifier() {
lines.len() >= 2,
"must have header + at least one option line"
// Line::styled puts style on line.style, not on individual spans.
// lines[1] is the first (focused) option.
lines[1].style.add_modifier.contains(Modifier::REVERSED),
"focused option must carry REVERSED modifier"
// lines[2] is the second (unfocused) option.
!lines[2].style.add_modifier.contains(Modifier::REVERSED),
"unfocused option must not carry REVERSED modifier"
fn focused_select_filter_hint_shows_query() {
sw.set_options(vec![SelectOption {
}]);
sw.filter_push('A');
let field = Field {
let header = line_text(&lines[0]);
header.contains("filter:A"),
"filter hint must show the typed query: {header}"
fn focused_option_beyond_cap_stays_visible_and_highlighted() {
sw.set_options(
(0..20)
.map(|i| SelectOption {
id: format!("id-{i}"),
label: format!("Opt{i}"),
})
.collect(),
for _ in 0..15 {
sw.next();
let lines = render_select_expanded("▸", "pick", &sw, Style::default(), 6);
let focused = lines
.find(|l| line_text(l) == " Opt15")
.expect("focused option past the cap must still be rendered");
focused.style.add_modifier.contains(Modifier::REVERSED),
"the windowed focused option must keep its highlight"
texts.iter().any(|l| l.contains("more above")),
"the window slid down, so an above-overflow indicator must show: {texts:?}"
fn unfocused_select_shows_single_line_no_options() {
// field index 0 but focus = 1 → not focused
let lines = render_field(0, &field, 1, 10);
assert_eq!(
lines.len(),
1,
"unfocused Select must render as single line"
// The single line shows the selected value as [Apple], but no separate
// indented option rows. Verify "Banana" doesn't appear (it's not selected).
!line_text(&lines[0]).contains("Banana"),
"unfocused Select header must not list all options: {}",
line_text(&lines[0])
fn select_more_than_cap_shows_truncation_indicator() {
let opts: Vec<SelectOption> = (0..10)
label: format!("Option {i}"),
let field = make_select_field(opts);
// header + 8 options + 1 truncation indicator = 10 lines
assert_eq!(lines.len(), 10, "header + 8 visible + truncation line");
texts.last().is_some_and(|l| l.contains("more")),
"truncation indicator must appear: {texts:?}"