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 {
362362
     },
363363
 }
364364
 
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
+
365397
 /// A single result from multi-file search
366398
 #[derive(Debug, Clone, PartialEq)]
367399
 struct FileSearchResult {
@@ -493,6 +525,8 @@ pub struct Editor {
493525
     terminal_resize_start_y: u16,
494526
     /// Terminal resize: starting height when drag began
495527
     terminal_resize_start_height: u16,
528
+    /// Current keyboard focus target
529
+    focus: Focus,
496530
 }
497531
 
498532
 impl Editor {
@@ -548,6 +582,7 @@ impl Editor {
548582
             terminal_resize_dragging: false,
549583
             terminal_resize_start_y: 0,
550584
             terminal_resize_start_height: 0,
585
+            focus: Focus::Editor,
551586
         };
552587
 
553588
         // If there are backups, show restore prompt
@@ -1189,8 +1224,10 @@ impl Editor {
11891224
     fn toggle_server_manager(&mut self) {
11901225
         if self.server_manager.visible {
11911226
             self.server_manager.hide();
1227
+            self.return_focus();
11921228
         } else {
11931229
             self.server_manager.show();
1230
+            self.focus = Focus::ServerManager;
11941231
         }
11951232
     }
11961233
 
@@ -1201,6 +1238,7 @@ impl Editor {
12011238
         // Alt+M toggles the panel closed
12021239
         if key == Key::Char('m') && mods.alt {
12031240
             self.server_manager.hide();
1241
+            self.return_focus();
12041242
             return Ok(());
12051243
         }
12061244
 
@@ -1416,15 +1454,22 @@ impl Editor {
14161454
         {
14171455
             let _ = self.terminal.toggle();
14181456
             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
+            }
14191463
             return Ok(());
14201464
         }
14211465
 
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
14251469
             if key_event.code == KeyCode::Esc {
14261470
                 self.terminal.hide();
14271471
                 self.terminal_resize_dragging = false;
1472
+                self.return_focus();
14281473
                 return Ok(());
14291474
             }
14301475
 
@@ -1441,6 +1486,7 @@ impl Editor {
14411486
                         if self.terminal.close_active_session() {
14421487
                             self.terminal.hide();
14431488
                             self.terminal_resize_dragging = false;
1489
+                            self.return_focus();
14441490
                         }
14451491
                         return Ok(());
14461492
                     }
@@ -1469,7 +1515,7 @@ impl Editor {
14691515
                 || (key_event.code == KeyCode::Char('b')
14701516
                     && key_event.modifiers.contains(KeyModifiers::CONTROL))
14711517
             {
1472
-                self.workspace.fuss.toggle();
1518
+                self.toggle_fuss_mode();
14731519
                 return Ok(());
14741520
             }
14751521
 
@@ -1527,6 +1573,58 @@ impl Editor {
15271573
         Ok(())
15281574
     }
15291575
 
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
+
15301628
     /// Handle mouse input
15311629
     fn handle_mouse(&mut self, mouse: Mouse) -> Result<()> {
15321630
         // Calculate offsets for fuss mode and tab bar
@@ -1545,6 +1643,31 @@ impl Editor {
15451643
         };
15461644
         let text_start_col = left_offset + line_num_width + 1;
15471645
 
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
+
15481671
         // Handle terminal resize dragging
15491672
         if self.terminal.visible {
15501673
             let title_row = self.screen.rows.saturating_sub(self.terminal.height);
@@ -1660,32 +1783,10 @@ impl Editor {
16601783
             0
16611784
         };
16621785
 
1663
-        // Render fuss mode sidebar if active
1786
+        // Update fuss mode viewport (actual rendering happens after terminal)
16641787
         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;
16721789
             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
-            }
16891790
         }
16901791
 
16911792
         // Build tab info for tab bar
@@ -1850,6 +1951,24 @@ impl Editor {
18501951
                 self.screen.render_terminal(&self.terminal, fuss_width)?;
18511952
             }
18521953
 
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
+
18531972
             // Render rename modal if active
18541973
             if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt {
18551974
                 self.screen.render_rename_modal(original_name, new_name)?;
@@ -2030,22 +2149,22 @@ impl Editor {
20302149
             return self.handle_prompt_key(key);
20312150
         }
20322151
 
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 {
20352154
             return self.handle_server_manager_key(key, mods);
20362155
         }
20372156
 
20382157
         // Clear message on any key
20392158
         self.message = None;
20402159
 
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)
20422161
         if matches!((&key, &mods), (Key::Char('b'), Modifiers { ctrl: true, .. }) | (Key::F(3), _)) {
20432162
             self.toggle_fuss_mode();
20442163
             return Ok(());
20452164
         }
20462165
 
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 {
20492168
             return self.handle_fuss_key(key, mods);
20502169
         }
20512170
 
@@ -4463,8 +4582,10 @@ impl Editor {
44634582
     fn toggle_fuss_mode(&mut self) {
44644583
         if !self.workspace.fuss.active {
44654584
             self.workspace.fuss.activate(&self.workspace.root);
4585
+            self.focus = Focus::FussMode;
44664586
         } else {
44674587
             self.workspace.fuss.deactivate();
4588
+            self.return_focus();
44684589
         }
44694590
     }
44704591
 
@@ -4484,6 +4605,7 @@ impl Editor {
44844605
             (Key::Escape, _) | (Key::F(3), _) => {
44854606
                 self.workspace.fuss.filter_clear();
44864607
                 self.workspace.fuss.deactivate();
4608
+                self.return_focus();
44874609
             }
44884610
 
44894611
             // Navigation