Skip to main content

max / alloy_tui

Move the widgets test module to a sibling file widgets.rs goes 2089 lines to 1418, its 42 tests moving to src/widgets/tests.rs. No public path moved, so this needs no version bump and no publish.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 23:24 UTC
Signed with PGP, not checked
Commit: 5b0df3d9e7a25a9730db90bcb2ed1acc37bf68d1
Parent: 36443b1
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