Skip to main content

fresh/input/
composite_router.rs

1//! Input routing for composite buffers
2//!
3//! Routes keyboard and mouse input to the appropriate source buffer
4//! based on focus state and cursor position within the composite view.
5
6use crate::model::composite_buffer::CompositeBuffer;
7use crate::model::event::BufferId;
8use crate::view::composite_view::CompositeViewState;
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11/// Result of routing an input event
12#[derive(Debug, Clone)]
13pub enum RoutedEvent {
14    /// Event affects composite view scrolling
15    CompositeScroll(ScrollAction),
16    /// Switch focus to another pane
17    SwitchPane(Direction),
18    /// Navigate to next/previous hunk
19    NavigateHunk(Direction),
20    /// Route to a source buffer for editing
21    ToSourceBuffer {
22        buffer_id: BufferId,
23        action: BufferAction,
24    },
25    /// Cursor movement within focused pane
26    PaneCursor(CursorAction),
27    /// Selection action
28    Selection(SelectionAction),
29    /// Yank/copy the selected text
30    Yank,
31    /// Event was blocked (e.g., editing read-only pane)
32    Blocked(&'static str),
33    /// Close the composite view
34    Close,
35    /// Event not handled by composite router
36    Unhandled,
37}
38
39/// Selection actions for visual mode
40#[derive(Debug, Clone, Copy)]
41pub enum SelectionAction {
42    /// Start visual selection at current position
43    StartVisual,
44    /// Start line-wise visual selection
45    StartVisualLine,
46    /// Clear selection
47    ClearSelection,
48    /// Extend selection up
49    ExtendUp,
50    /// Extend selection down
51    ExtendDown,
52    /// Extend selection left
53    ExtendLeft,
54    /// Extend selection right
55    ExtendRight,
56}
57
58/// Scroll actions for the composite view
59#[derive(Debug, Clone, Copy)]
60pub enum ScrollAction {
61    Up(usize),
62    Down(usize),
63    PageUp,
64    PageDown,
65    ToTop,
66    ToBottom,
67    ToRow(usize),
68}
69
70/// Direction for navigation
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Direction {
73    Next,
74    Prev,
75}
76
77/// Actions that modify buffer content
78#[derive(Debug, Clone)]
79pub enum BufferAction {
80    Insert(char),
81    InsertString(String),
82    Delete,
83    Backspace,
84    NewLine,
85}
86
87/// Cursor movement actions
88#[derive(Debug, Clone, Copy)]
89pub enum CursorAction {
90    Up,
91    Down,
92    Left,
93    Right,
94    LineStart,
95    LineEnd,
96    WordLeft,
97    WordRight,
98}
99
100/// Routes input events for a composite buffer
101pub struct CompositeInputRouter;
102
103impl CompositeInputRouter {
104    /// Route a key event to the appropriate action
105    pub fn route_key_event(
106        composite: &CompositeBuffer,
107        view_state: &CompositeViewState,
108        event: &KeyEvent,
109    ) -> RoutedEvent {
110        let focused_pane = composite.sources.get(view_state.focused_pane);
111
112        match (event.modifiers, event.code) {
113            // Scroll navigation
114            (KeyModifiers::NONE, KeyCode::Up) | (KeyModifiers::NONE, KeyCode::Char('k')) => {
115                RoutedEvent::CompositeScroll(ScrollAction::Up(1))
116            }
117            (KeyModifiers::NONE, KeyCode::Down) | (KeyModifiers::NONE, KeyCode::Char('j')) => {
118                RoutedEvent::CompositeScroll(ScrollAction::Down(1))
119            }
120            (KeyModifiers::CONTROL, KeyCode::Char('u')) => {
121                RoutedEvent::CompositeScroll(ScrollAction::PageUp)
122            }
123            (KeyModifiers::CONTROL, KeyCode::Char('d')) => {
124                RoutedEvent::CompositeScroll(ScrollAction::PageDown)
125            }
126            (KeyModifiers::NONE, KeyCode::PageUp) => {
127                RoutedEvent::CompositeScroll(ScrollAction::PageUp)
128            }
129            (KeyModifiers::NONE, KeyCode::PageDown) => {
130                RoutedEvent::CompositeScroll(ScrollAction::PageDown)
131            }
132            (KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::NONE, KeyCode::Char('g')) => {
133                RoutedEvent::CompositeScroll(ScrollAction::ToTop)
134            }
135            (KeyModifiers::SHIFT, KeyCode::Char('G')) | (KeyModifiers::NONE, KeyCode::End) => {
136                RoutedEvent::CompositeScroll(ScrollAction::ToBottom)
137            }
138
139            // Pane switching
140            (KeyModifiers::NONE, KeyCode::Tab) => RoutedEvent::SwitchPane(Direction::Next),
141            (KeyModifiers::SHIFT, KeyCode::BackTab) => RoutedEvent::SwitchPane(Direction::Prev),
142            (KeyModifiers::NONE, KeyCode::Char('h')) => RoutedEvent::SwitchPane(Direction::Prev),
143            (KeyModifiers::NONE, KeyCode::Char('l')) => RoutedEvent::SwitchPane(Direction::Next),
144
145            // Hunk navigation
146            (KeyModifiers::NONE, KeyCode::Char('n')) => RoutedEvent::NavigateHunk(Direction::Next),
147            (KeyModifiers::NONE, KeyCode::Char('p')) => RoutedEvent::NavigateHunk(Direction::Prev),
148            (KeyModifiers::NONE, KeyCode::Char(']')) => RoutedEvent::NavigateHunk(Direction::Next),
149            (KeyModifiers::NONE, KeyCode::Char('[')) => RoutedEvent::NavigateHunk(Direction::Prev),
150
151            // Close
152            (KeyModifiers::NONE, KeyCode::Char('q')) | (KeyModifiers::NONE, KeyCode::Esc) => {
153                RoutedEvent::Close
154            }
155
156            // Visual selection
157            (KeyModifiers::NONE, KeyCode::Char('v')) => {
158                RoutedEvent::Selection(SelectionAction::StartVisual)
159            }
160            (KeyModifiers::SHIFT, KeyCode::Char('V')) => {
161                RoutedEvent::Selection(SelectionAction::StartVisualLine)
162            }
163
164            // Yank (copy) selected text
165            (KeyModifiers::NONE, KeyCode::Char('y')) => RoutedEvent::Yank,
166
167            // Editing (if pane is editable)
168            (KeyModifiers::NONE, KeyCode::Char(c)) => {
169                if let Some(pane) = focused_pane {
170                    if pane.editable {
171                        RoutedEvent::ToSourceBuffer {
172                            buffer_id: pane.buffer_id,
173                            action: BufferAction::Insert(c),
174                        }
175                    } else {
176                        RoutedEvent::Blocked("Pane is read-only")
177                    }
178                } else {
179                    RoutedEvent::Unhandled
180                }
181            }
182            (KeyModifiers::NONE, KeyCode::Backspace) => {
183                if let Some(pane) = focused_pane {
184                    if pane.editable {
185                        RoutedEvent::ToSourceBuffer {
186                            buffer_id: pane.buffer_id,
187                            action: BufferAction::Backspace,
188                        }
189                    } else {
190                        RoutedEvent::Blocked("Pane is read-only")
191                    }
192                } else {
193                    RoutedEvent::Unhandled
194                }
195            }
196            (KeyModifiers::NONE, KeyCode::Delete) => {
197                if let Some(pane) = focused_pane {
198                    if pane.editable {
199                        RoutedEvent::ToSourceBuffer {
200                            buffer_id: pane.buffer_id,
201                            action: BufferAction::Delete,
202                        }
203                    } else {
204                        RoutedEvent::Blocked("Pane is read-only")
205                    }
206                } else {
207                    RoutedEvent::Unhandled
208                }
209            }
210            (KeyModifiers::NONE, KeyCode::Enter) => {
211                if let Some(pane) = focused_pane {
212                    if pane.editable {
213                        RoutedEvent::ToSourceBuffer {
214                            buffer_id: pane.buffer_id,
215                            action: BufferAction::NewLine,
216                        }
217                    } else {
218                        RoutedEvent::Blocked("Pane is read-only")
219                    }
220                } else {
221                    RoutedEvent::Unhandled
222                }
223            }
224
225            // Cursor movement in focused pane
226            (KeyModifiers::NONE, KeyCode::Left) => RoutedEvent::PaneCursor(CursorAction::Left),
227            (KeyModifiers::NONE, KeyCode::Right) => RoutedEvent::PaneCursor(CursorAction::Right),
228            (KeyModifiers::CONTROL, KeyCode::Left) => {
229                RoutedEvent::PaneCursor(CursorAction::WordLeft)
230            }
231            (KeyModifiers::CONTROL, KeyCode::Right) => {
232                RoutedEvent::PaneCursor(CursorAction::WordRight)
233            }
234
235            _ => RoutedEvent::Unhandled,
236        }
237    }
238
239    /// Convert display coordinates to source buffer coordinates
240    pub fn display_to_source(
241        composite: &CompositeBuffer,
242        _view_state: &CompositeViewState,
243        display_row: usize,
244        display_col: usize,
245        pane_index: usize,
246    ) -> Option<SourceCoordinate> {
247        let aligned_row = composite.alignment.get_row(display_row)?;
248        let source_ref = aligned_row.get_pane_line(pane_index)?;
249
250        Some(SourceCoordinate {
251            buffer_id: composite.sources.get(pane_index)?.buffer_id,
252            byte_offset: source_ref.byte_range.start + display_col,
253            line: source_ref.line,
254            column: display_col,
255        })
256    }
257
258    /// Determine which pane a click occurred in
259    pub fn click_to_pane(
260        view_state: &CompositeViewState,
261        click_x: u16,
262        area_x: u16,
263    ) -> Option<usize> {
264        let mut x = area_x;
265        for (i, &width) in view_state.pane_widths.iter().enumerate() {
266            if click_x >= x && click_x < x + width {
267                return Some(i);
268            }
269            x += width + 1; // +1 for separator
270        }
271        None
272    }
273
274    /// Navigate to the next or previous hunk
275    pub fn navigate_to_hunk(
276        composite: &CompositeBuffer,
277        view_state: &mut CompositeViewState,
278        direction: Direction,
279    ) -> bool {
280        let current_row = view_state.scroll_row;
281        let new_row = match direction {
282            Direction::Next => composite.alignment.next_hunk_row(current_row),
283            Direction::Prev => composite.alignment.prev_hunk_row(current_row),
284        };
285
286        if let Some(row) = new_row {
287            view_state.scroll_row = row;
288            true
289        } else {
290            false
291        }
292    }
293}
294
295/// Coordinates within a source buffer
296#[derive(Debug, Clone)]
297pub struct SourceCoordinate {
298    pub buffer_id: BufferId,
299    pub byte_offset: usize,
300    pub line: usize,
301    pub column: usize,
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::model::composite_buffer::{CompositeLayout, SourcePane};
308
309    fn create_test_composite() -> (CompositeBuffer, CompositeViewState) {
310        let sources = vec![
311            SourcePane::new(BufferId(1), "OLD", false),
312            SourcePane::new(BufferId(2), "NEW", true),
313        ];
314        let composite = CompositeBuffer::new(
315            BufferId(0),
316            "Test Diff".to_string(),
317            "diff-view".to_string(),
318            CompositeLayout::default(),
319            sources,
320        );
321        let view_state = CompositeViewState::new(BufferId(0), 2);
322        (composite, view_state)
323    }
324
325    #[test]
326    fn test_scroll_routing() {
327        let (composite, view_state) = create_test_composite();
328
329        let event = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
330        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
331
332        matches!(result, RoutedEvent::CompositeScroll(ScrollAction::Down(1)));
333    }
334
335    #[test]
336    fn test_pane_switch_routing() {
337        let (composite, view_state) = create_test_composite();
338
339        let event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
340        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
341
342        matches!(result, RoutedEvent::SwitchPane(Direction::Next));
343    }
344
345    #[test]
346    fn test_readonly_blocking() {
347        let (composite, view_state) = create_test_composite();
348        // Focused pane is 0 (OLD), which is read-only
349
350        let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
351        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
352
353        matches!(result, RoutedEvent::Blocked(_));
354    }
355
356    #[test]
357    fn test_editable_routing() {
358        let (composite, mut view_state) = create_test_composite();
359        view_state.focused_pane = 1; // NEW pane is editable
360
361        let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
362        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
363
364        matches!(
365            result,
366            RoutedEvent::ToSourceBuffer {
367                buffer_id: BufferId(2),
368                action: BufferAction::Insert('x'),
369            }
370        );
371    }
372}