1
use crate::form::{Field, Form, FormKind};
2
use crate::widgets::{SelectWidget, Widget};
3
use ratatui::style::{Modifier, Style};
4
use ratatui::text::{Line, Text};
5

            
6
const MAX_VISIBLE_OPTIONS: usize = 8;
7

            
8
12
pub(super) fn render_field(i: usize, f: &Field, focus: usize, width: usize) -> Vec<Line<'static>> {
9
12
    let focused = i == focus;
10
12
    let marker = if focused { "▸" } else { " " };
11
12
    let label = format!("{}:", f.label);
12
12
    let style = if focused {
13
7
        Style::default().add_modifier(Modifier::REVERSED)
14
    } else {
15
5
        Style::default()
16
    };
17
12
    match (&f.widget, focused) {
18
1
        (Widget::Splits(sw), _) => {
19
1
            let mut lines = vec![Line::styled(format!("{marker} {label}"), style)];
20
1
            lines.extend(
21
1
                sw.render_lines()
22
1
                    .into_iter()
23
1
                    .map(|l| Line::from(format!("    {l}"))),
24
            );
25
1
            lines
26
        }
27
4
        (Widget::Select(sw), true) => render_select_expanded(marker, &label, sw, style, width),
28
7
        _ => vec![Line::styled(
29
7
            format!("{marker} {label:<width$} [{}]", f.widget.display()),
30
7
            style,
31
        )],
32
    }
33
12
}
34

            
35
5
fn render_select_expanded(
36
5
    marker: &str,
37
5
    label: &str,
38
5
    sw: &SelectWidget,
39
5
    style: Style,
40
5
    width: usize,
41
5
) -> Vec<Line<'static>> {
42
5
    let mut lines = vec![Line::styled(
43
5
        format!(
44
            "{marker} {label:<width$} [{}]  filter:{}",
45
5
            sw.display(),
46
5
            sw.query()
47
        ),
48
5
        style,
49
    )];
50
5
    let matches = sw.matches();
51
5
    let focused_idx = sw.focused_in_filtered();
52
5
    let opts = sw.options();
53
5
    let total = matches.len();
54
    // Slide a fixed-size window so the focused option is always visible even
55
    // when the filtered list is longer than the cap.
56
5
    let window = MAX_VISIBLE_OPTIONS;
57
5
    let start = if total <= window || focused_idx < window {
58
4
        0
59
    } else {
60
1
        (focused_idx + 1 - window).min(total - window)
61
    };
62
5
    let end = (start + window).min(total);
63
5
    if start > 0 {
64
1
        lines.push(Line::from(format!("    ↑ ({start} more above)")));
65
4
    }
66
21
    for (local, &idx) in matches[start..end].iter().enumerate() {
67
21
        let row_style = if start + local == focused_idx {
68
5
            Style::default().add_modifier(Modifier::REVERSED)
69
        } else {
70
16
            Style::default()
71
        };
72
21
        lines.push(Line::styled(format!("    {}", opts[idx].label), row_style));
73
    }
74
5
    if end < total {
75
2
        lines.push(Line::from(format!("    ↓ (+{} more)", total - end)));
76
3
    }
77
5
    lines
78
5
}
79

            
80
3
pub(super) fn form_modal_content(form: &Form) -> (&'static str, Text<'static>) {
81
3
    let (title, footer) = match form.kind {
82
2
        FormKind::ConfigSet => ("Set config", "\n\nEnter to save, Esc to cancel."),
83
        FormKind::ReportParams { .. } => (
84
            "Report parameters",
85
            "\n\nEnter to fetch, Esc to cancel.\nDates: YYYY-MM-DD or RFC3339. Chart: bar | line | stacked.",
86
        ),
87
        FormKind::CommodityCreate => ("Create Commodity", "\n\nEnter to save, Esc to cancel."),
88
        FormKind::AccountCreate => ("Create Account", "\n\nEnter to save, Esc to cancel."),
89
1
        FormKind::TransactionCreate => (
90
1
            "Create Transaction",
91
1
            "\n\nDate: YYYY-MM-DDTHH:MM  Up/Down +/-1 day\
92
1
             \nTab/BTab cell (yields at edges)  Up/Down row or option  Enter submit  Esc cancel\
93
1
             \n+/C-n add row  - /C-d remove row",
94
1
        ),
95
        FormKind::TransactionEdit => (
96
            "Edit Transaction",
97
            "\n\nDate: YYYY-MM-DDTHH:MM  Up/Down +/-1 day\
98
             \nTab/BTab cell (yields at edges)  Up/Down row or option  Enter submit  Esc cancel\
99
             \n+/C-n add row  - /C-d remove row",
100
        ),
101
        FormKind::AccountTag => ("Set Account Tag", "\n\nEnter to save, Esc to cancel."),
102
        FormKind::TransactionTag => ("Set Transaction Tag", "\n\nEnter to save, Esc to cancel."),
103
        FormKind::CommodityConvert => (
104
            "Convert Amount",
105
            "\n\nUp/Down select commodity  Enter to convert  Esc to cancel.",
106
        ),
107
    };
108
7
    let width = form.fields.iter().map(|f| f.label.len()).max().unwrap_or(0) + 1;
109
3
    let mut lines: Vec<Line<'static>> = form
110
3
        .fields
111
3
        .iter()
112
3
        .enumerate()
113
7
        .flat_map(|(i, f)| render_field(i, f, form.focus, width))
114
3
        .collect();
115
    // The footer leads with `\n` that, in the old single-string body, served as
116
    // the break after the last field. Strip exactly one so it does not render as
117
    // an extra blank line now that fields are already discrete `Line`s.
118
3
    let footer_body = if lines.is_empty() {
119
        footer
120
    } else {
121
3
        footer.strip_prefix('\n').unwrap_or(footer)
122
    };
123
3
    lines.extend(footer_body.lines().map(Line::from));
124
3
    (title, Text::from(lines))
125
3
}
126

            
127
#[cfg(test)]
128
mod tests {
129
    use super::*;
130
    use crate::widgets::{EditMode, Editor, SelectOption, SelectWidget};
131

            
132
40
    fn line_text(line: &Line<'_>) -> String {
133
40
        line.spans.iter().map(|s| s.content.as_ref()).collect()
134
40
    }
135

            
136
    #[test]
137
1
    fn footer_separated_from_fields_by_single_blank_line() {
138
1
        let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
139
1
        let (_title, text) = form_modal_content(&form);
140
1
        let texts: Vec<String> = text.lines.iter().map(line_text).collect();
141
1
        let footer = texts
142
1
            .iter()
143
4
            .position(|l| l.contains("Enter to save"))
144
1
            .expect("footer line present");
145
1
        assert!(
146
1
            footer >= 1 && texts[footer - 1].trim().is_empty(),
147
            "exactly one blank line must precede the footer: {texts:?}"
148
        );
149
1
        assert!(
150
1
            footer < 2 || !texts[footer - 2].trim().is_empty(),
151
            "no double blank line before the footer: {texts:?}"
152
        );
153
1
    }
154

            
155
4
    fn make_select_field(opts: Vec<SelectOption>) -> Field {
156
4
        let mut sw = SelectWidget::new();
157
4
        sw.set_options(opts);
158
4
        Field {
159
4
            label: "pick",
160
4
            widget: Widget::Select(sw),
161
4
        }
162
4
    }
163

            
164
    #[test]
165
1
    fn focused_select_renders_option_labels() {
166
1
        let field = make_select_field(vec![
167
1
            SelectOption {
168
1
                id: "id-a".to_string(),
169
1
                label: "Apple".to_string(),
170
1
            },
171
1
            SelectOption {
172
1
                id: "id-b".to_string(),
173
1
                label: "Banana".to_string(),
174
1
            },
175
        ]);
176
1
        let lines = render_field(0, &field, 0, 10);
177
1
        let texts: Vec<String> = lines.iter().map(line_text).collect();
178
1
        assert!(
179
1
            texts.iter().any(|l| l.contains("Apple")),
180
            "Apple must appear in rendered lines: {texts:?}"
181
        );
182
1
        assert!(
183
3
            texts.iter().any(|l| l.contains("Banana")),
184
            "Banana must appear in rendered lines: {texts:?}"
185
        );
186
1
    }
187

            
188
    #[test]
189
1
    fn focused_select_focused_option_has_reversed_modifier() {
190
1
        let field = make_select_field(vec![
191
1
            SelectOption {
192
1
                id: "id-a".to_string(),
193
1
                label: "Apple".to_string(),
194
1
            },
195
1
            SelectOption {
196
1
                id: "id-b".to_string(),
197
1
                label: "Banana".to_string(),
198
1
            },
199
        ]);
200
1
        let lines = render_field(0, &field, 0, 10);
201
1
        assert!(
202
1
            lines.len() >= 2,
203
            "must have header + at least one option line"
204
        );
205
        // Line::styled puts style on line.style, not on individual spans.
206
        // lines[1] is the first (focused) option.
207
1
        assert!(
208
1
            lines[1].style.add_modifier.contains(Modifier::REVERSED),
209
            "focused option must carry REVERSED modifier"
210
        );
211
        // lines[2] is the second (unfocused) option.
212
1
        assert!(
213
1
            !lines[2].style.add_modifier.contains(Modifier::REVERSED),
214
            "unfocused option must not carry REVERSED modifier"
215
        );
216
1
    }
217

            
218
    #[test]
219
1
    fn focused_select_filter_hint_shows_query() {
220
1
        let mut sw = SelectWidget::new();
221
1
        sw.set_options(vec![SelectOption {
222
1
            id: "id-a".to_string(),
223
1
            label: "Apple".to_string(),
224
1
        }]);
225
1
        sw.filter_push('A');
226
1
        let field = Field {
227
1
            label: "pick",
228
1
            widget: Widget::Select(sw),
229
1
        };
230
1
        let lines = render_field(0, &field, 0, 10);
231
1
        let header = line_text(&lines[0]);
232
1
        assert!(
233
1
            header.contains("filter:A"),
234
            "filter hint must show the typed query: {header}"
235
        );
236
1
    }
237

            
238
    #[test]
239
1
    fn focused_option_beyond_cap_stays_visible_and_highlighted() {
240
1
        let mut sw = SelectWidget::new();
241
1
        sw.set_options(
242
1
            (0..20)
243
1
                .map(|i| SelectOption {
244
20
                    id: format!("id-{i}"),
245
20
                    label: format!("Opt{i}"),
246
20
                })
247
1
                .collect(),
248
        );
249
15
        for _ in 0..15 {
250
15
            sw.next();
251
15
        }
252
1
        let lines = render_select_expanded("▸", "pick", &sw, Style::default(), 6);
253
1
        let focused = lines
254
1
            .iter()
255
10
            .find(|l| line_text(l) == "    Opt15")
256
1
            .expect("focused option past the cap must still be rendered");
257
1
        assert!(
258
1
            focused.style.add_modifier.contains(Modifier::REVERSED),
259
            "the windowed focused option must keep its highlight"
260
        );
261
1
        let texts: Vec<String> = lines.iter().map(line_text).collect();
262
1
        assert!(
263
2
            texts.iter().any(|l| l.contains("more above")),
264
            "the window slid down, so an above-overflow indicator must show: {texts:?}"
265
        );
266
1
    }
267

            
268
    #[test]
269
1
    fn unfocused_select_shows_single_line_no_options() {
270
1
        let field = make_select_field(vec![
271
1
            SelectOption {
272
1
                id: "id-a".to_string(),
273
1
                label: "Apple".to_string(),
274
1
            },
275
1
            SelectOption {
276
1
                id: "id-b".to_string(),
277
1
                label: "Banana".to_string(),
278
1
            },
279
        ]);
280
        // field index 0 but focus = 1 → not focused
281
1
        let lines = render_field(0, &field, 1, 10);
282
1
        assert_eq!(
283
1
            lines.len(),
284
            1,
285
            "unfocused Select must render as single line"
286
        );
287
        // The single line shows the selected value as [Apple], but no separate
288
        // indented option rows. Verify "Banana" doesn't appear (it's not selected).
289
1
        assert!(
290
1
            !line_text(&lines[0]).contains("Banana"),
291
            "unfocused Select header must not list all options: {}",
292
            line_text(&lines[0])
293
        );
294
1
    }
295

            
296
    #[test]
297
1
    fn select_more_than_cap_shows_truncation_indicator() {
298
1
        let opts: Vec<SelectOption> = (0..10)
299
1
            .map(|i| SelectOption {
300
10
                id: format!("id-{i}"),
301
10
                label: format!("Option {i}"),
302
10
            })
303
1
            .collect();
304
1
        let field = make_select_field(opts);
305
1
        let lines = render_field(0, &field, 0, 10);
306
1
        let texts: Vec<String> = lines.iter().map(line_text).collect();
307
        // header + 8 options + 1 truncation indicator = 10 lines
308
1
        assert_eq!(lines.len(), 10, "header + 8 visible + truncation line");
309
1
        assert!(
310
1
            texts.last().is_some_and(|l| l.contains("more")),
311
            "truncation indicator must appear: {texts:?}"
312
        );
313
1
    }
314
}