Skip to main content

tui_prompts/
select_state.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
2
3use crate::prelude::*;
4
5/// The state for a [`SelectPrompt`](crate::SelectPrompt).
6///
7/// Rendering a [`SelectPrompt`](crate::SelectPrompt) keeps the focused index within the rendered
8/// options. Until the prompt has been rendered at least once, explicit focused-index changes are
9/// accepted and clamped on the next render. Once the state is completed or aborted,
10/// [`SelectState::handle_key_event`] ignores later key events.
11#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
12pub struct SelectState {
13    status: Status,
14    focus: FocusState,
15    pub(crate) focused_index: usize,
16    pub(crate) option_count: usize,
17}
18
19impl SelectState {
20    /// Creates a pending, unfocused select state.
21    #[must_use]
22    pub const fn new() -> Self {
23        Self {
24            status: Status::Pending,
25            focus: FocusState::Unfocused,
26            focused_index: 0,
27            option_count: 0,
28        }
29    }
30
31    /// Moves focus to the previous option.
32    ///
33    /// Focus stays at index 0 when the first option is already focused.
34    pub fn move_up(&mut self) {
35        if self.focused_index > 0 {
36            self.focused_index -= 1;
37        }
38    }
39
40    /// Moves focus to the next option.
41    ///
42    /// Focus stays at the last rendered option. The prompt learns the option count during render,
43    /// so this is a no-op until the state has been rendered with at least one visible option.
44    pub fn move_down(&mut self) {
45        if self.focused_index < self.option_count.saturating_sub(1) {
46            self.focused_index += 1;
47        }
48    }
49
50    /// Sets the prompt status.
51    #[must_use]
52    pub const fn with_status(mut self, status: Status) -> Self {
53        self.status = status;
54        self
55    }
56
57    /// Sets whether the prompt is focused.
58    #[must_use]
59    pub const fn with_focus(mut self, focus: FocusState) -> Self {
60        self.focus = focus;
61        self
62    }
63
64    /// Returns the currently focused option index.
65    #[must_use]
66    pub const fn focused_index(&self) -> usize {
67        self.focused_index
68    }
69
70    /// Sets the focused option index.
71    ///
72    /// If the state has already been rendered with options, the index is clamped to the last
73    /// available option. If the state has not been rendered yet, the value is accepted and clamped
74    /// during the next render.
75    pub fn set_focused_index(&mut self, index: usize) {
76        self.focused_index = self.clamp_focused_index(index);
77    }
78
79    /// Returns whether the select prompt has completed or aborted.
80    #[must_use]
81    pub const fn is_finished(&self) -> bool {
82        self.status.is_finished()
83    }
84
85    /// Returns the current prompt status.
86    #[must_use]
87    pub const fn status(&self) -> Status {
88        self.status
89    }
90
91    /// Sets focus to [`FocusState::Focused`].
92    pub fn focus(&mut self) {
93        self.focus = FocusState::Focused;
94    }
95
96    /// Sets focus to [`FocusState::Unfocused`].
97    pub fn blur(&mut self) {
98        self.focus = FocusState::Unfocused;
99    }
100
101    /// Returns whether the select prompt is focused.
102    #[must_use]
103    pub fn is_focused(&self) -> bool {
104        self.focus == FocusState::Focused
105    }
106
107    /// Completes the prompt.
108    pub fn complete(&mut self) {
109        self.status = Status::Done;
110    }
111
112    /// Aborts the prompt.
113    pub fn abort(&mut self) {
114        self.status = Status::Aborted;
115    }
116
117    /// Handles a key event for select prompt navigation and completion.
118    ///
119    /// Pressing Up or Down moves the focused option, Enter completes the prompt when an option is
120    /// visible, and Escape or Ctrl+C aborts the prompt. Key release events and events routed after
121    /// completion or abort are ignored.
122    pub fn handle_key_event(&mut self, key: KeyEvent) {
123        if key.kind == KeyEventKind::Release || self.status.is_finished() {
124            return;
125        }
126
127        match (key.code, key.modifiers) {
128            (KeyCode::Up, _) => self.move_up(),
129            (KeyCode::Down, _) => self.move_down(),
130            (KeyCode::Enter, _) if self.option_count > 0 => self.complete(),
131            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.abort(),
132            _ => {}
133        }
134    }
135
136    pub(crate) const fn clamp_focused_index(&self, index: usize) -> usize {
137        if self.option_count == 0 {
138            index
139        } else if index >= self.option_count {
140            self.option_count - 1
141        } else {
142            index
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn key(code: KeyCode, kind: KeyEventKind) -> KeyEvent {
152        KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind)
153    }
154
155    fn ctrl_key(code: KeyCode) -> KeyEvent {
156        KeyEvent::new_with_kind(code, KeyModifiers::CONTROL, KeyEventKind::Press)
157    }
158
159    #[test]
160    fn render_option_count_clamps_focused_index() {
161        let mut state = SelectState::new();
162
163        state.set_focused_index(5);
164        state.option_count = 3;
165        state.focused_index = state.clamp_focused_index(state.focused_index);
166
167        assert_eq!(state.focused_index(), 2);
168    }
169
170    #[test]
171    fn set_focused_index_clamps_when_option_count_is_known() {
172        let mut state = SelectState::new();
173
174        state.option_count = 3;
175        state.set_focused_index(5);
176
177        assert_eq!(state.focused_index(), 2);
178    }
179
180    #[test]
181    fn move_down_stops_at_last_option() {
182        let mut state = SelectState::new();
183        state.option_count = 2;
184
185        state.move_down();
186        state.move_down();
187
188        assert_eq!(state.focused_index(), 1);
189    }
190
191    #[test]
192    fn handle_key_event_accepts_repeated_navigation() {
193        let mut state = SelectState::new();
194        state.option_count = 2;
195
196        state.handle_key_event(key(KeyCode::Down, KeyEventKind::Repeat));
197
198        assert_eq!(state.focused_index(), 1);
199    }
200
201    #[test]
202    fn handle_key_event_ignores_key_release() {
203        let mut state = SelectState::new();
204        state.option_count = 2;
205
206        state.handle_key_event(key(KeyCode::Down, KeyEventKind::Release));
207
208        assert_eq!(state.focused_index(), 0);
209    }
210
211    #[test]
212    fn handle_key_event_aborts_on_ctrl_c() {
213        let mut state = SelectState::new();
214
215        state.handle_key_event(ctrl_key(KeyCode::Char('c')));
216
217        assert_eq!(state.status(), Status::Aborted);
218    }
219
220    #[test]
221    fn handle_key_event_ignores_events_after_completion() {
222        let mut state = SelectState::new();
223        state.option_count = 2;
224        state.handle_key_event(key(KeyCode::Enter, KeyEventKind::Press));
225
226        state.handle_key_event(key(KeyCode::Down, KeyEventKind::Press));
227        state.handle_key_event(key(KeyCode::Esc, KeyEventKind::Press));
228
229        assert_eq!(state.focused_index(), 0);
230        assert_eq!(state.status(), Status::Done);
231    }
232
233    #[test]
234    fn handle_key_event_ignores_events_after_abort() {
235        let mut state = SelectState::new();
236        state.option_count = 2;
237        state.handle_key_event(key(KeyCode::Esc, KeyEventKind::Press));
238
239        state.handle_key_event(key(KeyCode::Enter, KeyEventKind::Press));
240
241        assert_eq!(state.status(), Status::Aborted);
242    }
243
244    #[test]
245    fn handle_key_event_does_not_complete_without_visible_options() {
246        let mut state = SelectState::new();
247
248        state.handle_key_event(key(KeyCode::Enter, KeyEventKind::Press));
249
250        assert_eq!(state.status(), Status::Pending);
251    }
252}