@@ -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 | 397 | /// A single result from multi-file search |
| 366 | 398 | #[derive(Debug, Clone, PartialEq)] |
| 367 | 399 | struct FileSearchResult { |
@@ -493,6 +525,8 @@ pub struct Editor { |
| 493 | 525 | terminal_resize_start_y: u16, |
| 494 | 526 | /// Terminal resize: starting height when drag began |
| 495 | 527 | terminal_resize_start_height: u16, |
| 528 | + /// Current keyboard focus target |
| 529 | + focus: Focus, |
| 496 | 530 | } |
| 497 | 531 | |
| 498 | 532 | impl Editor { |
@@ -548,6 +582,7 @@ impl Editor { |
| 548 | 582 | terminal_resize_dragging: false, |
| 549 | 583 | terminal_resize_start_y: 0, |
| 550 | 584 | terminal_resize_start_height: 0, |
| 585 | + focus: Focus::Editor, |
| 551 | 586 | }; |
| 552 | 587 | |
| 553 | 588 | // If there are backups, show restore prompt |
@@ -1189,8 +1224,10 @@ impl Editor { |
| 1189 | 1224 | fn toggle_server_manager(&mut self) { |
| 1190 | 1225 | if self.server_manager.visible { |
| 1191 | 1226 | self.server_manager.hide(); |
| 1227 | + self.return_focus(); |
| 1192 | 1228 | } else { |
| 1193 | 1229 | self.server_manager.show(); |
| 1230 | + self.focus = Focus::ServerManager; |
| 1194 | 1231 | } |
| 1195 | 1232 | } |
| 1196 | 1233 | |
@@ -1201,6 +1238,7 @@ impl Editor { |
| 1201 | 1238 | // Alt+M toggles the panel closed |
| 1202 | 1239 | if key == Key::Char('m') && mods.alt { |
| 1203 | 1240 | self.server_manager.hide(); |
| 1241 | + self.return_focus(); |
| 1204 | 1242 | return Ok(()); |
| 1205 | 1243 | } |
| 1206 | 1244 | |
@@ -1416,15 +1454,22 @@ impl Editor { |
| 1416 | 1454 | { |
| 1417 | 1455 | let _ = self.terminal.toggle(); |
| 1418 | 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 | 1463 | return Ok(()); |
| 1420 | 1464 | } |
| 1421 | 1465 | |
| 1422 | | - // If terminal is visible, route input to terminal |
| 1423 | | - if self.terminal.visible { |
| 1424 | | - // ESC hides terminal |
| 1466 | + // Focus-based routing for terminal |
| 1467 | + if self.focus == Focus::Terminal && self.terminal.visible { |
| 1468 | + // ESC hides terminal and returns focus |
| 1425 | 1469 | if key_event.code == KeyCode::Esc { |
| 1426 | 1470 | self.terminal.hide(); |
| 1427 | 1471 | self.terminal_resize_dragging = false; |
| 1472 | + self.return_focus(); |
| 1428 | 1473 | return Ok(()); |
| 1429 | 1474 | } |
| 1430 | 1475 | |
@@ -1441,6 +1486,7 @@ impl Editor { |
| 1441 | 1486 | if self.terminal.close_active_session() { |
| 1442 | 1487 | self.terminal.hide(); |
| 1443 | 1488 | self.terminal_resize_dragging = false; |
| 1489 | + self.return_focus(); |
| 1444 | 1490 | } |
| 1445 | 1491 | return Ok(()); |
| 1446 | 1492 | } |
@@ -1469,7 +1515,7 @@ impl Editor { |
| 1469 | 1515 | || (key_event.code == KeyCode::Char('b') |
| 1470 | 1516 | && key_event.modifiers.contains(KeyModifiers::CONTROL)) |
| 1471 | 1517 | { |
| 1472 | | - self.workspace.fuss.toggle(); |
| 1518 | + self.toggle_fuss_mode(); |
| 1473 | 1519 | return Ok(()); |
| 1474 | 1520 | } |
| 1475 | 1521 | |
@@ -1527,6 +1573,58 @@ impl Editor { |
| 1527 | 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 | 1628 | /// Handle mouse input |
| 1531 | 1629 | fn handle_mouse(&mut self, mouse: Mouse) -> Result<()> { |
| 1532 | 1630 | // Calculate offsets for fuss mode and tab bar |
@@ -1545,6 +1643,31 @@ impl Editor { |
| 1545 | 1643 | }; |
| 1546 | 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 | 1671 | // Handle terminal resize dragging |
| 1549 | 1672 | if self.terminal.visible { |
| 1550 | 1673 | let title_row = self.screen.rows.saturating_sub(self.terminal.height); |
@@ -1660,32 +1783,10 @@ impl Editor { |
| 1660 | 1783 | 0 |
| 1661 | 1784 | }; |
| 1662 | 1785 | |
| 1663 | | - // Render fuss mode sidebar if active |
| 1786 | + // Update fuss mode viewport (actual rendering happens after terminal) |
| 1664 | 1787 | if self.workspace.fuss.active { |
| 1665 | | - // When terminal is visible, fuss mode should only render above it |
| 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; |
| 1788 | + let visible_rows = self.screen.rows.saturating_sub(2) as usize; |
| 1672 | 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 | 1792 | // Build tab info for tab bar |
@@ -1850,6 +1951,24 @@ impl Editor { |
| 1850 | 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 | 1972 | // Render rename modal if active |
| 1854 | 1973 | if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt { |
| 1855 | 1974 | self.screen.render_rename_modal(original_name, new_name)?; |
@@ -2030,22 +2149,22 @@ impl Editor { |
| 2030 | 2149 | return self.handle_prompt_key(key); |
| 2031 | 2150 | } |
| 2032 | 2151 | |
| 2033 | | - // Handle server manager panel when visible |
| 2034 | | - if self.server_manager.visible { |
| 2152 | + // Focus-based routing for server manager |
| 2153 | + if self.focus == Focus::ServerManager && self.server_manager.visible { |
| 2035 | 2154 | return self.handle_server_manager_key(key, mods); |
| 2036 | 2155 | } |
| 2037 | 2156 | |
| 2038 | 2157 | // Clear message on any key |
| 2039 | 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 | 2161 | if matches!((&key, &mods), (Key::Char('b'), Modifiers { ctrl: true, .. }) | (Key::F(3), _)) { |
| 2043 | 2162 | self.toggle_fuss_mode(); |
| 2044 | 2163 | return Ok(()); |
| 2045 | 2164 | } |
| 2046 | 2165 | |
| 2047 | | - // Route to fuss mode handler if active |
| 2048 | | - if self.workspace.fuss.active { |
| 2166 | + // Focus-based routing for fuss mode |
| 2167 | + if self.focus == Focus::FussMode && self.workspace.fuss.active { |
| 2049 | 2168 | return self.handle_fuss_key(key, mods); |
| 2050 | 2169 | } |
| 2051 | 2170 | |
@@ -4463,8 +4582,10 @@ impl Editor { |
| 4463 | 4582 | fn toggle_fuss_mode(&mut self) { |
| 4464 | 4583 | if !self.workspace.fuss.active { |
| 4465 | 4584 | self.workspace.fuss.activate(&self.workspace.root); |
| 4585 | + self.focus = Focus::FussMode; |
| 4466 | 4586 | } else { |
| 4467 | 4587 | self.workspace.fuss.deactivate(); |
| 4588 | + self.return_focus(); |
| 4468 | 4589 | } |
| 4469 | 4590 | } |
| 4470 | 4591 | |
@@ -4484,6 +4605,7 @@ impl Editor { |
| 4484 | 4605 | (Key::Escape, _) | (Key::F(3), _) => { |
| 4485 | 4606 | self.workspace.fuss.filter_clear(); |
| 4486 | 4607 | self.workspace.fuss.deactivate(); |
| 4608 | + self.return_focus(); |
| 4487 | 4609 | } |
| 4488 | 4610 | |
| 4489 | 4611 | // Navigation |