1use 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
18const 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
53fn 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 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
157fn 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 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
199fn 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 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
221fn 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 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 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
252const LABEL_W: u16 = 14;
254const COL_W: u16 = 12;
255const SPACING: u16 = 1;
256
257fn 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
269fn 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 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 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 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
367fn 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 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 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 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 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 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 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 app.click_at(rx, ry - 1);
495 app.click_at(x, y);
496 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
497 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 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 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 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 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 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 assert_eq!(col(&lines[y + 1], "^~~~~~~"), marker + 7);
637 app.edit = Some("Price * Quantty".into());
638 app.commit_edit();
639 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 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 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 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 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 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 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 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 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 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}