Lines
99.06 %
Functions
65.57 %
Branches
100 %
//! Dropdown / list select widget.
//!
//! Holds an ordered list of options and a focused index. Navigation wraps
//! around within the type-ahead filtered set. The widget submits the focused
//! option's `id` (usually a UUID), not its display label.
/// A single entry in a [`SelectWidget`] option list.
#[derive(Debug, Clone)]
pub struct SelectOption {
pub id: String,
pub label: String,
}
/// A scrollable pick-list widget with type-ahead filtering.
///
/// `focused` indexes the **filtered** set returned by [`Self::matches`], not
/// `options` directly. When the filter matches nothing, `value()`/`display()`
/// return `""` and `next()`/`prev()` are no-ops.
pub struct SelectWidget {
options: Vec<SelectOption>,
focused: usize,
pub open: bool,
query: String,
impl SelectWidget {
#[must_use]
pub fn new() -> Self {
Self {
options: Vec::new(),
focused: 0,
open: false,
query: String::new(),
/// Replace the option list, reset focus to 0, and clear any query.
pub fn set_options(&mut self, options: Vec<SelectOption>) {
self.options = options;
self.focused = 0;
self.query.clear();
/// Indices into `options` whose label contains `query` case-insensitively.
/// An empty query returns all indices.
pub fn matches(&self) -> Vec<usize> {
if self.query.is_empty() {
return (0..self.options.len()).collect();
let q = self.query.to_lowercase();
self.options
.iter()
.enumerate()
.filter(|(_, o)| o.label.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect()
/// The `id` of the focused filtered option, or `""` when no match exists.
pub fn value(&self) -> &str {
let m = self.matches();
m.get(self.focused)
.map(|&idx| self.options[idx].id.as_str())
.unwrap_or("")
/// The display label of the focused filtered option, or `""` when no match.
pub fn display(&self) -> &str {
.map(|&idx| self.options[idx].label.as_str())
/// Number of options currently held (unfiltered).
pub fn option_count(&self) -> usize {
self.options.len()
/// The raw option list, for rendering.
pub fn options(&self) -> &[SelectOption] {
&self.options
/// The active type-ahead query string.
pub fn query(&self) -> &str {
&self.query
/// Index of the focused entry within the filtered set (i.e. `matches()`).
pub fn focused_in_filtered(&self) -> usize {
self.focused
/// Advance focus by one step within the filtered set, wrapping around.
pub fn next(&mut self) {
let n = self.matches().len();
if n == 0 {
return;
self.focused = (self.focused + 1) % n;
/// Retreat focus by one step within the filtered set, wrapping around.
pub fn prev(&mut self) {
self.focused = (self.focused + n - 1) % n;
/// Append a character to the type-ahead query and reset focus to 0.
pub fn filter_push(&mut self, c: char) {
self.query.push(c);
/// Remove the last character from the type-ahead query and reset focus to 0.
pub fn filter_pop(&mut self) {
self.query.pop();
/// Resolve the current filtered selection to the raw options index, clear
/// the query, and close the dropdown. Call this on SelectConfirm so the
/// selection survives query removal.
pub fn confirm(&mut self) {
let Some(&raw_idx) = m.get(self.focused) else {
// Nothing matches the query — keep the filter open so the user can
// correct it rather than silently committing the first option.
};
self.focused = raw_idx;
self.open = false;
/// Replace the option list, keeping the previously selected id focused if
/// it exists in the new list; falls back to index 0 otherwise. Clears query.
pub fn set_options_preserving_id(&mut self, options: Vec<SelectOption>) {
let current_id = self.value().to_string();
self.focused = self
.options
.position(|o| o.id == current_id)
.unwrap_or(0);
impl Default for SelectWidget {
fn default() -> Self {
Self::new()
#[cfg(test)]
mod tests {
use super::*;
fn make_opts(n: usize) -> Vec<SelectOption> {
(0..n)
.map(|i| SelectOption {
id: format!("id-{i}"),
label: format!("Label {i}"),
})
#[test]
fn empty_widget_returns_empty_strings() {
let w = SelectWidget::new();
assert_eq!(w.value(), "");
assert_eq!(w.display(), "");
fn zero_match_confirm_is_noop_and_keeps_query() {
let mut w = SelectWidget::new();
w.set_options(make_opts(3));
for c in "zzz".chars() {
w.filter_push(c);
assert!(w.matches().is_empty(), "query matches nothing");
w.confirm();
assert_eq!(
w.value(),
"",
"confirm with no match must not silently select option 0"
);
w.query(),
"zzz",
"query kept so the user can fix the filter"
fn value_returns_focused_id() {
assert_eq!(w.value(), "id-0");
w.next();
assert_eq!(w.value(), "id-1");
fn display_returns_focused_label() {
assert_eq!(w.display(), "Label 0");
assert_eq!(w.display(), "Label 1");
fn next_wraps_around() {
w.next(); // 0 → 1 → 2
assert_eq!(w.value(), "id-2");
w.next(); // wraps to 0
fn prev_wraps_around() {
w.prev(); // 0 wraps to 2
w.prev(); // 2 → 1
fn next_and_prev_noop_on_empty() {
w.prev();
fn set_options_resets_focus() {
w.set_options(make_opts(2));
fn set_options_preserving_id_keeps_selected_uuid() {
w.set_options(vec![SelectOption {
id: "uuid-42".to_string(),
label: "uuid-42".to_string(),
}]);
assert_eq!(w.value(), "uuid-42");
let full_list = vec![
SelectOption {
id: "uuid-10".to_string(),
label: "First".to_string(),
},
label: "Second (proper label)".to_string(),
id: "uuid-99".to_string(),
label: "Third".to_string(),
];
w.set_options_preserving_id(full_list);
"uuid-42",
"uuid preserved after set_options_preserving_id"
w.display(),
"Second (proper label)",
"label updated to full label"
fn set_options_preserving_id_falls_back_to_zero_when_not_found() {
id: "old".to_string(),
label: "Old".to_string(),
let new_list = vec![
id: "new-a".to_string(),
label: "A".to_string(),
id: "new-b".to_string(),
label: "B".to_string(),
w.set_options_preserving_id(new_list);
assert_eq!(w.value(), "new-a");
fn confirm_closes_open_dropdown() {
w.open = true;
assert!(!w.open);
fn filter_push_narrows_matches() {
w.set_options(vec![
id: "a".to_string(),
label: "Apple".to_string(),
id: "b".to_string(),
label: "Grape".to_string(),
id: "c".to_string(),
label: "Apricot".to_string(),
]);
// 'p' matches "Apple" (has 'p') and "Apricot" (has 'p'), not "Grape" (has 'p' too!)
// Use 'i' instead: "Apricot" has 'i', "Apple" has no 'i', "Grape" has no 'i'
w.filter_push('i');
let m = w.matches();
assert_eq!(m.len(), 1, "only Apricot matches 'i'");
assert_eq!(w.value(), "c", "first (only) match is Apricot");
fn filter_next_wraps_within_filtered_set() {
id: "id-0".to_string(),
label: "Sword".to_string(),
id: "id-1".to_string(),
label: "Tuna".to_string(),
id: "id-2".to_string(),
label: "Squid".to_string(),
// 's' matches "Sword" (idx 0) and "Squid" (idx 2), not "Tuna"
w.filter_push('s');
assert_eq!(w.value(), "id-0", "first match is Sword");
assert_eq!(w.value(), "id-2", "second match is Squid");
assert_eq!(w.value(), "id-0", "wraps back to first match");
fn filter_pop_widens_matches() {
w.filter_push('1'); // only "Label 1" matches
assert_eq!(w.matches().len(), 1);
w.filter_pop();
assert_eq!(w.matches().len(), 3, "empty query matches all");
fn set_options_clears_query() {
w.filter_push('x');
assert_eq!(w.query(), "x");
assert_eq!(w.query(), "", "set_options must clear query");
fn set_options_preserving_id_clears_query() {
w.set_options_preserving_id(make_opts(2));
assert_eq!(w.query(), "", "set_options_preserving_id must clear query");
fn zero_match_value_and_display_empty() {
w.filter_push('z'); // no "Label N" contains 'z'
assert_eq!(w.matches().len(), 0);
assert_eq!(w.value(), "", "zero match → value is empty string");
assert_eq!(w.display(), "", "zero match → display is empty string");
fn zero_match_next_and_prev_no_panic() {
w.filter_push('z'); // zero matches
w.next(); // must not panic or index out of bounds
w.prev(); // must not panic or index out of bounds
fn confirm_resolves_filtered_selection_to_raw_index() {
id: "id-x".to_string(),
label: "Xenon".to_string(),
id: "id-a".to_string(),
id: "id-b".to_string(),
label: "Banana".to_string(),
// 'a' matches "Apple" (idx 1) and "Banana" (idx 2); focused=0 → "Apple"
w.filter_push('a');
assert_eq!(w.value(), "id-a");
assert_eq!(w.query(), "");
// After confirm, focused resolved to raw index of "Apple" (idx 1)
// With empty query, matches=[0,1,2], focused=1 → options[1] = "Apple"
"id-a",
"selection preserved after confirm+query clear"