@@ -362,6 +362,38 @@ enum PromptState { |
| 362 | }, | 362 | }, |
| 363 | } | 363 | } |
| 364 | | 364 | |
| | 365 | +/// Which UI component currently has keyboard focus |
| | 366 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| | 367 | +pub enum Focus { |
| | 368 | + /// Main editor panes |
| | 369 | + Editor, |
| | 370 | + /// Integrated terminal panel |
| | 371 | + Terminal, |
| | 372 | + /// Fuss mode file tree sidebar |
| | 373 | + FussMode, |
| | 374 | + /// LSP server manager panel |
| | 375 | + ServerManager, |
| | 376 | + /// Active prompt/modal (prompts are exclusive by nature) |
| | 377 | + Prompt, |
| | 378 | +} |
| | 379 | + |
| | 380 | +/// Hit test result for determining which region was clicked |
| | 381 | +#[derive(Debug, Clone, Copy)] |
| | 382 | +enum HitRegion { |
| | 383 | + /// Editor pane (with index for split panes) |
| | 384 | + Editor { pane_index: usize }, |
| | 385 | + /// Terminal panel |
| | 386 | + Terminal, |
| | 387 | + /// Fuss mode sidebar |
| | 388 | + FussMode, |
| | 389 | + /// Server manager panel |
| | 390 | + ServerManager, |
| | 391 | + /// Prompt/modal area |
| | 392 | + Prompt, |
| | 393 | + /// Outside any interactive region |
| | 394 | + None, |
| | 395 | +} |
| | 396 | + |
| 365 | /// A single result from multi-file search | 397 | /// A single result from multi-file search |
| 366 | #[derive(Debug, Clone, PartialEq)] | 398 | #[derive(Debug, Clone, PartialEq)] |
| 367 | struct FileSearchResult { | 399 | struct FileSearchResult { |
@@ -493,6 +525,8 @@ pub struct Editor { |
| 493 | terminal_resize_start_y: u16, | 525 | terminal_resize_start_y: u16, |
| 494 | /// Terminal resize: starting height when drag began | 526 | /// Terminal resize: starting height when drag began |
| 495 | terminal_resize_start_height: u16, | 527 | terminal_resize_start_height: u16, |
| | 528 | + /// Current keyboard focus target |
| | 529 | + focus: Focus, |
| 496 | } | 530 | } |
| 497 | | 531 | |
| 498 | impl Editor { | 532 | impl Editor { |
@@ -548,6 +582,7 @@ impl Editor { |
| 548 | terminal_resize_dragging: false, | 582 | terminal_resize_dragging: false, |
| 549 | terminal_resize_start_y: 0, | 583 | terminal_resize_start_y: 0, |
| 550 | terminal_resize_start_height: 0, | 584 | terminal_resize_start_height: 0, |
| | 585 | + focus: Focus::Editor, |
| 551 | }; | 586 | }; |
| 552 | | 587 | |
| 553 | // If there are backups, show restore prompt | 588 | // If there are backups, show restore prompt |
@@ -1189,8 +1224,10 @@ impl Editor { |
| 1189 | fn toggle_server_manager(&mut self) { | 1224 | fn toggle_server_manager(&mut self) { |
| 1190 | if self.server_manager.visible { | 1225 | if self.server_manager.visible { |
| 1191 | self.server_manager.hide(); | 1226 | self.server_manager.hide(); |
| | 1227 | + self.return_focus(); |
| 1192 | } else { | 1228 | } else { |
| 1193 | self.server_manager.show(); | 1229 | self.server_manager.show(); |
| | 1230 | + self.focus = Focus::ServerManager; |
| 1194 | } | 1231 | } |
| 1195 | } | 1232 | } |
| 1196 | | 1233 | |
@@ -1201,6 +1238,7 @@ impl Editor { |
| 1201 | // Alt+M toggles the panel closed | 1238 | // Alt+M toggles the panel closed |
| 1202 | if key == Key::Char('m') && mods.alt { | 1239 | if key == Key::Char('m') && mods.alt { |
| 1203 | self.server_manager.hide(); | 1240 | self.server_manager.hide(); |
| | 1241 | + self.return_focus(); |
| 1204 | return Ok(()); | 1242 | return Ok(()); |
| 1205 | } | 1243 | } |
| 1206 | | 1244 | |
@@ -1416,15 +1454,22 @@ impl Editor { |
| 1416 | { | 1454 | { |
| 1417 | let _ = self.terminal.toggle(); | 1455 | let _ = self.terminal.toggle(); |
| 1418 | self.terminal_resize_dragging = false; | 1456 | self.terminal_resize_dragging = false; |
| | 1457 | + // Set focus when opening, return focus when closing |
| | 1458 | + if self.terminal.visible { |
| | 1459 | + self.focus = Focus::Terminal; |
| | 1460 | + } else { |
| | 1461 | + self.return_focus(); |
| | 1462 | + } |
| 1419 | return Ok(()); | 1463 | return Ok(()); |
| 1420 | } | 1464 | } |
| 1421 | | 1465 | |
| 1422 | - // If terminal is visible, route input to terminal | 1466 | + // Focus-based routing for terminal |
| 1423 | - if self.terminal.visible { | 1467 | + if self.focus == Focus::Terminal && self.terminal.visible { |
| 1424 | - // ESC hides terminal | 1468 | + // ESC hides terminal and returns focus |
| 1425 | if key_event.code == KeyCode::Esc { | 1469 | if key_event.code == KeyCode::Esc { |
| 1426 | self.terminal.hide(); | 1470 | self.terminal.hide(); |
| 1427 | self.terminal_resize_dragging = false; | 1471 | self.terminal_resize_dragging = false; |
| | 1472 | + self.return_focus(); |
| 1428 | return Ok(()); | 1473 | return Ok(()); |
| 1429 | } | 1474 | } |
| 1430 | | 1475 | |
@@ -1441,6 +1486,7 @@ impl Editor { |
| 1441 | if self.terminal.close_active_session() { | 1486 | if self.terminal.close_active_session() { |
| 1442 | self.terminal.hide(); | 1487 | self.terminal.hide(); |
| 1443 | self.terminal_resize_dragging = false; | 1488 | self.terminal_resize_dragging = false; |
| | 1489 | + self.return_focus(); |
| 1444 | } | 1490 | } |
| 1445 | return Ok(()); | 1491 | return Ok(()); |
| 1446 | } | 1492 | } |
@@ -1469,7 +1515,7 @@ impl Editor { |
| 1469 | || (key_event.code == KeyCode::Char('b') | 1515 | || (key_event.code == KeyCode::Char('b') |
| 1470 | && key_event.modifiers.contains(KeyModifiers::CONTROL)) | 1516 | && key_event.modifiers.contains(KeyModifiers::CONTROL)) |
| 1471 | { | 1517 | { |
| 1472 | - self.workspace.fuss.toggle(); | 1518 | + self.toggle_fuss_mode(); |
| 1473 | return Ok(()); | 1519 | return Ok(()); |
| 1474 | } | 1520 | } |
| 1475 | | 1521 | |
@@ -1527,6 +1573,58 @@ impl Editor { |
| 1527 | Ok(()) | 1573 | Ok(()) |
| 1528 | } | 1574 | } |
| 1529 | | 1575 | |
| | 1576 | + /// Hit test to determine which UI region contains a screen coordinate |
| | 1577 | + fn hit_test(&self, col: u16, row: u16) -> HitRegion { |
| | 1578 | + // Check prompt/modal first (overlays everything) |
| | 1579 | + // Prompts take up various areas but for click purposes we consider them focused |
| | 1580 | + if self.prompt != PromptState::None { |
| | 1581 | + return HitRegion::Prompt; |
| | 1582 | + } |
| | 1583 | + |
| | 1584 | + // Check server manager panel (right side overlay) |
| | 1585 | + if self.server_manager.visible { |
| | 1586 | + let panel_width = 50.min(self.screen.cols / 2); |
| | 1587 | + let panel_start_col = self.screen.cols.saturating_sub(panel_width); |
| | 1588 | + if col >= panel_start_col { |
| | 1589 | + return HitRegion::ServerManager; |
| | 1590 | + } |
| | 1591 | + } |
| | 1592 | + |
| | 1593 | + // Check terminal (bottom region) |
| | 1594 | + if self.terminal.visible { |
| | 1595 | + let terminal_start_row = self.terminal.render_start_row(self.screen.rows); |
| | 1596 | + if row >= terminal_start_row { |
| | 1597 | + // Terminal shrinks when fuss mode is active |
| | 1598 | + let fuss_width = if self.workspace.fuss.active { |
| | 1599 | + self.workspace.fuss.width(self.screen.cols) |
| | 1600 | + } else { |
| | 1601 | + 0 |
| | 1602 | + }; |
| | 1603 | + if col >= fuss_width { |
| | 1604 | + return HitRegion::Terminal; |
| | 1605 | + } |
| | 1606 | + } |
| | 1607 | + } |
| | 1608 | + |
| | 1609 | + // Check fuss sidebar (left side) |
| | 1610 | + if self.workspace.fuss.active { |
| | 1611 | + let fuss_width = self.workspace.fuss.width(self.screen.cols); |
| | 1612 | + if col < fuss_width { |
| | 1613 | + return HitRegion::FussMode; |
| | 1614 | + } |
| | 1615 | + } |
| | 1616 | + |
| | 1617 | + // Otherwise it's the editor - determine which pane |
| | 1618 | + let pane_index = self.workspace.pane_at_position(col, row, self.screen.cols, self.screen.rows); |
| | 1619 | + HitRegion::Editor { pane_index } |
| | 1620 | + } |
| | 1621 | + |
| | 1622 | + /// Return focus to a sensible default after closing a component |
| | 1623 | + fn return_focus(&mut self) { |
| | 1624 | + // Return focus to the most recently visible component, defaulting to editor |
| | 1625 | + self.focus = Focus::Editor; |
| | 1626 | + } |
| | 1627 | + |
| 1530 | /// Handle mouse input | 1628 | /// Handle mouse input |
| 1531 | fn handle_mouse(&mut self, mouse: Mouse) -> Result<()> { | 1629 | fn handle_mouse(&mut self, mouse: Mouse) -> Result<()> { |
| 1532 | // Calculate offsets for fuss mode and tab bar | 1630 | // Calculate offsets for fuss mode and tab bar |
@@ -1545,6 +1643,31 @@ impl Editor { |
| 1545 | }; | 1643 | }; |
| 1546 | let text_start_col = left_offset + line_num_width + 1; | 1644 | let text_start_col = left_offset + line_num_width + 1; |
| 1547 | | 1645 | |
| | 1646 | + // Click-to-focus: determine which region was clicked and set focus |
| | 1647 | + if let Mouse::Click { col, row, .. } = mouse { |
| | 1648 | + let region = self.hit_test(col, row); |
| | 1649 | + match region { |
| | 1650 | + HitRegion::Terminal => { |
| | 1651 | + self.focus = Focus::Terminal; |
| | 1652 | + } |
| | 1653 | + HitRegion::FussMode => { |
| | 1654 | + self.focus = Focus::FussMode; |
| | 1655 | + } |
| | 1656 | + HitRegion::Editor { pane_index } => { |
| | 1657 | + self.focus = Focus::Editor; |
| | 1658 | + // Also set the active pane when clicking in editor area |
| | 1659 | + self.workspace.tabs[self.workspace.active_tab].active_pane = pane_index; |
| | 1660 | + } |
| | 1661 | + HitRegion::ServerManager => { |
| | 1662 | + self.focus = Focus::ServerManager; |
| | 1663 | + } |
| | 1664 | + HitRegion::Prompt => { |
| | 1665 | + self.focus = Focus::Prompt; |
| | 1666 | + } |
| | 1667 | + HitRegion::None => {} |
| | 1668 | + } |
| | 1669 | + } |
| | 1670 | + |
| 1548 | // Handle terminal resize dragging | 1671 | // Handle terminal resize dragging |
| 1549 | if self.terminal.visible { | 1672 | if self.terminal.visible { |
| 1550 | let title_row = self.screen.rows.saturating_sub(self.terminal.height); | 1673 | let title_row = self.screen.rows.saturating_sub(self.terminal.height); |
@@ -1660,32 +1783,10 @@ impl Editor { |
| 1660 | 0 | 1783 | 0 |
| 1661 | }; | 1784 | }; |
| 1662 | | 1785 | |
| 1663 | - // Render fuss mode sidebar if active | 1786 | + // Update fuss mode viewport (actual rendering happens after terminal) |
| 1664 | if self.workspace.fuss.active { | 1787 | if self.workspace.fuss.active { |
| 1665 | - // When terminal is visible, fuss mode should only render above it | 1788 | + let visible_rows = self.screen.rows.saturating_sub(2) as usize; |
| 1666 | - let max_fuss_rows = if self.terminal.visible { | | |
| 1667 | - Some(self.terminal.render_start_row(self.screen.rows)) | | |
| 1668 | - } else { | | |
| 1669 | - None | | |
| 1670 | - }; | | |
| 1671 | - let visible_rows = max_fuss_rows.unwrap_or(self.screen.rows).saturating_sub(2) as usize; | | |
| 1672 | self.workspace.fuss.update_viewport(visible_rows); | 1789 | self.workspace.fuss.update_viewport(visible_rows); |
| 1673 | - | | |
| 1674 | - if let Some(ref tree) = self.workspace.fuss.tree { | | |
| 1675 | - let repo_name = self.workspace.repo_name(); | | |
| 1676 | - let branch = self.workspace.git_branch(); | | |
| 1677 | - self.screen.render_fuss( | | |
| 1678 | - tree.visible_items(), | | |
| 1679 | - self.workspace.fuss.selected, | | |
| 1680 | - self.workspace.fuss.scroll, | | |
| 1681 | - fuss_width, | | |
| 1682 | - self.workspace.fuss.hints_expanded, | | |
| 1683 | - &repo_name, | | |
| 1684 | - branch.as_deref(), | | |
| 1685 | - self.workspace.fuss.git_mode, | | |
| 1686 | - max_fuss_rows, | | |
| 1687 | - )?; | | |
| 1688 | - } | | |
| 1689 | } | 1790 | } |
| 1690 | | 1791 | |
| 1691 | // Build tab info for tab bar | 1792 | // Build tab info for tab bar |
@@ -1850,6 +1951,24 @@ impl Editor { |
| 1850 | self.screen.render_terminal(&self.terminal, fuss_width)?; | 1951 | self.screen.render_terminal(&self.terminal, fuss_width)?; |
| 1851 | } | 1952 | } |
| 1852 | | 1953 | |
| | 1954 | + // Render fuss mode sidebar if active (after terminal so it paints on top) |
| | 1955 | + if self.workspace.fuss.active { |
| | 1956 | + if let Some(ref tree) = self.workspace.fuss.tree { |
| | 1957 | + let repo_name = self.workspace.repo_name(); |
| | 1958 | + let branch = self.workspace.git_branch(); |
| | 1959 | + self.screen.render_fuss( |
| | 1960 | + tree.visible_items(), |
| | 1961 | + self.workspace.fuss.selected, |
| | 1962 | + self.workspace.fuss.scroll, |
| | 1963 | + fuss_width, |
| | 1964 | + self.workspace.fuss.hints_expanded, |
| | 1965 | + &repo_name, |
| | 1966 | + branch.as_deref(), |
| | 1967 | + self.workspace.fuss.git_mode, |
| | 1968 | + )?; |
| | 1969 | + } |
| | 1970 | + } |
| | 1971 | + |
| 1853 | // Render rename modal if active | 1972 | // Render rename modal if active |
| 1854 | if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt { | 1973 | if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt { |
| 1855 | self.screen.render_rename_modal(original_name, new_name)?; | 1974 | self.screen.render_rename_modal(original_name, new_name)?; |
@@ -2030,22 +2149,22 @@ impl Editor { |
| 2030 | return self.handle_prompt_key(key); | 2149 | return self.handle_prompt_key(key); |
| 2031 | } | 2150 | } |
| 2032 | | 2151 | |
| 2033 | - // Handle server manager panel when visible | 2152 | + // Focus-based routing for server manager |
| 2034 | - if self.server_manager.visible { | 2153 | + if self.focus == Focus::ServerManager && self.server_manager.visible { |
| 2035 | return self.handle_server_manager_key(key, mods); | 2154 | return self.handle_server_manager_key(key, mods); |
| 2036 | } | 2155 | } |
| 2037 | | 2156 | |
| 2038 | // Clear message on any key | 2157 | // Clear message on any key |
| 2039 | self.message = None; | 2158 | self.message = None; |
| 2040 | | 2159 | |
| 2041 | - // Toggle fuss mode: Ctrl+B or F3 (works in both modes) | 2160 | + // Toggle fuss mode: Ctrl+B or F3 (global shortcut that sets focus) |
| 2042 | if matches!((&key, &mods), (Key::Char('b'), Modifiers { ctrl: true, .. }) | (Key::F(3), _)) { | 2161 | if matches!((&key, &mods), (Key::Char('b'), Modifiers { ctrl: true, .. }) | (Key::F(3), _)) { |
| 2043 | self.toggle_fuss_mode(); | 2162 | self.toggle_fuss_mode(); |
| 2044 | return Ok(()); | 2163 | return Ok(()); |
| 2045 | } | 2164 | } |
| 2046 | | 2165 | |
| 2047 | - // Route to fuss mode handler if active | 2166 | + // Focus-based routing for fuss mode |
| 2048 | - if self.workspace.fuss.active { | 2167 | + if self.focus == Focus::FussMode && self.workspace.fuss.active { |
| 2049 | return self.handle_fuss_key(key, mods); | 2168 | return self.handle_fuss_key(key, mods); |
| 2050 | } | 2169 | } |
| 2051 | | 2170 | |
@@ -4463,8 +4582,10 @@ impl Editor { |
| 4463 | fn toggle_fuss_mode(&mut self) { | 4582 | fn toggle_fuss_mode(&mut self) { |
| 4464 | if !self.workspace.fuss.active { | 4583 | if !self.workspace.fuss.active { |
| 4465 | self.workspace.fuss.activate(&self.workspace.root); | 4584 | self.workspace.fuss.activate(&self.workspace.root); |
| | 4585 | + self.focus = Focus::FussMode; |
| 4466 | } else { | 4586 | } else { |
| 4467 | self.workspace.fuss.deactivate(); | 4587 | self.workspace.fuss.deactivate(); |
| | 4588 | + self.return_focus(); |
| 4468 | } | 4589 | } |
| 4469 | } | 4590 | } |
| 4470 | | 4591 | |
@@ -4484,6 +4605,7 @@ impl Editor { |
| 4484 | (Key::Escape, _) | (Key::F(3), _) => { | 4605 | (Key::Escape, _) | (Key::F(3), _) => { |
| 4485 | self.workspace.fuss.filter_clear(); | 4606 | self.workspace.fuss.filter_clear(); |
| 4486 | self.workspace.fuss.deactivate(); | 4607 | self.workspace.fuss.deactivate(); |
| | 4608 | + self.return_focus(); |
| 4487 | } | 4609 | } |
| 4488 | | 4610 | |
| 4489 | // Navigation | 4611 | // Navigation |