返回 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 DockTabTarget, RailPanel, SIDE_WIDTH_MAX, SIDE_WIDTH_MIN, TOP_HEIGHT_MAX, TOP_HEIGHT_MIN,
8 WorkRow, WorkRowId, 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 /// Cycle the bottom view: `Ctrl+Tab` / `Ctrl+]` forward, `Ctrl+Shift+Tab`
18 /// back. Every view is reachable this way, content or not — an empty view
19 /// paints its one "nothing here" row rather than skipping, so the order the
20 /// user learns is the order they get. The choice is explicit until Esc.
21 pub fn cycle_view(app: &mut App, forward: bool) {
22 let next = if forward {
23 app.work_surface.panel.next()
24 } else {
25 app.work_surface.panel.prev()
26 };
27 super::interaction::select_dock_panel(app, next);
28 }
29
30 /// `← for agents`: switch the rail to the Agents panel and give it keyboard
31 /// ownership so ↑/↓ + Enter select and focus a worker. Returns `false` when
32 /// the rail cannot show agents right now (rail off, or nothing to list); the
33 /// caller then opens the `/agents` register instead so the key still lands.
34 pub fn enter_agents(app: &mut App) -> bool {
35 let previous_panel = app.work_surface.panel;
36 app.work_surface.panel = RailPanel::Agents;
37 if app.work_surface.placement == WorkSurfacePlacement::Off
38 || app.work_surface.effective_placement == WorkSurfacePlacement::Off
39 || app.work_surface.last_area.is_none()
40 {
41 app.work_surface.panel = previous_panel;
42 release_focus(app);
43 return false;
44 }
45 let rows = visible_rows_for_panel(app);
46 // A live strip is proven by the hitboxes it painted this frame. When the
47 // agents view itself was on screen the worker row must be among them;
48 // when another view was up (the dock opens on TODO), any painted row
49 // proves the strip is live and the switch paints the workers next frame.
50 let strip_is_live = !app.work_surface.hitboxes.is_empty();
51 let agents_were_painted = previous_panel == RailPanel::Agents;
52 let first_agent = rows
53 .iter()
54 .find(|row| {
55 row.selectable
56 && row.id.0.starts_with("worker:")
57 && if agents_were_painted {
58 app.work_surface
59 .hitboxes
60 .iter()
61 .any(|hitbox| hitbox.id == row.id)
62 } else {
63 strip_is_live
64 }
65 })
66 .map(|row| row.id.clone());
67 let Some(first_agent) = first_agent else {
68 app.work_surface.panel = previous_panel;
69 return false;
70 };
71 super::interaction::select_dock_panel(app, RailPanel::Agents);
72 claim_focus(app);
73 let selected_agent_is_visible = app.work_surface.selected.as_ref().is_some_and(|selected| {
74 rows.iter()
75 .any(|row| row.selectable && row.id == *selected && row.id.0.starts_with("worker:"))
76 && app
77 .work_surface
78 .hitboxes
79 .iter()
80 .any(|hitbox| hitbox.id == *selected)
81 });
82 if !selected_agent_is_visible {
83 app.work_surface.selected = Some(first_agent);
84 }
85 app.work_surface.clamp_selection(&rows);
86 app.needs_redraw = true;
87 true
88 }
89
90 /// Handle the work surface's focused keyboard contract. `Alt+W` enters the
91 /// surface from the composer; Esc returns ownership to the composer (or clears
92 /// a local stop arm / open detail first). Plain printable input always returns
93 /// ownership to the composer instead of becoming a hidden panel shortcut.
94 pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowAction>> {
95 // A starved, mini-window-hidden, or explicitly disabled rail owns no
96 // cells, so it cannot own keyboard focus. `collapse_strip` normally
97 // clears this at layout time; this guard also closes the pre-redraw race.
98 if app.work_surface.last_area.is_none() {
99 if app.work_surface.focused {
100 release_focus(app);
101 }
102 return None;
103 }
104 if app.work_surface.focused {
105 let tabs = app
106 .work_surface
107 .dock_tabs
108 .iter()
109 .filter_map(|hitbox| match hitbox.target {
110 DockTabTarget::Panel(panel) => Some(panel),
111 DockTabTarget::Close => None,
112 })
113 .collect::<Vec<_>>();
114 if !tabs.is_empty() {
115 let current = tabs
116 .iter()
117 .position(|panel| *panel == app.work_surface.panel)
118 .unwrap_or(0);
119 // Only a bare ←/→ walks the dock tabs. Ctrl/Alt/Super+←/→ are the
120 // composer's word-navigation chords; they fall through below and
121 // return ownership to the composer like printable input.
122 let bare = !key
123 .modifiers
124 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
125 let next = match key.code {
126 KeyCode::Left if bare => Some((current + tabs.len() - 1) % tabs.len()),
127 KeyCode::Right if bare => Some((current + 1) % tabs.len()),
128 _ => None,
129 };
130 if let Some(next) = next {
131 super::interaction::select_dock_panel(app, tabs[next]);
132 return Some(None);
133 }
134 }
135 }
136
137 let navigation_chord = key
138 .modifiers
139 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
140 if (matches!(key.code, KeyCode::Char(_)) && !navigation_chord)
141 || (app.work_surface.focused
142 && navigation_chord
143 && matches!(key.code, KeyCode::Left | KeyCode::Right))
144 {
145 release_focus(app);
146 return None;
147 }
148
149 // Keyboard and mouse share one row source per panel: Enter on the
150 // selected row must open the same world a click would. An explicitly
151 // opened empty view still owns Esc (close) so cycling into "no files
152 // touched" is never a trap.
153 let rows = visible_rows_for_panel(app);
154 if rows.is_empty() && !(app.work_surface.focused && app.work_surface.explicit_view) {
155 return None;
156 }
157 if !app.work_surface.focused {
158 if key.code == KeyCode::Char('w') && key.modifiers.contains(KeyModifiers::ALT) {
159 claim_focus(app);
160 app.work_surface.clamp_selection(&rows);
161 app.needs_redraw = true;
162 return Some(None);
163 }
164 return None;
165 }
166
167 // The details chord opens the selected row's own world; the transcript
168 // pager owns ⌥V only when no work row is selected.
169 if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) {
170 let action = selected_row(app, &rows)
171 .and_then(|row| activate_primary(app, &row.id, row.primary_action.clone()));
172 if action.is_some() {
173 app.work_surface.clamp_selection(&rows);
174 app.needs_redraw = true;
175 return Some(action);
176 }
177 return None;
178 }
179
180 let action = match key.code {
181 KeyCode::Esc => {
182 if super::interaction::opened_detail_on_screen(app) {
183 close_opened(app);
184 } else {
185 // A stale owner (the row's command opened no view) must not
186 // swallow the Esc the close control advertises.
187 app.work_surface.opened = None;
188 super::interaction::dismiss_dock(app);
189 }
190 return Some(None);
191 }
192 KeyCode::Up => {
193 move_selection(app, &rows, -1);
194 None
195 }
196 KeyCode::Down => {
197 move_selection(app, &rows, 1);
198 None
199 }
200 KeyCode::Home => {
201 select_edge(app, &rows, false);
202 None
203 }
204 KeyCode::End => {
205 select_edge(app, &rows, true);
206 None
207 }
208 KeyCode::PageUp => {
209 move_selection(app, &rows, -(app.work_surface.visible_rows.max(1) as isize));
210 None
211 }
212 KeyCode::PageDown => {
213 move_selection(app, &rows, app.work_surface.visible_rows.max(1) as isize);
214 None
215 }
216 KeyCode::Enter => selected_row(app, &rows)
217 .and_then(|row| activate_primary(app, &row.id, row.primary_action.clone())),
218 _ => return None,
219 };
220 app.work_surface.clamp_selection(&rows);
221 app.needs_redraw = true;
222 Some(action)
223 }
224
225 pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
226 let Some(area) = app.work_surface.last_area else {
227 return MouseOutcome::default();
228 };
229 let placement = app.work_surface.effective_placement;
230 let on_divider = match placement {
231 WorkSurfacePlacement::Off => false,
232 WorkSurfacePlacement::Top => {
233 mouse.row == area.bottom().saturating_sub(1)
234 && mouse.column >= area.x
235 && mouse.column < area.right()
236 }
237 WorkSurfacePlacement::Bottom => {
238 mouse.row == area.y && mouse.column >= area.x && mouse.column < area.right()
239 }
240 WorkSurfacePlacement::Left => {
241 mouse.column == area.right().saturating_sub(1)
242 && mouse.row >= area.y
243 && mouse.row < area.bottom()
244 }
245 WorkSurfacePlacement::Right => {
246 mouse.column == area.x && mouse.row >= area.y && mouse.row < area.bottom()
247 }
248 };
249
250 if matches!(mouse.kind, MouseEventKind::Moved) && app.work_surface.divider_hovered != on_divider
251 {
252 app.work_surface.divider_hovered = on_divider;
253 app.needs_redraw = true;
254 }
255
256 match mouse.kind {
257 MouseEventKind::Moved if on_divider => {
258 return MouseOutcome {
259 consumed: true,
260 action: None,
261 };
262 }
263 MouseEventKind::Down(MouseButton::Left) if on_divider => {
264 app.work_surface.resizing = true;
265 app.work_surface.divider_hovered = true;
266 app.work_surface.resize_anchor_column = mouse.column;
267 app.work_surface.resize_anchor_row = mouse.row;
268 app.work_surface.resize_anchor_size = match placement {
269 WorkSurfacePlacement::Top | WorkSurfacePlacement::Bottom => area.height,
270 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => area.width,
271 WorkSurfacePlacement::Off => area.width,
272 };
273 app.needs_redraw = true;
274 return MouseOutcome {
275 consumed: true,
276 action: None,
277 };
278 }
279 MouseEventKind::Drag(MouseButton::Left) if app.work_surface.resizing => {
280 let anchor = i32::from(app.work_surface.resize_anchor_size);
281 match placement {
282 WorkSurfacePlacement::Top => {
283 let delta =
284 i32::from(mouse.row) - i32::from(app.work_surface.resize_anchor_row);
285 app.work_surface.top_height = (anchor + delta)
286 .clamp(i32::from(TOP_HEIGHT_MIN), i32::from(TOP_HEIGHT_MAX))
287 as u16;
288 }
289 // The bottom strip's divider is its TOP edge: dragging up
290 // grows the strip, so the delta is inverted from Top.
291 WorkSurfacePlacement::Bottom => {
292 let delta =
293 i32::from(app.work_surface.resize_anchor_row) - i32::from(mouse.row);
294 app.work_surface.top_height = (anchor + delta)
295 .clamp(i32::from(TOP_HEIGHT_MIN), i32::from(TOP_HEIGHT_MAX))
296 as u16;
297 }
298 WorkSurfacePlacement::Left => {
299 let delta =
300 i32::from(mouse.column) - i32::from(app.work_surface.resize_anchor_column);
301 app.work_surface.side_width = (anchor + delta)
302 .clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
303 as u16;
304 }
305 WorkSurfacePlacement::Right => {
306 let delta =
307 i32::from(app.work_surface.resize_anchor_column) - i32::from(mouse.column);
308 app.work_surface.side_width = (anchor + delta)
309 .clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
310 as u16;
311 }
312 WorkSurfacePlacement::Off => {}
313 }
314 app.needs_redraw = true;
315 return MouseOutcome {
316 consumed: true,
317 action: None,
318 };
319 }
320 MouseEventKind::Up(MouseButton::Left) if app.work_surface.resizing => {
321 app.work_surface.resizing = false;
322 app.work_surface.divider_hovered = on_divider;
323 let top_height = app.work_surface.top_height;
324 let side_width = app.work_surface.side_width;
325 if let Err(error) = crate::settings::Settings::transact(|settings| {
326 settings.work_surface_top_height = top_height;
327 settings.work_surface_side_width = side_width;
328 Ok(())
329 }) {
330 app.status_message =
331 Some(format!("Failed to save To-do/Sub-agent bar size: {error}"));
332 }
333 app.needs_redraw = true;
334 return MouseOutcome {
335 consumed: true,
336 action: None,
337 };
338 }
339 _ => {}
340 }
341 let inside = mouse.column >= area.x
342 && mouse.column < area.right()
343 && mouse.row >= area.y
344 && mouse.row < area.bottom();
345 if !inside {
346 if matches!(
347 mouse.kind,
348 MouseEventKind::Down(MouseButton::Left)
349 | MouseEventKind::ScrollUp
350 | MouseEventKind::ScrollDown
351 ) && app.work_surface.focused
352 {
353 // Another region is taking the pointer — release strip focus so
354 // only one owner shows selection.
355 release_focus(app);
356 }
357 if matches!(mouse.kind, MouseEventKind::Moved)
358 && (app.work_surface.hovered.take().is_some()
359 || app.work_surface.hovered_tab.take().is_some())
360 {
361 app.needs_redraw = true;
362 }
363 return MouseOutcome::default();
364 }
365
366 if placement.is_strip() {
367 if let Some(target) = dock_tab_at(app, mouse.column, mouse.row) {
368 match mouse.kind {
369 MouseEventKind::Moved => {
370 let changed = app.work_surface.hovered_tab != Some(target)
371 || app.work_surface.hovered.take().is_some();
372 app.work_surface.hovered_tab = Some(target);
373 if changed {
374 app.needs_redraw = true;
375 }
376 return MouseOutcome {
377 consumed: true,
378 action: None,
379 };
380 }
381 MouseEventKind::Down(MouseButton::Left) => {
382 app.work_surface.pressed_tab = Some(target);
383 app.needs_redraw = true;
384 return MouseOutcome {
385 consumed: true,
386 action: None,
387 };
388 }
389 MouseEventKind::Up(MouseButton::Left) => {
390 let pressed = app.work_surface.pressed_tab.take();
391 if pressed.is_some() {
392 app.needs_redraw = true;
393 }
394 if pressed == Some(target) {
395 match target {
396 DockTabTarget::Panel(panel) if panel != app.work_surface.panel => {
397 super::interaction::select_dock_panel(app, panel);
398 }
399 DockTabTarget::Panel(_) | DockTabTarget::Close => {
400 super::interaction::dismiss_dock(app);
401 }
402 }
403 }
404 return MouseOutcome {
405 consumed: true,
406 action: None,
407 };
408 }
409 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
410 let tabs = dock_panels(app);
411 if !tabs.is_empty() {
412 let current = tabs
413 .iter()
414 .position(|panel| *panel == app.work_surface.panel)
415 .unwrap_or(0);
416 let delta = if matches!(mouse.kind, MouseEventKind::ScrollUp) {
417 tabs.len() - 1
418 } else {
419 1
420 };
421 super::interaction::select_dock_panel(
422 app,
423 tabs[(current + delta) % tabs.len()],
424 );
425 }
426 return MouseOutcome {
427 consumed: true,
428 action: None,
429 };
430 }
431 _ => {}
432 }
433 } else if (matches!(mouse.kind, MouseEventKind::Moved)
434 && app.work_surface.hovered_tab.take().is_some())
435 || (matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left))
436 && app.work_surface.pressed_tab.take().is_some())
437 {
438 app.needs_redraw = true;
439 }
440 }
441
442 match mouse.kind {
443 MouseEventKind::ScrollUp => {
444 claim_focus(app);
445 app.work_surface.scroll_offset = app.work_surface.scroll_offset.saturating_sub(2);
446 app.needs_redraw = true;
447 MouseOutcome {
448 consumed: true,
449 action: None,
450 }
451 }
452 MouseEventKind::ScrollDown => {
453 claim_focus(app);
454 let max = app
455 .work_surface
456 .total_rows
457 .saturating_sub(app.work_surface.visible_rows.max(1));
458 app.work_surface.scroll_offset =
459 app.work_surface.scroll_offset.saturating_add(2).min(max);
460 app.needs_redraw = true;
461 MouseOutcome {
462 consumed: true,
463 action: None,
464 }
465 }
466 MouseEventKind::Moved => {
467 let hovered = hit_row(app, mouse.row).map(|row| row.id.clone());
468 if app.work_surface.hovered != hovered {
469 app.work_surface.hovered = hovered;
470 app.needs_redraw = true;
471 }
472 MouseOutcome {
473 consumed: true,
474 action: None,
475 }
476 }
477 MouseEventKind::Down(MouseButton::Left) => {
478 let row = hit_row(app, mouse.row).cloned();
479 let Some(row) = row else {
480 claim_focus(app);
481 return MouseOutcome {
482 consumed: true,
483 action: None,
484 };
485 };
486 claim_focus(app);
487 app.work_surface.selected = Some(row.id.clone());
488 app.needs_redraw = true;
489
490 let action = activate_primary(app, &row.id, row.primary_action.clone());
491 MouseOutcome {
492 consumed: true,
493 action,
494 }
495 }
496 // Right-click is the context menu's (`mouse_ui::open_context_menu`
497 // reads the row under the pointer). The catch-all below used to mark
498 // it consumed, so no menu ever opened over the work surface.
499 MouseEventKind::Down(MouseButton::Right) => MouseOutcome::default(),
500 _ => MouseOutcome {
501 consumed: true,
502 action: None,
503 },
504 }
505 }
506
507 fn dock_tab_at(app: &App, column: u16, row: u16) -> Option<DockTabTarget> {
508 app.work_surface
509 .dock_tabs
510 .iter()
511 .find(|hitbox| hitbox.area.contains((column, row).into()))
512 .map(|hitbox| hitbox.target)
513 }
514
515 fn dock_panels(app: &App) -> Vec<RailPanel> {
516 app.work_surface
517 .dock_tabs
518 .iter()
519 .filter_map(|hitbox| match hitbox.target {
520 DockTabTarget::Panel(panel) => Some(panel),
521 DockTabTarget::Close => None,
522 })
523 .collect()
524 }
525
526 fn hit_row(app: &App, row_y: u16) -> Option<&WorkRow> {
527 let id = app
528 .work_surface
529 .hitboxes
530 .iter()
531 .find(|hitbox| hitbox.row_y == row_y)
532 .map(|hitbox| &hitbox.id)?;
533 app.work_surface
534 .latest_rows
535 .iter()
536 .find(|row| &row.id == id)
537 }
538
539 fn selected_row<'a>(app: &App, rows: &'a [WorkRow]) -> Option<&'a WorkRow> {
540 let selected = app.work_surface.selected.as_ref()?;
541 rows.iter().find(|row| &row.id == selected)
542 }
543
544 fn selectable_ids(rows: &[WorkRow]) -> Vec<WorkRowId> {
545 rows.iter()
546 .filter(|row| row.selectable)
547 .map(|row| row.id.clone())
548 .collect()
549 }
550
551 fn move_selection(app: &mut App, rows: &[WorkRow], delta: isize) {
552 let ids = selectable_ids(rows);
553 if ids.is_empty() {
554 return;
555 }
556 let Some(current) = app
557 .work_surface
558 .selected
559 .as_ref()
560 .and_then(|selected| ids.iter().position(|id| id == selected))
561 else {
562 // Nothing selected — the dock opens this way, and a stale id lands here
563 // too. `unwrap_or_default()` called that row 0, so the first Down moved
564 // to row 1 and the first row could never be reached by pressing Down.
565 // The first move lands on the edge it came from.
566 app.work_surface.selected = Some(if delta.is_negative() {
567 ids[ids.len().saturating_sub(1)].clone()
568 } else {
569 ids[0].clone()
570 });
571 return;
572 };
573 let next = if delta.is_negative() {
574 current.saturating_sub(delta.unsigned_abs())
575 } else {
576 current
577 .saturating_add(delta as usize)
578 .min(ids.len().saturating_sub(1))
579 };
580 app.work_surface.selected = Some(ids[next].clone());
581 }
582
583 fn select_edge(app: &mut App, rows: &[WorkRow], end: bool) {
584 let ids = selectable_ids(rows);
585 app.work_surface.selected = if end {
586 ids.last().cloned()
587 } else {
588 ids.first().cloned()
589 };
590 }
591
592 #[cfg(test)]
593 mod tests {
594 use super::{App, WorkRow, move_selection};
595 use crate::tui::work_surface::model::{WorkRowId, WorkTone};
596
597 fn row(id: &str) -> WorkRow {
598 WorkRow {
599 id: WorkRowId(id.to_string()),
600 mark: "·",
601 label: id.to_string(),
602 detail: String::new(),
603 tone: WorkTone::Muted,
604 selectable: true,
605 primary_action: None,
606 agent: None,
607 }
608 }
609
610 fn app() -> App {
611 App::new(
612 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
613 &crate::config::Config::default(),
614 )
615 }
616
617 /// The dock opens with nothing selected (`select_dock_panel` sets
618 /// `selected = None`). Resolving that to index 0 meant the first Down
619 /// landed on the *second* row and the first row could never be reached by
620 /// pressing Down at all. The same path catches a stale id that is no
621 /// longer in the list.
622 #[test]
623 fn first_move_lands_on_the_edge_it_came_from() {
624 let rows = [row("a"), row("b"), row("c")];
625
626 for (start, delta, expected) in [
627 (None, 1isize, "a"),
628 (None, -1, "c"),
629 (Some("gone"), 1, "a"),
630 (Some("gone"), -1, "c"),
631 // An established selection still moves by the delta.
632 (Some("a"), 1, "b"),
633 (Some("b"), -1, "a"),
634 ] {
635 let mut app = app();
636 app.work_surface.selected = start.map(|id| WorkRowId(id.to_string()));
637 move_selection(&mut app, &rows, delta);
638 assert_eq!(
639 app.work_surface.selected.as_ref().map(|id| id.0.as_str()),
640 Some(expected),
641 "start={start:?} delta={delta}"
642 );
643 }
644 }
645 }
646
646 lines RUST