Skip to main content

fresh/app/
composite_buffer_actions.rs

1//! Composite buffer management actions
2//!
3//! This module handles creating, managing, and closing composite buffers
4//! which display multiple source buffers in a single tab.
5//!
6//! ## Cursor and Selection Handling
7//!
8//! Composite buffers re-implement cursor movement and selection rather than routing
9//! to the underlying source buffers. This is a deliberate trade-off because:
10//!
11//! - Composite buffers use a display-row coordinate system with alignment rows that
12//!   may not have 1:1 mapping to source lines (e.g., padding rows for deleted lines)
13//! - The cursor position is shared across all panes but each pane may have different
14//!   content at the same display row
15//! - Horizontal scroll must sync across panes for side-by-side comparison
16//!
17//! Routing to underlying buffers was considered but would require complex coordinate
18//! translation and wouldn't handle padding rows or synced scrolling naturally.
19
20use crate::app::types::BufferMetadata;
21use crate::app::Editor;
22use crate::model::composite_buffer::{CompositeBuffer, CompositeLayout, LineAlignment, SourcePane};
23use crate::model::event::{BufferId, LeafId};
24use crate::view::composite_view::CompositeViewState;
25use anyhow::Result as AnyhowResult;
26use unicode_segmentation::UnicodeSegmentation;
27
28/// Information about the current cursor line needed for movement operations
29struct CursorLineInfo {
30    content: String,
31    length: usize,
32    pane_width: usize,
33}
34
35/// Direction for cursor movement
36#[derive(Clone, Copy)]
37enum CursorMovement {
38    Up,
39    Down,
40    Left,
41    Right,
42    LineStart,
43    LineEnd,
44    WordLeft,
45    WordRight,
46    WordEnd, // Move to end of current word
47}
48
49/// Find the previous word boundary position in a line
50fn find_word_boundary_left(line: &str, from_column: usize) -> usize {
51    let graphemes: Vec<&str> = line.graphemes(true).collect();
52    let mut pos = from_column;
53    // Skip spaces going left
54    while pos > 0
55        && graphemes
56            .get(pos.saturating_sub(1))
57            .is_some_and(|g| g.chars().all(|c| c.is_whitespace()))
58    {
59        pos -= 1;
60    }
61    // Skip word chars going left
62    while pos > 0
63        && graphemes
64            .get(pos.saturating_sub(1))
65            .is_some_and(|g| !g.chars().all(|c| c.is_whitespace()))
66    {
67        pos -= 1;
68    }
69    pos
70}
71
72/// Find the next word boundary position in a line
73fn find_word_boundary_right(line: &str, from_column: usize, line_length: usize) -> usize {
74    let graphemes: Vec<&str> = line.graphemes(true).collect();
75    let mut pos = from_column;
76    // Skip word chars going right
77    while pos < graphemes.len() && !graphemes[pos].chars().all(|c| c.is_whitespace()) {
78        pos += 1;
79    }
80    // Skip spaces going right
81    while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
82        pos += 1;
83    }
84    pos.min(line_length)
85}
86
87/// Find the end of the current word (or end of next word if on whitespace)
88fn find_word_end_right(line: &str, from_column: usize, line_length: usize) -> usize {
89    let graphemes: Vec<&str> = line.graphemes(true).collect();
90    let mut pos = from_column;
91
92    // If on whitespace, skip it first
93    while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
94        pos += 1;
95    }
96
97    // If on punctuation, consume it
98    if pos < graphemes.len()
99        && !graphemes[pos]
100            .chars()
101            .any(|c| c.is_alphanumeric() || c == '_')
102        && !graphemes[pos].chars().all(|c| c.is_whitespace())
103    {
104        while pos < graphemes.len()
105            && !graphemes[pos]
106                .chars()
107                .any(|c| c.is_alphanumeric() || c == '_')
108            && !graphemes[pos].chars().all(|c| c.is_whitespace())
109        {
110            pos += 1;
111        }
112    } else {
113        // Skip word chars (alphanumeric + underscore)
114        while pos < graphemes.len()
115            && graphemes[pos]
116                .chars()
117                .any(|c| c.is_alphanumeric() || c == '_')
118        {
119            pos += 1;
120        }
121    }
122
123    pos.min(line_length)
124}
125
126impl crate::app::window::Window {
127    /// Check if a buffer is a composite buffer
128    pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool {
129        self.composite_buffers.contains_key(&buffer_id)
130    }
131
132    /// Get a composite buffer by ID
133    pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> {
134        self.composite_buffers.get(&buffer_id)
135    }
136
137    /// Get a mutable composite buffer by ID
138    pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> {
139        self.composite_buffers.get_mut(&buffer_id)
140    }
141
142    /// Set the line alignment for a composite buffer
143    pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
144        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
145            composite.set_alignment(alignment);
146        }
147    }
148
149    /// Close a composite buffer and clean up associated state
150    pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
151        self.composite_buffers.remove(&buffer_id);
152        self.buffer_metadata.remove(&buffer_id);
153        self.composite_view_states
154            .retain(|(_, bid), _| *bid != buffer_id);
155    }
156
157    /// Switch focus to the next pane in a composite buffer
158    pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
159        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
160            composite.focus_next();
161        }
162        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
163            view_state.focus_next_pane();
164        }
165    }
166
167    /// Switch focus to the previous pane in a composite buffer
168    pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
169        if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
170            composite.focus_prev();
171        }
172        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
173            view_state.focus_prev_pane();
174        }
175    }
176
177    /// Effective viewport height for composite-buffer scrolling on
178    /// `split_id`. Subtracts the one-row composite header from the
179    /// raw split viewport. Falls back to a 24-row default when the
180    /// split doesn't yet have a viewport (pre-first-render).
181    fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
182        const COMPOSITE_HEADER_HEIGHT: u16 = 1;
183        const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
184
185        self.buffers
186            .splits()
187            .map(|(_, vs)| vs)
188            .expect("window must have a populated split layout")
189            .get(&split_id)
190            .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
191            .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
192    }
193
194    /// Mirror the composite view's cursor row/column back onto the
195    /// underlying buffer's `EditorState` and the active split's view
196    /// state. Called from hunk-navigation handlers so the status-bar
197    /// `Ln/Col` reflects the new alignment row instead of stale
198    /// pre-jump data.
199    fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
200        let (cursor_row, cursor_column, focused_pane) = self
201            .composite_view_states
202            .get(&(split_id, buffer_id))
203            .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
204            .unwrap_or((0, 0, 0));
205
206        let display_line = self
207            .composite_buffers
208            .get(&buffer_id)
209            .and_then(|composite| composite.alignment.get_row(cursor_row))
210            .and_then(|row| row.get_pane_line(focused_pane))
211            .map(|line_ref| line_ref.line)
212            .unwrap_or(cursor_row);
213
214        if let Some(state) = self.buffers.get_mut(&buffer_id) {
215            state.primary_cursor_line_number =
216                crate::model::buffer::LineNumber::Absolute(display_line);
217        }
218
219        let active_split = self
220            .buffers
221            .splits()
222            .map(|(mgr, _)| mgr)
223            .expect("window must have a populated split layout")
224            .active_split();
225        if let Some((_, vs_map)) = self.buffers.splits_mut() {
226            if let Some(view_state) = vs_map.get_mut(&active_split) {
227                view_state.cursors.primary_mut().position = cursor_column;
228            }
229        }
230    }
231
232    /// Navigate to the next hunk (composite-buffer diff view) on
233    /// `split_id`. Centers the new hunk header roughly a third of the
234    /// way down the viewport and syncs the editor cursor so the status
235    /// bar's Ln/Col follows. Returns `true` iff a next hunk existed.
236    pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
237        let viewport_height = self.get_composite_viewport_height(split_id);
238        let moved = if let (Some(composite), Some(view_state)) = (
239            self.composite_buffers.get(&buffer_id),
240            self.composite_view_states.get_mut(&(split_id, buffer_id)),
241        ) {
242            if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
243                view_state.cursor_row = next_row;
244                let context_above = viewport_height / 3;
245                view_state.scroll_row = next_row.saturating_sub(context_above);
246                true
247            } else {
248                false
249            }
250        } else {
251            false
252        };
253        if moved {
254            self.sync_editor_cursor_from_composite(split_id, buffer_id);
255        }
256        moved
257    }
258
259    /// Navigate to the previous hunk in a composite-buffer diff view.
260    /// See `composite_next_hunk` for behaviour.
261    pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
262        let viewport_height = self.get_composite_viewport_height(split_id);
263        let moved = if let (Some(composite), Some(view_state)) = (
264            self.composite_buffers.get(&buffer_id),
265            self.composite_view_states.get_mut(&(split_id, buffer_id)),
266        ) {
267            if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
268                view_state.cursor_row = prev_row;
269                let context_above = viewport_height / 3;
270                view_state.scroll_row = prev_row.saturating_sub(context_above);
271                true
272            } else {
273                false
274            }
275        } else {
276            false
277        };
278        if moved {
279            self.sync_editor_cursor_from_composite(split_id, buffer_id);
280        }
281        moved
282    }
283
284    /// Hunk navigation entry point that resolves `split_id` from this
285    /// window's active split. Used by keybinding handlers that don't
286    /// carry a split id.
287    pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
288        let split_id = self
289            .buffers
290            .splits()
291            .map(|(mgr, _)| mgr)
292            .expect("window must have a populated split layout")
293            .active_split();
294        self.composite_next_hunk(split_id, buffer_id)
295    }
296
297    /// `composite_prev_hunk` flavour for the active split.
298    pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
299        let split_id = self
300            .buffers
301            .splits()
302            .map(|(mgr, _)| mgr)
303            .expect("window must have a populated split layout")
304            .active_split();
305        self.composite_prev_hunk(split_id, buffer_id)
306    }
307
308    /// Scroll a composite-buffer view by `delta` rows, clamped to the
309    /// composite's row count. No-op if the buffer or view state is
310    /// missing.
311    pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
312        if let (Some(composite), Some(view_state)) = (
313            self.composite_buffers.get(&buffer_id),
314            self.composite_view_states.get_mut(&(split_id, buffer_id)),
315        ) {
316            let max_row = composite.row_count().saturating_sub(1);
317            view_state.scroll(delta, max_row);
318        }
319    }
320
321    /// Scroll a composite-buffer view to absolute row `row`, clamped.
322    pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
323        if let (Some(composite), Some(view_state)) = (
324            self.composite_buffers.get(&buffer_id),
325            self.composite_view_states.get_mut(&(split_id, buffer_id)),
326        ) {
327            let max_row = composite.row_count().saturating_sub(1);
328            view_state.set_scroll_row(row, max_row);
329        }
330    }
331}
332
333impl Editor {
334    // =========================================================================
335    // Layout Flush (synchronous state materialization)
336    // =========================================================================
337
338    /// Force-materialize render-dependent state for all visible splits.
339    ///
340    /// This is the editor's equivalent of iOS `layoutIfNeeded()` or browser
341    /// forced reflow. It ensures that `CompositeViewState` entries exist for
342    /// any visible composite buffer, using the split's current viewport
343    /// dimensions. After calling this, commands like `compositeNextHunk` can
344    /// safely read and modify view state that would otherwise only exist after
345    /// the next render cycle.
346    pub fn flush_layout(&mut self) {
347        use crate::view::composite_view::CompositeViewState;
348
349        let visible = self
350            .windows
351            .get(&self.active_window)
352            .and_then(|w| w.buffers.splits())
353            .map(|(mgr, _)| mgr)
354            .expect("active window must have a populated split layout")
355            .get_visible_buffers(ratatui::layout::Rect {
356                x: 0,
357                y: 0,
358                width: self.terminal_width,
359                height: self.terminal_height,
360            });
361
362        for (split_id, buffer_id, _area) in &visible {
363            // Only process composite buffers
364            if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
365                let pane_count = composite.pane_count();
366                self.active_window_mut()
367                    .composite_view_states
368                    .entry((*split_id, *buffer_id))
369                    .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
370            }
371        }
372    }
373
374    // =========================================================================
375    // Composite Buffer Methods
376    // =========================================================================
377    //
378    // The simple read/write helpers (`is_composite_buffer`, `get_composite`,
379    // `get_composite_mut`, `set_composite_alignment`, `close_composite_buffer`,
380    // `composite_focus_next`, `composite_focus_prev`) live on `impl Window`
381    // above — call them via `self.active_window().X` /
382    // `self.active_window_mut().X` from `impl Editor`. The remaining
383    // methods below stay on `impl Editor` because they read editor-global
384    // state (`terminal_width`/`height`, plugin manager, status messages)
385    // alongside their window-scoped work.
386
387    /// Get or create composite view state for a split
388    pub fn get_composite_view_state(
389        &mut self,
390        split_id: LeafId,
391        buffer_id: BufferId,
392    ) -> Option<&mut CompositeViewState> {
393        if !self
394            .active_window()
395            .composite_buffers
396            .contains_key(&buffer_id)
397        {
398            return None;
399        }
400
401        let pane_count = self
402            .active_window()
403            .composite_buffers
404            .get(&buffer_id)?
405            .pane_count();
406
407        Some(
408            self.active_window_mut()
409                .composite_view_states
410                .entry((split_id, buffer_id))
411                .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
412        )
413    }
414
415    /// Create a new composite buffer
416    ///
417    /// # Arguments
418    /// * `name` - Display name for the composite buffer (shown in tab)
419    /// * `mode` - Mode for keybindings (e.g., "diff-view")
420    /// * `layout` - How panes are arranged (side-by-side, stacked, unified)
421    /// * `sources` - Source panes to display
422    ///
423    /// # Returns
424    /// The ID of the newly created composite buffer
425    pub fn create_composite_buffer(
426        &mut self,
427        name: String,
428        mode: String,
429        layout: CompositeLayout,
430        sources: Vec<SourcePane>,
431    ) -> BufferId {
432        let buffer_id = self.alloc_buffer_id();
433
434        let composite =
435            CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
436        self.active_window_mut()
437            .composite_buffers
438            .insert(buffer_id, composite);
439
440        // Add metadata for display
441        // Note: We use virtual_buffer() but override hidden_from_tabs since composite buffers
442        // should be visible in tabs (unlike their hidden source panes)
443        let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
444        metadata.hidden_from_tabs = false;
445        self.active_window_mut()
446            .buffer_metadata
447            .insert(buffer_id, metadata);
448
449        // Create an EditorState entry so the buffer can be shown in tabs and via showBuffer()
450        // The actual content rendering is handled by the composite renderer
451        let mut state = crate::state::EditorState::new(
452            80,
453            24,
454            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
455            std::sync::Arc::clone(&self.authority.filesystem),
456        );
457        state.is_composite_buffer = true;
458        state.editing_disabled = true;
459        state.mode = mode;
460        self.windows
461            .get_mut(&self.active_window)
462            .map(|w| &mut w.buffers)
463            .expect("active window present")
464            .insert(buffer_id, state);
465        // Create an event log entry (required for many editor operations)
466        self.active_window_mut()
467            .event_logs
468            .insert(buffer_id, crate::model::event::EventLog::new());
469
470        // Register with the active split so it appears in tabs
471        let split_id = self
472            .windows
473            .get(&self.active_window)
474            .and_then(|w| w.buffers.splits())
475            .map(|(mgr, _)| mgr)
476            .expect("active window must have a populated split layout")
477            .active_split();
478        if let Some(view_state) = self
479            .windows
480            .get_mut(&self.active_window)
481            .and_then(|w| w.split_view_states_mut())
482            .expect("active window must have a populated split layout")
483            .get_mut(&split_id)
484        {
485            view_state.add_buffer(buffer_id);
486        }
487
488        buffer_id
489    }
490
491    // `set_composite_alignment`, `close_composite_buffer`,
492    // `composite_focus_next`, `composite_focus_prev` moved to
493    // `impl Window` above. Editor callers reach them via
494    // `self.active_window_mut().X(...)`.
495
496    // =========================================================================
497    // Action Handling for Composite Buffers
498    // =========================================================================
499
500    /// Get information about the line at the cursor position
501    fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
502        let composite = self.active_window().composite_buffers.get(&buffer_id);
503        let view_state = self
504            .active_window()
505            .composite_view_states
506            .get(&(split_id, buffer_id));
507
508        if let (Some(composite), Some(view_state)) = (composite, view_state) {
509            let pane_line = composite
510                .alignment
511                .get_row(view_state.cursor_row)
512                .and_then(|row| row.get_pane_line(view_state.focused_pane));
513
514            tracing::debug!(
515                "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
516                view_state.cursor_row,
517                view_state.focused_pane,
518                pane_line
519            );
520
521            let line_bytes = pane_line.and_then(|line_ref| {
522                let source = composite.sources.get(view_state.focused_pane)?;
523                self.windows
524                    .get(&self.active_window)
525                    .map(|w| &w.buffers)
526                    .expect("active window present")
527                    .get(&source.buffer_id)?
528                    .buffer
529                    .get_line(line_ref.line)
530            });
531
532            let content = line_bytes
533                .as_ref()
534                .map(|b| {
535                    let s = String::from_utf8_lossy(b).to_string();
536                    // Strip trailing newline - cursor shouldn't go past end of visible content
537                    s.trim_end_matches('\n').trim_end_matches('\r').to_string()
538                })
539                .unwrap_or_default();
540            let length = content.graphemes(true).count();
541            let pane_width = view_state
542                .pane_widths
543                .get(view_state.focused_pane)
544                .copied()
545                .unwrap_or(40) as usize;
546
547            CursorLineInfo {
548                content,
549                length,
550                pane_width,
551            }
552        } else {
553            CursorLineInfo {
554                content: String::new(),
555                length: 0,
556                pane_width: 40,
557            }
558        }
559    }
560
561    /// Apply a cursor movement to a composite view state
562    fn apply_cursor_movement(
563        &mut self,
564        split_id: LeafId,
565        buffer_id: BufferId,
566        movement: CursorMovement,
567        line_info: &CursorLineInfo,
568        viewport_height: usize,
569    ) {
570        let max_row = self
571            .active_window_mut()
572            .composite_buffers
573            .get(&buffer_id)
574            .map(|c| c.row_count().saturating_sub(1))
575            .unwrap_or(0);
576
577        let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
578        let mut wrapped_to_new_line = false;
579
580        // Get alignment reference for wrap checks
581        let win = self.active_window_mut();
582        let composite = win.composite_buffers.get(&buffer_id);
583
584        if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
585            match movement {
586                CursorMovement::Down => {
587                    view_state.move_cursor_down(max_row, viewport_height);
588                }
589                CursorMovement::Up => {
590                    view_state.move_cursor_up(viewport_height);
591                }
592                CursorMovement::Left => {
593                    if view_state.cursor_column > 0 {
594                        view_state.move_cursor_left();
595                    } else if view_state.cursor_row > 0 {
596                        // Try to wrap to end of previous line - find a row with content
597                        if let Some(composite) = composite {
598                            let focused_pane = view_state.focused_pane;
599                            let mut target_row = view_state.cursor_row - 1;
600                            while target_row > 0 {
601                                if let Some(row) = composite.alignment.get_row(target_row) {
602                                    if row.get_pane_line(focused_pane).is_some() {
603                                        break;
604                                    }
605                                }
606                                target_row -= 1;
607                            }
608                            // Only wrap if target row has content
609                            if let Some(row) = composite.alignment.get_row(target_row) {
610                                if row.get_pane_line(focused_pane).is_some() {
611                                    view_state.cursor_row = target_row;
612                                    if view_state.cursor_row < view_state.scroll_row {
613                                        view_state.scroll_row = view_state.cursor_row;
614                                    }
615                                    wrapped_to_new_line = true;
616                                }
617                            }
618                        }
619                    }
620                }
621                CursorMovement::Right => {
622                    if view_state.cursor_column < line_info.length {
623                        view_state.move_cursor_right(line_info.length, line_info.pane_width);
624                    } else if view_state.cursor_row < max_row {
625                        // Try to wrap to start of next line - find a row with content
626                        if let Some(composite) = composite {
627                            let focused_pane = view_state.focused_pane;
628                            let mut target_row = view_state.cursor_row + 1;
629                            while target_row < max_row {
630                                if let Some(row) = composite.alignment.get_row(target_row) {
631                                    if row.get_pane_line(focused_pane).is_some() {
632                                        break;
633                                    }
634                                }
635                                target_row += 1;
636                            }
637                            // Only wrap if target row has content
638                            if let Some(row) = composite.alignment.get_row(target_row) {
639                                if row.get_pane_line(focused_pane).is_some() {
640                                    view_state.cursor_row = target_row;
641                                    view_state.cursor_column = 0;
642                                    view_state.sticky_column = 0;
643                                    if view_state.cursor_row
644                                        >= view_state.scroll_row + viewport_height
645                                    {
646                                        view_state.scroll_row = view_state
647                                            .cursor_row
648                                            .saturating_sub(viewport_height - 1);
649                                    }
650                                    // Reset horizontal scroll for ALL panes
651                                    for viewport in &mut view_state.pane_viewports {
652                                        viewport.left_column = 0;
653                                    }
654                                }
655                            }
656                        }
657                    }
658                }
659                CursorMovement::LineStart => {
660                    view_state.move_cursor_to_line_start();
661                }
662                CursorMovement::LineEnd => {
663                    view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
664                }
665                CursorMovement::WordLeft => {
666                    let new_col =
667                        find_word_boundary_left(&line_info.content, view_state.cursor_column);
668                    if new_col < view_state.cursor_column {
669                        view_state.cursor_column = new_col;
670                        view_state.sticky_column = new_col;
671                        // Update horizontal scroll for ALL panes to keep cursor visible
672                        let current_left = view_state
673                            .pane_viewports
674                            .get(view_state.focused_pane)
675                            .map(|v| v.left_column)
676                            .unwrap_or(0);
677                        if view_state.cursor_column < current_left {
678                            for viewport in &mut view_state.pane_viewports {
679                                viewport.left_column = view_state.cursor_column;
680                            }
681                        }
682                    } else if view_state.cursor_row > 0 {
683                        // At start of line, wrap to end of previous line - find a row with content
684                        if let Some(composite) = composite {
685                            let focused_pane = view_state.focused_pane;
686                            let mut target_row = view_state.cursor_row - 1;
687                            while target_row > 0 {
688                                if let Some(row) = composite.alignment.get_row(target_row) {
689                                    if row.get_pane_line(focused_pane).is_some() {
690                                        break;
691                                    }
692                                }
693                                target_row -= 1;
694                            }
695                            // Only wrap if target row has content
696                            if let Some(row) = composite.alignment.get_row(target_row) {
697                                if row.get_pane_line(focused_pane).is_some() {
698                                    view_state.cursor_row = target_row;
699                                    if view_state.cursor_row < view_state.scroll_row {
700                                        view_state.scroll_row = view_state.cursor_row;
701                                    }
702                                    wrapped_to_new_line = true;
703                                }
704                            }
705                        }
706                    }
707                }
708                CursorMovement::WordRight => {
709                    let new_col = find_word_boundary_right(
710                        &line_info.content,
711                        view_state.cursor_column,
712                        line_info.length,
713                    );
714                    if new_col > view_state.cursor_column {
715                        view_state.cursor_column = new_col;
716                        view_state.sticky_column = new_col;
717                        // Update horizontal scroll for ALL panes to keep cursor visible
718                        let visible_width = line_info.pane_width.saturating_sub(4);
719                        let current_left = view_state
720                            .pane_viewports
721                            .get(view_state.focused_pane)
722                            .map(|v| v.left_column)
723                            .unwrap_or(0);
724                        if visible_width > 0
725                            && view_state.cursor_column >= current_left + visible_width
726                        {
727                            let new_left = view_state
728                                .cursor_column
729                                .saturating_sub(visible_width.saturating_sub(1));
730                            for viewport in &mut view_state.pane_viewports {
731                                viewport.left_column = new_left;
732                            }
733                        }
734                    } else if view_state.cursor_row < max_row {
735                        // At end of line, wrap to start of next line - find a row with content
736                        if let Some(composite) = composite {
737                            let focused_pane = view_state.focused_pane;
738                            let mut target_row = view_state.cursor_row + 1;
739                            while target_row < max_row {
740                                if let Some(row) = composite.alignment.get_row(target_row) {
741                                    if row.get_pane_line(focused_pane).is_some() {
742                                        break;
743                                    }
744                                }
745                                target_row += 1;
746                            }
747                            // Only wrap if target row has content
748                            if let Some(row) = composite.alignment.get_row(target_row) {
749                                if row.get_pane_line(focused_pane).is_some() {
750                                    view_state.cursor_row = target_row;
751                                    view_state.cursor_column = 0;
752                                    view_state.sticky_column = 0;
753                                    if view_state.cursor_row
754                                        >= view_state.scroll_row + viewport_height
755                                    {
756                                        view_state.scroll_row = view_state
757                                            .cursor_row
758                                            .saturating_sub(viewport_height - 1);
759                                    }
760                                    // Reset horizontal scroll for ALL panes
761                                    for viewport in &mut view_state.pane_viewports {
762                                        viewport.left_column = 0;
763                                    }
764                                }
765                            }
766                        }
767                    }
768                }
769                CursorMovement::WordEnd => {
770                    let new_col = find_word_end_right(
771                        &line_info.content,
772                        view_state.cursor_column,
773                        line_info.length,
774                    );
775                    if new_col > view_state.cursor_column {
776                        view_state.cursor_column = new_col;
777                        view_state.sticky_column = new_col;
778                        // Update horizontal scroll for ALL panes to keep cursor visible
779                        let visible_width = line_info.pane_width.saturating_sub(4);
780                        let current_left = view_state
781                            .pane_viewports
782                            .get(view_state.focused_pane)
783                            .map(|v| v.left_column)
784                            .unwrap_or(0);
785                        if visible_width > 0
786                            && view_state.cursor_column >= current_left + visible_width
787                        {
788                            let new_left = view_state
789                                .cursor_column
790                                .saturating_sub(visible_width.saturating_sub(1));
791                            for viewport in &mut view_state.pane_viewports {
792                                viewport.left_column = new_left;
793                            }
794                        }
795                    } else if view_state.cursor_row < max_row {
796                        // At end of line, wrap to start of next line - find a row with content
797                        if let Some(composite) = composite {
798                            let focused_pane = view_state.focused_pane;
799                            let mut target_row = view_state.cursor_row + 1;
800                            while target_row < max_row {
801                                if let Some(row) = composite.alignment.get_row(target_row) {
802                                    if row.get_pane_line(focused_pane).is_some() {
803                                        break;
804                                    }
805                                }
806                                target_row += 1;
807                            }
808                            // Only wrap if target row has content
809                            if let Some(row) = composite.alignment.get_row(target_row) {
810                                if row.get_pane_line(focused_pane).is_some() {
811                                    view_state.cursor_row = target_row;
812                                    view_state.cursor_column = 0;
813                                    view_state.sticky_column = 0;
814                                    if view_state.cursor_row
815                                        >= view_state.scroll_row + viewport_height
816                                    {
817                                        view_state.scroll_row = view_state
818                                            .cursor_row
819                                            .saturating_sub(viewport_height - 1);
820                                    }
821                                    // Reset horizontal scroll for ALL panes
822                                    for viewport in &mut view_state.pane_viewports {
823                                        viewport.left_column = 0;
824                                    }
825                                }
826                            }
827                        }
828                    }
829                }
830            }
831        }
832
833        // For vertical movement or line wrap, get line info for the NEW row and clamp/set cursor column
834        if is_vertical || wrapped_to_new_line {
835            let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
836            if let Some(view_state) = self
837                .active_window_mut()
838                .composite_view_states
839                .get_mut(&(split_id, buffer_id))
840            {
841                if wrapped_to_new_line
842                    && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
843                {
844                    // Wrapping left goes to end of previous line
845                    tracing::debug!(
846                        "Wrap left to row {}, setting column to line length {}",
847                        view_state.cursor_row,
848                        new_line_info.length
849                    );
850                    view_state.cursor_column = new_line_info.length;
851                    view_state.sticky_column = new_line_info.length;
852                    // Scroll ALL panes horizontally to show cursor at end of line
853                    let visible_width = new_line_info.pane_width.saturating_sub(4);
854                    if visible_width > 0 && view_state.cursor_column >= visible_width {
855                        let new_left = view_state
856                            .cursor_column
857                            .saturating_sub(visible_width.saturating_sub(1));
858                        for viewport in &mut view_state.pane_viewports {
859                            viewport.left_column = new_left;
860                        }
861                    }
862                } else {
863                    view_state.clamp_cursor_to_line(new_line_info.length);
864                }
865            }
866        }
867    }
868
869    /// Handle cursor movement actions (both Move and Select variants)
870    fn handle_cursor_movement_action(
871        &mut self,
872        split_id: LeafId,
873        buffer_id: BufferId,
874        movement: CursorMovement,
875        extend_selection: bool,
876    ) -> Option<bool> {
877        let viewport_height = self.active_window().get_composite_viewport_height(split_id);
878
879        let line_info = self.get_cursor_line_info(split_id, buffer_id);
880
881        if extend_selection {
882            // Start visual selection if extending and not already in visual mode
883            if let Some(view_state) = self
884                .active_window_mut()
885                .composite_view_states
886                .get_mut(&(split_id, buffer_id))
887            {
888                if !view_state.visual_mode {
889                    view_state.start_visual_selection();
890                }
891            }
892        } else {
893            // Clear selection when moving without shift
894            if let Some(view_state) = self
895                .active_window_mut()
896                .composite_view_states
897                .get_mut(&(split_id, buffer_id))
898            {
899                if view_state.visual_mode {
900                    view_state.clear_selection();
901                }
902            }
903        }
904
905        self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
906        self.active_window_mut()
907            .sync_editor_cursor_from_composite(split_id, buffer_id);
908
909        Some(true)
910    }
911
912    /// Handle an action for a composite buffer.
913    ///
914    /// For navigation and selection actions, this forwards to the focused source buffer
915    /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through
916    /// to normal buffer handling.
917    pub fn handle_composite_action(
918        &mut self,
919        buffer_id: BufferId,
920        action: &crate::input::keybindings::Action,
921    ) -> Option<bool> {
922        use crate::input::keybindings::Action;
923
924        let split_id = self
925            .windows
926            .get(&self.active_window)
927            .and_then(|w| w.buffers.splits())
928            .map(|(mgr, _)| mgr)
929            .expect("active window must have a populated split layout")
930            .active_split();
931
932        // Verify this is a valid composite buffer
933        let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
934        let _view_state = self
935            .active_window()
936            .composite_view_states
937            .get(&(split_id, buffer_id))?;
938
939        match action {
940            // Tab switches between panes
941            Action::InsertTab => {
942                self.active_window_mut()
943                    .composite_focus_next(split_id, buffer_id);
944                Some(true)
945            }
946
947            // Copy from the focused pane
948            Action::Copy => {
949                self.handle_composite_copy(split_id, buffer_id);
950                Some(true)
951            }
952
953            // Cursor movement (without selection)
954            Action::MoveDown => {
955                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
956            }
957            Action::MoveUp => {
958                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
959            }
960            Action::MoveLeft => {
961                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
962            }
963            Action::MoveRight => self.handle_cursor_movement_action(
964                split_id,
965                buffer_id,
966                CursorMovement::Right,
967                false,
968            ),
969            Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
970                split_id,
971                buffer_id,
972                CursorMovement::LineStart,
973                false,
974            ),
975            Action::MoveLineEnd => self.handle_cursor_movement_action(
976                split_id,
977                buffer_id,
978                CursorMovement::LineEnd,
979                false,
980            ),
981            Action::MoveWordLeft => self.handle_cursor_movement_action(
982                split_id,
983                buffer_id,
984                CursorMovement::WordLeft,
985                false,
986            ),
987            Action::MoveWordRight => self.handle_cursor_movement_action(
988                split_id,
989                buffer_id,
990                CursorMovement::WordRight,
991                false,
992            ),
993            Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
994                split_id,
995                buffer_id,
996                CursorMovement::WordEnd,
997                false,
998            ),
999            Action::MoveLeftInLine => {
1000                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1001            }
1002            Action::MoveRightInLine => self.handle_cursor_movement_action(
1003                split_id,
1004                buffer_id,
1005                CursorMovement::Right,
1006                false,
1007            ),
1008
1009            // Cursor movement with selection
1010            Action::SelectDown => {
1011                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1012            }
1013            Action::SelectUp => {
1014                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1015            }
1016            Action::SelectLeft => {
1017                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1018            }
1019            Action::SelectRight => {
1020                self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1021            }
1022            Action::SelectLineStart => self.handle_cursor_movement_action(
1023                split_id,
1024                buffer_id,
1025                CursorMovement::LineStart,
1026                true,
1027            ),
1028            Action::SelectLineEnd => self.handle_cursor_movement_action(
1029                split_id,
1030                buffer_id,
1031                CursorMovement::LineEnd,
1032                true,
1033            ),
1034            Action::SelectWordLeft => self.handle_cursor_movement_action(
1035                split_id,
1036                buffer_id,
1037                CursorMovement::WordLeft,
1038                true,
1039            ),
1040            Action::SelectWordRight => self.handle_cursor_movement_action(
1041                split_id,
1042                buffer_id,
1043                CursorMovement::WordRight,
1044                true,
1045            ),
1046            Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1047                split_id,
1048                buffer_id,
1049                CursorMovement::WordEnd,
1050                true,
1051            ),
1052
1053            // Page navigation
1054            Action::MovePageDown | Action::MovePageUp => {
1055                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1056                let win = self.active_window_mut();
1057                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1058                {
1059                    if matches!(action, Action::MovePageDown) {
1060                        if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1061                            let max_row = composite.row_count().saturating_sub(1);
1062                            view_state.page_down(viewport_height, max_row);
1063                            view_state.cursor_row = view_state.scroll_row;
1064                        }
1065                    } else {
1066                        view_state.page_up(viewport_height);
1067                        view_state.cursor_row = view_state.scroll_row;
1068                    }
1069                }
1070                self.active_window_mut()
1071                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1072                Some(true)
1073            }
1074
1075            // Document start/end
1076            Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1077                let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1078                let win = self.active_window_mut();
1079                if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1080                {
1081                    if matches!(action, Action::MoveDocumentStart) {
1082                        view_state.move_cursor_to_top();
1083                    } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1084                        let max_row = composite.row_count().saturating_sub(1);
1085                        view_state.move_cursor_to_bottom(max_row, viewport_height);
1086                    }
1087                }
1088                self.active_window_mut()
1089                    .sync_editor_cursor_from_composite(split_id, buffer_id);
1090                Some(true)
1091            }
1092
1093            // Scroll without moving cursor
1094            Action::ScrollDown | Action::ScrollUp => {
1095                let delta = if matches!(action, Action::ScrollDown) {
1096                    1
1097                } else {
1098                    -1
1099                };
1100                self.active_window_mut()
1101                    .composite_scroll(split_id, buffer_id, delta);
1102                Some(true)
1103            }
1104
1105            // For other actions, return None to fall through to normal handling
1106            _ => None,
1107        }
1108    }
1109
1110    /// Handle copy action for composite buffer
1111    fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1112        let text = {
1113            let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1114                Some(c) => c,
1115                None => return,
1116            };
1117            let view_state = match self
1118                .active_window()
1119                .composite_view_states
1120                .get(&(split_id, buffer_id))
1121            {
1122                Some(vs) => vs,
1123                None => return,
1124            };
1125
1126            let (start_row, end_row) = match view_state.selection_row_range() {
1127                Some(range) => range,
1128                None => return,
1129            };
1130
1131            let source = match composite.sources.get(view_state.focused_pane) {
1132                Some(s) => s,
1133                None => return,
1134            };
1135
1136            let source_state = match self
1137                .windows
1138                .get(&self.active_window)
1139                .map(|w| &w.buffers)
1140                .expect("active window present")
1141                .get(&source.buffer_id)
1142            {
1143                Some(s) => s,
1144                None => return,
1145            };
1146
1147            // Collect text from selected rows
1148            let mut text = String::new();
1149            for row in start_row..=end_row {
1150                if let Some(aligned_row) = composite.alignment.rows.get(row) {
1151                    if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1152                        if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1153                            if !text.is_empty() {
1154                                text.push('\n');
1155                            }
1156                            // Strip trailing newline from line content to avoid double newlines
1157                            let line_str = String::from_utf8_lossy(&line_bytes);
1158                            let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1159                            text.push_str(line_trimmed);
1160                        }
1161                    }
1162                }
1163            }
1164            text
1165        };
1166
1167        if !text.is_empty() {
1168            self.clipboard.copy(text);
1169        }
1170
1171        // Don't clear selection after copy - user may want to continue working with it
1172    }
1173
1174    // =========================================================================
1175    // Plugin Command Handlers
1176    // =========================================================================
1177
1178    /// Handle the CreateCompositeBuffer plugin command
1179    pub(crate) fn handle_create_composite_buffer(
1180        &mut self,
1181        name: String,
1182        mode: String,
1183        layout_config: fresh_core::api::CompositeLayoutConfig,
1184        source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1185        hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1186        initial_focus_hunk: Option<usize>,
1187        _request_id: Option<u64>,
1188    ) {
1189        use crate::model::composite_buffer::{
1190            CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1191        };
1192
1193        // Convert layout config
1194        let layout = match layout_config.layout_type.as_str() {
1195            "stacked" => CompositeLayout::Stacked {
1196                spacing: layout_config.spacing.unwrap_or(1),
1197            },
1198            "unified" => CompositeLayout::Unified,
1199            _ => CompositeLayout::SideBySide {
1200                ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1201                show_separator: layout_config.show_separator,
1202            },
1203        };
1204
1205        // Convert source configs
1206        let sources: Vec<SourcePane> = source_configs
1207            .into_iter()
1208            .map(|src| {
1209                let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1210                if let Some(style_config) = src.style {
1211                    let gutter_style = match style_config.gutter_style.as_deref() {
1212                        Some("diff-markers") => GutterStyle::DiffMarkers,
1213                        Some("both") => GutterStyle::Both,
1214                        Some("none") => GutterStyle::None,
1215                        _ => GutterStyle::LineNumbers,
1216                    };
1217                    // Convert [u8; 3] arrays to (u8, u8, u8) tuples
1218                    let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1219                    pane.style = PaneStyle {
1220                        add_bg: style_config.add_bg.map(to_tuple),
1221                        remove_bg: style_config.remove_bg.map(to_tuple),
1222                        modify_bg: style_config.modify_bg.map(to_tuple),
1223                        gutter_style,
1224                    };
1225                }
1226                pane
1227            })
1228            .collect();
1229
1230        // Create the composite buffer
1231        let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1232
1233        // Set alignment from hunks if provided
1234        if let Some(hunk_configs) = hunks {
1235            let diff_hunks: Vec<DiffHunk> = hunk_configs
1236                .into_iter()
1237                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1238                .collect();
1239
1240            // Get line counts from source buffers
1241            let old_line_count = self
1242                .buffers()
1243                .get(
1244                    &self
1245                        .active_window()
1246                        .composite_buffers
1247                        .get(&buffer_id)
1248                        .unwrap()
1249                        .sources[0]
1250                        .buffer_id,
1251                )
1252                .and_then(|s| s.buffer.line_count())
1253                .unwrap_or(0);
1254            let new_line_count = self
1255                .buffers()
1256                .get(
1257                    &self
1258                        .active_window()
1259                        .composite_buffers
1260                        .get(&buffer_id)
1261                        .unwrap()
1262                        .sources[1]
1263                        .buffer_id,
1264                )
1265                .and_then(|s| s.buffer.line_count())
1266                .unwrap_or(0);
1267
1268            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1269            self.active_window_mut()
1270                .set_composite_alignment(buffer_id, alignment);
1271        }
1272
1273        // Store initial focus hunk for the first render to apply
1274        if initial_focus_hunk.is_some() {
1275            if let Some(composite) = self
1276                .active_window_mut()
1277                .composite_buffers
1278                .get_mut(&buffer_id)
1279            {
1280                composite.initial_focus_hunk = initial_focus_hunk;
1281            }
1282        }
1283
1284        tracing::info!(
1285            "Created composite buffer '{}' with mode '{}' (id={:?})",
1286            name,
1287            mode,
1288            buffer_id
1289        );
1290
1291        // Resolve callback with buffer_id if request_id is provided
1292        if let Some(req_id) = _request_id {
1293            // Return just the buffer ID as a number (consistent with createVirtualBuffer)
1294            let result = buffer_id.0.to_string();
1295            self.plugin_manager
1296                .read()
1297                .unwrap()
1298                .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1299            tracing::info!(
1300                "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1301                req_id
1302            );
1303        }
1304    }
1305
1306    /// Handle the UpdateCompositeAlignment plugin command
1307    pub(crate) fn handle_update_composite_alignment(
1308        &mut self,
1309        buffer_id: BufferId,
1310        hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1311    ) {
1312        use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1313
1314        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1315            let diff_hunks: Vec<DiffHunk> = hunk_configs
1316                .into_iter()
1317                .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1318                .collect();
1319
1320            // Get line counts from source buffers
1321            let old_line_count = self
1322                .buffers()
1323                .get(&composite.sources[0].buffer_id)
1324                .and_then(|s| s.buffer.line_count())
1325                .unwrap_or(0);
1326            let new_line_count = self
1327                .buffers()
1328                .get(&composite.sources[1].buffer_id)
1329                .and_then(|s| s.buffer.line_count())
1330                .unwrap_or(0);
1331
1332            let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1333            self.active_window_mut()
1334                .set_composite_alignment(buffer_id, alignment);
1335        }
1336    }
1337
1338    /// Handle a mouse click in a composite buffer view
1339    pub(crate) fn handle_composite_click(
1340        &mut self,
1341        col: u16,
1342        row: u16,
1343        split_id: LeafId,
1344        buffer_id: BufferId,
1345        content_rect: ratatui::layout::Rect,
1346    ) -> AnyhowResult<()> {
1347        // Calculate which pane was clicked based on x coordinate
1348        let pane_idx = if let Some(view_state) = self
1349            .active_window()
1350            .composite_view_states
1351            .get(&(split_id, buffer_id))
1352        {
1353            let mut x = content_rect.x;
1354            let mut found_pane = 0;
1355            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1356                if col >= x && col < x + width {
1357                    found_pane = i;
1358                    break;
1359                }
1360                x += width + 1; // +1 for separator
1361            }
1362            found_pane
1363        } else {
1364            0
1365        };
1366
1367        // Calculate the clicked row (relative to scroll position)
1368        // Subtract 1 for the header row ("OLD (HEAD)" / "NEW (Working)")
1369        let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1370
1371        // Calculate column within the pane (accounting for gutter and horizontal scroll)
1372        let (pane_start_x, left_column) = if let Some(view_state) = self
1373            .active_window()
1374            .composite_view_states
1375            .get(&(split_id, buffer_id))
1376        {
1377            let mut x = content_rect.x;
1378            for (i, &width) in view_state.pane_widths.iter().enumerate() {
1379                if i == pane_idx {
1380                    break;
1381                }
1382                x += width + 1;
1383            }
1384            let left_col = view_state
1385                .pane_viewports
1386                .get(pane_idx)
1387                .map(|vp| vp.left_column)
1388                .unwrap_or(0);
1389            (x, left_col)
1390        } else {
1391            (content_rect.x, 0)
1392        };
1393        let gutter_width = 4; // Line number width
1394        let visual_col = col
1395            .saturating_sub(pane_start_x)
1396            .saturating_sub(gutter_width) as usize;
1397        // Convert visual column to actual column by adding horizontal scroll offset
1398        let click_col = left_column + visual_col;
1399
1400        // Get line length to clamp cursor position
1401        let display_row = if let Some(view_state) = self
1402            .active_window()
1403            .composite_view_states
1404            .get(&(split_id, buffer_id))
1405        {
1406            view_state.scroll_row + content_row
1407        } else {
1408            content_row
1409        };
1410
1411        let line_length =
1412            if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1413                composite
1414                    .alignment
1415                    .get_row(display_row)
1416                    .and_then(|row| row.get_pane_line(pane_idx))
1417                    .and_then(|line_ref| {
1418                        let source = composite.sources.get(pane_idx)?;
1419                        self.windows
1420                            .get(&self.active_window)
1421                            .map(|w| &w.buffers)
1422                            .expect("active window present")
1423                            .get(&source.buffer_id)?
1424                            .buffer
1425                            .get_line(line_ref.line)
1426                    })
1427                    .map(|bytes| {
1428                        let s = String::from_utf8_lossy(&bytes);
1429                        // Strip trailing newline - cursor shouldn't go past end of visible content
1430                        let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1431                        trimmed.graphemes(true).count()
1432                    })
1433                    .unwrap_or(0)
1434            } else {
1435                0
1436            };
1437
1438        // Clamp click column to line length
1439        let clamped_col = click_col.min(line_length);
1440
1441        // Update composite buffer's active pane
1442        if let Some(composite) = self
1443            .active_window_mut()
1444            .composite_buffers
1445            .get_mut(&buffer_id)
1446        {
1447            composite.active_pane = pane_idx;
1448        }
1449
1450        // Update composite view state with click position
1451        if let Some(view_state) = self
1452            .active_window_mut()
1453            .composite_view_states
1454            .get_mut(&(split_id, buffer_id))
1455        {
1456            view_state.focused_pane = pane_idx;
1457            view_state.cursor_row = display_row;
1458            view_state.cursor_column = clamped_col;
1459            view_state.sticky_column = clamped_col;
1460
1461            // Clear selection on click (will start fresh selection on drag)
1462            view_state.clear_selection();
1463        }
1464
1465        // Store state for potential text selection drag
1466        self.active_window_mut().mouse_state.dragging_text_selection = false; // Disable regular text selection for composite
1467        self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1468
1469        // Sync cursor position to EditorState for status bar display
1470        self.active_window_mut()
1471            .sync_editor_cursor_from_composite(split_id, buffer_id);
1472
1473        Ok(())
1474    }
1475}