返回 CodeWhale
input.rs
根目录 / crates / tui / src / tui / work_surface / input.rs
1 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
2
3 use crate::tui::app::{App, SidebarRowAction};
4
5 use super::interaction::{activate_primary, claim_focus, close_opened, release_focus};
6 use super::model::{
7 SIDE_WIDTH_MAX, SIDE_WIDTH_MIN, TOP_HEIGHT_MAX, TOP_HEIGHT_MIN, WorkRow, WorkRowId,
8 WorkSurfacePlacement, visible_rows_for_panel,
9 };
10
11 #[derive(Debug, Default)]
12 pub struct MouseOutcome {
13 pub consumed: bool,
14 pub action: Option<SidebarRowAction>,
15 }
16
17 /// Handle the work surface's focused keyboard contract. `Alt+W` enters the
18 /// surface from the composer; Esc returns ownership to the composer (or clears
19 /// a local stop arm / open detail first). Plain printable input always returns
20 /// ownership to the composer instead of becoming a hidden panel shortcut.
21 pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowAction>> {
22 // Keyboard and mouse share one row source per panel: Enter on the
23 // selected row must open the same world a click would.
24 let rows = visible_rows_for_panel(app);
25 if rows.is_empty() {
26 return None;
27 }
28 if !app.work_surface.focused {
29 if key.code == KeyCode::Char('w') && key.modifiers.contains(KeyModifiers::ALT) {
30 claim_focus(app);
31 app.work_surface.clamp_selection(&rows);
32 app.needs_redraw = true;
33 return Some(None);
34 }
35 return None;
36 }
37
38 if matches!(key.code, KeyCode::Char(_))
39 && !key
40 .modifiers
41 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
42 {
43 release_focus(app);
44 return None;
45 }
46
47 let action = match key.code {
48 KeyCode::Esc => {
49 if app.work_surface.opened.is_some() {
50 close_opened(app);
51 } else {
52 release_focus(app);
53 }
54 return Some(None);
55 }
56 KeyCode::Up => {
57 move_selection(app, &rows, -1);
58 None
59 }
60 KeyCode::Down => {
61 move_selection(app, &rows, 1);
62 None
63 }
64 KeyCode::Home => {
65 select_edge(app, &rows, false);
66 None
67 }
68 KeyCode::End => {
69 select_edge(app, &rows, true);
70 None
71 }
72 KeyCode::PageUp => {
73 move_selection(app, &rows, -(app.work_surface.visible_rows.max(1) as isize));
74 None
75 }
76 KeyCode::PageDown => {
77 move_selection(app, &rows, app.work_surface.visible_rows.max(1) as isize);
78 None
79 }
80 KeyCode::Enter => selected_row(app, &rows)
81 .and_then(|row| activate_primary(app, &row.id, row.primary_action.clone())),
82 _ => return None,
83 };
84 app.work_surface.clamp_selection(&rows);
85 app.needs_redraw = true;
86 Some(action)
87 }
88
89 pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
90 let Some(area) = app.work_surface.last_area else {
91 return MouseOutcome::default();
92 };
93 let placement = app.work_surface.effective_placement;
94 let on_divider = match placement {
95 WorkSurfacePlacement::Off => false,
96 WorkSurfacePlacement::Top => {
97 mouse.row == area.bottom().saturating_sub(1)
98 && mouse.column >= area.x
99 && mouse.column < area.right()
100 }
101 WorkSurfacePlacement::Left => {
102 mouse.column == area.right().saturating_sub(1)
103 && mouse.row >= area.y
104 && mouse.row < area.bottom()
105 }
106 WorkSurfacePlacement::Right => {
107 mouse.column == area.x && mouse.row >= area.y && mouse.row < area.bottom()
108 }
109 };
110
111 if matches!(mouse.kind, MouseEventKind::Moved) && app.work_surface.divider_hovered != on_divider
112 {
113 app.work_surface.divider_hovered = on_divider;
114 app.needs_redraw = true;
115 }
116
117 match mouse.kind {
118 MouseEventKind::Moved if on_divider => {
119 return MouseOutcome {
120 consumed: true,
121 action: None,
122 };
123 }
124 MouseEventKind::Down(MouseButton::Left) if on_divider => {
125 app.work_surface.resizing = true;
126 app.work_surface.divider_hovered = true;
127 app.work_surface.resize_anchor_column = mouse.column;
128 app.work_surface.resize_anchor_row = mouse.row;
129 app.work_surface.resize_anchor_size = match placement {
130 WorkSurfacePlacement::Top => area.height,
131 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => area.width,
132 WorkSurfacePlacement::Off => area.width,
133 };
134 app.needs_redraw = true;
135 return MouseOutcome {
136 consumed: true,
137 action: None,
138 };
139 }
140 MouseEventKind::Drag(MouseButton::Left) if app.work_surface.resizing => {
141 let anchor = i32::from(app.work_surface.resize_anchor_size);
142 match placement {
143 WorkSurfacePlacement::Top => {
144 let delta =
145 i32::from(mouse.row) - i32::from(app.work_surface.resize_anchor_row);
146 app.work_surface.top_height = (anchor + delta)
147 .clamp(i32::from(TOP_HEIGHT_MIN), i32::from(TOP_HEIGHT_MAX))
148 as u16;
149 }
150 WorkSurfacePlacement::Left => {
151 let delta =
152 i32::from(mouse.column) - i32::from(app.work_surface.resize_anchor_column);
153 app.work_surface.side_width = (anchor + delta)
154 .clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
155 as u16;
156 }
157 WorkSurfacePlacement::Right => {
158 let delta =
159 i32::from(app.work_surface.resize_anchor_column) - i32::from(mouse.column);
160 app.work_surface.side_width = (anchor + delta)
161 .clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
162 as u16;
163 }
164 WorkSurfacePlacement::Off => {}
165 }
166 app.needs_redraw = true;
167 return MouseOutcome {
168 consumed: true,
169 action: None,
170 };
171 }
172 MouseEventKind::Up(MouseButton::Left) if app.work_surface.resizing => {
173 app.work_surface.resizing = false;
174 app.work_surface.divider_hovered = on_divider;
175 let top_height = app.work_surface.top_height;
176 let side_width = app.work_surface.side_width;
177 if let Err(error) = crate::settings::Settings::transact(|settings| {
178 settings.work_surface_top_height = top_height;
179 settings.work_surface_side_width = side_width;
180 Ok(())
181 }) {
182 app.status_message =
183 Some(format!("Failed to save To-do/Sub-agent bar size: {error}"));
184 }
185 app.needs_redraw = true;
186 return MouseOutcome {
187 consumed: true,
188 action: None,
189 };
190 }
191 _ => {}
192 }
193 let inside = mouse.column >= area.x
194 && mouse.column < area.right()
195 && mouse.row >= area.y
196 && mouse.row < area.bottom();
197 if !inside {
198 if matches!(
199 mouse.kind,
200 MouseEventKind::Down(MouseButton::Left)
201 | MouseEventKind::ScrollUp
202 | MouseEventKind::ScrollDown
203 ) && app.work_surface.focused
204 {
205 // Another region is taking the pointer — release strip focus so
206 // only one owner shows selection.
207 release_focus(app);
208 }
209 if matches!(mouse.kind, MouseEventKind::Moved) && app.work_surface.hovered.take().is_some()
210 {
211 app.needs_redraw = true;
212 }
213 return MouseOutcome::default();
214 }
215
216 match mouse.kind {
217 MouseEventKind::ScrollUp => {
218 claim_focus(app);
219 app.work_surface.scroll_offset = app.work_surface.scroll_offset.saturating_sub(2);
220 app.needs_redraw = true;
221 MouseOutcome {
222 consumed: true,
223 action: None,
224 }
225 }
226 MouseEventKind::ScrollDown => {
227 claim_focus(app);
228 let max = app
229 .work_surface
230 .total_rows
231 .saturating_sub(app.work_surface.visible_rows.max(1));
232 app.work_surface.scroll_offset =
233 app.work_surface.scroll_offset.saturating_add(2).min(max);
234 app.needs_redraw = true;
235 MouseOutcome {
236 consumed: true,
237 action: None,
238 }
239 }
240 MouseEventKind::Moved => {
241 let hovered = hit_row(app, mouse.row).map(|row| row.id.clone());
242 if app.work_surface.hovered != hovered {
243 app.work_surface.hovered = hovered;
244 app.needs_redraw = true;
245 }
246 MouseOutcome {
247 consumed: true,
248 action: None,
249 }
250 }
251 MouseEventKind::Down(MouseButton::Left) => {
252 let row = hit_row(app, mouse.row).cloned();
253 let Some(row) = row else {
254 claim_focus(app);
255 return MouseOutcome {
256 consumed: true,
257 action: None,
258 };
259 };
260 claim_focus(app);
261 app.work_surface.selected = Some(row.id.clone());
262 app.needs_redraw = true;
263
264 let action = activate_primary(app, &row.id, row.primary_action.clone());
265 MouseOutcome {
266 consumed: true,
267 action,
268 }
269 }
270 _ => MouseOutcome {
271 consumed: true,
272 action: None,
273 },
274 }
275 }
276
277 fn hit_row(app: &App, row_y: u16) -> Option<&WorkRow> {
278 let id = app
279 .work_surface
280 .hitboxes
281 .iter()
282 .find(|hitbox| hitbox.row_y == row_y)
283 .map(|hitbox| &hitbox.id)?;
284 app.work_surface
285 .latest_rows
286 .iter()
287 .find(|row| &row.id == id)
288 }
289
290 fn selected_row<'a>(app: &App, rows: &'a [WorkRow]) -> Option<&'a WorkRow> {
291 let selected = app.work_surface.selected.as_ref()?;
292 rows.iter().find(|row| &row.id == selected)
293 }
294
295 fn selectable_ids(rows: &[WorkRow]) -> Vec<WorkRowId> {
296 rows.iter()
297 .filter(|row| row.selectable)
298 .map(|row| row.id.clone())
299 .collect()
300 }
301
302 fn move_selection(app: &mut App, rows: &[WorkRow], delta: isize) {
303 let ids = selectable_ids(rows);
304 if ids.is_empty() {
305 return;
306 }
307 let current = app
308 .work_surface
309 .selected
310 .as_ref()
311 .and_then(|selected| ids.iter().position(|id| id == selected))
312 .unwrap_or_default();
313 let next = if delta.is_negative() {
314 current.saturating_sub(delta.unsigned_abs())
315 } else {
316 current
317 .saturating_add(delta as usize)
318 .min(ids.len().saturating_sub(1))
319 };
320 app.work_surface.selected = Some(ids[next].clone());
321 }
322
323 fn select_edge(app: &mut App, rows: &[WorkRow], end: bool) {
324 let ids = selectable_ids(rows);
325 app.work_surface.selected = if end {
326 ids.last().cloned()
327 } else {
328 ids.first().cloned()
329 };
330 }
331
331 lines RUST