max / alloy_tui
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
2 files changed,
+500 insertions,
-497 deletions
| @@ -1415,675 +1415,4 @@ | |||
| 1415 | 1415 | } | |
| 1416 | 1416 | ||
| 1417 | 1417 | #[cfg(test)] | |
| 1418 | - | mod tests { | |
| 1419 | - | use super::*; | |
| 1420 | - | use ratatui::style::Color; | |
| 1421 | - | ||
| 1422 | - | fn theme() -> Theme { | |
| 1423 | - | crate::theme::test_theme(crate::theme::Mode::Dark) | |
| 1424 | - | } | |
| 1425 | - | ||
| 1426 | - | fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> { | |
| 1427 | - | // Leaked so the test list can hold a 'static theme reference; the | |
| 1428 | - | // widget borrows rather than owns, and these are per-test one-offs. | |
| 1429 | - | let theme: &'static Theme = Box::leak(Box::new(theme())); | |
| 1430 | - | let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); | |
| 1431 | - | AlloyList::new(theme, items).selected(selected) | |
| 1432 | - | } | |
| 1433 | - | ||
| 1434 | - | #[test] | |
| 1435 | - | fn short_list_never_scrolls() { | |
| 1436 | - | assert_eq!(list_of(3, Some(2)).offset(10), 0); | |
| 1437 | - | } | |
| 1438 | - | ||
| 1439 | - | // Selection near the top must not scroll past the start of the list — a | |
| 1440 | - | // naive `selected - height/2` underflows or shows blank rows above row 0. | |
| 1441 | - | #[test] | |
| 1442 | - | fn offset_clamps_at_the_top() { | |
| 1443 | - | assert_eq!(list_of(50, Some(0)).offset(10), 0); | |
| 1444 | - | assert_eq!(list_of(50, Some(2)).offset(10), 0); | |
| 1445 | - | } | |
| 1446 | - | ||
| 1447 | - | // Selection at the end must land the last row on the last visible line, | |
| 1448 | - | // not scroll into empty space past the end of the list. | |
| 1449 | - | #[test] | |
| 1450 | - | fn offset_clamps_at_the_bottom() { | |
| 1451 | - | assert_eq!(list_of(50, Some(49)).offset(10), 40); | |
| 1452 | - | } | |
| 1453 | - | ||
| 1454 | - | #[test] | |
| 1455 | - | fn offset_centers_a_midlist_selection() { | |
| 1456 | - | assert_eq!(list_of(50, Some(25)).offset(10), 20); | |
| 1457 | - | } | |
| 1458 | - | ||
| 1459 | - | #[test] | |
| 1460 | - | fn row_y_maps_visible_items_to_screen_rows() { | |
| 1461 | - | let area = Rect::new(0, 5, 20, 10); | |
| 1462 | - | assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); | |
| 1463 | - | assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); | |
| 1464 | - | } | |
| 1465 | - | ||
| 1466 | - | // After a scroll the mapping has to follow the offset. A connector using a | |
| 1467 | - | // separate copy of the scroll rule is exactly what this prevents. | |
| 1468 | - | #[test] | |
| 1469 | - | fn row_y_accounts_for_scrolling() { | |
| 1470 | - | let area = Rect::new(0, 0, 20, 10); | |
| 1471 | - | // 50 items, selection at 25 => offset 20, so item 20 is the top row. | |
| 1472 | - | assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); | |
| 1473 | - | assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); | |
| 1474 | - | } | |
| 1475 | - | ||
| 1476 | - | #[test] | |
| 1477 | - | fn row_y_is_none_for_rows_scrolled_out_of_view() { | |
| 1478 | - | let area = Rect::new(0, 0, 20, 10); | |
| 1479 | - | assert_eq!( | |
| 1480 | - | list_row_y(area, 50, Some(25), 0), | |
| 1481 | - | None, | |
| 1482 | - | "above the viewport" | |
| 1483 | - | ); | |
| 1484 | - | assert_eq!( | |
| 1485 | - | list_row_y(area, 50, Some(25), 49), | |
| 1486 | - | None, | |
| 1487 | - | "below the viewport" | |
| 1488 | - | ); | |
| 1489 | - | assert_eq!( | |
| 1490 | - | list_row_y(area, 3, Some(0), 9), | |
| 1491 | - | None, | |
| 1492 | - | "past the end of the list" | |
| 1493 | - | ); | |
| 1494 | - | } | |
| 1495 | - | ||
| 1496 | - | fn render_tabs(selected: usize, width: u16) -> String { | |
| 1497 | - | let theme = theme(); | |
| 1498 | - | let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); | |
| 1499 | - | AlloyTabs::new(&theme, ["installed", "boxes", "system"]) | |
| 1500 | - | .selected(selected) | |
| 1501 | - | .render(Rect::new(0, 0, width, 1), &mut buf); | |
| 1502 | - | buf.content() | |
| 1503 | - | .iter() | |
| 1504 | - | .map(ratatui::buffer::Cell::symbol) | |
| 1505 | - | .collect() | |
| 1506 | - | } | |
| 1507 | - | ||
| 1508 | - | #[test] | |
| 1509 | - | fn selected_tab_is_bracketed_and_others_are_not() { | |
| 1510 | - | let rendered = render_tabs(0, 60); | |
| 1511 | - | assert!( | |
| 1512 | - | rendered.contains("[ installed ]"), | |
| 1513 | - | "selected tab is bracketed" | |
| 1514 | - | ); | |
| 1515 | - | assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); | |
| 1516 | - | assert!(rendered.contains("boxes"), "unselected labels still render"); | |
| 1517 | - | } | |
| 1518 | - | ||
| 1519 | - | // The bar must not shift horizontally as selection moves, or every tab | |
| 1520 | - | // change reads as the whole row twitching. Unselected labels pad to the | |
| 1521 | - | // bracket width for exactly this reason. | |
| 1522 | - | #[test] | |
| 1523 | - | fn labels_hold_their_columns_across_selections() { | |
| 1524 | - | let first = render_tabs(0, 60); | |
| 1525 | - | let last = render_tabs(2, 60); | |
| 1526 | - | assert_eq!( | |
| 1527 | - | first.find("system"), | |
| 1528 | - | last.find("system"), | |
| 1529 | - | "a label sits in the same columns whichever tab is selected" | |
| 1530 | - | ); | |
| 1531 | - | } | |
| 1532 | - | ||
| 1533 | - | // FocusRing::focus ignores out-of-range slots rather than clamping, and the | |
| 1534 | - | // bar has to agree: showing a neighbouring tab as current would misreport | |
| 1535 | - | // which screen the user is looking at. | |
| 1536 | - | #[test] | |
| 1537 | - | fn out_of_range_selection_brackets_nothing() { | |
| 1538 | - | let rendered = render_tabs(9, 60); | |
| 1539 | - | assert!(!rendered.contains('['), "no tab is marked current"); | |
| 1540 | - | assert!(rendered.contains("installed"), "labels still render"); | |
| 1541 | - | } | |
| 1542 | - | ||
| 1543 | - | #[test] | |
| 1544 | - | fn zero_height_area_renders_nothing_rather_than_panicking() { | |
| 1545 | - | let theme = theme(); | |
| 1546 | - | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); | |
| 1547 | - | AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 1548 | - | AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); | |
| 1549 | - | } | |
| 1550 | - | ||
| 1551 | - | fn render_modal(area: Rect) -> Vec<String> { | |
| 1552 | - | let theme = theme(); | |
| 1553 | - | let mut buf = Buffer::empty(area); | |
| 1554 | - | AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); | |
| 1555 | - | (0..area.height) | |
| 1556 | - | .map(|y| { | |
| 1557 | - | (0..area.width) | |
| 1558 | - | .map(|x| buf[(x, y)].symbol()) | |
| 1559 | - | .collect::<String>() | |
| 1560 | - | }) | |
| 1561 | - | .collect() | |
| 1562 | - | } | |
| 1563 | - | ||
| 1564 | - | #[test] | |
| 1565 | - | fn modal_shows_its_message_and_both_keys() { | |
| 1566 | - | let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); | |
| 1567 | - | assert!(rows.contains("Remove tailscale?"), "message renders"); | |
| 1568 | - | assert!(rows.contains("remove"), "title renders"); | |
| 1569 | - | assert!(rows.contains("Enter"), "confirm key renders"); | |
| 1570 | - | assert!(rows.contains("Esc"), "cancel key renders"); | |
| 1571 | - | } | |
| 1572 | - | ||
| 1573 | - | // The keys are pinned to the last inner row rather than flowing after the | |
| 1574 | - | // message. A prompt whose dismiss keys move with message length, or fall | |
| 1575 | - | // off a short box, is a modal the user cannot get out of. | |
| 1576 | - | #[test] | |
| 1577 | - | fn keys_sit_on_the_last_row_whatever_the_message_length() { | |
| 1578 | - | for height in [5, 7, 12] { | |
| 1579 | - | let rows = render_modal(Rect::new(0, 0, 40, height)); | |
| 1580 | - | let last_inner = &rows[height as usize - 2]; | |
| 1581 | - | assert!( | |
| 1582 | - | last_inner.contains("Enter") && last_inner.contains("Esc"), | |
| 1583 | - | "height {height}: keys belong on the last inner row, got {last_inner:?}" | |
| 1584 | - | ); | |
| 1585 | - | } | |
| 1586 | - | } | |
| 1587 | - | ||
| 1588 | - | #[test] | |
| 1589 | - | fn modal_survives_an_area_too_small_to_draw_in() { | |
| 1590 | - | let theme = theme(); | |
| 1591 | - | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); | |
| 1592 | - | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 1593 | - | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); | |
| 1594 | - | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); | |
| 1595 | - | } | |
| 1596 | - | ||
| 1597 | - | fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec<String> { | |
| 1598 | - | let area = Rect::new(0, 0, width, height); | |
| 1599 | - | let mut buf = Buffer::empty(area); | |
| 1600 | - | draw(&mut buf, area); | |
| 1601 | - | (0..height) | |
| 1602 | - | .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>()) | |
| 1603 | - | .collect() | |
| 1604 | - | } | |
| 1605 | - | ||
| 1606 | - | /// Column of `needle` in `row`, counted in characters. | |
| 1607 | - | /// | |
| 1608 | - | /// `str::find` counts bytes, and the focus marker is three of them, so a | |
| 1609 | - | /// byte offset says a focused row's value starts two columns right of an | |
| 1610 | - | /// unfocused one when both are in the same column. | |
| 1611 | - | fn column(row: &str, needle: &str) -> Option<usize> { | |
| 1612 | - | let at = row.find(needle)?; | |
| 1613 | - | Some(row[..at].chars().count()) | |
| 1614 | - | } | |
| 1615 | - | ||
| 1616 | - | fn field_rows(theme: &Theme) -> Vec<FormRow<'_>> { | |
| 1617 | - | vec![ | |
| 1618 | - | FormRow::Section { | |
| 1619 | - | label: "cursor", | |
| 1620 | - | open: true, | |
| 1621 | - | }, | |
| 1622 | - | FormRow::Field( | |
| 1623 | - | AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" }) | |
| 1624 | - | .help(Some("Cursor shape.")), | |
| 1625 | - | ), | |
| 1626 | - | FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))), | |
| 1627 | - | FormRow::Section { | |
| 1628 | - | label: "colors", | |
| 1629 | - | open: false, | |
| 1630 | - | }, | |
| 1631 | - | ] | |
| 1632 | - | } | |
| 1633 | - | ||
| 1634 | - | #[test] | |
| 1635 | - | fn a_toggle_reads_without_color_or_a_patched_font() { | |
| 1636 | - | let rows = render_to(40, 1, |buf, area| { | |
| 1637 | - | AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf); | |
| 1638 | - | }); | |
| 1639 | - | assert!(rows[0].contains("[x]"), "{rows:?}"); | |
| 1640 | - | ||
| 1641 | - | let rows = render_to(40, 1, |buf, area| { | |
| 1642 | - | AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf); | |
| 1643 | - | }); | |
| 1644 | - | assert!(rows[0].contains("[ ]"), "{rows:?}"); | |
| 1645 | - | } | |
| 1646 | - | ||
| 1647 | - | #[test] | |
| 1648 | - | fn a_color_field_draws_a_swatch_beside_its_hex() { | |
| 1649 | - | let rows = render_to(40, 1, |buf, area| { | |
| 1650 | - | AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" }) | |
| 1651 | - | .render(area, buf); | |
| 1652 | - | }); | |
| 1653 | - | assert!(rows[0].contains("██ #e4ded6"), "{rows:?}"); | |
| 1654 | - | } | |
| 1655 | - | ||
| 1656 | - | // A hex the widget cannot parse still shows its text. The swatch helps read | |
| 1657 | - | // a value; it is not the value, and dropping the text would hide the one | |
| 1658 | - | // thing the user needs to see to fix it. | |
| 1659 | - | #[test] | |
| 1660 | - | fn an_unparseable_color_still_renders_its_text() { | |
| 1661 | - | let rows = render_to(40, 1, |buf, area| { | |
| 1662 | - | AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" }) | |
| 1663 | - | .render(area, buf); | |
| 1664 | - | }); | |
| 1665 | - | assert!(rows[0].contains("e4ded6"), "{rows:?}"); | |
| 1666 | - | assert!( | |
| 1667 | - | !rows[0].contains('█'), | |
| 1668 | - | "no swatch for a value it cannot parse" | |
| 1669 | - | ); | |
| 1670 | - | } | |
| 1671 | - | ||
| 1672 | - | #[test] | |
| 1673 | - | fn swatch_parses_both_hex_lengths_and_rejects_the_rest() { | |
| 1674 | - | assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6))); | |
| 1675 | - | assert_eq!( | |
| 1676 | - | swatch("#e4ded6ff"), | |
| 1677 | - | Some(Color::Rgb(0xe4, 0xde, 0xd6)), | |
| 1678 | - | "alpha is dropped, not refused", | |
| 1679 | - | ); | |
| 1680 | - | assert_eq!(swatch("e4ded6"), None, "no hash"); | |
| 1681 | - | assert_eq!(swatch("#e4ded"), None, "wrong length"); | |
| 1682 | - | assert_eq!(swatch("#gggggg"), None, "not hex"); | |
| 1683 | - | } | |
| 1684 | - | ||
| 1685 | - | // An edited row shows the buffer and its caret, not the committed value. | |
| 1686 | - | #[test] | |
| 1687 | - | fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() { | |
| 1688 | - | let mut buffer = TextField::new(); | |
| 1689 | - | buffer.set("Departure Mono"); | |
| 1690 | - | buffer.home(); | |
| 1691 | - | let rows = render_to(60, 1, |buf, area| { | |
| 1692 | - | AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm")) | |
| 1693 | - | .edit(Some(&buffer)) | |
| 1694 | - | .render(area, buf); | |
| 1695 | - | }); | |
| 1696 | - | assert!(rows[0].contains("Departure Mono"), "{rows:?}"); | |
| 1697 | - | assert!( | |
| 1698 | - | !rows[0].contains("IosevkaTerm"), | |
| 1699 | - | "the committed value is not drawn" | |
| 1700 | - | ); | |
| 1701 | - | } | |
| 1702 | - | ||
| 1703 | - | // The caret has to be visible while appending, which is where it spends | |
| 1704 | - | // most of its life. Past the end of the line there is no character under | |
| 1705 | - | // it, so it draws on a space. | |
| 1706 | - | #[test] | |
| 1707 | - | fn a_caret_past_the_end_of_the_line_still_has_a_cell() { | |
| 1708 | - | let mut buffer = TextField::new(); | |
| 1709 | - | buffer.set("alloy"); | |
| 1710 | - | let (before, under, after) = buffer.split(); | |
| 1711 | - | assert_eq!((before, under, after), ("alloy", None, "")); | |
| 1712 | - | ||
| 1713 | - | let spans = caret_spans(&theme(), &buffer, Style::default()); | |
| 1714 | - | assert_eq!(spans[1].content, " ", "the caret sits on a space"); | |
| 1715 | - | } | |
| 1716 | - | ||
| 1717 | - | #[test] | |
| 1718 | - | fn a_form_lines_its_value_column_up_across_rows() { | |
| 1719 | - | let theme = theme(); | |
| 1720 | - | let rows = render_to(50, 6, |buf, area| { | |
| 1721 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1722 | - | .selected(1) | |
| 1723 | - | .render(area, buf); | |
| 1724 | - | }); | |
| 1725 | - | // "blinking" is the longest label, so both values start in the same | |
| 1726 | - | // column despite "shape" being three characters shorter. | |
| 1727 | - | let shape = column(&rows[1], "block").expect("enum label renders"); | |
| 1728 | - | let blinking = column(&rows[2], "[ ]").expect("toggle renders"); | |
| 1729 | - | assert_eq!(shape, blinking, "{rows:?}"); | |
| 1730 | - | } | |
| 1731 | - | ||
| 1732 | - | #[test] | |
| 1733 | - | fn a_section_header_shows_whether_it_is_folded() { | |
| 1734 | - | let theme = theme(); | |
| 1735 | - | let rows = render_to(50, 6, |buf, area| { | |
| 1736 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1737 | - | .selected(0) | |
| 1738 | - | .render(area, buf); | |
| 1739 | - | }); | |
| 1740 | - | assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}"); | |
| 1741 | - | assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}"); | |
| 1742 | - | } | |
| 1743 | - | ||
| 1744 | - | #[test] | |
| 1745 | - | fn the_focused_row_carries_the_same_marker_a_list_row_would() { | |
| 1746 | - | let theme = theme(); | |
| 1747 | - | let rows = render_to(50, 6, |buf, area| { | |
| 1748 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1749 | - | .selected(2) | |
| 1750 | - | .render(area, buf); | |
| 1751 | - | }); | |
| 1752 | - | assert!(rows[2].starts_with(MARKER), "{rows:?}"); | |
| 1753 | - | assert!(!rows[1].starts_with(MARKER), "only one row is focused"); | |
| 1754 | - | } | |
| 1755 | - | ||
| 1756 | - | // The footer belongs to whichever row is focused, and a diagnostic beats | |
| 1757 | - | // help: it is the reason an edit did not commit. | |
| 1758 | - | #[test] | |
| 1759 | - | fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() { | |
| 1760 | - | let theme = theme(); | |
| 1761 | - | let rows = render_to(50, 6, |buf, area| { | |
| 1762 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1763 | - | .selected(1) | |
| 1764 | - | .render(area, buf); | |
| 1765 | - | }); | |
| 1766 | - | assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}"); | |
| 1767 | - | ||
| 1768 | - | let rows = render_to(50, 6, |buf, area| { | |
| 1769 | - | let mut rows = field_rows(&theme); | |
| 1770 | - | rows[1] = FormRow::Field( | |
| 1771 | - | AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" }) | |
| 1772 | - | .help(Some("Cursor shape.")) | |
| 1773 | - | .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))), | |
| 1774 | - | ); | |
| 1775 | - | AlloyForm::new(&theme, rows).selected(1).render(area, buf); | |
| 1776 | - | }); | |
| 1777 | - | assert!(rows[5].contains("not a declared value"), "{rows:?}"); | |
| 1778 | - | assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins"); | |
| 1779 | - | } | |
| 1780 | - | ||
| 1781 | - | // The footer line is reserved whether or not it has anything in it. A form | |
| 1782 | - | // whose rows reflow as focus moves is one where the row under the cursor | |
| 1783 | - | // moves out from under it. | |
| 1784 | - | #[test] | |
| 1785 | - | fn rows_hold_their_lines_whether_the_footer_has_content_or_not() { | |
| 1786 | - | let theme = theme(); | |
| 1787 | - | let with_help = render_to(50, 6, |buf, area| { | |
| 1788 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1789 | - | .selected(1) | |
| 1790 | - | .render(area, buf); | |
| 1791 | - | }); | |
| 1792 | - | let without = render_to(50, 6, |buf, area| { | |
| 1793 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1794 | - | .selected(2) | |
| 1795 | - | .render(area, buf); | |
| 1796 | - | }); | |
| 1797 | - | assert_eq!( | |
| 1798 | - | with_help[0], without[0], | |
| 1799 | - | "the section header sits on the same line either way", | |
| 1800 | - | ); | |
| 1801 | - | assert!(without[5].trim().is_empty(), "no help, an empty footer"); | |
| 1802 | - | } | |
| 1803 | - | ||
| 1804 | - | // One line per row is what keeps the stateless scroll math valid, so a form | |
| 1805 | - | // longer than its area scrolls exactly the way a list does. | |
| 1806 | - | #[test] | |
| 1807 | - | fn a_form_longer_than_its_area_scrolls_like_a_list() { | |
| 1808 | - | let theme = theme(); | |
| 1809 | - | let many: Vec<FormRow> = (0..50) | |
| 1810 | - | .map(|i| { | |
| 1811 | - | FormRow::Field(AlloyField::new( | |
| 1812 | - | &theme, | |
| 1813 | - | "slot", | |
| 1814 | - | FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }), | |
| 1815 | - | )) | |
| 1816 | - | }) | |
| 1817 | - | .collect(); | |
| 1818 | - | let rows = render_to(50, 11, |buf, area| { | |
| 1819 | - | AlloyForm::new(&theme, many).selected(25).render(area, buf); | |
| 1820 | - | }); | |
| 1821 | - | // 50 rows, 10 row-lines after the footer, selection 25 => offset 20. | |
| 1822 | - | assert!(rows[5].contains("twentyfive"), "{rows:?}"); | |
| 1823 | - | } | |
| 1824 | - | ||
| 1825 | - | #[test] | |
| 1826 | - | fn a_form_survives_an_area_too_small_to_draw_in() { | |
| 1827 | - | let theme = theme(); | |
| 1828 | - | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); | |
| 1829 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1830 | - | .selected(0) | |
| 1831 | - | .render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 1832 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1833 | - | .selected(0) | |
| 1834 | - | .render(Rect::new(0, 0, 0, 6), &mut buf); | |
| 1835 | - | // One line tall: the row wins over the footer. | |
| 1836 | - | let rows = render_to(40, 1, |buf, area| { | |
| 1837 | - | AlloyForm::new(&theme, field_rows(&theme)) | |
| 1838 | - | .selected(0) | |
| 1839 | - | .render(area, buf); | |
| 1840 | - | }); | |
| 1841 | - | assert!(rows[0].contains("cursor"), "{rows:?}"); | |
| 1842 | - | } | |
| 1843 | - | ||
| 1844 | - | fn picker_rows() -> Vec<PickRow<'static>> { | |
| 1845 | - | vec![ | |
| 1846 | - | PickRow::new("Block").description(Some("Solid block.")), | |
| 1847 | - | PickRow::new("Beam").description(Some("Thin vertical bar.")), | |
| 1848 | - | ] | |
| 1849 | - | } | |
| 1850 | - | ||
| 1851 | - | fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> { | |
| 1852 | - | let theme = theme(); | |
| 1853 | - | render_to(50, height, |buf, area| { | |
| 1854 | - | AlloyPicker::new(&theme, "cursor.shape", filter, rows) | |
| 1855 | - | .selected(Some(0)) | |
| 1856 | - | .render(area, buf); | |
| 1857 | - | }) | |
| 1858 | - | } | |
| 1859 | - | ||
| 1860 | - | #[test] | |
| 1861 | - | fn a_picker_shows_its_choices_with_what_they_mean() { | |
| 1862 | - | let rows = render_picker(&TextField::new(), picker_rows(), 8); | |
| 1863 | - | let joined = rows.join("\n"); | |
| 1864 | - | assert!(joined.contains("cursor.shape"), "title: {joined}"); | |
| 1865 | - | assert!(joined.contains("Block"), "{joined}"); | |
| 1866 | - | assert!(joined.contains("Solid block."), "{joined}"); | |
| 1867 | - | assert!(joined.contains("Thin vertical bar."), "{joined}"); | |
| 1868 | - | } | |
| 1869 | - | ||
| 1870 | - | #[test] | |
| 1871 | - | fn the_filter_row_carries_a_caret() { | |
| 1872 | - | let mut filter = TextField::new(); | |
| 1873 | - | filter.set("bl"); | |
| 1874 | - | let rows = render_picker(&filter, picker_rows(), 8); | |
| 1875 | - | assert!(rows[1].contains("/ bl"), "{rows:?}"); | |
| 1876 | - | } | |
| 1877 | - | ||
| 1878 | - | // A picker that goes blank when the filter matches nothing reads as broken | |
| 1879 | - | // rather than as narrowed. | |
| 1880 | - | #[test] | |
| 1881 | - | fn a_filter_that_matches_nothing_says_so() { | |
| 1882 | - | let rows = render_picker(&TextField::new(), Vec::new(), 8); | |
| 1883 | - | assert!(rows.join("\n").contains("no matches"), "{rows:?}"); | |
| 1884 | - | } | |
| 1885 | - | ||
| 1886 | - | // Same rule AlloyModal follows: a floating thing whose dismiss keys move | |
| 1887 | - | // with its content is one the user can lose. | |
| 1888 | - | #[test] | |
| 1889 | - | fn the_keys_sit_on_the_last_row_whatever_the_choice_count() { | |
| 1890 | - | for height in [6, 8, 14] { | |
| 1891 | - | let rows = render_picker(&TextField::new(), picker_rows(), height); | |
| 1892 | - | let last = &rows[height as usize - 2]; | |
| 1893 | - | assert!( | |
| 1894 | - | last.contains("enter") && last.contains("esc"), | |
| 1895 | - | "height {height}: {last:?}" | |
| 1896 | - | ); | |
| 1897 | - | } | |
| 1898 | - | } | |
| 1899 | - | ||
| 1900 | - | #[test] | |
| 1901 | - | fn a_picker_is_wide_enough_for_its_widest_choice() { | |
| 1902 | - | let theme = theme(); | |
| 1903 | - | let filter = TextField::new(); | |
| 1904 | - | let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width(); | |
| 1905 | - | // "Beam" plus the gap plus "Thin vertical bar." is the longest row. | |
| 1906 | - | assert_eq!(width as usize, 4 + 2 + 18 + 4); | |
| 1907 | - | assert_eq!( | |
| 1908 | - | AlloyPicker::height(2), | |
| 1909 | - | 6, | |
| 1910 | - | "two rows, a filter, keys, borders" | |
| 1911 | - | ); | |
| 1912 | - | } | |
| 1913 | - | ||
| 1914 | - | #[test] |
Lines truncated
| @@ -1,0 +1,672 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | use ratatui::style::Color; | |
| 5 | + | ||
| 6 | + | fn theme() -> Theme { | |
| 7 | + | crate::theme::test_theme(crate::theme::Mode::Dark) | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> { | |
| 11 | + | // Leaked so the test list can hold a 'static theme reference; the | |
| 12 | + | // widget borrows rather than owns, and these are per-test one-offs. | |
| 13 | + | let theme: &'static Theme = Box::leak(Box::new(theme())); | |
| 14 | + | let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); | |
| 15 | + | AlloyList::new(theme, items).selected(selected) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | #[test] | |
| 19 | + | fn short_list_never_scrolls() { | |
| 20 | + | assert_eq!(list_of(3, Some(2)).offset(10), 0); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Selection near the top must not scroll past the start of the list — a | |
| 24 | + | // naive `selected - height/2` underflows or shows blank rows above row 0. | |
| 25 | + | #[test] | |
| 26 | + | fn offset_clamps_at_the_top() { | |
| 27 | + | assert_eq!(list_of(50, Some(0)).offset(10), 0); | |
| 28 | + | assert_eq!(list_of(50, Some(2)).offset(10), 0); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Selection at the end must land the last row on the last visible line, | |
| 32 | + | // not scroll into empty space past the end of the list. | |
| 33 | + | #[test] | |
| 34 | + | fn offset_clamps_at_the_bottom() { | |
| 35 | + | assert_eq!(list_of(50, Some(49)).offset(10), 40); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[test] | |
| 39 | + | fn offset_centers_a_midlist_selection() { | |
| 40 | + | assert_eq!(list_of(50, Some(25)).offset(10), 20); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[test] | |
| 44 | + | fn row_y_maps_visible_items_to_screen_rows() { | |
| 45 | + | let area = Rect::new(0, 5, 20, 10); | |
| 46 | + | assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); | |
| 47 | + | assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // After a scroll the mapping has to follow the offset. A connector using a | |
| 51 | + | // separate copy of the scroll rule is exactly what this prevents. | |
| 52 | + | #[test] | |
| 53 | + | fn row_y_accounts_for_scrolling() { | |
| 54 | + | let area = Rect::new(0, 0, 20, 10); | |
| 55 | + | // 50 items, selection at 25 => offset 20, so item 20 is the top row. | |
| 56 | + | assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); | |
| 57 | + | assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[test] | |
| 61 | + | fn row_y_is_none_for_rows_scrolled_out_of_view() { | |
| 62 | + | let area = Rect::new(0, 0, 20, 10); | |
| 63 | + | assert_eq!( | |
| 64 | + | list_row_y(area, 50, Some(25), 0), | |
| 65 | + | None, | |
| 66 | + | "above the viewport" | |
| 67 | + | ); | |
| 68 | + | assert_eq!( | |
| 69 | + | list_row_y(area, 50, Some(25), 49), | |
| 70 | + | None, | |
| 71 | + | "below the viewport" | |
| 72 | + | ); | |
| 73 | + | assert_eq!( | |
| 74 | + | list_row_y(area, 3, Some(0), 9), | |
| 75 | + | None, | |
| 76 | + | "past the end of the list" | |
| 77 | + | ); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | fn render_tabs(selected: usize, width: u16) -> String { | |
| 81 | + | let theme = theme(); | |
| 82 | + | let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); | |
| 83 | + | AlloyTabs::new(&theme, ["installed", "boxes", "system"]) | |
| 84 | + | .selected(selected) | |
| 85 | + | .render(Rect::new(0, 0, width, 1), &mut buf); | |
| 86 | + | buf.content() | |
| 87 | + | .iter() | |
| 88 | + | .map(ratatui::buffer::Cell::symbol) | |
| 89 | + | .collect() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn selected_tab_is_bracketed_and_others_are_not() { | |
| 94 | + | let rendered = render_tabs(0, 60); | |
| 95 | + | assert!( | |
| 96 | + | rendered.contains("[ installed ]"), | |
| 97 | + | "selected tab is bracketed" | |
| 98 | + | ); | |
| 99 | + | assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); | |
| 100 | + | assert!(rendered.contains("boxes"), "unselected labels still render"); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // The bar must not shift horizontally as selection moves, or every tab | |
| 104 | + | // change reads as the whole row twitching. Unselected labels pad to the | |
| 105 | + | // bracket width for exactly this reason. | |
| 106 | + | #[test] | |
| 107 | + | fn labels_hold_their_columns_across_selections() { | |
| 108 | + | let first = render_tabs(0, 60); | |
| 109 | + | let last = render_tabs(2, 60); | |
| 110 | + | assert_eq!( | |
| 111 | + | first.find("system"), | |
| 112 | + | last.find("system"), | |
| 113 | + | "a label sits in the same columns whichever tab is selected" | |
| 114 | + | ); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // FocusRing::focus ignores out-of-range slots rather than clamping, and the | |
| 118 | + | // bar has to agree: showing a neighbouring tab as current would misreport | |
| 119 | + | // which screen the user is looking at. | |
| 120 | + | #[test] | |
| 121 | + | fn out_of_range_selection_brackets_nothing() { | |
| 122 | + | let rendered = render_tabs(9, 60); | |
| 123 | + | assert!(!rendered.contains('['), "no tab is marked current"); | |
| 124 | + | assert!(rendered.contains("installed"), "labels still render"); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[test] | |
| 128 | + | fn zero_height_area_renders_nothing_rather_than_panicking() { | |
| 129 | + | let theme = theme(); | |
| 130 | + | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); | |
| 131 | + | AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 132 | + | AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn render_modal(area: Rect) -> Vec<String> { | |
| 136 | + | let theme = theme(); | |
| 137 | + | let mut buf = Buffer::empty(area); | |
| 138 | + | AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); | |
| 139 | + | (0..area.height) | |
| 140 | + | .map(|y| { | |
| 141 | + | (0..area.width) | |
| 142 | + | .map(|x| buf[(x, y)].symbol()) | |
| 143 | + | .collect::<String>() | |
| 144 | + | }) | |
| 145 | + | .collect() | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn modal_shows_its_message_and_both_keys() { | |
| 150 | + | let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); | |
| 151 | + | assert!(rows.contains("Remove tailscale?"), "message renders"); | |
| 152 | + | assert!(rows.contains("remove"), "title renders"); | |
| 153 | + | assert!(rows.contains("Enter"), "confirm key renders"); | |
| 154 | + | assert!(rows.contains("Esc"), "cancel key renders"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // The keys are pinned to the last inner row rather than flowing after the | |
| 158 | + | // message. A prompt whose dismiss keys move with message length, or fall | |
| 159 | + | // off a short box, is a modal the user cannot get out of. | |
| 160 | + | #[test] | |
| 161 | + | fn keys_sit_on_the_last_row_whatever_the_message_length() { | |
| 162 | + | for height in [5, 7, 12] { | |
| 163 | + | let rows = render_modal(Rect::new(0, 0, 40, height)); | |
| 164 | + | let last_inner = &rows[height as usize - 2]; | |
| 165 | + | assert!( | |
| 166 | + | last_inner.contains("Enter") && last_inner.contains("Esc"), | |
| 167 | + | "height {height}: keys belong on the last inner row, got {last_inner:?}" | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn modal_survives_an_area_too_small_to_draw_in() { | |
| 174 | + | let theme = theme(); | |
| 175 | + | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); | |
| 176 | + | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 177 | + | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); | |
| 178 | + | AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec<String> { | |
| 182 | + | let area = Rect::new(0, 0, width, height); | |
| 183 | + | let mut buf = Buffer::empty(area); | |
| 184 | + | draw(&mut buf, area); | |
| 185 | + | (0..height) | |
| 186 | + | .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>()) | |
| 187 | + | .collect() | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Column of `needle` in `row`, counted in characters. | |
| 191 | + | /// | |
| 192 | + | /// `str::find` counts bytes, and the focus marker is three of them, so a | |
| 193 | + | /// byte offset says a focused row's value starts two columns right of an | |
| 194 | + | /// unfocused one when both are in the same column. | |
| 195 | + | fn column(row: &str, needle: &str) -> Option<usize> { | |
| 196 | + | let at = row.find(needle)?; | |
| 197 | + | Some(row[..at].chars().count()) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn field_rows(theme: &Theme) -> Vec<FormRow<'_>> { | |
| 201 | + | vec![ | |
| 202 | + | FormRow::Section { | |
| 203 | + | label: "cursor", | |
| 204 | + | open: true, | |
| 205 | + | }, | |
| 206 | + | FormRow::Field( | |
| 207 | + | AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" }) | |
| 208 | + | .help(Some("Cursor shape.")), | |
| 209 | + | ), | |
| 210 | + | FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))), | |
| 211 | + | FormRow::Section { | |
| 212 | + | label: "colors", | |
| 213 | + | open: false, | |
| 214 | + | }, | |
| 215 | + | ] | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[test] | |
| 219 | + | fn a_toggle_reads_without_color_or_a_patched_font() { | |
| 220 | + | let rows = render_to(40, 1, |buf, area| { | |
| 221 | + | AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf); | |
| 222 | + | }); | |
| 223 | + | assert!(rows[0].contains("[x]"), "{rows:?}"); | |
| 224 | + | ||
| 225 | + | let rows = render_to(40, 1, |buf, area| { | |
| 226 | + | AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf); | |
| 227 | + | }); | |
| 228 | + | assert!(rows[0].contains("[ ]"), "{rows:?}"); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | #[test] | |
| 232 | + | fn a_color_field_draws_a_swatch_beside_its_hex() { | |
| 233 | + | let rows = render_to(40, 1, |buf, area| { | |
| 234 | + | AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" }) | |
| 235 | + | .render(area, buf); | |
| 236 | + | }); | |
| 237 | + | assert!(rows[0].contains("██ #e4ded6"), "{rows:?}"); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // A hex the widget cannot parse still shows its text. The swatch helps read | |
| 241 | + | // a value; it is not the value, and dropping the text would hide the one | |
| 242 | + | // thing the user needs to see to fix it. | |
| 243 | + | #[test] | |
| 244 | + | fn an_unparseable_color_still_renders_its_text() { | |
| 245 | + | let rows = render_to(40, 1, |buf, area| { | |
| 246 | + | AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" }) | |
| 247 | + | .render(area, buf); | |
| 248 | + | }); | |
| 249 | + | assert!(rows[0].contains("e4ded6"), "{rows:?}"); | |
| 250 | + | assert!( | |
| 251 | + | !rows[0].contains('█'), | |
| 252 | + | "no swatch for a value it cannot parse" | |
| 253 | + | ); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn swatch_parses_both_hex_lengths_and_rejects_the_rest() { | |
| 258 | + | assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6))); | |
| 259 | + | assert_eq!( | |
| 260 | + | swatch("#e4ded6ff"), | |
| 261 | + | Some(Color::Rgb(0xe4, 0xde, 0xd6)), | |
| 262 | + | "alpha is dropped, not refused", | |
| 263 | + | ); | |
| 264 | + | assert_eq!(swatch("e4ded6"), None, "no hash"); | |
| 265 | + | assert_eq!(swatch("#e4ded"), None, "wrong length"); | |
| 266 | + | assert_eq!(swatch("#gggggg"), None, "not hex"); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // An edited row shows the buffer and its caret, not the committed value. | |
| 270 | + | #[test] | |
| 271 | + | fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() { | |
| 272 | + | let mut buffer = TextField::new(); | |
| 273 | + | buffer.set("Departure Mono"); | |
| 274 | + | buffer.home(); | |
| 275 | + | let rows = render_to(60, 1, |buf, area| { | |
| 276 | + | AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm")) | |
| 277 | + | .edit(Some(&buffer)) | |
| 278 | + | .render(area, buf); | |
| 279 | + | }); | |
| 280 | + | assert!(rows[0].contains("Departure Mono"), "{rows:?}"); | |
| 281 | + | assert!( | |
| 282 | + | !rows[0].contains("IosevkaTerm"), | |
| 283 | + | "the committed value is not drawn" | |
| 284 | + | ); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // The caret has to be visible while appending, which is where it spends | |
| 288 | + | // most of its life. Past the end of the line there is no character under | |
| 289 | + | // it, so it draws on a space. | |
| 290 | + | #[test] | |
| 291 | + | fn a_caret_past_the_end_of_the_line_still_has_a_cell() { | |
| 292 | + | let mut buffer = TextField::new(); | |
| 293 | + | buffer.set("alloy"); | |
| 294 | + | let (before, under, after) = buffer.split(); | |
| 295 | + | assert_eq!((before, under, after), ("alloy", None, "")); | |
| 296 | + | ||
| 297 | + | let spans = caret_spans(&theme(), &buffer, Style::default()); | |
| 298 | + | assert_eq!(spans[1].content, " ", "the caret sits on a space"); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn a_form_lines_its_value_column_up_across_rows() { | |
| 303 | + | let theme = theme(); | |
| 304 | + | let rows = render_to(50, 6, |buf, area| { | |
| 305 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 306 | + | .selected(1) | |
| 307 | + | .render(area, buf); | |
| 308 | + | }); | |
| 309 | + | // "blinking" is the longest label, so both values start in the same | |
| 310 | + | // column despite "shape" being three characters shorter. | |
| 311 | + | let shape = column(&rows[1], "block").expect("enum label renders"); | |
| 312 | + | let blinking = column(&rows[2], "[ ]").expect("toggle renders"); | |
| 313 | + | assert_eq!(shape, blinking, "{rows:?}"); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn a_section_header_shows_whether_it_is_folded() { | |
| 318 | + | let theme = theme(); | |
| 319 | + | let rows = render_to(50, 6, |buf, area| { | |
| 320 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 321 | + | .selected(0) | |
| 322 | + | .render(area, buf); | |
| 323 | + | }); | |
| 324 | + | assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}"); | |
| 325 | + | assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}"); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | #[test] | |
| 329 | + | fn the_focused_row_carries_the_same_marker_a_list_row_would() { | |
| 330 | + | let theme = theme(); | |
| 331 | + | let rows = render_to(50, 6, |buf, area| { | |
| 332 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 333 | + | .selected(2) | |
| 334 | + | .render(area, buf); | |
| 335 | + | }); | |
| 336 | + | assert!(rows[2].starts_with(MARKER), "{rows:?}"); | |
| 337 | + | assert!(!rows[1].starts_with(MARKER), "only one row is focused"); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // The footer belongs to whichever row is focused, and a diagnostic beats | |
| 341 | + | // help: it is the reason an edit did not commit. | |
| 342 | + | #[test] | |
| 343 | + | fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() { | |
| 344 | + | let theme = theme(); | |
| 345 | + | let rows = render_to(50, 6, |buf, area| { | |
| 346 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 347 | + | .selected(1) | |
| 348 | + | .render(area, buf); | |
| 349 | + | }); | |
| 350 | + | assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}"); | |
| 351 | + | ||
| 352 | + | let rows = render_to(50, 6, |buf, area| { | |
| 353 | + | let mut rows = field_rows(&theme); | |
| 354 | + | rows[1] = FormRow::Field( | |
| 355 | + | AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" }) | |
| 356 | + | .help(Some("Cursor shape.")) | |
| 357 | + | .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))), | |
| 358 | + | ); | |
| 359 | + | AlloyForm::new(&theme, rows).selected(1).render(area, buf); | |
| 360 | + | }); | |
| 361 | + | assert!(rows[5].contains("not a declared value"), "{rows:?}"); | |
| 362 | + | assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins"); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // The footer line is reserved whether or not it has anything in it. A form | |
| 366 | + | // whose rows reflow as focus moves is one where the row under the cursor | |
| 367 | + | // moves out from under it. | |
| 368 | + | #[test] | |
| 369 | + | fn rows_hold_their_lines_whether_the_footer_has_content_or_not() { | |
| 370 | + | let theme = theme(); | |
| 371 | + | let with_help = render_to(50, 6, |buf, area| { | |
| 372 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 373 | + | .selected(1) | |
| 374 | + | .render(area, buf); | |
| 375 | + | }); | |
| 376 | + | let without = render_to(50, 6, |buf, area| { | |
| 377 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 378 | + | .selected(2) | |
| 379 | + | .render(area, buf); | |
| 380 | + | }); | |
| 381 | + | assert_eq!( | |
| 382 | + | with_help[0], without[0], | |
| 383 | + | "the section header sits on the same line either way", | |
| 384 | + | ); | |
| 385 | + | assert!(without[5].trim().is_empty(), "no help, an empty footer"); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // One line per row is what keeps the stateless scroll math valid, so a form | |
| 389 | + | // longer than its area scrolls exactly the way a list does. | |
| 390 | + | #[test] | |
| 391 | + | fn a_form_longer_than_its_area_scrolls_like_a_list() { | |
| 392 | + | let theme = theme(); | |
| 393 | + | let many: Vec<FormRow> = (0..50) | |
| 394 | + | .map(|i| { | |
| 395 | + | FormRow::Field(AlloyField::new( | |
| 396 | + | &theme, | |
| 397 | + | "slot", | |
| 398 | + | FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }), | |
| 399 | + | )) | |
| 400 | + | }) | |
| 401 | + | .collect(); | |
| 402 | + | let rows = render_to(50, 11, |buf, area| { | |
| 403 | + | AlloyForm::new(&theme, many).selected(25).render(area, buf); | |
| 404 | + | }); | |
| 405 | + | // 50 rows, 10 row-lines after the footer, selection 25 => offset 20. | |
| 406 | + | assert!(rows[5].contains("twentyfive"), "{rows:?}"); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn a_form_survives_an_area_too_small_to_draw_in() { | |
| 411 | + | let theme = theme(); | |
| 412 | + | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); | |
| 413 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 414 | + | .selected(0) | |
| 415 | + | .render(Rect::new(0, 0, 40, 0), &mut buf); | |
| 416 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 417 | + | .selected(0) | |
| 418 | + | .render(Rect::new(0, 0, 0, 6), &mut buf); | |
| 419 | + | // One line tall: the row wins over the footer. | |
| 420 | + | let rows = render_to(40, 1, |buf, area| { | |
| 421 | + | AlloyForm::new(&theme, field_rows(&theme)) | |
| 422 | + | .selected(0) | |
| 423 | + | .render(area, buf); | |
| 424 | + | }); | |
| 425 | + | assert!(rows[0].contains("cursor"), "{rows:?}"); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | fn picker_rows() -> Vec<PickRow<'static>> { | |
| 429 | + | vec![ | |
| 430 | + | PickRow::new("Block").description(Some("Solid block.")), | |
| 431 | + | PickRow::new("Beam").description(Some("Thin vertical bar.")), | |
| 432 | + | ] | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> { | |
| 436 | + | let theme = theme(); | |
| 437 | + | render_to(50, height, |buf, area| { | |
| 438 | + | AlloyPicker::new(&theme, "cursor.shape", filter, rows) | |
| 439 | + | .selected(Some(0)) | |
| 440 | + | .render(area, buf); | |
| 441 | + | }) | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | #[test] | |
| 445 | + | fn a_picker_shows_its_choices_with_what_they_mean() { | |
| 446 | + | let rows = render_picker(&TextField::new(), picker_rows(), 8); | |
| 447 | + | let joined = rows.join("\n"); | |
| 448 | + | assert!(joined.contains("cursor.shape"), "title: {joined}"); | |
| 449 | + | assert!(joined.contains("Block"), "{joined}"); | |
| 450 | + | assert!(joined.contains("Solid block."), "{joined}"); | |
| 451 | + | assert!(joined.contains("Thin vertical bar."), "{joined}"); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[test] | |
| 455 | + | fn the_filter_row_carries_a_caret() { | |
| 456 | + | let mut filter = TextField::new(); | |
| 457 | + | filter.set("bl"); | |
| 458 | + | let rows = render_picker(&filter, picker_rows(), 8); | |
| 459 | + | assert!(rows[1].contains("/ bl"), "{rows:?}"); | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // A picker that goes blank when the filter matches nothing reads as broken | |
| 463 | + | // rather than as narrowed. | |
| 464 | + | #[test] | |
| 465 | + | fn a_filter_that_matches_nothing_says_so() { | |
| 466 | + | let rows = render_picker(&TextField::new(), Vec::new(), 8); | |
| 467 | + | assert!(rows.join("\n").contains("no matches"), "{rows:?}"); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // Same rule AlloyModal follows: a floating thing whose dismiss keys move | |
| 471 | + | // with its content is one the user can lose. | |
| 472 | + | #[test] | |
| 473 | + | fn the_keys_sit_on_the_last_row_whatever_the_choice_count() { | |
| 474 | + | for height in [6, 8, 14] { | |
| 475 | + | let rows = render_picker(&TextField::new(), picker_rows(), height); | |
| 476 | + | let last = &rows[height as usize - 2]; | |
| 477 | + | assert!( | |
| 478 | + | last.contains("enter") && last.contains("esc"), | |
| 479 | + | "height {height}: {last:?}" | |
| 480 | + | ); | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | #[test] | |
| 485 | + | fn a_picker_is_wide_enough_for_its_widest_choice() { | |
| 486 | + | let theme = theme(); | |
| 487 | + | let filter = TextField::new(); | |
| 488 | + | let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width(); | |
| 489 | + | // "Beam" plus the gap plus "Thin vertical bar." is the longest row. | |
| 490 | + | assert_eq!(width as usize, 4 + 2 + 18 + 4); | |
| 491 | + | assert_eq!( | |
| 492 | + | AlloyPicker::height(2), | |
| 493 | + | 6, | |
| 494 | + | "two rows, a filter, keys, borders" | |
| 495 | + | ); | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | #[test] | |
| 499 | + | fn a_picker_survives_an_area_too_small_to_draw_in() { | |
| 500 | + | let theme = theme(); |
Lines truncated