1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
//! Fuzzy/substring select — opt-in interactive single-select with filter.
//!
//! # Core (always available, dep-free)
//!
//! [`FuzzySelectState<T>`] is a pure-logic state machine: no terminal I/O, no
//! feature gates. It holds a list of items, a current filter string, the
//! filtered+ranked indices, and the highlighted index. All the unit tests run
//! against this type.
//!
//! # Interactive driver (opt-in via `tty-select` feature)
//!
//! When the `tty-select` feature is enabled, [`FuzzySelect`] wraps the state
//! and adds crossterm raw-mode I/O.
//!
//! # Example (core, dep-free)
//!
//! ```
//! use gilt::fuzzy_select::FuzzySelectState;
//!
//! let mut state = FuzzySelectState::new(vec!["apple", "banana", "grape"]);
//! state.set_filter("ap");
//! assert!(state.filtered().len() >= 2); // apple, grape
//! state.move_down();
//! let sel = state.selection();
//! assert!(sel.is_some());
//! ```
// ---------------------------------------------------------------------------
// FuzzySelectState — dep-free core (NOT feature-gated)
// ---------------------------------------------------------------------------
/// Pure-logic state for a fuzzy/substring filter + arrow-key selection UI.
///
/// `T` is the item type; items must implement `AsRef<str>` for matching.
/// This type is NOT feature-gated — it is always compiled and unit-testable.
pub struct FuzzySelectState<T: AsRef<str>> {
items: Vec<T>,
filter: String,
/// Indices into `items` that match the current filter, in match-quality order.
filtered: Vec<usize>,
/// Highlighted index within `filtered` (not into `items`).
highlight: usize,
}
impl<T: AsRef<str>> FuzzySelectState<T> {
/// Create a new `FuzzySelectState` from a list of items.
///
/// The initial filter is empty (all items shown), highlight at 0.
pub fn new(items: Vec<T>) -> Self {
let n = items.len();
let mut state = FuzzySelectState {
items,
filter: String::new(),
filtered: (0..n).collect(),
highlight: 0,
};
// Sort/rank with the empty filter to ensure deterministic order
state.recompute_filtered();
state
}
/// Update the filter string and re-rank the filtered list.
///
/// The matching algorithm is case-insensitive substring containment.
/// Items are ranked by match position (earlier = higher rank), then by
/// original index for a stable, deterministic ordering.
pub fn set_filter(&mut self, filter: &str) {
self.filter = filter.to_string();
self.recompute_filtered();
// Reset highlight whenever the filter changes so it's always in-bounds.
self.highlight = 0;
}
/// Move the highlight one step toward the end of the list (down).
///
/// Clamped at the last item — does not wrap.
pub fn move_down(&mut self) {
if !self.filtered.is_empty() {
let last = self.filtered.len() - 1;
if self.highlight < last {
self.highlight += 1;
}
}
}
/// Move the highlight one step toward the beginning of the list (up).
///
/// Clamped at 0 — does not wrap.
pub fn move_up(&mut self) {
if self.highlight > 0 {
self.highlight -= 1;
}
}
/// Return the currently highlighted item, or `None` when the list is empty.
pub fn selection(&self) -> Option<&T> {
let idx = self.filtered.get(self.highlight)?;
self.items.get(*idx)
}
/// Return the filtered+ranked indices into the original `items` slice.
///
/// An empty filter returns all items in their original order.
pub fn filtered(&self) -> &[usize] {
&self.filtered
}
// ------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------
fn recompute_filtered(&mut self) {
let filter_lower = self.filter.to_lowercase();
if filter_lower.is_empty() {
// No filter — all items, original order.
self.filtered = (0..self.items.len()).collect();
return;
}
// Case-insensitive substring match, ranked by match position then index.
let mut matches: Vec<(usize, usize)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
let haystack = item.as_ref().to_lowercase();
haystack.find(&filter_lower).map(|pos| (pos, i))
})
.collect();
// Sort by (match_position, original_index) for stability.
matches.sort_unstable_by_key(|&(pos, idx)| (pos, idx));
self.filtered = matches.into_iter().map(|(_, idx)| idx).collect();
}
}
// ---------------------------------------------------------------------------
// FuzzySelect — interactive driver (opt-in: tty-select feature)
// ---------------------------------------------------------------------------
/// Interactive single-select with a fuzzy filter and arrow-key navigation.
///
/// Requires the `tty-select` feature. The pure-logic core is in
/// [`FuzzySelectState`] and is always available.
///
/// ```text
/// // Example usage (requires tty-select feature):
/// // use gilt::fuzzy_select::FuzzySelect;
/// // let item = FuzzySelect::new(vec!["apple", "banana"]).ask()?;
/// ```
#[cfg(feature = "tty-select")]
pub struct FuzzySelect<T: AsRef<str> + Clone> {
items: Vec<T>,
prompt: String,
}
#[cfg(feature = "tty-select")]
impl<T: AsRef<str> + Clone> FuzzySelect<T> {
/// Create a new `FuzzySelect` with the given items.
pub fn new(items: Vec<T>) -> Self {
FuzzySelect {
items,
prompt: "Select:".to_string(),
}
}
/// Set the prompt text.
#[must_use]
pub fn with_prompt(mut self, prompt: &str) -> Self {
self.prompt = prompt.to_string();
self
}
/// Enter raw mode, show the interactive UI, and return the selected item.
///
/// Returns `Ok(Some(item))` on Enter, `Ok(None)` on Esc/empty list.
/// Restores the terminal via a RAII guard even on panic or `?`.
pub fn ask(self) -> std::io::Result<Option<T>> {
use crossterm::{
cursor,
event::{self, Event, KeyCode},
execute,
terminal::{self, ClearType},
};
use std::io::{stdout, Write};
// RAII guard: disables raw mode when dropped (even on panic).
struct RawModeGuard;
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
}
}
if self.items.is_empty() {
return Ok(None);
}
terminal::enable_raw_mode()?;
let _guard = RawModeGuard;
let mut state = FuzzySelectState::new(self.items.clone());
let mut filter = String::new();
loop {
// Redraw
execute!(
stdout(),
terminal::Clear(ClearType::CurrentLine),
cursor::MoveToColumn(0)
)?;
print!("{} [{}] ", self.prompt, filter);
stdout().flush()?;
for (display_idx, &item_idx) in state.filtered().iter().enumerate() {
let item = self.items[item_idx].as_ref();
if display_idx == state.highlight {
print!("\r\n> {}", item);
} else {
print!("\r\n {}", item);
}
}
// Move cursor back up to prompt line
let rows = state.filtered().len();
if rows > 0 {
execute!(stdout(), cursor::MoveUp(rows as u16))?;
}
stdout().flush()?;
// Read next key event
if let Event::Key(key_event) = event::read()? {
match key_event.code {
KeyCode::Enter => {
// Clear the candidate lines before returning
for _ in 0..=state.filtered().len() {
execute!(
stdout(),
terminal::Clear(ClearType::CurrentLine),
cursor::MoveDown(1)
)?;
}
execute!(stdout(), cursor::MoveUp(state.filtered().len() as u16 + 1))?;
return Ok(state.selection().cloned());
}
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Up => state.move_up(),
KeyCode::Down => state.move_down(),
KeyCode::Backspace => {
filter.pop();
state.set_filter(&filter);
}
KeyCode::Char(c) => {
filter.push(c);
state.set_filter(&filter);
}
_ => {}
}
}
}
}
}