Skip to main content

wisp/surfaces/
input.rs

1use crate::command::GitCommand;
2use crate::view::filterable_list::FilterableList;
3use crate::view::selection::Direction;
4use acp_utils::notifications::WorkspaceMoveTarget;
5use agent_client_protocol::schema::v1::SessionId;
6use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
7use std::path::PathBuf;
8
9const COMPOSED_MODIFIERS: KeyModifiers = KeyModifiers::CONTROL
10    .union(KeyModifiers::ALT)
11    .union(KeyModifiers::SUPER)
12    .union(KeyModifiers::HYPER)
13    .union(KeyModifiers::META);
14
15pub(crate) fn is_composed_char(key: KeyEvent) -> bool {
16    matches!(key.code, KeyCode::Char(_)) && key.modifiers.intersects(COMPOSED_MODIFIERS)
17}
18
19pub(crate) fn is_press(key: KeyEvent) -> bool {
20    matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
21}
22
23#[derive(Debug)]
24pub enum SessionPickerOutput {
25    Close,
26    Load { session_id: SessionId, cwd: PathBuf },
27    Preview(String),
28}
29
30#[derive(Debug)]
31pub enum WorkspacePickerOutput {
32    Close,
33    Move { target: WorkspaceMoveTarget },
34}
35
36#[derive(Debug)]
37pub enum SettingsOutput {
38    Close,
39    SetConfigOption { config_id: String, value: String },
40    SetTheme(String),
41    AuthenticateServer(String),
42    AuthenticateProvider(String),
43}
44
45#[derive(Debug)]
46pub enum ElicitationOutput {
47    Close,
48}
49
50/// How a review screen ended. Both full-screen reviews report through this, so
51/// the root translates every review the same way: a cancellation closes the
52/// route, a submission carries the text the review produced.
53#[derive(Debug)]
54pub enum ReviewOutcome {
55    Cancelled,
56    Submitted(String),
57}
58
59#[derive(Debug)]
60pub enum GitReviewOutput {
61    Outcome(ReviewOutcome),
62    Task(GitCommand),
63}
64
65#[derive(Debug)]
66pub enum PlanReviewOutput {
67    Outcome(ReviewOutcome),
68}
69
70#[derive(Debug)]
71pub enum RootOutput {
72    Session(SessionPickerOutput),
73    Workspace(WorkspacePickerOutput),
74    Settings(SettingsOutput),
75    Elicitation(ElicitationOutput),
76    GitReview(GitReviewOutput),
77    PlanReview(PlanReviewOutput),
78}
79
80pub enum UiEvent {
81    Key(KeyEvent),
82    Paste(String),
83    Mouse(MouseAction, (u16, u16)),
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum MouseAction {
88    ScrollUp,
89    ScrollDown,
90    Click,
91}
92
93impl MouseAction {
94    pub fn from_event(kind: crossterm::event::MouseEventKind) -> Option<Self> {
95        use crossterm::event::MouseEventKind;
96        match kind {
97            MouseEventKind::ScrollUp => Some(Self::ScrollUp),
98            MouseEventKind::ScrollDown => Some(Self::ScrollDown),
99            MouseEventKind::Down(_) => Some(Self::Click),
100            _ => None,
101        }
102    }
103
104    /// The list direction a scroll notch maps to; a click maps to none.
105    pub fn direction(self) -> Option<Direction> {
106        match self {
107            Self::ScrollUp => Some(Direction::Backward),
108            Self::ScrollDown => Some(Direction::Forward),
109            Self::Click => None,
110        }
111    }
112}
113
114/// What a navigation event asked of the pane hosting a [`FilterableList`].
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub(crate) enum Nav {
117    /// The pane should close.
118    Close,
119    /// Enter chose the focused entry.
120    Activate,
121    /// A click landed on an entry and selected it.
122    Clicked,
123    /// The selection or filter query changed.
124    Moved,
125    /// Not a navigation event this list handles.
126    Unhandled,
127}
128
129impl<T> FilterableList<T> {
130    /// The controller every typeahead picker shares: Esc closes, Up/Down and
131    /// the scroll wheel move, Enter activates, a click selects, Backspace and
132    /// printable characters edit the filter query. The hosting pane matches on
133    /// the outcome and adds only its own behavior.
134    pub(crate) fn on_nav_event(&mut self, event: &UiEvent) -> Nav {
135        match event {
136            UiEvent::Key(key) if is_press(*key) => self.on_nav_key(*key),
137            UiEvent::Key(_) | UiEvent::Paste(_) => Nav::Unhandled,
138            UiEvent::Mouse(action, (_, row)) => match action.direction() {
139                Some(direction) => {
140                    self.step(direction);
141                    Nav::Moved
142                }
143                None if self.select_at(*row) => Nav::Clicked,
144                None => Nav::Unhandled,
145            },
146        }
147    }
148
149    fn on_nav_key(&mut self, key: KeyEvent) -> Nav {
150        match key.code {
151            KeyCode::Esc => Nav::Close,
152            KeyCode::Enter => Nav::Activate,
153            KeyCode::Up => {
154                self.step(Direction::Backward);
155                Nav::Moved
156            }
157            KeyCode::Down => {
158                self.step(Direction::Forward);
159                Nav::Moved
160            }
161            KeyCode::Backspace => {
162                self.pop_query_char();
163                Nav::Moved
164            }
165            KeyCode::Char(c) if !c.is_control() && !is_composed_char(key) => {
166                self.push_query_char(c);
167                Nav::Moved
168            }
169            _ => Nav::Unhandled,
170        }
171    }
172}
173
174pub(crate) fn one<T>(action: Option<T>) -> Vec<T> {
175    action.into_iter().collect()
176}