Skip to main content

max / alloy

settings: pick an enum from a filterable overlay The last field type that could not be changed. Enter on an enum opens the overlay on the value the field already holds, so a pick that changes nothing is Enter twice and the list says what is set rather than making the user read the row behind it. Typing narrows, on the raw value as well as the label, since someone who knows the value types the value. Movement is the arrows and Tab, never j/k: those are characters the filter has first claim on. That is the price of type-to-filter and the reason the overlay lists its keys and the footer carries the rest. Picking is its own mode rather than an edit with a list attached. The two ask different questions -- what should this be, and which of these is it -- and only one of them can fail to parse. Committing writes the raw value, not the label the overlay showed. Enter with nothing highlighted closes the overlay and writes nothing, rather than sitting there looking stuck.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:45 UTC
Signed with PGP, not checked
Commit: 8cb7f2a701e97fff82523f4ae86ef7f86c7562c2
Parent: cf58326
2 files changed, +369 insertions, -32 deletions
@@ -160,7 +160,9 @@
160 160
161 161 `TextField`, the single-line caret buffer `AlloyField` shows in edit mode, promotes out of the console binary's `field.rs` unchanged; it started there because the crate is a separate published repo and a widget there is a release, and this is that release (`alloy_tui` 1.2).
162 162
163 - The enum pick overlay needs **no new widget**, which is worth stating because it grew a requirement. It was specced against three-value enums like rio's `cursor.shape`; the System tab's zone row is an enum over `timedatectl list-timezones`, about 600 entries, so the overlay filters as you type. But that is `AlloyModal` around an `AlloyList` with a `TextField` above it, all three already here, and the filter itself is a substring match over the values, which is state and therefore the binary's. Nothing about it belongs in the crate.
163 + `AlloyPicker` is the overlay a closed vocabulary opens. It was specced against three-value enums like rio's `cursor.shape`; the System tab's zone row is an enum over `timedatectl list-timezones`, about 600 entries, so the overlay filters as you type: a `TextField` above an `AlloyList`, matching on a plain substring. No fuzzy match, because zone names are terse and hierarchical and a ranker over 600 strings is a scoring function to tune for no gain a substring does not give.
164 +
165 + An earlier draft of this section said the 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. Every floating thing in every Alloy TUI should read the same, which is the argument for the crate and against the view. `AlloyPicker` owns nothing, like the rest: the buffer, the filtering, and the selection are the caller's.
164 166
165 167 ## Focus and keymap model
166 168
@@ -27,8 +27,9 @@
27 27
28 28 use alloy_tui::keys::{Action, classify};
29 29 use alloy_tui::{
30 - AlloyBlock, AlloyConnector, AlloyField, AlloyForm, AlloyList, AlloyTabs, Cursor, FieldKind,
31 - FocusRing, FormRow, Hint, Severity, TextField, Theme, hint, layout, list_row_y, text,
30 + AlloyBlock, AlloyConnector, AlloyField, AlloyForm, AlloyList, AlloyPicker, AlloyTabs, Cursor,
31 + FieldKind, FocusRing, FormRow, Hint, PickRow, Severity, TextField, Theme, hint, layout,
32 + list_row_y, text,
32 33 };
33 34 use ratatui::Frame;
34 35 use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -163,6 +164,18 @@
163 164 /// this rather than dropping the user's text on the floor.
164 165 error: Option<String>,
165 166 },
167 + /// An enum row, open on its pick overlay.
168 + ///
169 + /// A separate mode and not an `Editing` with a list attached: the two
170 + /// answer different questions. Editing asks what the value should be and a
171 + /// pick asks which of these it is, and only one of them can fail to parse.
172 + Picking {
173 + row: usize,
174 + /// The substring the choices are narrowed by.
175 + filter: TextField,
176 + /// Rides the *filtered* list, not the full one.
177 + cursor: Cursor,
178 + },
166 179 }
167 180
168 181 /// One line of a rendered form.
@@ -334,6 +347,150 @@
334 347 matches!(self.mode, Mode::Editing { .. })
335 348 }
336 349
350 + fn picking(&self) -> bool {
351 + matches!(self.mode, Mode::Picking { .. })
352 + }
353 +
354 + /// Whether the keyboard belongs to a text buffer rather than to the view.
355 + fn typing(&self) -> bool {
356 + self.editing() || self.picking()
357 + }
358 +
359 + /// The choices matching the current filter, as (raw value, label,
360 + /// description).
361 + ///
362 + /// Case-insensitive, and matched against the raw value as well as the
363 + /// label: a user who knows the value types the value, and a zone list where
364 + /// the two are the same string should not care which one is being searched.
365 + fn choices(&self) -> Vec<(&str, &str, Option<&str>)> {
366 + let Mode::Picking { row, filter, .. } = &self.mode else {
367 + return Vec::new();
368 + };
369 + let rows = self.rows();
370 + let Some(Row::Field(field)) = rows.get(*row) else {
371 + return Vec::new();
372 + };
373 + let schema::FieldKind::Enum { values, .. } = &field.kind else {
374 + return Vec::new();
375 + };
376 + let needle = filter.value().to_lowercase();
377 + values
378 + .iter()
379 + .filter(|choice| {
380 + needle.is_empty()
381 + || choice.value.to_lowercase().contains(&needle)
382 + || choice.label.to_lowercase().contains(&needle)
383 + })
384 + .map(|choice| {
385 + (
386 + choice.value.as_str(),
387 + choice.label.as_str(),
388 + choice.description.as_deref(),
389 + )
390 + })
391 + .collect()
392 + }
393 +
394 + /// Open the enum under the cursor on its overlay, selecting what it holds.
395 + fn begin_pick(&mut self) -> bool {
396 + let Some(row) = self.cursor.selected() else {
397 + return false;
398 + };
399 + let Some(field) = self.selected_field() else {
400 + return false;
401 + };
402 + let schema::FieldKind::Enum { values, .. } = &field.kind else {
403 + return false;
404 + };
405 +
406 + // Opens on the current value rather than at the top, so a pick that
407 + // changes nothing is Enter twice and the list says what is set.
408 + let current = self
409 + .bind
410 + .read(&field.path)
411 + .or_else(|| field.default_value());
412 + let at = match &current {
413 + Some(Value::String(raw)) => values.iter().position(|choice| &choice.value == raw),
414 + _ => None,
415 + };
416 +
417 + let mut cursor = Cursor::new();
418 + cursor.resize(values.len());
419 + if let Some(at) = at {
420 + cursor.move_by(at as isize);
421 + }
422 + self.mode = Mode::Picking {
423 + row,
424 + filter: TextField::new(),
425 + cursor,
426 + };
427 + true
428 + }
429 +
430 + /// Commit the highlighted choice.
431 + fn commit_pick(&mut self, log: &mut CommandLog) {
432 + let Mode::Picking { row, cursor, .. } = &self.mode else {
433 + return;
434 + };
435 + let (row, at) = (*row, cursor.selected());
436 + // An empty list has nothing to commit, and Enter on one should close
437 + // the overlay rather than sit there looking stuck.
438 + let value = at.and_then(|at| self.choices().get(at).map(|(raw, ..)| (*raw).to_string()));
439 +
440 + let rows = self.rows();
441 + let path = match rows.get(row) {
442 + Some(Row::Field(field)) => field.path.clone(),
443 + _ => String::new(),
444 + };
445 + drop(rows);
446 +
447 + if let Some(value) = value
448 + && !path.is_empty()
449 + {
450 + self.commit(&path, Value::String(value), log);
451 + }
452 + self.mode = Mode::Navigate;
453 + }
454 +
455 + /// Route a key into the filter, and keep the cursor on a row that exists.
456 + fn filter_key(&mut self, key: KeyEvent) {
457 + let Mode::Picking { filter, cursor, .. } = &mut self.mode else {
458 + return;
459 + };
460 + match key.code {
461 + // Movement is the arrows and Tab, never j/k: those are characters
462 + // the filter has first claim on, which is the price of type-to-
463 + // filter and the reason the overlay lists its keys.
464 + KeyCode::Down | KeyCode::Tab => {
465 + cursor.next();
466 + return;
467 + }
468 + KeyCode::Up | KeyCode::BackTab => {
469 + cursor.prev();
470 + return;
471 + }
472 + KeyCode::Char(c) => filter.insert(c),
473 + KeyCode::Backspace => filter.backspace(),
474 + KeyCode::Delete => filter.delete(),
475 + KeyCode::Left => filter.left(),
476 + KeyCode::Right => filter.right(),
477 + KeyCode::Home => filter.home(),
478 + KeyCode::End => filter.end(),
479 + _ => return,
480 + }
481 + // The list just changed under the cursor. Back to the top, because
482 + // after narrowing, the first match is the one being looked for.
483 + let len = self.choices().len();
484 + if let Mode::Picking { cursor, .. } = &mut self.mode {
485 + cursor.resize(0);
486 + cursor.resize(len);
487 + }
488 + }
489 +
490 + fn cancel_pick(&mut self) {
491 + self.mode = Mode::Navigate;
492 + }
493 +
337 494 /// The field under the cursor, if the cursor is on one.
338 495 fn selected_field(&self) -> Option<&Field> {
339 496 match self.rows().get(self.cursor.selected()?)? {
@@ -345,11 +502,11 @@
345 502 /// Open the row under the cursor for editing.
346 503 ///
347 504 /// Bools are not edited: they flip, which is what [`toggle_bool`] is for.
348 - /// Enums do not free-type either, and their pick overlay is the next step
349 - /// of the build order, so Enter on one does nothing yet rather than
350 - /// dropping the user into a text field over a closed vocabulary.
505 + /// Enums do not free-type either, and go to [`begin_pick`] instead, so
506 + /// this is reached only for the kinds where any text could be the answer.
351 507 ///
352 508 /// [`toggle_bool`]: Form::toggle_bool
509 + /// [`begin_pick`]: Form::begin_pick
353 510 fn begin_edit(&mut self) -> bool {
354 511 let Some(row) = self.cursor.selected() else {
355 512 return false;
@@ -357,10 +514,7 @@
357 514 let Some(field) = self.selected_field() else {
358 515 return false;
359 516 };
360 - if matches!(
361 - field.kind,
362 - schema::FieldKind::Bool { .. } | schema::FieldKind::Enum { .. }
363 - ) {
517 + if matches!(field.kind, schema::FieldKind::Bool { .. }) {
364 518 return false;
365 519 }
366 520
@@ -691,10 +845,48 @@
691 845 .any(|form| form.bind.dirty())
692 846 }
693 847
694 - fn editing(&self) -> bool {
848 + /// Whether a text buffer, of either kind, has the keyboard.
849 + fn typing(&self) -> bool {
695 850 self.app()
696 851 .and_then(|app| app.state.as_ref().ok())
697 - .is_some_and(Form::editing)
852 + .is_some_and(Form::typing)
853 + }
854 +
855 + /// Draw the pick overlay over the whole screen area, if one is open.
856 + fn render_pick(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
857 + let Some(form) = self.app().and_then(|app| app.state.as_ref().ok()) else {
858 + return;
859 + };
860 + let Mode::Picking {
861 + row,
862 + filter,
863 + cursor,
864 + } = &form.mode
865 + else {
866 + return;
867 + };
868 + let rows = form.rows();
869 + let Some(Row::Field(field)) = rows.get(*row) else {
870 + return;
871 + };
872 +
873 + let choices = form.choices();
874 + let picker = AlloyPicker::new(
875 + theme,
876 + &field.path,
877 + filter,
878 + choices
879 + .iter()
880 + .map(|(_, label, description)| PickRow::new(label).description(*description)),
881 + )
882 + .selected(cursor.selected());
883 +
884 + // Sized to its content and centered, clamped by `centered` to whatever
885 + // the terminal actually has. A list of six hundred zones asks for more
886 + // height than any screen has, so the visible count is what fits.
887 + let height = AlloyPicker::height(choices.len().min(u16::MAX as usize) as u16);
888 + let area = layout::centered(area, picker.width(), height.min(area.height));
889 + frame.render_widget(picker, area);
698 890 }
699 891
700 892 /// Write the form the user is looking at.
@@ -894,10 +1086,19 @@
894 1086 }
895 1087
896 1088 fn hints(&self) -> Vec<Hint> {
897 - // Editing owns the keyboard, so the footer stops advertising keys that
898 - // would land in the field as characters.
899 - if self.editing() {
900 - return vec![hint("enter", "commit"), hint("esc", "discard")];
1089 + // A buffer owns the keyboard, so the footer stops advertising keys
1090 + // that would land in it as characters. The overlay lists its own two,
1091 + // so the footer covers the movement the overlay cannot spare a row for.
1092 + if self.typing() {
1093 + let mut hints = vec![hint("enter", "commit"), hint("esc", "discard")];
1094 + if self
1095 + .app()
1096 + .and_then(|app| app.state.as_ref().ok())
1097 + .is_some_and(Form::picking)
1098 + {
1099 + hints.push(hint("up/down", "choose"));
1100 + }
1101 + return hints;
901 1102 }
902 1103
903 1104 let mut hints = vec![hint("h/l", "tab")];
@@ -959,10 +1160,15 @@
959 1160 Tab::System => Self::render_system(frame, body, theme),
960 1161 Tab::Applications => self.render_applications(frame, body, theme),
961 1162 }
1163 +
1164 + // Last, and over the whole area rather than the body, so the overlay
1165 + // floats above the tab bar too. It is a question about one row, not a
1166 + // pane of the screen behind it.
1167 + self.render_pick(frame, area, theme);
962 1168 }
963 1169
964 1170 fn text_entry(&self) -> bool {
965 - self.editing()
1171 + self.typing()
966 1172 }
967 1173
968 1174 /// Esc backs out one layer at a time: out of an edit, then out of the view.
@@ -970,9 +1176,10 @@
970 1176 /// The unsaved question is asked by [`quit`](View::quit), which both Esc
971 1177 /// and `q` reach; asking it here as well would ask twice for one key.
972 1178 fn cancel(&mut self) -> Flow {
973 - if self.editing() {
1179 + if self.typing() {
974 1180 if let Some(form) = self.form_mut() {
975 1181 form.cancel_edit();
1182 + form.cancel_pick();
976 1183 }
977 1184 return Flow::Continue;
978 1185 }
@@ -1001,27 +1208,44 @@
1001 1208 // the two that are not characters and the rest go to the buffer. This
1002 1209 // is the obligation `classify` documents, honored at the one place in
1003 1210 // this view that takes typing.
1004 - if self.editing() {
1211 + if self.typing() {
1212 + let picking = self
1213 + .app()
1214 + .and_then(|app| app.state.as_ref().ok())
1215 + .is_some_and(Form::picking);
1216 +
1005 1217 match classify(key) {
1006 1218 Action::Activate => {
1007 1219 if let Some(form) = self.form_mut() {
1008 - form.commit_edit(log);
1220 + if picking {
1221 + form.commit_pick(log);
1222 + } else {
1223 + form.commit_edit(log);
1224 + }
1009 1225 }
1010 1226 }
1011 1227 Action::Save => {
1012 - // Commit the field first, then write. Ctrl-S mid-edit means
1013 - // "and save this too", not "save everything except what I
1014 - // am looking at".
1228 + // Commit what is open first, then write. Ctrl-S mid-edit
1229 + // means "and save this too", not "save everything except
1230 + // what I am looking at".
1015 1231 if let Some(form) = self.form_mut() {
1016 - form.commit_edit(log);
1232 + if picking {
1233 + form.commit_pick(log);
1234 + } else {
1235 + form.commit_edit(log);
1236 + }
1017 1237 }
1018 - if !self.editing() {
1238 + if !self.typing() {
1019 1239 self.save(log);
1020 1240 }
1021 1241 }
1022 1242 _ => {
1023 1243 if let Some(form) = self.form_mut() {
1024 - form.type_key(key);
1244 + if picking {
1245 + form.filter_key(key);
1246 + } else {
1247 + form.type_key(key);
1248 + }
1025 1249 }
1026 1250 }
1027 1251 }
@@ -1052,7 +1276,10 @@
1052 1276 Action::Activate => {
1053 1277 if self.panes.is_focused(PANE_FORM) {
1054 1278 if let Some(form) = self.form_mut() {
1055 - form.begin_edit();
1279 + // A closed vocabulary picks; anything else edits.
1280 + if !form.begin_pick() {
1281 + form.begin_edit();
1282 + }
1056 1283 }
1057 1284 } else {
1058 1285 // Enter on an app is "open this one", which means moving
@@ -1291,6 +1518,16 @@
1291 1518 KeyEvent::new(code, ratatui::crossterm::event::KeyModifiers::NONE)
1292 1519 }
1293 1520
1521 + fn type_filter(form: &mut Form, text: &str) {
1522 + let Mode::Picking { filter, .. } = &mut form.mode else {
1523 + panic!("the overlay is open")
1524 + };
1525 + filter.set("");
1526 + for c in text.chars() {
1527 + form.filter_key(key(KeyCode::Char(c)));
1528 + }
1529 + }
1530 +
1294 1531 fn type_text(form: &mut Form, text: &str) {
1295 1532 for c in text.chars() {
1296 1533 form.type_key(key(KeyCode::Char(c)));
@@ -1465,17 +1702,141 @@
1465 1702 assert!(!form.bind.dirty());
1466 1703 }
1467 1704
1468 - // An enum is a closed vocabulary, so it does not free-type. Until the pick
1469 - // overlay lands, Enter on one does nothing rather than opening a field the
1470 - // user could type anything into.
1705 + // ---- the pick overlay ----
1706 +
1707 + // An enum is a closed vocabulary: it opens the overlay, never a text field
1708 + // the user could type anything into.
1471 1709 #[test]
1472 - fn an_enum_does_not_open_a_text_field() {
1710 + fn an_enum_opens_the_overlay_and_not_a_text_field() {
1473 1711 let mut form = form("");
1474 1712 focus(&mut form, "cursor.shape");
1475 - assert!(!form.begin_edit());
1713 + assert!(form.begin_pick());
1714 + assert!(form.picking());
1476 1715 assert!(!form.editing());
1477 1716 }
1478 1717
1718 + // Opening on the current value means a pick that changes nothing is Enter
1719 + // twice, and the list says what is already set rather than making the user
1720 + // find out by reading the row behind it.
1721 + #[test]
1722 + fn the_overlay_opens_on_the_value_the_field_holds() {
1723 + let mut form = form("[cursor]\nshape = \"beam\"\n");
1724 + focus(&mut form, "cursor.shape");
1725 + form.begin_pick();
1726 + let Mode::Picking { cursor, .. } = &form.mode else {
1727 + unreachable!()
1728 + };
1729 + // "beam" is the second of the two declared values.
1730 + assert_eq!(cursor.selected(), Some(1));
1731 + assert_eq!(form.choices()[1].0, "beam");
1732 + }
1733 +
1734 + #[test]
1735 + fn typing_narrows_the_choices_and_matches_either_column() {
1736 + let mut form = form("");
1737 + focus(&mut form, "cursor.shape");
1738 + form.begin_pick();
1739 + assert_eq!(form.choices().len(), 2);
1740 +
1741 + // "Block" is the label; "block" is the raw value.
1742 + type_filter(&mut form, "BLO");
1743 + assert_eq!(form.choices().len(), 1);
1744 + assert_eq!(form.choices()[0].0, "block");
1745 +
1746 + type_filter(&mut form, "zzz");
1747 + assert!(form.choices().is_empty(), "a filter can match nothing");
1748 + }
1749 +
1750 + // The cursor rides the filtered list, so narrowing has to put it back on a
1751 + // row that exists, and the first match is the one being looked for.
1752 + #[test]
1753 + fn narrowing_puts_the_cursor_on_the_first_match() {
1754 + let mut form = form("[cursor]\nshape = \"beam\"\n");
1755 + focus(&mut form, "cursor.shape");
1756 + form.begin_pick();
1757 + let Mode::Picking { cursor, .. } = &form.mode else {
1758 + unreachable!()
1759 + };
1760 + assert_eq!(cursor.selected(), Some(1), "opened on the current value");
1761 +
1762 + type_filter(&mut form, "b");
1763 + let Mode::Picking { cursor, .. } = &form.mode else {
1764 + unreachable!()
1765 + };
1766 + assert_eq!(cursor.selected(), Some(0));
1767 + assert_eq!(form.choices().len(), 2, "both still match");
1768 + }
1769 +
1770 + #[test]
1771 + fn choosing_writes_the_raw_value_and_not_the_label() {
1772 + let mut log = CommandLog::new();
1773 + let mut form = form("");
1774 + focus(&mut form, "cursor.shape");
1775 + form.begin_pick();
1776 + type_filter(&mut form, "beam");
1777 + form.commit_pick(&mut log);
1778 +
1779 + assert!(!form.picking());
1780 + assert_eq!(
1781 + form.bind.read("cursor.shape"),
1782 + Some(Value::String("beam".into())),
1783 + "the value, not the \"Beam\" label the overlay showed",
1784 + );
1785 + }
1786 +
1787 + // Enter with nothing highlighted closes the overlay rather than sitting
1788 + // there looking stuck.
1789 + #[test]
1790 + fn choosing_from_an_empty_filter_result_writes_nothing() {
1791 + let mut log = CommandLog::new();
1792 + let mut form = form("");
1793 + focus(&mut form, "cursor.shape");
1794 + form.begin_pick();
1795 + type_filter(&mut form, "zzz");
1796 + form.commit_pick(&mut log);
1797 +
1798 + assert!(!form.picking(), "the overlay closed");
1799 + assert!(!form.bind.dirty(), "and nothing was written");
1800 + }
1801 +
1802 + #[test]
1803 + fn discarding_a_pick_leaves_the_value_alone() {
1804 + let mut form = form("[cursor]\nshape = \"beam\"\n");
1805 + focus(&mut form, "cursor.shape");
1806 + form.begin_pick();
1807 + type_filter(&mut form, "block");
1808 + form.cancel_pick();
1809 + assert_eq!(
1810 + form.bind.read("cursor.shape"),
1811 + Some(Value::String("beam".into()))
1812 + );
1813 + assert!(!form.bind.dirty());
Lines truncated