max / alloy
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 ¤t { | |
| 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