tenseleyflow/fackr / f49b5d3

Browse files

feat: implement focus-based input routing system

Add active window focus system where keyboard input is routed to the
last-activated component instead of using fixed priority.

Changes:
- Add Focus enum (Editor, Terminal, FussMode, ServerManager, Prompt)
- Add HitRegion enum for mouse hit testing
- Add focus field to Editor struct
- Implement hit_test() to determine clicked region
- Implement return_focus() helper for closing components
- Add click-to-focus in mouse handler
- Update toggle handlers to set focus when opening/closing
- Refactor keyboard routing from priority-based to focus-based

Behavior:
- Clicking any component gives it keyboard focus
- Keyboard shortcuts that open components also focus them
- Clicking in split panes makes that pane active
- ESC closes focused component and returns focus sensibly
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f49b5d34131913c070f6df62fcdccc339fcc2cb5
Parents
9905b80
Tree
0268e1d

1 changed file

StatusFile+-
M src/editor/state.rs 155 33
src/editor/state.rsmodified
@@ -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