Skip to main content

improv_tui/
ui.rs

1//! Rendering: a status box (title, prompt/message, pages, filters, key help)
2//! sized to its content, over the pivot grid as a table.
3
4use crate::app::{App, CommandKind, Grid, GridGeom};
5use crate::diagnose::Diagnostic;
6use crate::edit::LineEdit;
7use crate::fnhelp::FnHelp;
8use ratatui::{
9    layout::{Constraint, Direction, Layout, Rect},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{
13        Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, Wrap,
14    },
15    Frame,
16};
17
18/// Key help entries, packed greedily into lines that fit the status box.
19const HELP: &[&str] = &[
20    "arrows/click: move",
21    "Shift+arrows: range",
22    "e/Enter: edit",
23    "Tab (formula): complete",
24    "[ ]: page",
25    "p: pivot",
26    "f/F: filter",
27    "S: save view",
28    "v: view",
29    "Tab/m: measure",
30    "I: import-csv",
31    "E: export-csv",
32    "R: revert",
33    "?/F1: functions",
34    "q: quit",
35];
36
37fn help_lines(width: usize) -> Vec<Line<'static>> {
38    let mut lines = Vec::new();
39    let mut cur = String::new();
40    for h in HELP {
41        if !cur.is_empty() && cur.len() + 2 + h.len() > width {
42            lines.push(Line::from(std::mem::take(&mut cur)));
43        }
44        if !cur.is_empty() {
45            cur.push_str("  ");
46        }
47        cur.push_str(h);
48    }
49    lines.push(Line::from(cur));
50    lines
51}
52
53/// Status box content for an inner width of `width` columns. The prompt or
54/// message comes right after the title, so a short terminal clips the help
55/// text first, never the line being typed.
56fn status_lines(app: &App, width: usize) -> Vec<Line<'static>> {
57    let g = &app.grid;
58    let axes = match (&g.row_cat, &g.col_cat) {
59        (Some((_, r)), Some((_, c))) => format!("rows: {r}  cols: {c}"),
60        (Some((_, r)), None) => format!("rows: {r}"),
61        (None, _) => "scalar".to_string(),
62    };
63    let kind = if app
64        .model
65        .measures
66        .get(&g.measure)
67        .map(|m| m.is_derived())
68        .unwrap_or(false)
69    {
70        "derived"
71    } else {
72        "input"
73    };
74    let sum = match app.selection_sum() {
75        Some(v) => format!("Sum {v}"),
76        None => "Sum \u{2014}".to_string(),
77    };
78    let coord = format!(
79        "cell [{}, {}] = {}   {sum}",
80        g.rows
81            .get(app.cursor_row)
82            .map(|(_, n)| n.as_str())
83            .unwrap_or(""),
84        g.cols
85            .get(app.cursor_col)
86            .map(|(_, n)| n.as_str())
87            .unwrap_or(""),
88        g.display_at(app.cursor_row, app.cursor_col),
89    );
90
91    let mut lines = vec![Line::from(format!(
92        "{} [{}]   {axes}   {coord}",
93        g.measure_name, kind
94    ))];
95    if let Some(f) = app.formula_text() {
96        lines.push(Line::from(format!("{} = {f}", g.measure_name)));
97    }
98    if let Some(e) = &app.edit {
99        let formula = app.editing_formula();
100        let prompt = if formula { "formula> " } else { "edit> " };
101        let d = app.diagnostic.as_ref();
102        lines.push(edit_line(e, prompt, formula, d));
103        if let Some(d) = d {
104            lines.extend(diagnostic_lines(e, d, prompt));
105        } else if formula {
106            lines.extend(signature_line(e));
107        }
108    } else if let Some((kind, buf)) = &app.command {
109        let prompt = match kind {
110            CommandKind::ImportCsv => "import-csv",
111            CommandKind::ExportCsv => "export-csv",
112        };
113        lines.push(Line::from(format!("{prompt}> {buf}")));
114    }
115    // Under the prompt, so an edit's error shows while it is still open.
116    if let Some(msg) = &app.status {
117        lines.push(Line::from(msg.clone()));
118    }
119    if !g.pages.is_empty() {
120        let pages = g
121            .pages
122            .iter()
123            .map(|p| {
124                format!(
125                    "{}={} ({}/{})",
126                    p.cat_name,
127                    p.item_name,
128                    p.item_index + 1,
129                    p.item_count
130                )
131            })
132            .collect::<Vec<_>>()
133            .join(", ");
134        lines.push(Line::from(format!("pages: {pages}   ([ ] to change)")));
135    }
136    if !app.filters.is_empty() {
137        let f = app
138            .filters
139            .iter()
140            .map(|f| {
141                let cname = app
142                    .model
143                    .categories
144                    .get(&f.category)
145                    .map(|c| c.name.0.clone())
146                    .unwrap_or_default();
147                format!("{cname}: {} shown", f.items.len())
148            })
149            .collect::<Vec<_>>()
150            .join(", ");
151        lines.push(Line::from(format!("filters: {f}   (F to clear)")));
152    }
153    lines.extend(help_lines(width));
154    lines
155}
156
157/// The `prompt` (`edit> `, or `formula> ` for a formula) and the text,
158/// the cell under the caret reversed and a diagnostic's span red and
159/// underlined. In a formula with the caret at the end inside a call, the
160/// parameters still to type follow, dimmed.
161fn edit_line(
162    e: &LineEdit,
163    prompt: &'static str,
164    formula: bool,
165    d: Option<&Diagnostic>,
166) -> Line<'static> {
167    let bad = d.and_then(|d| d.span.clone()).unwrap_or_default();
168    let err = Style::default()
169        .fg(Color::Red)
170        .add_modifier(Modifier::UNDERLINED);
171    let mut spans = vec![Span::raw(prompt)];
172    // One span per char: few enough to not matter on an edit line.
173    let chars = e.text.char_indices().map(|(i, c)| (i, c.to_string()));
174    for (i, c) in chars.chain(std::iter::once((e.text.len(), " ".to_string()))) {
175        let mut style = if bad.contains(&i) {
176            err
177        } else {
178            Style::default()
179        };
180        if i == e.caret {
181            style = style.add_modifier(Modifier::REVERSED);
182        } else if i == e.text.len() {
183            continue;
184        }
185        spans.push(Span::styled(c, style));
186    }
187    if let Some(g) = formula
188        .then(|| crate::complete::ghost(e, e.caret))
189        .flatten()
190    {
191        spans.push(Span::styled(
192            g,
193            Style::default().add_modifier(Modifier::DIM),
194        ));
195    }
196    Line::from(spans)
197}
198
199/// Under the edit line: a `^~~~` marker under the diagnostic's span (lined
200/// up past `prompt`), then the message and its hint.
201fn diagnostic_lines(e: &LineEdit, d: &Diagnostic, prompt: &str) -> Vec<Line<'static>> {
202    let red = Style::default().fg(Color::Red);
203    let mut lines = Vec::new();
204    if let Some(s) = &d.span {
205        // Display columns, so wide characters keep the marker aligned.
206        let pad = prompt.len() + Span::raw(&e.text[..s.start]).width();
207        let n = Span::raw(&e.text[s.clone()]).width().max(1);
208        let marker = format!("^{}", "~".repeat(n - 1));
209        lines.push(Line::from(vec![
210            Span::raw(" ".repeat(pad)),
211            Span::styled(marker, red.add_modifier(Modifier::BOLD)),
212        ]));
213    }
214    lines.push(Line::styled(format!("error: {}", d.message), red));
215    if let Some(h) = &d.hint {
216        lines.push(Line::from(format!("hint: {h}")));
217    }
218    lines
219}
220
221/// The signature of the call the caret is in, the current argument marked
222/// `‹pv›` and reversed, then the description:
223/// `PMT(rate, nper, ‹pv›, [fv], [type]) — payment per period`.
224fn signature_line(e: &LineEdit) -> Option<Line<'static>> {
225    let (d, arg) = improv_core_model::functions::call_at(e, e.caret)?;
226    let names = d.param_names();
227    // A variadic tail (`…`) takes every argument past the named ones.
228    let cur = (arg < names.len() || d.variadic).then(|| arg.min(names.len().saturating_sub(1)));
229    let mut spans = vec![Span::raw(format!("{}(", d.name))];
230    for (i, n) in names.iter().enumerate() {
231        if i > 0 {
232            spans.push(Span::raw(", "));
233        }
234        spans.push(if Some(i) == cur {
235            Span::styled(
236                format!("\u{2039}{n}\u{203a}"),
237                Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
238            )
239        } else {
240            Span::raw(n.clone())
241        });
242    }
243    // Drop a doc's trailing restatement of the signature.
244    let doc = d
245        .doc
246        .find(&format!(": {}(", d.name))
247        .map_or(d.doc, |i| &d.doc[..i]);
248    spans.push(Span::raw(format!(") \u{2014} {doc}")));
249    Some(Line::from(spans))
250}
251
252/// Row-label column width, data column width, and inter-column spacing.
253const LABEL_W: u16 = 14;
254const COL_W: u16 = 12;
255const SPACING: u16 = 1;
256
257/// First visible index after scrolling minimally from `prev` so `cursor`
258/// lies in a window of `vis` entries.
259fn scroll(prev: usize, cursor: usize, vis: usize) -> usize {
260    if cursor < prev {
261        cursor
262    } else if cursor >= prev + vis {
263        cursor + 1 - vis
264    } else {
265        prev
266    }
267}
268
269/// The grid table over the visible window described by `geom`.
270fn grid_table<'a>(app: &'a App, g: &'a Grid, geom: &GridGeom) -> Table<'a> {
271    let cols = geom.col0..(geom.col0 + geom.cols).min(g.n_cols());
272    let rows = geom.row0..(geom.row0 + geom.rows).min(g.n_rows());
273    // Header: a corner cell (row-category name) then one per column item.
274    let corner = g
275        .row_cat
276        .as_ref()
277        .map(|(_, n)| n.clone())
278        .unwrap_or_default();
279    let mut header_cells = vec![Cell::from(corner)];
280    header_cells.extend(
281        g.cols[cols.clone()]
282            .iter()
283            .map(|(_, n)| Cell::from(n.clone())),
284    );
285    let header = Row::new(header_cells)
286        .style(Style::default().add_modifier(Modifier::BOLD))
287        .bottom_margin(0);
288
289    let sel = Style::default()
290        .bg(Color::Blue)
291        .fg(Color::White)
292        .add_modifier(Modifier::BOLD);
293
294    let cols_r = cols.clone();
295    let range = Style::default().bg(Color::Cyan).fg(Color::Black);
296    let rows = rows.map(move |r| {
297        let rname = g.rows[r].1.clone();
298        let mut cells =
299            vec![Cell::from(rname).style(Style::default().add_modifier(Modifier::BOLD))];
300        for c in cols_r.clone() {
301            let text = g.display_at(r, c);
302            let cell = Cell::from(text);
303            let cell = if r == app.cursor_row && c == app.cursor_col {
304                cell.style(sel)
305            } else if app.is_selected(r, c) {
306                cell.style(range)
307            } else {
308                cell
309            };
310            cells.push(cell);
311        }
312        Row::new(cells)
313    });
314
315    // One header column plus one per visible data column.
316    let mut widths = vec![Constraint::Length(LABEL_W)];
317    widths.extend(cols.clone().map(|_| Constraint::Length(COL_W)));
318
319    Table::new(rows, widths)
320        .header(header)
321        .block(Block::default().borders(Borders::ALL).title(" Improv "))
322        .column_spacing(SPACING)
323}
324
325pub fn render(f: &mut Frame, app: &mut App) {
326    let lines = status_lines(app, f.area().width.saturating_sub(2) as usize);
327    let chunks = Layout::default()
328        .direction(Direction::Vertical)
329        .constraints([
330            Constraint::Length(lines.len() as u16 + 2),
331            Constraint::Min(3),
332        ])
333        .split(f.area());
334
335    let status = ratatui::widgets::Paragraph::new(lines)
336        .block(Block::default().borders(Borders::ALL).title(" Status "));
337    f.render_widget(status, chunks[0]);
338
339    // Window the grid to what fits inside the border (minus the header row
340    // and row-label column), scrolled so the cursor stays visible, and record
341    // that geometry so mouse clicks map back to cells.
342    let area = chunks[1];
343    let inner_w = area.width.saturating_sub(2);
344    let vis_rows = (area.height.saturating_sub(3) as usize).max(1);
345    let vis_cols = ((inner_w.saturating_sub(LABEL_W) / (SPACING + COL_W)) as usize).max(1);
346    let prev = app.grid_geom.map_or((0, 0), |g| (g.row0, g.col0));
347    let geom = GridGeom {
348        x0: area.x + 1 + LABEL_W + SPACING,
349        y0: area.y + 2,
350        col_w: COL_W,
351        spacing: SPACING,
352        row0: scroll(prev.0, app.cursor_row, vis_rows),
353        col0: scroll(prev.1, app.cursor_col, vis_cols),
354        rows: vis_rows,
355        cols: vis_cols,
356    };
357    app.grid_geom = Some(geom);
358
359    let table = grid_table(app, &app.grid, &geom);
360    f.render_widget(table, area);
361
362    if let Some(h) = &mut app.help {
363        render_help(f, h, f.area());
364    }
365}
366
367/// The function help overlay over `area` (inset by a margin): the search
368/// line, the result list scrolled to the selection, and the selected
369/// function's details.
370fn render_help(f: &mut Frame, h: &mut FnHelp, area: Rect) {
371    let area = area.inner(ratatui::layout::Margin::new(2, 1));
372    f.render_widget(Clear, area);
373    let block = Block::default()
374        .borders(Borders::ALL)
375        .title(" Functions  (type to search, Enter inserts, Esc closes) ");
376    let inner = block.inner(area);
377    f.render_widget(block, area);
378    let chunks = Layout::default()
379        .direction(Direction::Vertical)
380        .constraints([
381            Constraint::Length(1),
382            Constraint::Min(1),
383            Constraint::Length(6),
384        ])
385        .split(inner);
386
387    let found = format!("  ({} found)", h.results.len());
388    f.render_widget(
389        Paragraph::new(Line::from(vec![
390            Span::raw(format!("search> {}", h.query)),
391            Span::styled(found, Style::default().add_modifier(Modifier::DIM)),
392        ])),
393        chunks[0],
394    );
395
396    h.page = chunks[1].height as usize;
397    let items: Vec<ListItem> = h.results.iter().map(|d| ListItem::new(d.hint())).collect();
398    let list = List::new(items)
399        .block(Block::default().borders(Borders::TOP | Borders::BOTTOM))
400        .highlight_style(
401            Style::default()
402                .bg(Color::Blue)
403                .fg(Color::White)
404                .add_modifier(Modifier::BOLD),
405        );
406    let mut state = ListState::default()
407        .with_selected(h.current().map(|_| h.selected))
408        .with_offset(h.top);
409    f.render_stateful_widget(list, chunks[1], &mut state);
410    h.top = state.offset();
411    // The list's top and bottom borders take two of its rows.
412    h.page = h.page.saturating_sub(2);
413
414    let detail = match h.current() {
415        Some(d) => {
416            let mut lines = vec![
417                Line::styled(d.hint(), Style::default().add_modifier(Modifier::BOLD)),
418                Line::from(format!("category: {:?}", d.category)),
419            ];
420            if !d.aliases.is_empty() {
421                lines.push(Line::from(format!("aliases: {}", d.aliases.join(", "))));
422            }
423            lines.push(Line::from(d.doc));
424            lines
425        }
426        None => vec![Line::from("no function matches")],
427    };
428    f.render_widget(Paragraph::new(detail).wrap(Wrap { trim: true }), chunks[2]);
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::app::tests::revenue_model;
435    use ratatui::{backend::TestBackend, buffer::Buffer, Terminal};
436
437    /// Render `app` into a `w`x`h` test terminal and return the buffer.
438    fn draw(app: &mut App, w: u16, h: u16) -> Buffer {
439        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
440        term.draw(|f| render(f, app)).unwrap();
441        term.backend().buffer().clone()
442    }
443
444    /// The buffer as one string per row.
445    fn text(buf: &Buffer) -> Vec<String> {
446        (0..buf.area.height)
447            .map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
448            .collect()
449    }
450
451    fn row_of(lines: &[String], needle: &str) -> usize {
452        let row = lines.iter().position(|l| l.contains(needle));
453        assert!(
454            row.is_some(),
455            "{needle:?} not on screen:\n{}",
456            lines.join("\n")
457        );
458        row.unwrap()
459    }
460
461    /// Screen position of the first occurrence of `needle`.
462    fn find(lines: &[String], needle: &str) -> (u16, u16) {
463        let y = row_of(lines, needle);
464        let x = lines[y].find(needle).unwrap();
465        (lines[y][..x].chars().count() as u16, y as u16)
466    }
467
468    #[test]
469    fn scroll_keeps_cursor_in_window() {
470        assert_eq!(scroll(0, 3, 5), 0);
471        assert_eq!(scroll(0, 7, 5), 3);
472        assert_eq!(scroll(4, 2, 5), 2);
473    }
474
475    #[test]
476    fn grid_scrolls_to_cursor_and_clicks_hit_the_cell_drawn() {
477        // Regression: the grid never scrolled (cursor could sit off-screen),
478        // and click hit-testing ignored the row-label column, so clicking a
479        // cell selected the column to its right.
480        let mut app = App::new(revenue_model()).unwrap();
481        crate::app::tests::view(&mut app, improv_core_model::MeasureId(101));
482        app.cursor_col = 1;
483        // 32 cols: border + 14 label + 1 + one 12-wide column fits.
484        let lines = text(&draw(&mut app, 32, 20));
485        assert!(
486            !lines.iter().any(|l| l.contains("WidgetA")),
487            "scrolled past col 0"
488        );
489        let (x, y) = find(&lines, "WidgetB");
490        let (rx, ry) = find(&lines, "2026");
491        app.click_at(x + 3, ry);
492        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
493        // Clicks on the row-label column or header are not cells.
494        app.click_at(rx, ry - 1);
495        app.click_at(x, y);
496        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
497        // The cursor cell is drawn in the selection style.
498        let buf = draw(&mut app, 32, 20);
499        assert_eq!(buf[(x, ry)].bg, Color::Blue);
500        assert_ne!(buf[(rx, ry)].bg, Color::Blue);
501    }
502
503    #[test]
504    fn tiny_terminals_render_without_panicking() {
505        let mut app = App::new(revenue_model()).unwrap();
506        app.status = Some("a long status message that will not fit".into());
507        for (w, h) in [(20, 5), (1, 1), (0, 0), (5, 40), (200, 3)] {
508            draw(&mut app, w, h);
509            app.move_cursor(1, 1);
510        }
511    }
512
513    #[test]
514    fn status_shows_formula_sum_and_pages_and_range_style() {
515        let mut m = revenue_model();
516        let mut q = m.measures[&improv_core_model::MeasureId(101)].clone();
517        q.id = improv_core_model::MeasureId(300);
518        m.add_category(improv_core_model::CategoryId(3), "Region");
519        m.add_item(
520            improv_core_model::ItemId(30),
521            improv_core_model::CategoryId(3),
522            "North",
523        );
524        q.categories.push(improv_core_model::CategoryId(3));
525        m.add_measure(q);
526        let mut app = App::new(m).unwrap();
527        let lines = text(&draw(&mut app, 100, 30));
528        row_of(&lines, "Revenue = Price times Quantity");
529        row_of(&lines, "Sum 1000");
530        crate::app::tests::view(&mut app, improv_core_model::MeasureId(300));
531        let lines = text(&draw(&mut app, 100, 30));
532        row_of(&lines, "pages: Region=North (1/1)");
533        row_of(&lines, "Sum \u{2014}");
534        crate::app::tests::view(&mut app, improv_core_model::MeasureId(101));
535        app.step(1, 0, true);
536        let buf = draw(&mut app, 100, 30);
537        let lines = text(&buf);
538        row_of(&lines, "Sum 220");
539        let (x, y) = find(&lines, "2025");
540        let data_x = x + LABEL_W + SPACING;
541        assert_eq!(
542            buf[(data_x, y)].bg,
543            Color::Cyan,
544            "anchor cell in range style"
545        );
546        assert_eq!(buf[(data_x, y + 1)].bg, Color::Blue, "cursor cell");
547    }
548
549    #[test]
550    fn formula_edit_shows_signature_and_ghost_text() {
551        let mut app = App::new(revenue_model()).unwrap();
552        crate::app::tests::view(&mut app, improv_core_model::MeasureId(102));
553        app.begin_edit();
554        app.edit = Some("Price * PMT(0.05, 12, ".into());
555        let buf = draw(&mut app, 120, 30);
556        let lines = text(&buf);
557        let (x, y) = find(&lines, "pv, [fv], [type])");
558        assert_eq!(row_of(&lines, "formula> Price * PMT(0.05, 12,"), y as usize);
559        assert!(buf[(x, y)].modifier.contains(Modifier::DIM), "ghost is dim");
560        let sig = "PMT(rate, nper, \u{2039}pv\u{203a}, [fv], [type]) \u{2014} payment per period";
561        let (sx, sy) = find(&lines, sig);
562        assert_eq!(sy, y + 1, "the hint is right under the edit line");
563        let at = sx + "PMT(rate, nper, ".chars().count() as u16;
564        assert!(buf[(at, sy)].modifier.contains(Modifier::REVERSED));
565        assert!(!buf[(sx, sy)].modifier.contains(Modifier::REVERSED));
566        // A caret before the end: no ghost text, the hint follows the caret.
567        app.edit.as_mut().unwrap().caret = "Price * PMT(0".len();
568        let lines = text(&draw(&mut app, 120, 30));
569        assert!(!lines.iter().any(|l| l.contains("12, pv")));
570        row_of(&lines, "PMT(\u{2039}rate\u{203a}, nper");
571        // A variadic tail marks its last parameter; too many marks none.
572        app.edit = Some("CONCAT(1, 2, 3".into());
573        row_of(
574            &text(&draw(&mut app, 120, 30)),
575            "CONCAT(text1, \u{2039}\u{2026}\u{203a})",
576        );
577        app.edit = Some("ABS(1, 2".into());
578        let lines = text(&draw(&mut app, 120, 30));
579        assert!(!lines.iter().any(|l| l.contains('\u{2039}')));
580        // Outside a call, and in a value edit, neither shows.
581        app.edit = Some("Price".into());
582        let lines = text(&draw(&mut app, 120, 30));
583        assert!(!lines.iter().any(|l| l.contains('\u{2014}')));
584        crate::app::tests::view(&mut app, improv_core_model::MeasureId(101));
585        app.edit = Some("PMT(".into());
586        let lines = text(&draw(&mut app, 120, 30));
587        assert!(!lines.iter().any(|l| l.contains("rate")));
588    }
589
590    #[test]
591    fn formula_errors_are_located_and_hinted() {
592        let mut app = App::new(revenue_model()).unwrap();
593        crate::app::tests::view(&mut app, improv_core_model::MeasureId(102));
594        app.begin_edit();
595        app.edit = Some("Price * Quantty".into());
596        app.commit_edit();
597        let buf = draw(&mut app, 100, 30);
598        let lines = text(&buf);
599        let (x, y) = find(&lines, "formula> Price * Quantty");
600        let bad = x + "formula> Price * ".len() as u16;
601        assert_eq!(buf[(bad, y)].fg, Color::Red);
602        assert!(buf[(bad, y)].modifier.contains(Modifier::UNDERLINED));
603        assert_ne!(buf[(bad - 2, y)].fg, Color::Red, "only the bad token");
604        // The marker sits right under the span, then the message and hint.
605        let (mx, my) = find(&lines, "^~~~~~~");
606        assert_eq!((mx, my), (bad, y + 1));
607        assert!(
608            !lines[my as usize].contains("^~~~~~~~"),
609            "as long as the token"
610        );
611        assert_eq!(
612            row_of(&lines, "error: unknown measure: Quantty"),
613            y as usize + 2
614        );
615        assert_eq!(
616            row_of(&lines, "hint: did you mean Quantity?"),
617            y as usize + 3
618        );
619        // Wide characters before and in the span keep it aligned.
620        app.edit = Some("\u{5e02}\u{5834} + Quantty".into());
621        app.commit_edit();
622        let lines = text(&draw(&mut app, 100, 30));
623        let y = row_of(&lines, "formula> ");
624        let col = |l: &str, pat: &str| l[..l.find(pat).unwrap()].chars().count();
625        let marker = col(&lines[y + 1], "^");
626        assert_eq!(marker, col(&lines[y], "formula> ") + "formula> ".len());
627        assert!(
628            lines[y + 1].contains("^~~~ "),
629            "two wide chars, four columns"
630        );
631        app.edit = Some("\"\u{5e02}\" & Quantty".into());
632        app.commit_edit();
633        let lines = text(&draw(&mut app, 100, 30));
634        let y = row_of(&lines, "formula> ");
635        // `"市" & ` is 7 columns wide: the 2-wide 市 counts twice.
636        assert_eq!(col(&lines[y + 1], "^~~~~~~"), marker + 7);
637        app.edit = Some("Price * Quantty".into());
638        app.commit_edit();
639        // An error without a position has no marker.
640        app.diagnostic.as_mut().unwrap().span = None;
641        let lines = text(&draw(&mut app, 100, 30));
642        assert!(!lines.iter().any(|l| l.contains('^')));
643        row_of(&lines, "error: unknown measure");
644        // Typing clears it.
645        app.edit_key(crossterm::event::KeyCode::Char('x'));
646        let lines = text(&draw(&mut app, 100, 30));
647        assert!(!lines.iter().any(|l| l.contains("error:")));
648    }
649
650    #[test]
651    fn help_overlay_lists_searches_and_details() {
652        let mut app = App::new(revenue_model()).unwrap();
653        app.help = Some(FnHelp::new());
654        let lines = text(&draw(&mut app, 100, 30));
655        row_of(&lines, "search> ");
656        row_of(&lines, "ABS(");
657        for c in "pmt".chars() {
658            app.help.as_mut().unwrap().push(c);
659        }
660        let buf = draw(&mut app, 100, 30);
661        let lines = text(&buf);
662        // The selected row is the list's first, in the selection style.
663        let (x, y) = find(&lines, "PMT(rate, nper, pv, [fv], [type])");
664        assert_eq!(buf[(x, y)].bg, Color::Blue);
665        row_of(&lines, "category: Financial");
666        row_of(&lines, "payment per period");
667        // Aliases show for a function that has them.
668        let h = app.help.as_mut().unwrap();
669        h.query.clear();
670        for c in "avg".chars() {
671            h.push(c);
672        }
673        let lines = text(&draw(&mut app, 100, 30));
674        row_of(&lines, "aliases: AVG");
675        // Paging scrolls the list so the selection stays visible.
676        let mut app = App::new(revenue_model()).unwrap();
677        app.help = Some(FnHelp::new());
678        draw(&mut app, 100, 30);
679        let h = app.help.as_mut().unwrap();
680        assert!(h.page > 5, "page size comes from the render: {}", h.page);
681        h.page_by(1);
682        h.page_by(1);
683        let name = h.current().unwrap().hint();
684        let lines = text(&draw(&mut app, 100, 30));
685        row_of(&lines, &name);
686        assert!(app.help.as_ref().unwrap().top > 0, "scrolled");
687        // No match, and tiny terminals, still render.
688        let h = app.help.as_mut().unwrap();
689        for c in "zzzzq".chars() {
690            h.push(c);
691        }
692        row_of(&text(&draw(&mut app, 100, 30)), "no function matches");
693        for (w, h) in [(3, 3), (0, 0), (20, 6)] {
694            draw(&mut app, w, h);
695        }
696    }
697
698    #[test]
699    fn help_packs_to_width() {
700        assert_eq!(help_lines(1000).len(), 1);
701        let narrow = help_lines(40);
702        assert!(narrow.len() > 2);
703        assert!(narrow.iter().all(|l| l.width() <= 40));
704        assert_eq!(help_lines(0).len(), HELP.len(), "one entry per line");
705    }
706
707    #[test]
708    fn prompt_and_full_help_visible_with_pages_and_filters() {
709        // Regression: a fixed 5-row status box (3 inner lines) pushed the
710        // edit prompt off-screen once a pages: or filters: line appeared, and
711        // the one-line help was cut off at the right edge.
712        let mut app = App::new(revenue_model()).unwrap();
713        crate::app::tests::view(&mut app, improv_core_model::MeasureId(101));
714        app.toggle_filter_item(
715            improv_core_model::CategoryId(1),
716            improv_core_model::ItemId(10),
717        );
718        app.edit = Some("42".into());
719        let lines = text(&draw(&mut app, 60, 30));
720        let prompt = row_of(&lines, "edit> 42");
721        // The caret sits after the text, and moves with Left.
722        let buf = draw(&mut app, 60, 30);
723        let (x, _) = find(&lines, "edit> 42");
724        assert!(buf[(x + 8, prompt as u16)]
725            .modifier
726            .contains(Modifier::REVERSED));
727        app.edit.as_mut().unwrap().caret = 1;
728        let buf = draw(&mut app, 60, 30);
729        assert!(buf[(x + 7, prompt as u16)]
730            .modifier
731            .contains(Modifier::REVERSED));
732        // An error shows under the open edit.
733        app.status = Some("not a number: 'x'".into());
734        let lines = text(&draw(&mut app, 60, 30));
735        assert_eq!(row_of(&lines, "not a number"), prompt + 1);
736        // A derived measure's edit is its formula.
737        crate::app::tests::view(&mut app, improv_core_model::MeasureId(102));
738        app.begin_edit();
739        row_of(
740            &text(&draw(&mut app, 60, 30)),
741            "formula> Price times Quantity",
742        );
743        assert!(prompt < row_of(&lines, "filters: Time: 1 shown"));
744        assert!(row_of(&lines, "arrows/click: move") > prompt);
745        row_of(&lines, "q: quit");
746    }
747}