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 {
643 let focused_pane = view_state.focused_pane;
644 let mut target_row = view_state.cursor_row - 1;
645 while target_row > 0 {
646 if let Some(row) = composite.alignment.get_row(target_row) {
647 if row.get_pane_line(focused_pane).is_some() {
648 break;
649 }
650 }
651 target_row -= 1;
652 }
653 if let Some(row) = composite.alignment.get_row(target_row) {
655 if row.get_pane_line(focused_pane).is_some() {
656 view_state.cursor_row = target_row;
657 if view_state.cursor_row < view_state.scroll_row {
658 view_state.scroll_row = view_state.cursor_row;
659 }
660 wrapped_to_new_line = true;
661 }
662 }
663 }
664 }
665 }
666 CursorMovement::Right => {
667 if view_state.cursor_column < line_info.length {
668 view_state.move_cursor_right(line_info.length, line_info.pane_width);
669 } else if view_state.cursor_row < max_row {
670 if let Some(composite) = composite {
672 let focused_pane = view_state.focused_pane;
673 let mut target_row = view_state.cursor_row + 1;
674 while target_row < max_row {
675 if let Some(row) = composite.alignment.get_row(target_row) {
676 if row.get_pane_line(focused_pane).is_some() {
677 break;
678 }
679 }
680 target_row += 1;
681 }
682 if let Some(row) = composite.alignment.get_row(target_row) {
684 if row.get_pane_line(focused_pane).is_some() {
685 view_state.cursor_row = target_row;
686 view_state.cursor_column = 0;
687 view_state.sticky_column = 0;
688 if view_state.cursor_row
689 >= view_state.scroll_row + viewport_height
690 {
691 view_state.scroll_row = view_state
692 .cursor_row
693 .saturating_sub(viewport_height - 1);
694 }
695 for viewport in &mut view_state.pane_viewports {
697 viewport.left_column = 0;
698 }
699 }
700 }
701 }
702 }
703 }
704 CursorMovement::LineStart => {
705 view_state.move_cursor_to_line_start();
706 }
707 CursorMovement::LineEnd => {
708 view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
709 }
710 CursorMovement::WordLeft => {
711 let new_col =
712 find_word_boundary_left(&line_info.content, view_state.cursor_column);
713 if new_col < view_state.cursor_column {
714 view_state.cursor_column = new_col;
715 view_state.sticky_column = new_col;
716 let current_left = view_state
718 .pane_viewports
719 .get(view_state.focused_pane)
720 .map(|v| v.left_column)
721 .unwrap_or(0);
722 if view_state.cursor_column < current_left {
723 for viewport in &mut view_state.pane_viewports {
724 viewport.left_column = view_state.cursor_column;
725 }
726 }
727 } else if view_state.cursor_row > 0 {
728 if let Some(composite) = composite {
730 let focused_pane = view_state.focused_pane;
731 let mut target_row = view_state.cursor_row - 1;
732 while target_row > 0 {
733 if let Some(row) = composite.alignment.get_row(target_row) {
734 if row.get_pane_line(focused_pane).is_some() {
735 break;
736 }
737 }
738 target_row -= 1;
739 }
740 if let Some(row) = composite.alignment.get_row(target_row) {
742 if row.get_pane_line(focused_pane).is_some() {
743 view_state.cursor_row = target_row;
744 if view_state.cursor_row < view_state.scroll_row {
745 view_state.scroll_row = view_state.cursor_row;
746 }
747 wrapped_to_new_line = true;
748 }
749 }
750 }
751 }
752 }
753 CursorMovement::WordRight => {
754 let new_col = find_word_boundary_right(
755 &line_info.content,
756 view_state.cursor_column,
757 line_info.length,
758 );
759 if new_col > view_state.cursor_column {
760 view_state.cursor_column = new_col;
761 view_state.sticky_column = new_col;
762 let visible_width = line_info.pane_width.saturating_sub(4);
764 let current_left = view_state
765 .pane_viewports
766 .get(view_state.focused_pane)
767 .map(|v| v.left_column)
768 .unwrap_or(0);
769 if visible_width > 0
770 && view_state.cursor_column >= current_left + visible_width
771 {
772 let new_left = view_state
773 .cursor_column
774 .saturating_sub(visible_width.saturating_sub(1));
775 for viewport in &mut view_state.pane_viewports {
776 viewport.left_column = new_left;
777 }
778 }
779 } else if view_state.cursor_row < max_row {
780 if let Some(composite) = composite {
782 let focused_pane = view_state.focused_pane;
783 let mut target_row = view_state.cursor_row + 1;
784 while target_row < max_row {
785 if let Some(row) = composite.alignment.get_row(target_row) {
786 if row.get_pane_line(focused_pane).is_some() {
787 break;
788 }
789 }
790 target_row += 1;
791 }
792 if let Some(row) = composite.alignment.get_row(target_row) {
794 if row.get_pane_line(focused_pane).is_some() {
795 view_state.cursor_row = target_row;
796 view_state.cursor_column = 0;
797 view_state.sticky_column = 0;
798 if view_state.cursor_row
799 >= view_state.scroll_row + viewport_height
800 {
801 view_state.scroll_row = view_state
802 .cursor_row
803 .saturating_sub(viewport_height - 1);
804 }
805 for viewport in &mut view_state.pane_viewports {
807 viewport.left_column = 0;
808 }
809 }
810 }
811 }
812 }
813 }
814 CursorMovement::WordEnd => {
815 let new_col = find_word_end_right(
816 &line_info.content,
817 view_state.cursor_column,
818 line_info.length,
819 );
820 if new_col > view_state.cursor_column {
821 view_state.cursor_column = new_col;
822 view_state.sticky_column = new_col;
823 let visible_width = line_info.pane_width.saturating_sub(4);
825 let current_left = view_state
826 .pane_viewports
827 .get(view_state.focused_pane)
828 .map(|v| v.left_column)
829 .unwrap_or(0);
830 if visible_width > 0
831 && view_state.cursor_column >= current_left + visible_width
832 {
833 let new_left = view_state
834 .cursor_column
835 .saturating_sub(visible_width.saturating_sub(1));
836 for viewport in &mut view_state.pane_viewports {
837 viewport.left_column = new_left;
838 }
839 }
840 } else if view_state.cursor_row < max_row {
841 if let Some(composite) = composite {
843 let focused_pane = view_state.focused_pane;
844 let mut target_row = view_state.cursor_row + 1;
845 while target_row < max_row {
846 if let Some(row) = composite.alignment.get_row(target_row) {
847 if row.get_pane_line(focused_pane).is_some() {
848 break;
849 }
850 }
851 target_row += 1;
852 }
853 if let Some(row) = composite.alignment.get_row(target_row) {
855 if row.get_pane_line(focused_pane).is_some() {
856 view_state.cursor_row = target_row;
857 view_state.cursor_column = 0;
858 view_state.sticky_column = 0;
859 if view_state.cursor_row
860 >= view_state.scroll_row + viewport_height
861 {
862 view_state.scroll_row = view_state
863 .cursor_row
864 .saturating_sub(viewport_height - 1);
865 }
866 for viewport in &mut view_state.pane_viewports {
868 viewport.left_column = 0;
869 }
870 }
871 }
872 }
873 }
874 }
875 }
876 }
877
878 if is_vertical || wrapped_to_new_line {
880 let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
881 if let Some(view_state) = self
882 .active_window_mut()
883 .composite_view_states
884 .get_mut(&(split_id, buffer_id))
885 {
886 if wrapped_to_new_line
887 && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
888 {
889 tracing::debug!(
891 "Wrap left to row {}, setting column to line length {}",
892 view_state.cursor_row,
893 new_line_info.length
894 );
895 view_state.cursor_column = new_line_info.length;
896 view_state.sticky_column = new_line_info.length;
897 let visible_width = new_line_info.pane_width.saturating_sub(4);
899 if visible_width > 0 && view_state.cursor_column >= visible_width {
900 let new_left = view_state
901 .cursor_column
902 .saturating_sub(visible_width.saturating_sub(1));
903 for viewport in &mut view_state.pane_viewports {
904 viewport.left_column = new_left;
905 }
906 }
907 } else {
908 view_state.clamp_cursor_to_line(new_line_info.length);
909 }
910 }
911 }
912 }
913
914 fn handle_cursor_movement_action(
916 &mut self,
917 split_id: LeafId,
918 buffer_id: BufferId,
919 movement: CursorMovement,
920 extend_selection: bool,
921 ) -> Option<bool> {
922 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
923
924 let line_info = self.get_cursor_line_info(split_id, buffer_id);
925
926 if extend_selection {
927 if let Some(view_state) = self
929 .active_window_mut()
930 .composite_view_states
931 .get_mut(&(split_id, buffer_id))
932 {
933 if !view_state.visual_mode {
934 view_state.start_visual_selection();
935 }
936 }
937 } else {
938 if let Some(view_state) = self
940 .active_window_mut()
941 .composite_view_states
942 .get_mut(&(split_id, buffer_id))
943 {
944 if view_state.visual_mode {
945 view_state.clear_selection();
946 }
947 }
948 }
949
950 self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
951 self.active_window_mut()
952 .sync_editor_cursor_from_composite(split_id, buffer_id);
953
954 Some(true)
955 }
956
957 pub fn handle_composite_action(
963 &mut self,
964 buffer_id: BufferId,
965 action: &crate::input::keybindings::Action,
966 ) -> Option<bool> {
967 use crate::input::keybindings::Action;
968
969 let split_id = self.active_window().effective_active_pair().0;
976
977 let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
979 let _view_state = self
980 .active_window()
981 .composite_view_states
982 .get(&(split_id, buffer_id))?;
983
984 match action {
985 Action::InsertTab => {
987 self.active_window_mut()
988 .composite_focus_next(split_id, buffer_id);
989 Some(true)
990 }
991
992 Action::Copy => {
994 self.handle_composite_copy(split_id, buffer_id);
995 Some(true)
996 }
997
998 Action::MoveDown => {
1000 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
1001 }
1002 Action::MoveUp => {
1003 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
1004 }
1005 Action::MoveLeft => {
1006 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1007 }
1008 Action::MoveRight => self.handle_cursor_movement_action(
1009 split_id,
1010 buffer_id,
1011 CursorMovement::Right,
1012 false,
1013 ),
1014 Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
1015 split_id,
1016 buffer_id,
1017 CursorMovement::LineStart,
1018 false,
1019 ),
1020 Action::MoveLineEnd => self.handle_cursor_movement_action(
1021 split_id,
1022 buffer_id,
1023 CursorMovement::LineEnd,
1024 false,
1025 ),
1026 Action::MoveWordLeft => self.handle_cursor_movement_action(
1027 split_id,
1028 buffer_id,
1029 CursorMovement::WordLeft,
1030 false,
1031 ),
1032 Action::MoveWordRight => self.handle_cursor_movement_action(
1033 split_id,
1034 buffer_id,
1035 CursorMovement::WordRight,
1036 false,
1037 ),
1038 Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
1039 split_id,
1040 buffer_id,
1041 CursorMovement::WordEnd,
1042 false,
1043 ),
1044 Action::MoveLeftInLine => {
1045 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1046 }
1047 Action::MoveRightInLine => self.handle_cursor_movement_action(
1048 split_id,
1049 buffer_id,
1050 CursorMovement::Right,
1051 false,
1052 ),
1053
1054 Action::SelectDown => {
1056 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1057 }
1058 Action::SelectUp => {
1059 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1060 }
1061 Action::SelectLeft => {
1062 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1063 }
1064 Action::SelectRight => {
1065 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1066 }
1067 Action::SelectLineStart => self.handle_cursor_movement_action(
1068 split_id,
1069 buffer_id,
1070 CursorMovement::LineStart,
1071 true,
1072 ),
1073 Action::SelectLineEnd => self.handle_cursor_movement_action(
1074 split_id,
1075 buffer_id,
1076 CursorMovement::LineEnd,
1077 true,
1078 ),
1079 Action::SelectWordLeft => self.handle_cursor_movement_action(
1080 split_id,
1081 buffer_id,
1082 CursorMovement::WordLeft,
1083 true,
1084 ),
1085 Action::SelectWordRight => self.handle_cursor_movement_action(
1086 split_id,
1087 buffer_id,
1088 CursorMovement::WordRight,
1089 true,
1090 ),
1091 Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1092 split_id,
1093 buffer_id,
1094 CursorMovement::WordEnd,
1095 true,
1096 ),
1097
1098 Action::MovePageDown | Action::MovePageUp => {
1100 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1101 let win = self.active_window_mut();
1102 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1103 {
1104 if matches!(action, Action::MovePageDown) {
1105 if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1106 let max_row = composite.row_count().saturating_sub(1);
1107 view_state.page_down(viewport_height, max_row);
1108 view_state.cursor_row = view_state.scroll_row;
1109 }
1110 } else {
1111 view_state.page_up(viewport_height);
1112 view_state.cursor_row = view_state.scroll_row;
1113 }
1114 }
1115 self.active_window_mut()
1116 .sync_editor_cursor_from_composite(split_id, buffer_id);
1117 Some(true)
1118 }
1119
1120 Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1122 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1123 let win = self.active_window_mut();
1124 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1125 {
1126 if matches!(action, Action::MoveDocumentStart) {
1127 view_state.move_cursor_to_top();
1128 } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1129 let max_row = composite.row_count().saturating_sub(1);
1130 view_state.move_cursor_to_bottom(max_row, viewport_height);
1131 }
1132 }
1133 self.active_window_mut()
1134 .sync_editor_cursor_from_composite(split_id, buffer_id);
1135 Some(true)
1136 }
1137
1138 Action::ScrollDown | Action::ScrollUp => {
1140 let delta = if matches!(action, Action::ScrollDown) {
1141 1
1142 } else {
1143 -1
1144 };
1145 self.active_window_mut()
1146 .composite_scroll(split_id, buffer_id, delta);
1147 Some(true)
1148 }
1149
1150 _ => None,
1152 }
1153 }
1154
1155 fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1157 let text = {
1158 let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1159 Some(c) => c,
1160 None => return,
1161 };
1162 let view_state = match self
1163 .active_window()
1164 .composite_view_states
1165 .get(&(split_id, buffer_id))
1166 {
1167 Some(vs) => vs,
1168 None => return,
1169 };
1170
1171 let (start_row, end_row) = match view_state.selection_row_range() {
1172 Some(range) => range,
1173 None => return,
1174 };
1175
1176 let source = match composite.sources.get(view_state.focused_pane) {
1177 Some(s) => s,
1178 None => return,
1179 };
1180
1181 let source_state = match self
1182 .windows
1183 .get(&self.active_window)
1184 .map(|w| &w.buffers)
1185 .expect("active window present")
1186 .get(&source.buffer_id)
1187 {
1188 Some(s) => s,
1189 None => return,
1190 };
1191
1192 let mut text = String::new();
1194 for row in start_row..=end_row {
1195 if let Some(aligned_row) = composite.alignment.rows.get(row) {
1196 if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1197 if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1198 if !text.is_empty() {
1199 text.push('\n');
1200 }
1201 let line_str = String::from_utf8_lossy(&line_bytes);
1203 let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1204 text.push_str(line_trimmed);
1205 }
1206 }
1207 }
1208 }
1209 text
1210 };
1211
1212 if !text.is_empty() {
1213 self.clipboard.copy(text);
1214 }
1215
1216 }
1218
1219 #[cfg(feature = "plugins")]
1225 pub(crate) fn handle_create_composite_buffer(
1226 &mut self,
1227 name: String,
1228 mode: String,
1229 layout_config: fresh_core::api::CompositeLayoutConfig,
1230 source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1231 hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1232 initial_focus_hunk: Option<usize>,
1233 _request_id: Option<u64>,
1234 ) {
1235 use crate::model::composite_buffer::{
1236 CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1237 };
1238
1239 let layout = match layout_config.layout_type.as_str() {
1241 "stacked" => CompositeLayout::Stacked {
1242 spacing: layout_config.spacing.unwrap_or(1),
1243 },
1244 "unified" => CompositeLayout::Unified,
1245 _ => CompositeLayout::SideBySide {
1246 ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1247 show_separator: layout_config.show_separator,
1248 },
1249 };
1250
1251 let sources: Vec<SourcePane> = source_configs
1253 .into_iter()
1254 .map(|src| {
1255 let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1256 if let Some(style_config) = src.style {
1257 let gutter_style = match style_config.gutter_style.as_deref() {
1258 Some("diff-markers") => GutterStyle::DiffMarkers,
1259 Some("both") => GutterStyle::Both,
1260 Some("none") => GutterStyle::None,
1261 _ => GutterStyle::LineNumbers,
1262 };
1263 let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1265 pane.style = PaneStyle {
1266 add_bg: style_config.add_bg.map(to_tuple),
1267 remove_bg: style_config.remove_bg.map(to_tuple),
1268 modify_bg: style_config.modify_bg.map(to_tuple),
1269 gutter_style,
1270 };
1271 }
1272 pane
1273 })
1274 .collect();
1275
1276 let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1278
1279 if let Some(hunk_configs) = hunks {
1281 let diff_hunks: Vec<DiffHunk> = hunk_configs
1282 .into_iter()
1283 .map(|h| {
1284 DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1285 .with_ops(h.ops)
1286 })
1287 .collect();
1288
1289 let old_line_count = self
1291 .buffers()
1292 .get(
1293 &self
1294 .active_window()
1295 .composite_buffers
1296 .get(&buffer_id)
1297 .unwrap()
1298 .sources[0]
1299 .buffer_id,
1300 )
1301 .and_then(|s| s.buffer.line_count())
1302 .unwrap_or(0);
1303 let new_line_count = self
1304 .buffers()
1305 .get(
1306 &self
1307 .active_window()
1308 .composite_buffers
1309 .get(&buffer_id)
1310 .unwrap()
1311 .sources[1]
1312 .buffer_id,
1313 )
1314 .and_then(|s| s.buffer.line_count())
1315 .unwrap_or(0);
1316
1317 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1318 self.active_window_mut()
1319 .set_composite_alignment(buffer_id, alignment);
1320 }
1321
1322 if initial_focus_hunk.is_some() {
1324 if let Some(composite) = self
1325 .active_window_mut()
1326 .composite_buffers
1327 .get_mut(&buffer_id)
1328 {
1329 composite.initial_focus_hunk = initial_focus_hunk;
1330 }
1331 }
1332
1333 tracing::info!(
1334 "Created composite buffer '{}' with mode '{}' (id={:?})",
1335 name,
1336 mode,
1337 buffer_id
1338 );
1339
1340 if let Some(req_id) = _request_id {
1342 let result = buffer_id.0.to_string();
1344 self.plugin_manager
1345 .read()
1346 .unwrap()
1347 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1348 tracing::info!(
1349 "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1350 req_id
1351 );
1352 }
1353 }
1354
1355 #[cfg(feature = "plugins")]
1357 pub(crate) fn handle_update_composite_alignment(
1358 &mut self,
1359 buffer_id: BufferId,
1360 hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1361 ) {
1362 use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1363
1364 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1365 let diff_hunks: Vec<DiffHunk> = hunk_configs
1366 .into_iter()
1367 .map(|h| {
1368 DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)
1369 .with_ops(h.ops)
1370 })
1371 .collect();
1372
1373 let old_line_count = self
1375 .buffers()
1376 .get(&composite.sources[0].buffer_id)
1377 .and_then(|s| s.buffer.line_count())
1378 .unwrap_or(0);
1379 let new_line_count = self
1380 .buffers()
1381 .get(&composite.sources[1].buffer_id)
1382 .and_then(|s| s.buffer.line_count())
1383 .unwrap_or(0);
1384
1385 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1386 self.active_window_mut()
1387 .set_composite_alignment(buffer_id, alignment);
1388 }
1389 }
1390
1391 pub(crate) fn handle_composite_click(
1393 &mut self,
1394 col: u16,
1395 row: u16,
1396 split_id: LeafId,
1397 buffer_id: BufferId,
1398 content_rect: ratatui::layout::Rect,
1399 ) -> AnyhowResult<()> {
1400 let pane_idx = if let Some(view_state) = self
1402 .active_window()
1403 .composite_view_states
1404 .get(&(split_id, buffer_id))
1405 {
1406 let mut x = content_rect.x;
1407 let mut found_pane = 0;
1408 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1409 if col >= x && col < x + width {
1410 found_pane = i;
1411 break;
1412 }
1413 x += width + 1; }
1415 found_pane
1416 } else {
1417 0
1418 };
1419
1420 let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1423
1424 let (pane_start_x, left_column) = if let Some(view_state) = self
1426 .active_window()
1427 .composite_view_states
1428 .get(&(split_id, buffer_id))
1429 {
1430 let mut x = content_rect.x;
1431 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1432 if i == pane_idx {
1433 break;
1434 }
1435 x += width + 1;
1436 }
1437 let left_col = view_state
1438 .pane_viewports
1439 .get(pane_idx)
1440 .map(|vp| vp.left_column)
1441 .unwrap_or(0);
1442 (x, left_col)
1443 } else {
1444 (content_rect.x, 0)
1445 };
1446 let gutter_width = 4; let visual_col = col
1448 .saturating_sub(pane_start_x)
1449 .saturating_sub(gutter_width) as usize;
1450 let click_col = left_column + visual_col;
1452
1453 let display_row = if let Some(view_state) = self
1455 .active_window()
1456 .composite_view_states
1457 .get(&(split_id, buffer_id))
1458 {
1459 view_state.scroll_row + content_row
1460 } else {
1461 content_row
1462 };
1463
1464 let line_length =
1465 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1466 composite
1467 .alignment
1468 .get_row(display_row)
1469 .and_then(|row| row.get_pane_line(pane_idx))
1470 .and_then(|line_ref| {
1471 let source = composite.sources.get(pane_idx)?;
1472 self.windows
1473 .get(&self.active_window)
1474 .map(|w| &w.buffers)
1475 .expect("active window present")
1476 .get(&source.buffer_id)?
1477 .buffer
1478 .get_line(line_ref.line)
1479 })
1480 .map(|bytes| {
1481 let s = String::from_utf8_lossy(&bytes);
1482 let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1484 trimmed.graphemes(true).count()
1485 })
1486 .unwrap_or(0)
1487 } else {
1488 0
1489 };
1490
1491 let clamped_col = click_col.min(line_length);
1493
1494 if let Some(composite) = self
1496 .active_window_mut()
1497 .composite_buffers
1498 .get_mut(&buffer_id)
1499 {
1500 composite.active_pane = pane_idx;
1501 }
1502
1503 if let Some(view_state) = self
1505 .active_window_mut()
1506 .composite_view_states
1507 .get_mut(&(split_id, buffer_id))
1508 {
1509 view_state.focused_pane = pane_idx;
1510 view_state.cursor_row = display_row;
1511 view_state.cursor_column = clamped_col;
1512 view_state.sticky_column = clamped_col;
1513
1514 view_state.clear_selection();
1516 }
1517
1518 self.active_window_mut().mouse_state.dragging_text_selection = false; self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1521
1522 self.active_window_mut()
1524 .sync_editor_cursor_from_composite(split_id, buffer_id);
1525
1526 Ok(())
1527 }
1528}