Skip to main content

wisp/surfaces/
input.rs

1use crate::command::{GitCommand, GitWatchCommand};
2use crate::view::filterable_list::FilterableList;
3use crate::view::selection::Direction;
4use acp_utils::notifications::WorkspaceMoveTarget;
5use agent_client_protocol::schema::v2::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    Resume { 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    SetTheme(String),
62    Outcome(ReviewOutcome),
63    Task(GitCommand),
64    Watch(GitWatchCommand),
65}
66
67#[derive(Debug)]
68pub enum PlanReviewOutput {
69    Outcome(ReviewOutcome),
70    SetTheme(String),
71}
72
73#[derive(Debug)]
74pub enum RootOutput {
75    Session(SessionPickerOutput),
76    Workspace(WorkspacePickerOutput),
77    Settings(SettingsOutput),
78    Elicitation(ElicitationOutput),
79    GitReview(GitReviewOutput),
80    PlanReview(PlanReviewOutput),
81}
82
83pub enum UiEvent {
84    Key(KeyEvent),
85    Paste(String),
86    Mouse(MouseAction, (u16, u16)),
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum MouseAction {
91    ScrollUp,
92    ScrollDown,
93    Click,
94}
95
96impl MouseAction {
97    pub fn from_event(kind: crossterm::event::MouseEventKind) -> Option<Self> {
98        use crossterm::event::MouseEventKind;
99        match kind {
100            MouseEventKind::ScrollUp => Some(Self::ScrollUp),
101            MouseEventKind::ScrollDown => Some(Self::ScrollDown),
102            MouseEventKind::Down(_) => Some(Self::Click),
103            _ => None,
104        }
105    }
106
107    /// The list direction a scroll notch maps to; a click maps to none.
108    pub fn direction(self) -> Option<Direction> {
109        match self {
110            Self::ScrollUp => Some(Direction::Backward),
111            Self::ScrollDown => Some(Direction::Forward),
112            Self::Click => None,
113        }
114    }
115}
116
117/// What a navigation event asked of the pane hosting a [`FilterableList`].
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub(crate) enum Nav {
120    /// The pane should close.
121    Close,
122    /// Enter chose the focused entry.
123    Activate,
124    /// A click landed on an entry and selected it.
125    Clicked,
126    /// The selection or filter query changed.
127    Moved,
128    /// Not a navigation event this list handles.
129    Unhandled,
130}
131
132impl<T> FilterableList<T> {
133    /// The controller every typeahead picker shares: Esc closes, Up/Down and
134    /// the scroll wheel move, Enter activates, a click selects, Backspace and
135    /// printable characters edit the filter query. The hosting pane matches on
136    /// the outcome and adds only its own behavior.
137    pub(crate) fn on_nav_event(&mut self, event: &UiEvent) -> Nav {
138        match event {
139            UiEvent::Key(key) if is_press(*key) => self.on_nav_key(*key),
140            UiEvent::Key(_) | UiEvent::Paste(_) => Nav::Unhandled,
141            UiEvent::Mouse(action, (_, row)) => match action.direction() {
142                Some(direction) => {
143                    self.step(direction);
144                    Nav::Moved
145                }
146                None if self.select_at(*row) => Nav::Clicked,
147                None => Nav::Unhandled,
148            },
149        }
150    }
151
152    fn on_nav_key(&mut self, key: KeyEvent) -> Nav {
153        match key.code {
154            KeyCode::Esc => Nav::Close,
155            KeyCode::Enter => Nav::Activate,
156            KeyCode::Up => {
157                self.step(Direction::Backward);
158                Nav::Moved
159            }
160            KeyCode::Down => {
161                self.step(Direction::Forward);
162                Nav::Moved
163            }
164            KeyCode::Backspace => {
165                self.pop_query_char();
166                Nav::Moved
167            }
168            KeyCode::Char(c) if !c.is_control() && !is_composed_char(key) => {
169                self.push_query_char(c);
170                Nav::Moved
171            }
172            _ => Nav::Unhandled,
173        }
174    }
175}
176
177pub(crate) fn one<T>(action: Option<T>) -> Vec<T> {
178    action.into_iter().collect()
179}