1use 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
28struct CursorLineInfo {
30 content: String,
31 length: usize,
32 pane_width: usize,
33}
34
35#[derive(Clone, Copy)]
37enum CursorMovement {
38 Up,
39 Down,
40 Left,
41 Right,
42 LineStart,
43 LineEnd,
44 WordLeft,
45 WordRight,
46 WordEnd, }
48
49fn 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 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 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
72fn 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 while pos < graphemes.len() && !graphemes[pos].chars().all(|c| c.is_whitespace()) {
78 pos += 1;
79 }
80 while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
82 pos += 1;
83 }
84 pos.min(line_length)
85}
86
87fn 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 while pos < graphemes.len() && graphemes[pos].chars().all(|c| c.is_whitespace()) {
94 pos += 1;
95 }
96
97 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 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 pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool {
129 self.composite_buffers.contains_key(&buffer_id)
130 }
131
132 pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> {
134 self.composite_buffers.get(&buffer_id)
135 }
136
137 pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> {
139 self.composite_buffers.get_mut(&buffer_id)
140 }
141
142 pub fn active_composite_cursor_info(&self) -> Option<(usize, usize, Vec<Option<usize>>)> {
161 let (split_id, buffer_id) = self.effective_active_pair();
162 if !self.is_composite_buffer(buffer_id) {
163 return None;
164 }
165 let composite = self.composite_buffers.get(&buffer_id)?;
166 let view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
167 let pane_count = composite.sources.len();
168 let row_count = composite.alignment.row_count();
169
170 let row_has_content = |r: usize| -> bool {
171 composite
172 .alignment
173 .get_row(r)
174 .map(|row| (0..pane_count).any(|i| row.get_pane_line(i).is_some()))
175 .unwrap_or(false)
176 };
177
178 let start = view_state.cursor_row;
179 let resolved_row = if row_has_content(start) {
180 Some(start)
181 } else {
182 let mut found = None;
183 let mut delta = 1;
184 while delta < row_count {
185 if start + delta < row_count && row_has_content(start + delta) {
186 found = Some(start + delta);
187 break;
188 }
189 if start >= delta && row_has_content(start - delta) {
190 found = Some(start - delta);
191 break;
192 }
193 delta += 1;
194 }
195 found
196 };
197
198 let row = resolved_row.and_then(|r| composite.alignment.get_row(r));
199 let lines: Vec<Option<usize>> = (0..pane_count)
200 .map(|i| {
201 row.and_then(|r| r.get_pane_line(i))
202 .map(|line_ref| line_ref.line)
203 })
204 .collect();
205 Some((view_state.focused_pane, pane_count, lines))
206 }
207
208 pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
210 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
211 composite.set_alignment(alignment);
212 }
213 }
214
215 pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
217 self.composite_buffers.remove(&buffer_id);
218 self.buffer_metadata.remove(&buffer_id);
219 self.composite_view_states
220 .retain(|(_, bid), _| *bid != buffer_id);
221 }
222
223 pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
225 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
226 composite.focus_next();
227 }
228 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
229 view_state.focus_next_pane();
230 }
231 }
232
233 pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
235 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
236 composite.focus_prev();
237 }
238 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
239 view_state.focus_prev_pane();
240 }
241 }
242
243 fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
248 const COMPOSITE_HEADER_HEIGHT: u16 = 1;
249 const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
250
251 self.buffers
252 .splits()
253 .map(|(_, vs)| vs)
254 .expect("window must have a populated split layout")
255 .get(&split_id)
256 .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
257 .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
258 }
259
260 fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
266 let (cursor_row, cursor_column, focused_pane) = self
267 .composite_view_states
268 .get(&(split_id, buffer_id))
269 .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
270 .unwrap_or((0, 0, 0));
271
272 let display_line = self
273 .composite_buffers
274 .get(&buffer_id)
275 .and_then(|composite| composite.alignment.get_row(cursor_row))
276 .and_then(|row| row.get_pane_line(focused_pane))
277 .map(|line_ref| line_ref.line)
278 .unwrap_or(cursor_row);
279
280 if let Some(state) = self.buffers.get_mut(&buffer_id) {
281 state.primary_cursor_line_number =
282 crate::model::buffer::LineNumber::Absolute(display_line);
283 }
284
285 if let Some((_, vs_map)) = self.buffers.splits_mut() {
290 if let Some(view_state) = vs_map.get_mut(&split_id) {
291 view_state.cursors.primary_mut().position = cursor_column;
292 }
293 }
294 }
295
296 pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
301 let viewport_height = self.get_composite_viewport_height(split_id);
302 let moved = if let (Some(composite), Some(view_state)) = (
303 self.composite_buffers.get(&buffer_id),
304 self.composite_view_states.get_mut(&(split_id, buffer_id)),
305 ) {
306 if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
307 view_state.cursor_row = next_row;
308 let context_above = viewport_height / 3;
309 view_state.scroll_row = next_row.saturating_sub(context_above);
310 true
311 } else {
312 false
313 }
314 } else {
315 false
316 };
317 if moved {
318 self.sync_editor_cursor_from_composite(split_id, buffer_id);
319 }
320 moved
321 }
322
323 pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
326 let viewport_height = self.get_composite_viewport_height(split_id);
327 let moved = if let (Some(composite), Some(view_state)) = (
328 self.composite_buffers.get(&buffer_id),
329 self.composite_view_states.get_mut(&(split_id, buffer_id)),
330 ) {
331 if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
332 view_state.cursor_row = prev_row;
333 let context_above = viewport_height / 3;
334 view_state.scroll_row = prev_row.saturating_sub(context_above);
335 true
336 } else {
337 false
338 }
339 } else {
340 false
341 };
342 if moved {
343 self.sync_editor_cursor_from_composite(split_id, buffer_id);
344 }
345 moved
346 }
347
348 pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
352 let split_id = self.effective_active_pair().0;
355 self.composite_next_hunk(split_id, buffer_id)
356 }
357
358 pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
360 let split_id = self.effective_active_pair().0;
361 self.composite_prev_hunk(split_id, buffer_id)
362 }
363
364 pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
368 if let (Some(composite), Some(view_state)) = (
369 self.composite_buffers.get(&buffer_id),
370 self.composite_view_states.get_mut(&(split_id, buffer_id)),
371 ) {
372 let max_row = composite.row_count().saturating_sub(1);
373 view_state.scroll(delta, max_row);
374 }
375 }
376
377 pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
379 if let (Some(composite), Some(view_state)) = (
380 self.composite_buffers.get(&buffer_id),
381 self.composite_view_states.get_mut(&(split_id, buffer_id)),
382 ) {
383 let max_row = composite.row_count().saturating_sub(1);
384 view_state.set_scroll_row(row, max_row);
385 }
386 }
387}
388
389impl Editor {
390 pub fn flush_layout(&mut self) {
403 use crate::view::composite_view::CompositeViewState;
404
405 let visible = self
406 .windows
407 .get(&self.active_window)
408 .and_then(|w| w.buffers.splits())
409 .map(|(mgr, _)| mgr)
410 .expect("active window must have a populated split layout")
411 .get_visible_buffers(ratatui::layout::Rect {
412 x: 0,
413 y: 0,
414 width: self.terminal_width,
415 height: self.terminal_height,
416 });
417
418 for (split_id, buffer_id, _area) in &visible {
419 if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
421 let pane_count = composite.pane_count();
422 self.active_window_mut()
423 .composite_view_states
424 .entry((*split_id, *buffer_id))
425 .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
426 }
427 }
428 }
429
430 pub fn get_composite_view_state(
445 &mut self,
446 split_id: LeafId,
447 buffer_id: BufferId,
448 ) -> Option<&mut CompositeViewState> {
449 if !self
450 .active_window()
451 .composite_buffers
452 .contains_key(&buffer_id)
453 {
454 return None;
455 }
456
457 let pane_count = self
458 .active_window()
459 .composite_buffers
460 .get(&buffer_id)?
461 .pane_count();
462
463 Some(
464 self.active_window_mut()
465 .composite_view_states
466 .entry((split_id, buffer_id))
467 .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
468 )
469 }
470
471 pub fn create_composite_buffer(
482 &mut self,
483 name: String,
484 mode: String,
485 layout: CompositeLayout,
486 sources: Vec<SourcePane>,
487 ) -> BufferId {
488 let buffer_id = self.alloc_buffer_id();
489
490 let composite =
491 CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
492 self.active_window_mut()
493 .composite_buffers
494 .insert(buffer_id, composite);
495
496 let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
500 metadata.hidden_from_tabs = false;
501 self.active_window_mut()
502 .buffer_metadata
503 .insert(buffer_id, metadata);
504
505 let mut state = crate::state::EditorState::new(
508 80,
509 24,
510 crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
511 std::sync::Arc::clone(&self.authority().filesystem),
512 );
513 state.is_composite_buffer = true;
514 state.editing_disabled = true;
515 state.mode = mode;
516 self.windows
517 .get_mut(&self.active_window)
518 .map(|w| &mut w.buffers)
519 .expect("active window present")
520 .insert(buffer_id, state);
521 self.active_window_mut()
523 .event_logs
524 .insert(buffer_id, crate::model::event::EventLog::new());
525
526 buffer_id
534 }
535
536 fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
547 let composite = self.active_window().composite_buffers.get(&buffer_id);
548 let view_state = self
549 .active_window()
550 .composite_view_states
551 .get(&(split_id, buffer_id));
552
553 if let (Some(composite), Some(view_state)) = (composite, view_state) {
554 let pane_line = composite
555 .alignment
556 .get_row(view_state.cursor_row)
557 .and_then(|row| row.get_pane_line(view_state.focused_pane));
558
559 tracing::debug!(
560 "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
561 view_state.cursor_row,
562 view_state.focused_pane,
563 pane_line
564 );
565
566 let line_bytes = pane_line.and_then(|line_ref| {
567 let source = composite.sources.get(view_state.focused_pane)?;
568 self.windows
569 .get(&self.active_window)
570 .map(|w| &w.buffers)
571 .expect("active window present")
572 .get(&source.buffer_id)?
573 .buffer
574 .get_line(line_ref.line)
575 });
576
577 let content = line_bytes
578 .as_ref()
579 .map(|b| {
580 let s = String::from_utf8_lossy(b).to_string();
581 s.trim_end_matches('\n').trim_end_matches('\r').to_string()
583 })
584 .unwrap_or_default();
585 let length = content.graphemes(true).count();
586 let pane_width = view_state
587 .pane_widths
588 .get(view_state.focused_pane)
589 .copied()
590 .unwrap_or(40) as usize;
591
592 CursorLineInfo {
593 content,
594 length,
595 pane_width,
596 }
597 } else {
598 CursorLineInfo {
599 content: String::new(),
600 length: 0,
601 pane_width: 40,
602 }
603 }
604 }
605
606 fn apply_cursor_movement(
608 &mut self,
609 split_id: LeafId,
610 buffer_id: BufferId,
611 movement: CursorMovement,
612 line_info: &CursorLineInfo,
613 viewport_height: usize,
614 ) {
615 let max_row = self
616 .active_window_mut()
617 .composite_buffers
618 .get(&buffer_id)
619 .map(|c| c.row_count().saturating_sub(1))
620 .unwrap_or(0);
621
622 let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
623 let mut wrapped_to_new_line = false;
624
625 let win = self.active_window_mut();
627 let composite = win.composite_buffers.get(&buffer_id);
628
629 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
630 match movement {
631 CursorMovement::Down => {
632 view_state.move_cursor_down(max_row, viewport_height);
633 }
634 CursorMovement::Up => {
635 view_state.move_cursor_up(viewport_height);
636 }
637 CursorMovement::Left => {
638 if view_state.cursor_column > 0 {
639 view_state.move_cursor_left();
640 } else if view_state.cursor_row > 0 {
641 if let Some(composite) = composite {
642 wrapped_to_new_line =
643 wrap_cursor_to_prev_content_row(view_state, composite);
644 }
645 }
646 }
647 CursorMovement::Right => {
648 if view_state.cursor_column < line_info.length {
649 view_state.move_cursor_right(line_info.length, line_info.pane_width);
650 } else if view_state.cursor_row < max_row {
651 if let Some(composite) = composite {
652 wrap_cursor_to_next_content_row(
653 view_state,
654 composite,
655 max_row,
656 viewport_height,
657 );
658 }
659 }
660 }
661 CursorMovement::LineStart => {
662 view_state.move_cursor_to_line_start();
663 }
664 CursorMovement::LineEnd => {
665 view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
666 }
667 CursorMovement::WordLeft => {
668 let new_col =
669 find_word_boundary_left(&line_info.content, view_state.cursor_column);
670 if new_col < view_state.cursor_column {
671 view_state.cursor_column = new_col;
672 view_state.sticky_column = new_col;
673 let current_left = view_state
675 .pane_viewports
676 .get(view_state.focused_pane)
677 .map(|v| v.left_column)
678 .unwrap_or(0);
679 if view_state.cursor_column < current_left {
680 for viewport in &mut view_state.pane_viewports {
681 viewport.left_column = view_state.cursor_column;
682 }
683 }
684 } else if view_state.cursor_row > 0 {
685 if let Some(composite) = composite {
686 wrapped_to_new_line =
687 wrap_cursor_to_prev_content_row(view_state, composite);
688 }
689 }
690 }
691 CursorMovement::WordRight => {
692 let new_col = find_word_boundary_right(
693 &line_info.content,
694 view_state.cursor_column,
695 line_info.length,
696 );
697 if new_col > view_state.cursor_column {
698 view_state.cursor_column = new_col;
699 view_state.sticky_column = new_col;
700 scroll_panes_right_to_cursor(view_state, line_info.pane_width);
701 } else if view_state.cursor_row < max_row {
702 if let Some(composite) = composite {
703 wrap_cursor_to_next_content_row(
704 view_state,
705 composite,
706 max_row,
707 viewport_height,
708 );
709 }
710 }
711 }
712 CursorMovement::WordEnd => {
713 let new_col = find_word_end_right(
714 &line_info.content,
715 view_state.cursor_column,
716 line_info.length,
717 );
718 if new_col > view_state.cursor_column {
719 view_state.cursor_column = new_col;
720 view_state.sticky_column = new_col;
721 scroll_panes_right_to_cursor(view_state, line_info.pane_width);
722 } else if view_state.cursor_row < max_row {
723 if let Some(composite) = composite {
724 wrap_cursor_to_next_content_row(
725 view_state,
726 composite,
727 max_row,
728 viewport_height,
729 );
730 }
731 }
732 }
733 }
734 }
735
736 if is_vertical || wrapped_to_new_line {
738 let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
739 if let Some(view_state) = self
740 .active_window_mut()
741 .composite_view_states
742 .get_mut(&(split_id, buffer_id))
743 {
744 if wrapped_to_new_line
745 && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
746 {
747 tracing::debug!(
749 "Wrap left to row {}, setting column to line length {}",
750 view_state.cursor_row,
751 new_line_info.length
752 );
753 view_state.cursor_column = new_line_info.length;
754 view_state.sticky_column = new_line_info.length;
755 let visible_width = new_line_info.pane_width.saturating_sub(4);
757 if visible_width > 0 && view_state.cursor_column >= visible_width {
758 let new_left = view_state
759 .cursor_column
760 .saturating_sub(visible_width.saturating_sub(1));
761 for viewport in &mut view_state.pane_viewports {
762 viewport.left_column = new_left;
763 }
764 }
765 } else {
766 view_state.clamp_cursor_to_line(new_line_info.length);
767 }
768 }
769 }
770 }
771
772 fn handle_cursor_movement_action(
774 &mut self,
775 split_id: LeafId,
776 buffer_id: BufferId,
777 movement: CursorMovement,
778 extend_selection: bool,
779 ) -> Option<bool> {
780 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
781
782 let line_info = self.get_cursor_line_info(split_id, buffer_id);
783
784 if extend_selection {
785 if let Some(view_state) = self
787 .active_window_mut()
788 .composite_view_states
789 .get_mut(&(split_id, buffer_id))
790 {
791 if !view_state.visual_mode {
792 view_state.start_visual_selection();
793 }
794 }
795 } else {
796 if let Some(view_state) = self
798 .active_window_mut()
799 .composite_view_states
800 .get_mut(&(split_id, buffer_id))
801 {
802 if view_state.visual_mode {
803 view_state.clear_selection();
804 }
805 }
806 }
807
808 self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
809 self.active_window_mut()
810 .sync_editor_cursor_from_composite(split_id, buffer_id);
811
812 Some(true)
813 }
814
815 pub fn handle_composite_action(
821 &mut self,
822 buffer_id: BufferId,
823 action: &crate::input::keybindings::Action,
824 ) -> Option<bool> {
825 use crate::input::keybindings::Action;
826
827 let split_id = self.active_window().effective_active_pair().0;
834
835 let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
837 let _view_state = self
838 .active_window()
839 .composite_view_states
840 .get(&(split_id, buffer_id))?;
841
842 match action {
843 Action::InsertTab => {
845 self.active_window_mut()
846 .composite_focus_next(split_id, buffer_id);
847 Some(true)
848 }
849
850 Action::Copy => {
852 self.handle_composite_copy(split_id, buffer_id);
853 Some(true)
854 }
855
856 Action::MoveDown => {
858 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
859 }
860 Action::MoveUp => {
861 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
862 }
863 Action::MoveLeft => {
864 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
865 }
866 Action::MoveRight => self.handle_cursor_movement_action(
867 split_id,
868 buffer_id,
869 CursorMovement::Right,
870 false,
871 ),
872 Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
873 split_id,
874 buffer_id,
875 CursorMovement::LineStart,
876 false,
877 ),
878 Action::MoveLineEnd => self.handle_cursor_movement_action(
879 split_id,
880 buffer_id,
881 CursorMovement::LineEnd,
882 false,
883 ),
884 Action::MoveWordLeft => self.handle_cursor_movement_action(
885 split_id,
886 buffer_id,
887 CursorMovement::WordLeft,
888 false,
889 ),
890 Action::MoveWordRight => self.handle_cursor_movement_action(
891 split_id,
892 buffer_id,
893 CursorMovement::WordRight,
894 false,
895 ),
896 Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
897 split_id,
898 buffer_id,
899 CursorMovement::WordEnd,
900 false,
901 ),
902 Action::MoveLeftInLine => {
903 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
904 }
905 Action::MoveRightInLine => self.handle_cursor_movement_action(
906 split_id,
907 buffer_id,
908 CursorMovement::Right,
909 false,
910 ),
911
912 Action::SelectDown => {
914 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
915 }
916 Action::SelectUp => {
917 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
918 }
919 Action::SelectLeft => {
920 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
921 }
922 Action::SelectRight => {
923 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
924 }
925 Action::SelectLineStart => self.handle_cursor_movement_action(
926 split_id,
927 buffer_id,
928 CursorMovement::LineStart,
929 true,
930 ),
931 Action::SelectLineEnd => self.handle_cursor_movement_action(
932 split_id,
933 buffer_id,
934 CursorMovement::LineEnd,
935 true,
936 ),
937 Action::SelectWordLeft => self.handle_cursor_movement_action(
938 split_id,
939 buffer_id,
940 CursorMovement::WordLeft,
941 true,
942 ),
943 Action::SelectWordRight => self.handle_cursor_movement_action(
944 split_id,
945 buffer_id,
946 CursorMovement::WordRight,
947 true,
948 ),
949 Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
950 split_id,
951 buffer_id,
952 CursorMovement::WordEnd,
953 true,
954 ),
955
956 Action::MovePageDown | Action::MovePageUp => {
958 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
959 let win = self.active_window_mut();
960 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
961 {
962 if matches!(action, Action::MovePageDown) {
963 if let Some(composite) = win.composite_buffers.get(&buffer_id) {
964 let max_row = composite.row_count().saturating_sub(1);
965 view_state.page_down(viewport_height, max_row);
966 view_state.cursor_row = view_state.scroll_row;
967 }
968 } else {
969 view_state.page_up(viewport_height);
970 view_state.cursor_row = view_state.scroll_row;
971 }
972 }
973 self.active_window_mut()
974 .sync_editor_cursor_from_composite(split_id, buffer_id);
975 Some(true)
976 }
977
978 Action::MoveDocumentStart | Action::MoveDocumentEnd => {
980 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
981 let win = self.active_window_mut();
982 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
983 {
984 if matches!(action, Action::MoveDocumentStart) {
985 view_state.move_cursor_to_top();
986 } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
987 let max_row = composite.row_count().saturating_sub(1);
988 view_state.move_cursor_to_bottom(max_row, viewport_height);
989 }
990 }
991 self.active_window_mut()
992 .sync_editor_cursor_from_composite(split_id, buffer_id);
993 Some(true)
994 }
995
996 Action::ScrollDown | Action::ScrollUp => {
998 let delta = if matches!(action, Action::ScrollDown) {
999 1
1000 } else {
1001 -1
1002 };
1003 self.active_window_mut()
1004 .composite_scroll(split_id, buffer_id, delta);
1005 Some(true)
1006 }
1007
1008 _ => None,
1010 }
1011 }
1012
1013 fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1015 let text = {
1016 let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1017 Some(c) => c,
1018 None => return,
1019 };
1020 let view_state = match self
1021 .active_window()
1022 .composite_view_states
1023 .get(&(split_id, buffer_id))
1024 {
1025 Some(vs) => vs,
1026 None => return,
1027 };
1028
1029 let (start_row, end_row) = match view_state.selection_row_range() {
1030 Some(range) => range,
1031 None => return,
1032 };
1033
1034 let source = match composite.sources.get(view_state.focused_pane) {
1035 Some(s) => s,
1036 None => return,
1037 };
1038
1039 let source_state = match self
1040 .windows
1041 .get(&self.active_window)
1042 .map(|w| &w.buffers)
1043 .expect("active window present")
1044 .get(&source.buffer_id)
1045 {
1046 Some(s) => s,
1047 None => return,
1048 };
1049
1050 let mut text = String::new();
1052 for row in start_row..=end_row {
1053 if let Some(aligned_row) = composite.alignment.rows.get(row) {
1054 if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1055 if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1056 if !text.is_empty() {
1057 text.push('\n');
1058 }
1059 let line_str = String::from_utf8_lossy(&line_bytes);
1061 let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1062 text.push_str(line_trimmed);
1063 }
1064 }
1065 }
1066 }
1067 text
1068 };
1069
1070 let text = crate::primitives::ansi::strip_ansi_codes(&text);
1073 if !text.is_empty() {
1074 self.clipboard.copy(text);
1075 }
1076
1077 }
1079
1080 #[cfg(feature = "plugins")]
1086 pub(crate) fn handle_create_composite_buffer(
1087 &mut self,
1088 name: String,
1089 mode: String,
1090 layout_config: fresh_core::api::CompositeLayoutConfig,
1091 source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1092 hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1093 initial_focus_hunk: Option<usize>,
1094 _request_id: Option<u64>,
1095 ) {
1096 use crate::model::composite_buffer::{
1097 CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1098 };
1099
1100 let layout = match layout_config.layout_type.as_str() {
1102 "stacked" => CompositeLayout::Stacked {
1103 spacing: layout_config.spacing.unwrap_or(1),
1104 },
1105 "unified" => CompositeLayout::Unified,
1106 _ => CompositeLayout::SideBySide {
1107 ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1108 show_separator: layout_config.show_separator,
1109 },
1110 };
1111
1112 let sources: Vec<SourcePane> = source_configs
1114 .into_iter()
1115 .map(|src| {
1116 let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1117 if let Some(style_config) = src.style {
1118 let gutter_style = match style_config.gutter_style.as_deref() {
1119 Some("diff-markers") => GutterStyle::DiffMarkers,
1120 Some("both") => GutterStyle::Both,
1121 Some("none") => GutterStyle::None,
1122 _ => GutterStyle::LineNumbers,
1123 };
1124 let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1126 pane.style = PaneStyle {
1127 add_bg: style_config.add_bg.map(to_tuple),
1128 remove_bg: style_config.remove_bg.map(to_tuple),
1129 modify_bg: style_config.modify_bg.map(to_tuple),
1130 gutter_style,
1131 };
1132 }
1133 pane
1134 })
1135 .collect();
1136
1137 let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1139
1140 if let Some(hunk_configs) = hunks {
1142 let diff_hunks: Vec<DiffHunk> = hunk_configs
1143 .into_iter()
1144 .map(|h| {
1145 DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1146 .with_ops(h.ops)
1147 })
1148 .collect();
1149
1150 let old_line_count = self
1152 .buffers()
1153 .get(
1154 &self
1155 .active_window()
1156 .composite_buffers
1157 .get(&buffer_id)
1158 .unwrap()
1159 .sources[0]
1160 .buffer_id,
1161 )
1162 .and_then(|s| s.buffer.line_count())
1163 .unwrap_or(0);
1164 let new_line_count = self
1165 .buffers()
1166 .get(
1167 &self
1168 .active_window()
1169 .composite_buffers
1170 .get(&buffer_id)
1171 .unwrap()
1172 .sources[1]
1173 .buffer_id,
1174 )
1175 .and_then(|s| s.buffer.line_count())
1176 .unwrap_or(0);
1177
1178 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1179 self.active_window_mut()
1180 .set_composite_alignment(buffer_id, alignment);
1181 }
1182
1183 if initial_focus_hunk.is_some() {
1185 if let Some(composite) = self
1186 .active_window_mut()
1187 .composite_buffers
1188 .get_mut(&buffer_id)
1189 {
1190 composite.initial_focus_hunk = initial_focus_hunk;
1191 }
1192 }
1193
1194 tracing::info!(
1195 "Created composite buffer '{}' with mode '{}' (id={:?})",
1196 name,
1197 mode,
1198 buffer_id
1199 );
1200
1201 if let Some(req_id) = _request_id {
1203 let result = buffer_id.0.to_string();
1205 self.plugin_manager
1206 .read()
1207 .unwrap()
1208 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1209 tracing::info!(
1210 "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1211 req_id
1212 );
1213 }
1214 }
1215
1216 #[cfg(feature = "plugins")]
1218 pub(crate) fn handle_update_composite_alignment(
1219 &mut self,
1220 buffer_id: BufferId,
1221 hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1222 ) {
1223 use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1224
1225 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1226 let diff_hunks: Vec<DiffHunk> = hunk_configs
1227 .into_iter()
1228 .map(|h| {
1229 DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1230 .with_ops(h.ops)
1231 })
1232 .collect();
1233
1234 let old_line_count = self
1236 .buffers()
1237 .get(&composite.sources[0].buffer_id)
1238 .and_then(|s| s.buffer.line_count())
1239 .unwrap_or(0);
1240 let new_line_count = self
1241 .buffers()
1242 .get(&composite.sources[1].buffer_id)
1243 .and_then(|s| s.buffer.line_count())
1244 .unwrap_or(0);
1245
1246 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1247 self.active_window_mut()
1248 .set_composite_alignment(buffer_id, alignment);
1249 }
1250 }
1251
1252 pub(crate) fn handle_composite_click(
1254 &mut self,
1255 col: u16,
1256 row: u16,
1257 split_id: LeafId,
1258 buffer_id: BufferId,
1259 content_rect: ratatui::layout::Rect,
1260 ) -> AnyhowResult<()> {
1261 let pane_idx = if let Some(view_state) = self
1263 .active_window()
1264 .composite_view_states
1265 .get(&(split_id, buffer_id))
1266 {
1267 let mut x = content_rect.x;
1268 let mut found_pane = 0;
1269 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1270 if col >= x && col < x + width {
1271 found_pane = i;
1272 break;
1273 }
1274 x += width + 1; }
1276 found_pane
1277 } else {
1278 0
1279 };
1280
1281 let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1284
1285 let (pane_start_x, left_column) = if let Some(view_state) = self
1287 .active_window()
1288 .composite_view_states
1289 .get(&(split_id, buffer_id))
1290 {
1291 let mut x = content_rect.x;
1292 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1293 if i == pane_idx {
1294 break;
1295 }
1296 x += width + 1;
1297 }
1298 let left_col = view_state
1299 .pane_viewports
1300 .get(pane_idx)
1301 .map(|vp| vp.left_column)
1302 .unwrap_or(0);
1303 (x, left_col)
1304 } else {
1305 (content_rect.x, 0)
1306 };
1307 let gutter_width = 4; let visual_col = col
1309 .saturating_sub(pane_start_x)
1310 .saturating_sub(gutter_width) as usize;
1311 let click_col = left_column + visual_col;
1313
1314 let display_row = if let Some(view_state) = self
1316 .active_window()
1317 .composite_view_states
1318 .get(&(split_id, buffer_id))
1319 {
1320 view_state.scroll_row + content_row
1321 } else {
1322 content_row
1323 };
1324
1325 let line_length =
1326 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1327 composite
1328 .alignment
1329 .get_row(display_row)
1330 .and_then(|row| row.get_pane_line(pane_idx))
1331 .and_then(|line_ref| {
1332 let source = composite.sources.get(pane_idx)?;
1333 self.windows
1334 .get(&self.active_window)
1335 .map(|w| &w.buffers)
1336 .expect("active window present")
1337 .get(&source.buffer_id)?
1338 .buffer
1339 .get_line(line_ref.line)
1340 })
1341 .map(|bytes| {
1342 let s = String::from_utf8_lossy(&bytes);
1343 let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1345 trimmed.graphemes(true).count()
1346 })
1347 .unwrap_or(0)
1348 } else {
1349 0
1350 };
1351
1352 let clamped_col = click_col.min(line_length);
1354
1355 if let Some(composite) = self
1357 .active_window_mut()
1358 .composite_buffers
1359 .get_mut(&buffer_id)
1360 {
1361 composite.active_pane = pane_idx;
1362 }
1363
1364 if let Some(view_state) = self
1366 .active_window_mut()
1367 .composite_view_states
1368 .get_mut(&(split_id, buffer_id))
1369 {
1370 view_state.focused_pane = pane_idx;
1371 view_state.cursor_row = display_row;
1372 view_state.cursor_column = clamped_col;
1373 view_state.sticky_column = clamped_col;
1374
1375 view_state.clear_selection();
1377 }
1378
1379 self.active_window_mut().mouse_state.dragging_text_selection = false; self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1382
1383 self.active_window_mut()
1385 .sync_editor_cursor_from_composite(split_id, buffer_id);
1386
1387 Ok(())
1388 }
1389}
1390
1391fn wrap_cursor_to_prev_content_row(
1396 view_state: &mut CompositeViewState,
1397 composite: &CompositeBuffer,
1398) -> bool {
1399 let focused_pane = view_state.focused_pane;
1400 let has_content = |row: usize| {
1401 composite
1402 .alignment
1403 .get_row(row)
1404 .is_some_and(|r| r.get_pane_line(focused_pane).is_some())
1405 };
1406
1407 let mut target_row = view_state.cursor_row - 1;
1408 while target_row > 0 && !has_content(target_row) {
1409 target_row -= 1;
1410 }
1411 if !has_content(target_row) {
1412 return false;
1413 }
1414
1415 view_state.cursor_row = target_row;
1416 if view_state.cursor_row < view_state.scroll_row {
1417 view_state.scroll_row = view_state.cursor_row;
1418 }
1419 true
1420}
1421
1422fn wrap_cursor_to_next_content_row(
1428 view_state: &mut CompositeViewState,
1429 composite: &CompositeBuffer,
1430 max_row: usize,
1431 viewport_height: usize,
1432) -> bool {
1433 let focused_pane = view_state.focused_pane;
1434 let has_content = |row: usize| {
1435 composite
1436 .alignment
1437 .get_row(row)
1438 .is_some_and(|r| r.get_pane_line(focused_pane).is_some())
1439 };
1440
1441 let mut target_row = view_state.cursor_row + 1;
1442 while target_row < max_row && !has_content(target_row) {
1443 target_row += 1;
1444 }
1445 if !has_content(target_row) {
1446 return false;
1447 }
1448
1449 view_state.cursor_row = target_row;
1450 view_state.cursor_column = 0;
1451 view_state.sticky_column = 0;
1452 if view_state.cursor_row >= view_state.scroll_row + viewport_height {
1453 view_state.scroll_row = view_state.cursor_row.saturating_sub(viewport_height - 1);
1454 }
1455 for viewport in &mut view_state.pane_viewports {
1457 viewport.left_column = 0;
1458 }
1459 true
1460}
1461
1462fn scroll_panes_right_to_cursor(view_state: &mut CompositeViewState, pane_width: usize) {
1464 let visible_width = pane_width.saturating_sub(4);
1465 let current_left = view_state
1466 .pane_viewports
1467 .get(view_state.focused_pane)
1468 .map(|v| v.left_column)
1469 .unwrap_or(0);
1470 if visible_width > 0 && view_state.cursor_column >= current_left + visible_width {
1471 let new_left = view_state
1472 .cursor_column
1473 .saturating_sub(visible_width.saturating_sub(1));
1474 for viewport in &mut view_state.pane_viewports {
1475 viewport.left_column = new_left;
1476 }
1477 }
1478}