Skip to main content

max / alloy_tui

1.2.0: AlloyPicker, the overlay a closed vocabulary opens Folded into 1.2.0 rather than a version of its own, since 1.2.0 has not been published and no phantom of it exists anywhere to be inconsistent with. An earlier note in the Alloy repo said this overlay needed no new widget, on the reasoning that AlloyModal could wrap a list. It cannot: AlloyModal renders its own message paragraph, so composing in the binary would have meant hand-rolling the surface.overlay chrome next to a widget that already owns it, and every floating thing in every Alloy TUI should read the same. The filter is not optional chrome. The overlay was specced against enums the size of a cursor shape, and the settings screen's zone row is an enum over six hundred timezones. Substring and not fuzzy: zone names are terse and hierarchical, and a ranker over six hundred strings is a scoring function to tune for no gain a substring does not give. An empty result says "no matches" rather than going blank, which reads as broken instead of narrowed. Keys are pinned to the last row, same rule and same reason as AlloyModal.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:45 UTC
Commit: 8522ae317f2f7ad198d0a44b626be9e3aee78715
Parent: c01e27a
2 files changed, +308 insertions, -1 deletion
M README.md +2 -1
@@ -14,7 +14,8 @@ rather than each re-deriving colors from a palette.
14 14 locally so theme files stay minimal and cross-app compatible.
15 15 - **Widgets** — `AlloyBlock`, `AlloyList`, `AlloyTabs`, `AlloyStatusBar`,
16 16 `AlloyModal`, `AlloyLog`, each taking a `&Theme`.
17 - - **Forms** — `AlloyForm` over `AlloyField` rows, plus a display-only
17 + - **Forms** — `AlloyForm` over `AlloyField` rows, `AlloyPicker` for the
18 + filterable overlay a closed vocabulary opens, plus a display-only
18 19 `AlloyTable`. One field widget carrying a `FieldKind` value cell rather than
19 20 one widget per value type: the widgets own no value and do no validation, so
20 21 they differ only in how the cell paints. `TextField` is the caret buffer a
@@ -908,6 +908,227 @@ impl Widget for AlloyForm<'_> {
908 908 }
909 909 }
910 910
911 + /// One choice in a picker: what it reads as, and what it means.
912 + pub struct PickRow<'a> {
913 + pub label: &'a str,
914 + pub description: Option<&'a str>,
915 + }
916 +
917 + impl<'a> PickRow<'a> {
918 + pub fn new(label: &'a str) -> Self {
919 + Self {
920 + label,
921 + description: None,
922 + }
923 + }
924 +
925 + #[must_use]
926 + pub fn description(mut self, description: Option<&'a str>) -> Self {
927 + self.description = description;
928 + self
929 + }
930 + }
931 +
932 + /// A filterable pick list, drawn over the view that raised it.
933 + ///
934 + /// The overlay an enum field opens: choices do not free-type, so picking one is
935 + /// a different act from editing a value and gets a different surface. Sits on
936 + /// `surface.overlay` alongside [`AlloyModal`], which is the whole reason it
937 + /// lives here rather than being assembled per view — every floating thing in
938 + /// every Alloy TUI should read the same.
939 + ///
940 + /// **The filter is not optional chrome.** The design that specced a pick
941 + /// overlay assumed enums the size of a cursor shape; `timedatectl
942 + /// list-timezones` is about six hundred entries and locales are worse. A plain
943 + /// substring match is deliberate: zone names are terse and hierarchical, and a
944 + /// fuzzy ranker over six hundred strings is a scoring function to tune for no
945 + /// gain a substring does not already give.
946 + ///
947 + /// Like every widget here it owns nothing. The buffer, the filtering, and the
948 + /// selection are the caller's; this paints them.
949 + pub struct AlloyPicker<'a> {
950 + theme: &'a Theme,
951 + title: &'a str,
952 + filter: &'a TextField,
953 + rows: Vec<PickRow<'a>>,
954 + selected: Option<usize>,
955 + empty: &'a str,
956 + }
957 +
958 + impl<'a> AlloyPicker<'a> {
959 + pub fn new(
960 + theme: &'a Theme,
961 + title: &'a str,
962 + filter: &'a TextField,
963 + rows: impl IntoIterator<Item = PickRow<'a>>,
964 + ) -> Self {
965 + Self {
966 + theme,
967 + title,
968 + filter,
969 + rows: rows.into_iter().collect(),
970 + selected: None,
971 + empty: "no matches",
972 + }
973 + }
974 +
975 + #[must_use]
976 + pub fn selected(mut self, selected: Option<usize>) -> Self {
977 + self.selected = selected;
978 + self
979 + }
980 +
981 + /// What to show when the filter matches nothing. A picker that goes blank
982 + /// reads as broken rather than as narrowed.
983 + #[must_use]
984 + pub fn empty(mut self, empty: &'a str) -> Self {
985 + self.empty = empty;
986 + self
987 + }
988 +
989 + /// Width the content wants, borders included.
990 + ///
991 + /// Callers size the overlay with this and [`layout::centered`], rather than
992 + /// this widget picking its own area: where an overlay sits is the view's
993 + /// business and how wide its content is, is not.
994 + ///
995 + /// [`layout::centered`]: crate::layout::centered
996 + pub fn width(&self) -> u16 {
997 + let content = self
998 + .rows
999 + .iter()
1000 + .map(|row| {
1001 + row.label.chars().count()
1002 + + row
1003 + .description
1004 + .map_or(0, |text| text.chars().count() + DESCRIPTION_GAP.len())
1005 + })
1006 + .chain(std::iter::once(self.title.chars().count()))
1007 + .max()
1008 + .unwrap_or(0);
1009 + // Two for the border, two for the gutter the list draws its marker in.
1010 + (content + 4) as u16
1011 + }
1012 +
1013 + /// Height for `rows` visible choices, borders included: the filter row and
1014 + /// the key row on top of them.
1015 + pub fn height(rows: u16) -> u16 {
1016 + rows + 4
1017 + }
1018 + }
1019 +
1020 + /// Separates a choice from what it means.
1021 + const DESCRIPTION_GAP: &str = " ";
1022 +
1023 + impl Widget for AlloyPicker<'_> {
1024 + fn render(self, area: Rect, buf: &mut Buffer) {
1025 + if area.height == 0 || area.width == 0 {
1026 + return;
1027 + }
1028 +
1029 + let base = Style::default()
1030 + .bg(self.theme.surface_overlay)
1031 + .fg(self.theme.content_primary);
1032 + let block = Block::default()
1033 + .borders(Borders::ALL)
1034 + .border_style(Style::default().fg(self.theme.border_strong))
1035 + .style(base)
1036 + .title(format!(" {} ", self.title));
1037 + let inner = block.inner(area);
1038 + block.render(area, buf);
1039 +
1040 + if inner.height == 0 {
1041 + return;
1042 + }
1043 +
1044 + // Filter on top, keys on the bottom, list between. The keys are pinned
1045 + // for the same reason AlloyModal pins its own: a prompt whose dismiss
1046 + // keys move with content length is one the user can lose.
1047 + let (before, under, after) = self.filter.split();
1048 + let caret = Style::default()
1049 + .bg(self.theme.content_primary)
1050 + .fg(self.theme.surface_overlay);
1051 + Paragraph::new(Line::from(vec![
1052 + Span::styled("/ ", base.fg(self.theme.content_muted)),
1053 + Span::styled(before.to_string(), base),
1054 + Span::styled(under.map_or(" ".to_string(), String::from), caret),
1055 + Span::styled(after.to_string(), base),
1056 + ]))
1057 + .style(base)
1058 + .render(Rect { height: 1, ..inner }, buf);
1059 +
1060 + let keys = Line::from(vec![
1061 + text::action(self.theme, "enter"),
1062 + Span::styled(" select", Style::default().fg(self.theme.content_muted)),
1063 + Span::raw(" "),
1064 + text::action(self.theme, "esc"),
1065 + Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
1066 + ]);
1067 + if inner.height > 1 {
1068 + Paragraph::new(keys).style(base).render(
1069 + Rect {
1070 + y: inner.y + inner.height - 1,
1071 + height: 1,
1072 + ..inner
1073 + },
1074 + buf,
1075 + );
1076 + }
1077 +
1078 + let list_area = Rect {
1079 + y: inner.y + 1,
1080 + height: inner.height.saturating_sub(2),
1081 + ..inner
1082 + };
1083 + if list_area.height == 0 {
1084 + return;
1085 + }
1086 +
1087 + if self.rows.is_empty() {
1088 + Paragraph::new(Line::from(Span::styled(
1089 + self.empty,
1090 + base.fg(self.theme.content_muted),
1091 + )))
1092 + .style(base)
1093 + .render(list_area, buf);
1094 + return;
1095 + }
1096 +
1097 + // Descriptions line up in a column, so a list of choices reads as two
1098 + // columns rather than as ragged sentences.
1099 + let label_width = self
1100 + .rows
1101 + .iter()
1102 + .map(|row| row.label.chars().count())
1103 + .max()
1104 + .unwrap_or(0);
1105 +
1106 + let items: Vec<Line> = self
1107 + .rows
1108 + .iter()
1109 + .map(|row| {
1110 + let mut spans = vec![Span::styled(
1111 + format!("{:label_width$}", row.label),
1112 + Style::default(),
1113 + )];
1114 + if let Some(description) = row.description {
1115 + spans.push(Span::styled(
1116 + format!("{DESCRIPTION_GAP}{description}"),
1117 + Style::default().fg(self.theme.content_muted),
1118 + ));
1119 + }
1120 + Line::from(spans)
1121 + })
1122 + .collect();
1123 +
1124 + // Rendered as an ordinary list so selection, the marker, and scrolling
1125 + // are the same here as anywhere else in the console.
1126 + AlloyList::new(self.theme, items)
1127 + .selected(self.selected)
1128 + .render(list_area, buf);
1129 + }
1130 + }
1131 +
911 1132 /// A display-only table.
912 1133 ///
913 1134 /// v1 renders schema list-of-tables records (rio's `bindings.keys`) read-only;
@@ -1444,6 +1665,91 @@ mod tests {
1444 1665 assert!(rows[0].contains("cursor"), "{rows:?}");
1445 1666 }
1446 1667
1668 + fn picker_rows() -> Vec<PickRow<'static>> {
1669 + vec![
1670 + PickRow::new("Block").description(Some("Solid block.")),
1671 + PickRow::new("Beam").description(Some("Thin vertical bar.")),
1672 + ]
1673 + }
1674 +
1675 + fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> {
1676 + let theme = theme();
1677 + render_to(50, height, |buf, area| {
1678 + AlloyPicker::new(&theme, "cursor.shape", filter, rows)
1679 + .selected(Some(0))
1680 + .render(area, buf);
1681 + })
1682 + }
1683 +
1684 + #[test]
1685 + fn a_picker_shows_its_choices_with_what_they_mean() {
1686 + let rows = render_picker(&TextField::new(), picker_rows(), 8);
1687 + let joined = rows.join("\n");
1688 + assert!(joined.contains("cursor.shape"), "title: {joined}");
1689 + assert!(joined.contains("Block"), "{joined}");
1690 + assert!(joined.contains("Solid block."), "{joined}");
1691 + assert!(joined.contains("Thin vertical bar."), "{joined}");
1692 + }
1693 +
1694 + #[test]
1695 + fn the_filter_row_carries_a_caret() {
1696 + let mut filter = TextField::new();
1697 + filter.set("bl");
1698 + let rows = render_picker(&filter, picker_rows(), 8);
1699 + assert!(rows[1].contains("/ bl"), "{rows:?}");
1700 + }
1701 +
1702 + // A picker that goes blank when the filter matches nothing reads as broken
1703 + // rather than as narrowed.
1704 + #[test]
1705 + fn a_filter_that_matches_nothing_says_so() {
1706 + let rows = render_picker(&TextField::new(), Vec::new(), 8);
1707 + assert!(rows.join("\n").contains("no matches"), "{rows:?}");
1708 + }
1709 +
1710 + // Same rule AlloyModal follows: a floating thing whose dismiss keys move
1711 + // with its content is one the user can lose.
1712 + #[test]
1713 + fn the_keys_sit_on_the_last_row_whatever_the_choice_count() {
1714 + for height in [6, 8, 14] {
1715 + let rows = render_picker(&TextField::new(), picker_rows(), height);
1716 + let last = &rows[height as usize - 2];
1717 + assert!(
1718 + last.contains("enter") && last.contains("esc"),
1719 + "height {height}: {last:?}"
1720 + );
1721 + }
1722 + }
1723 +
1724 + #[test]
1725 + fn a_picker_is_wide_enough_for_its_widest_choice() {
1726 + let theme = theme();
1727 + let filter = TextField::new();
1728 + let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width();
1729 + // "Beam" plus the gap plus "Thin vertical bar." is the longest row.
1730 + assert_eq!(width as usize, 4 + 2 + 18 + 4);
1731 + assert_eq!(
1732 + AlloyPicker::height(2),
1733 + 6,
1734 + "two rows, a filter, keys, borders"
1735 + );
1736 + }
1737 +
1738 + #[test]
1739 + fn a_picker_survives_an_area_too_small_to_draw_in() {
1740 + let theme = theme();
1741 + let filter = TextField::new();
1742 + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
1743 + for area in [
1744 + Rect::new(0, 0, 40, 0),
1745 + Rect::new(0, 0, 0, 8),
1746 + Rect::new(0, 0, 4, 2),
1747 + Rect::new(0, 0, 40, 3),
1748 + ] {
1749 + AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf);
1750 + }
1751 + }
1752 +
1447 1753 #[test]
1448 1754 fn a_table_aligns_its_columns_under_its_headers() {
1449 1755 let theme = theme();