| 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 |