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