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    Top,
99    Bottom,
100}
101
102/// Routes input events for a composite buffer
103pub struct CompositeInputRouter;
104
105impl CompositeInputRouter {
106    /// Route a key event to the appropriate action.
107    ///
108    /// Only intercepts keys that need composite-specific handling (pane
109    /// switching, hunk navigation, close). Everything else — arrows,
110    /// Home/End, PageUp/PageDown, typing — returns `Unhandled` so the
111    /// editor's normal key dispatch handles it natively.
112    pub fn route_key_event(
113        _composite: &CompositeBuffer,
114        _view_state: &CompositeViewState,
115        event: &KeyEvent,
116    ) -> RoutedEvent {
117        match (event.modifiers, event.code) {
118            // Scroll (j/k act as line-by-line scroll in the composite view)
119            (KeyModifiers::NONE, KeyCode::Char('j')) => {
120                RoutedEvent::CompositeScroll(ScrollAction::Down(1))
121            }
122            (KeyModifiers::NONE, KeyCode::Char('k')) => {
123                RoutedEvent::CompositeScroll(ScrollAction::Up(1))
124            }
125
126            // Pane switching
127            (KeyModifiers::NONE, KeyCode::Tab) => RoutedEvent::SwitchPane(Direction::Next),
128            (KeyModifiers::SHIFT, KeyCode::BackTab) => RoutedEvent::SwitchPane(Direction::Prev),
129
130            // Hunk navigation (n/p/]/[) and close (q/Esc) are handled by the
131            // Action system via CompositeBuffer context keybindings, making
132            // them rebindable through the keybinding editor.
133            _ => RoutedEvent::Unhandled,
134        }
135    }
136
137    /// Convert display coordinates to source buffer coordinates
138    pub fn display_to_source(
139        composite: &CompositeBuffer,
140        _view_state: &CompositeViewState,
141        display_row: usize,
142        display_col: usize,
143        pane_index: usize,
144    ) -> Option<SourceCoordinate> {
145        let aligned_row = composite.alignment.get_row(display_row)?;
146        let source_ref = aligned_row.get_pane_line(pane_index)?;
147
148        Some(SourceCoordinate {
149            buffer_id: composite.sources.get(pane_index)?.buffer_id,
150            byte_offset: source_ref.byte_range.start + display_col,
151            line: source_ref.line,
152            column: display_col,
153        })
154    }
155
156    /// Determine which pane a click occurred in
157    pub fn click_to_pane(
158        view_state: &CompositeViewState,
159        click_x: u16,
160        area_x: u16,
161    ) -> Option<usize> {
162        let mut x = area_x;
163        for (i, &width) in view_state.pane_widths.iter().enumerate() {
164            if click_x >= x && click_x < x + width {
165                return Some(i);
166            }
167            x += width + 1; // +1 for separator
168        }
169        None
170    }
171
172    /// Navigate to the next or previous hunk
173    pub fn navigate_to_hunk(
174        composite: &CompositeBuffer,
175        view_state: &mut CompositeViewState,
176        direction: Direction,
177    ) -> bool {
178        let current_row = view_state.scroll_row;
179        let new_row = match direction {
180            Direction::Next => composite.alignment.next_hunk_row(current_row),
181            Direction::Prev => composite.alignment.prev_hunk_row(current_row),
182        };
183
184        if let Some(row) = new_row {
185            view_state.scroll_row = row;
186            true
187        } else {
188            false
189        }
190    }
191}
192
193/// Coordinates within a source buffer
194#[derive(Debug, Clone)]
195pub struct SourceCoordinate {
196    pub buffer_id: BufferId,
197    pub byte_offset: usize,
198    pub line: usize,
199    pub column: usize,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::model::composite_buffer::{CompositeLayout, SourcePane};
206
207    fn create_test_composite() -> (CompositeBuffer, CompositeViewState) {
208        let sources = vec![
209            SourcePane::new(BufferId(1), "OLD", false),
210            SourcePane::new(BufferId(2), "NEW", true),
211        ];
212        let composite = CompositeBuffer::new(
213            BufferId(0),
214            "Test Diff".to_string(),
215            "diff-view".to_string(),
216            CompositeLayout::default(),
217            sources,
218        );
219        let view_state = CompositeViewState::new(BufferId(0), 2);
220        (composite, view_state)
221    }
222
223    #[test]
224    fn test_scroll_routing() {
225        let (composite, view_state) = create_test_composite();
226
227        let event = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
228        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
229
230        matches!(result, RoutedEvent::CompositeScroll(ScrollAction::Down(1)));
231    }
232
233    #[test]
234    fn test_pane_switch_routing() {
235        let (composite, view_state) = create_test_composite();
236
237        let event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
238        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
239
240        matches!(result, RoutedEvent::SwitchPane(Direction::Next));
241    }
242
243    #[test]
244    fn test_readonly_blocking() {
245        let (composite, view_state) = create_test_composite();
246        // Focused pane is 0 (OLD), which is read-only
247
248        let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
249        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
250
251        matches!(result, RoutedEvent::Blocked(_));
252    }
253
254    #[test]
255    fn test_editable_routing() {
256        let (composite, mut view_state) = create_test_composite();
257        view_state.focused_pane = 1; // NEW pane is editable
258
259        let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
260        let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event);
261
262        matches!(
263            result,
264            RoutedEvent::ToSourceBuffer {
265                buffer_id: BufferId(2),
266                action: BufferAction::Insert('x'),
267            }
268        );
269    }
270}