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 Editor {
127 pub fn flush_layout(&mut self) {
140 use crate::view::composite_view::CompositeViewState;
141
142 let visible = self
143 .split_manager
144 .get_visible_buffers(ratatui::layout::Rect {
145 x: 0,
146 y: 0,
147 width: self.terminal_width,
148 height: self.terminal_height,
149 });
150
151 for (split_id, buffer_id, _area) in &visible {
152 if let Some(composite) = self.composite_buffers.get(buffer_id) {
154 let pane_count = composite.pane_count();
155 self.composite_view_states
156 .entry((*split_id, *buffer_id))
157 .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
158 }
159 }
160 }
161
162 pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool {
168 self.composite_buffers.contains_key(&buffer_id)
169 }
170
171 pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> {
173 self.composite_buffers.get(&buffer_id)
174 }
175
176 pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> {
178 self.composite_buffers.get_mut(&buffer_id)
179 }
180
181 pub fn get_composite_view_state(
183 &mut self,
184 split_id: LeafId,
185 buffer_id: BufferId,
186 ) -> Option<&mut CompositeViewState> {
187 if !self.composite_buffers.contains_key(&buffer_id) {
188 return None;
189 }
190
191 let pane_count = self.composite_buffers.get(&buffer_id)?.pane_count();
192
193 Some(
194 self.composite_view_states
195 .entry((split_id, buffer_id))
196 .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
197 )
198 }
199
200 pub fn create_composite_buffer(
211 &mut self,
212 name: String,
213 mode: String,
214 layout: CompositeLayout,
215 sources: Vec<SourcePane>,
216 ) -> BufferId {
217 let buffer_id = BufferId(self.next_buffer_id);
218 self.next_buffer_id += 1;
219
220 let composite =
221 CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
222 self.composite_buffers.insert(buffer_id, composite);
223
224 let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
228 metadata.hidden_from_tabs = false;
229 self.buffer_metadata.insert(buffer_id, metadata);
230
231 let mut state = crate::state::EditorState::new(
234 80,
235 24,
236 crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
237 std::sync::Arc::clone(&self.authority.filesystem),
238 );
239 state.is_composite_buffer = true;
240 state.editing_disabled = true;
241 state.mode = mode;
242 self.buffers.insert(buffer_id, state);
243
244 self.event_logs
246 .insert(buffer_id, crate::model::event::EventLog::new());
247
248 let split_id = self.split_manager.active_split();
250 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
251 view_state.add_buffer(buffer_id);
252 }
253
254 buffer_id
255 }
256
257 pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
262 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
263 composite.set_alignment(alignment);
264 }
265 }
266
267 pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
269 self.composite_buffers.remove(&buffer_id);
270 self.buffer_metadata.remove(&buffer_id);
271
272 self.composite_view_states
274 .retain(|(_, bid), _| *bid != buffer_id);
275 }
276
277 pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
279 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
280 composite.focus_next();
281 }
282 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
284 view_state.focus_next_pane();
285 }
286 }
287
288 pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
290 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
291 composite.focus_prev();
292 }
293 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
295 view_state.focus_prev_pane();
296 }
297 }
298
299 pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
301 let split_id = self.split_manager.active_split();
302 self.composite_next_hunk(split_id, buffer_id)
303 }
304
305 pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
307 let split_id = self.split_manager.active_split();
308 self.composite_prev_hunk(split_id, buffer_id)
309 }
310
311 pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
314 let viewport_height = self.get_composite_viewport_height(split_id);
315 let moved = if let (Some(composite), Some(view_state)) = (
316 self.composite_buffers.get(&buffer_id),
317 self.composite_view_states.get_mut(&(split_id, buffer_id)),
318 ) {
319 if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
322 view_state.cursor_row = next_row;
323 let context_above = viewport_height / 3;
325 view_state.scroll_row = next_row.saturating_sub(context_above);
326 true
327 } else {
328 false
329 }
330 } else {
331 false
332 };
333 if moved {
338 self.sync_editor_cursor_from_composite(split_id, buffer_id);
339 }
340 moved
341 }
342
343 pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
346 let viewport_height = self.get_composite_viewport_height(split_id);
347 let moved = if let (Some(composite), Some(view_state)) = (
348 self.composite_buffers.get(&buffer_id),
349 self.composite_view_states.get_mut(&(split_id, buffer_id)),
350 ) {
351 if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
352 view_state.cursor_row = prev_row;
353 let context_above = viewport_height / 3;
354 view_state.scroll_row = prev_row.saturating_sub(context_above);
355 true
356 } else {
357 false
358 }
359 } else {
360 false
361 };
362 if moved {
363 self.sync_editor_cursor_from_composite(split_id, buffer_id);
364 }
365 moved
366 }
367
368 pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
370 if let (Some(composite), Some(view_state)) = (
371 self.composite_buffers.get(&buffer_id),
372 self.composite_view_states.get_mut(&(split_id, buffer_id)),
373 ) {
374 let max_row = composite.row_count().saturating_sub(1);
375 view_state.scroll(delta, max_row);
376 }
377 }
378
379 pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
381 if let (Some(composite), Some(view_state)) = (
382 self.composite_buffers.get(&buffer_id),
383 self.composite_view_states.get_mut(&(split_id, buffer_id)),
384 ) {
385 let max_row = composite.row_count().saturating_sub(1);
386 view_state.set_scroll_row(row, max_row);
387 }
388 }
389
390 fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
397 const COMPOSITE_HEADER_HEIGHT: u16 = 1;
398 const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
399
400 self.split_view_states
401 .get(&split_id)
402 .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
403 .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
404 }
405
406 fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
408 let composite = self.composite_buffers.get(&buffer_id);
409 let view_state = self.composite_view_states.get(&(split_id, buffer_id));
410
411 if let (Some(composite), Some(view_state)) = (composite, view_state) {
412 let pane_line = composite
413 .alignment
414 .get_row(view_state.cursor_row)
415 .and_then(|row| row.get_pane_line(view_state.focused_pane));
416
417 tracing::debug!(
418 "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
419 view_state.cursor_row,
420 view_state.focused_pane,
421 pane_line
422 );
423
424 let line_bytes = pane_line.and_then(|line_ref| {
425 let source = composite.sources.get(view_state.focused_pane)?;
426 self.buffers
427 .get(&source.buffer_id)?
428 .buffer
429 .get_line(line_ref.line)
430 });
431
432 let content = line_bytes
433 .as_ref()
434 .map(|b| {
435 let s = String::from_utf8_lossy(b).to_string();
436 s.trim_end_matches('\n').trim_end_matches('\r').to_string()
438 })
439 .unwrap_or_default();
440 let length = content.graphemes(true).count();
441 let pane_width = view_state
442 .pane_widths
443 .get(view_state.focused_pane)
444 .copied()
445 .unwrap_or(40) as usize;
446
447 CursorLineInfo {
448 content,
449 length,
450 pane_width,
451 }
452 } else {
453 CursorLineInfo {
454 content: String::new(),
455 length: 0,
456 pane_width: 40,
457 }
458 }
459 }
460
461 fn apply_cursor_movement(
463 &mut self,
464 split_id: LeafId,
465 buffer_id: BufferId,
466 movement: CursorMovement,
467 line_info: &CursorLineInfo,
468 viewport_height: usize,
469 ) {
470 let max_row = self
471 .composite_buffers
472 .get(&buffer_id)
473 .map(|c| c.row_count().saturating_sub(1))
474 .unwrap_or(0);
475
476 let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
477 let mut wrapped_to_new_line = false;
478
479 let composite = self.composite_buffers.get(&buffer_id);
481
482 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
483 match movement {
484 CursorMovement::Down => {
485 view_state.move_cursor_down(max_row, viewport_height);
486 }
487 CursorMovement::Up => {
488 view_state.move_cursor_up(viewport_height);
489 }
490 CursorMovement::Left => {
491 if view_state.cursor_column > 0 {
492 view_state.move_cursor_left();
493 } else if view_state.cursor_row > 0 {
494 if let Some(composite) = composite {
496 let focused_pane = view_state.focused_pane;
497 let mut target_row = view_state.cursor_row - 1;
498 while target_row > 0 {
499 if let Some(row) = composite.alignment.get_row(target_row) {
500 if row.get_pane_line(focused_pane).is_some() {
501 break;
502 }
503 }
504 target_row -= 1;
505 }
506 if let Some(row) = composite.alignment.get_row(target_row) {
508 if row.get_pane_line(focused_pane).is_some() {
509 view_state.cursor_row = target_row;
510 if view_state.cursor_row < view_state.scroll_row {
511 view_state.scroll_row = view_state.cursor_row;
512 }
513 wrapped_to_new_line = true;
514 }
515 }
516 }
517 }
518 }
519 CursorMovement::Right => {
520 if view_state.cursor_column < line_info.length {
521 view_state.move_cursor_right(line_info.length, line_info.pane_width);
522 } else if view_state.cursor_row < max_row {
523 if let Some(composite) = composite {
525 let focused_pane = view_state.focused_pane;
526 let mut target_row = view_state.cursor_row + 1;
527 while target_row < max_row {
528 if let Some(row) = composite.alignment.get_row(target_row) {
529 if row.get_pane_line(focused_pane).is_some() {
530 break;
531 }
532 }
533 target_row += 1;
534 }
535 if let Some(row) = composite.alignment.get_row(target_row) {
537 if row.get_pane_line(focused_pane).is_some() {
538 view_state.cursor_row = target_row;
539 view_state.cursor_column = 0;
540 view_state.sticky_column = 0;
541 if view_state.cursor_row
542 >= view_state.scroll_row + viewport_height
543 {
544 view_state.scroll_row = view_state
545 .cursor_row
546 .saturating_sub(viewport_height - 1);
547 }
548 for viewport in &mut view_state.pane_viewports {
550 viewport.left_column = 0;
551 }
552 }
553 }
554 }
555 }
556 }
557 CursorMovement::LineStart => {
558 view_state.move_cursor_to_line_start();
559 }
560 CursorMovement::LineEnd => {
561 view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
562 }
563 CursorMovement::WordLeft => {
564 let new_col =
565 find_word_boundary_left(&line_info.content, view_state.cursor_column);
566 if new_col < view_state.cursor_column {
567 view_state.cursor_column = new_col;
568 view_state.sticky_column = new_col;
569 let current_left = view_state
571 .pane_viewports
572 .get(view_state.focused_pane)
573 .map(|v| v.left_column)
574 .unwrap_or(0);
575 if view_state.cursor_column < current_left {
576 for viewport in &mut view_state.pane_viewports {
577 viewport.left_column = view_state.cursor_column;
578 }
579 }
580 } else if view_state.cursor_row > 0 {
581 if let Some(composite) = composite {
583 let focused_pane = view_state.focused_pane;
584 let mut target_row = view_state.cursor_row - 1;
585 while target_row > 0 {
586 if let Some(row) = composite.alignment.get_row(target_row) {
587 if row.get_pane_line(focused_pane).is_some() {
588 break;
589 }
590 }
591 target_row -= 1;
592 }
593 if let Some(row) = composite.alignment.get_row(target_row) {
595 if row.get_pane_line(focused_pane).is_some() {
596 view_state.cursor_row = target_row;
597 if view_state.cursor_row < view_state.scroll_row {
598 view_state.scroll_row = view_state.cursor_row;
599 }
600 wrapped_to_new_line = true;
601 }
602 }
603 }
604 }
605 }
606 CursorMovement::WordRight => {
607 let new_col = find_word_boundary_right(
608 &line_info.content,
609 view_state.cursor_column,
610 line_info.length,
611 );
612 if new_col > view_state.cursor_column {
613 view_state.cursor_column = new_col;
614 view_state.sticky_column = new_col;
615 let visible_width = line_info.pane_width.saturating_sub(4);
617 let current_left = view_state
618 .pane_viewports
619 .get(view_state.focused_pane)
620 .map(|v| v.left_column)
621 .unwrap_or(0);
622 if visible_width > 0
623 && view_state.cursor_column >= current_left + visible_width
624 {
625 let new_left = view_state
626 .cursor_column
627 .saturating_sub(visible_width.saturating_sub(1));
628 for viewport in &mut view_state.pane_viewports {
629 viewport.left_column = new_left;
630 }
631 }
632 } else if view_state.cursor_row < max_row {
633 if let Some(composite) = composite {
635 let focused_pane = view_state.focused_pane;
636 let mut target_row = view_state.cursor_row + 1;
637 while target_row < max_row {
638 if let Some(row) = composite.alignment.get_row(target_row) {
639 if row.get_pane_line(focused_pane).is_some() {
640 break;
641 }
642 }
643 target_row += 1;
644 }
645 if let Some(row) = composite.alignment.get_row(target_row) {
647 if row.get_pane_line(focused_pane).is_some() {
648 view_state.cursor_row = target_row;
649 view_state.cursor_column = 0;
650 view_state.sticky_column = 0;
651 if view_state.cursor_row
652 >= view_state.scroll_row + viewport_height
653 {
654 view_state.scroll_row = view_state
655 .cursor_row
656 .saturating_sub(viewport_height - 1);
657 }
658 for viewport in &mut view_state.pane_viewports {
660 viewport.left_column = 0;
661 }
662 }
663 }
664 }
665 }
666 }
667 CursorMovement::WordEnd => {
668 let new_col = find_word_end_right(
669 &line_info.content,
670 view_state.cursor_column,
671 line_info.length,
672 );
673 if new_col > view_state.cursor_column {
674 view_state.cursor_column = new_col;
675 view_state.sticky_column = new_col;
676 let visible_width = line_info.pane_width.saturating_sub(4);
678 let current_left = view_state
679 .pane_viewports
680 .get(view_state.focused_pane)
681 .map(|v| v.left_column)
682 .unwrap_or(0);
683 if visible_width > 0
684 && view_state.cursor_column >= current_left + visible_width
685 {
686 let new_left = view_state
687 .cursor_column
688 .saturating_sub(visible_width.saturating_sub(1));
689 for viewport in &mut view_state.pane_viewports {
690 viewport.left_column = new_left;
691 }
692 }
693 } else if view_state.cursor_row < max_row {
694 if let Some(composite) = composite {
696 let focused_pane = view_state.focused_pane;
697 let mut target_row = view_state.cursor_row + 1;
698 while target_row < max_row {
699 if let Some(row) = composite.alignment.get_row(target_row) {
700 if row.get_pane_line(focused_pane).is_some() {
701 break;
702 }
703 }
704 target_row += 1;
705 }
706 if let Some(row) = composite.alignment.get_row(target_row) {
708 if row.get_pane_line(focused_pane).is_some() {
709 view_state.cursor_row = target_row;
710 view_state.cursor_column = 0;
711 view_state.sticky_column = 0;
712 if view_state.cursor_row
713 >= view_state.scroll_row + viewport_height
714 {
715 view_state.scroll_row = view_state
716 .cursor_row
717 .saturating_sub(viewport_height - 1);
718 }
719 for viewport in &mut view_state.pane_viewports {
721 viewport.left_column = 0;
722 }
723 }
724 }
725 }
726 }
727 }
728 }
729 }
730
731 if is_vertical || wrapped_to_new_line {
733 let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
734 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
735 if wrapped_to_new_line
736 && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
737 {
738 tracing::debug!(
740 "Wrap left to row {}, setting column to line length {}",
741 view_state.cursor_row,
742 new_line_info.length
743 );
744 view_state.cursor_column = new_line_info.length;
745 view_state.sticky_column = new_line_info.length;
746 let visible_width = new_line_info.pane_width.saturating_sub(4);
748 if visible_width > 0 && view_state.cursor_column >= visible_width {
749 let new_left = view_state
750 .cursor_column
751 .saturating_sub(visible_width.saturating_sub(1));
752 for viewport in &mut view_state.pane_viewports {
753 viewport.left_column = new_left;
754 }
755 }
756 } else {
757 view_state.clamp_cursor_to_line(new_line_info.length);
758 }
759 }
760 }
761 }
762
763 fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
765 let (cursor_row, cursor_column, focused_pane) = self
766 .composite_view_states
767 .get(&(split_id, buffer_id))
768 .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
769 .unwrap_or((0, 0, 0));
770
771 let display_line = self
775 .composite_buffers
776 .get(&buffer_id)
777 .and_then(|composite| composite.alignment.get_row(cursor_row))
778 .and_then(|row| row.get_pane_line(focused_pane))
779 .map(|line_ref| line_ref.line)
780 .unwrap_or(cursor_row); if let Some(state) = self.buffers.get_mut(&buffer_id) {
783 state.primary_cursor_line_number =
784 crate::model::buffer::LineNumber::Absolute(display_line);
785 }
786 let active_split = self.split_manager.active_split();
788 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
789 view_state.cursors.primary_mut().position = cursor_column;
790 }
791 }
792
793 fn handle_cursor_movement_action(
795 &mut self,
796 split_id: LeafId,
797 buffer_id: BufferId,
798 movement: CursorMovement,
799 extend_selection: bool,
800 ) -> Option<bool> {
801 let viewport_height = self.get_composite_viewport_height(split_id);
802
803 let line_info = self.get_cursor_line_info(split_id, buffer_id);
804
805 if extend_selection {
806 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
808 if !view_state.visual_mode {
809 view_state.start_visual_selection();
810 }
811 }
812 } else {
813 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
815 if view_state.visual_mode {
816 view_state.clear_selection();
817 }
818 }
819 }
820
821 self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
822 self.sync_editor_cursor_from_composite(split_id, buffer_id);
823
824 Some(true)
825 }
826
827 pub fn handle_composite_action(
833 &mut self,
834 buffer_id: BufferId,
835 action: &crate::input::keybindings::Action,
836 ) -> Option<bool> {
837 use crate::input::keybindings::Action;
838
839 let split_id = self.split_manager.active_split();
840
841 let _composite = self.composite_buffers.get(&buffer_id)?;
843 let _view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
844
845 match action {
846 Action::InsertTab => {
848 self.composite_focus_next(split_id, buffer_id);
849 Some(true)
850 }
851
852 Action::Copy => {
854 self.handle_composite_copy(split_id, buffer_id);
855 Some(true)
856 }
857
858 Action::MoveDown => {
860 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
861 }
862 Action::MoveUp => {
863 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
864 }
865 Action::MoveLeft => {
866 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
867 }
868 Action::MoveRight => self.handle_cursor_movement_action(
869 split_id,
870 buffer_id,
871 CursorMovement::Right,
872 false,
873 ),
874 Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
875 split_id,
876 buffer_id,
877 CursorMovement::LineStart,
878 false,
879 ),
880 Action::MoveLineEnd => self.handle_cursor_movement_action(
881 split_id,
882 buffer_id,
883 CursorMovement::LineEnd,
884 false,
885 ),
886 Action::MoveWordLeft => self.handle_cursor_movement_action(
887 split_id,
888 buffer_id,
889 CursorMovement::WordLeft,
890 false,
891 ),
892 Action::MoveWordRight => self.handle_cursor_movement_action(
893 split_id,
894 buffer_id,
895 CursorMovement::WordRight,
896 false,
897 ),
898 Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
899 split_id,
900 buffer_id,
901 CursorMovement::WordEnd,
902 false,
903 ),
904 Action::MoveLeftInLine => {
905 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
906 }
907 Action::MoveRightInLine => self.handle_cursor_movement_action(
908 split_id,
909 buffer_id,
910 CursorMovement::Right,
911 false,
912 ),
913
914 Action::SelectDown => {
916 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
917 }
918 Action::SelectUp => {
919 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
920 }
921 Action::SelectLeft => {
922 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
923 }
924 Action::SelectRight => {
925 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
926 }
927 Action::SelectLineStart => self.handle_cursor_movement_action(
928 split_id,
929 buffer_id,
930 CursorMovement::LineStart,
931 true,
932 ),
933 Action::SelectLineEnd => self.handle_cursor_movement_action(
934 split_id,
935 buffer_id,
936 CursorMovement::LineEnd,
937 true,
938 ),
939 Action::SelectWordLeft => self.handle_cursor_movement_action(
940 split_id,
941 buffer_id,
942 CursorMovement::WordLeft,
943 true,
944 ),
945 Action::SelectWordRight => self.handle_cursor_movement_action(
946 split_id,
947 buffer_id,
948 CursorMovement::WordRight,
949 true,
950 ),
951 Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
952 split_id,
953 buffer_id,
954 CursorMovement::WordEnd,
955 true,
956 ),
957
958 Action::MovePageDown | Action::MovePageUp => {
960 let viewport_height = self.get_composite_viewport_height(split_id);
961
962 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
963 {
964 if matches!(action, Action::MovePageDown) {
965 if let Some(composite) = self.composite_buffers.get(&buffer_id) {
966 let max_row = composite.row_count().saturating_sub(1);
967 view_state.page_down(viewport_height, max_row);
968 view_state.cursor_row = view_state.scroll_row;
969 }
970 } else {
971 view_state.page_up(viewport_height);
972 view_state.cursor_row = view_state.scroll_row;
973 }
974 }
975 self.sync_editor_cursor_from_composite(split_id, buffer_id);
976 Some(true)
977 }
978
979 Action::MoveDocumentStart | Action::MoveDocumentEnd => {
981 let viewport_height = self.get_composite_viewport_height(split_id);
982
983 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id))
984 {
985 if matches!(action, Action::MoveDocumentStart) {
986 view_state.move_cursor_to_top();
987 } else if let Some(composite) = self.composite_buffers.get(&buffer_id) {
988 let max_row = composite.row_count().saturating_sub(1);
989 view_state.move_cursor_to_bottom(max_row, viewport_height);
990 }
991 }
992 self.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.composite_scroll(split_id, buffer_id, delta);
1004 Some(true)
1005 }
1006
1007 _ => None,
1009 }
1010 }
1011
1012 fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1014 let text = {
1015 let composite = match self.composite_buffers.get(&buffer_id) {
1016 Some(c) => c,
1017 None => return,
1018 };
1019 let view_state = match self.composite_view_states.get(&(split_id, buffer_id)) {
1020 Some(vs) => vs,
1021 None => return,
1022 };
1023
1024 let (start_row, end_row) = match view_state.selection_row_range() {
1025 Some(range) => range,
1026 None => return,
1027 };
1028
1029 let source = match composite.sources.get(view_state.focused_pane) {
1030 Some(s) => s,
1031 None => return,
1032 };
1033
1034 let source_state = match self.buffers.get(&source.buffer_id) {
1035 Some(s) => s,
1036 None => return,
1037 };
1038
1039 let mut text = String::new();
1041 for row in start_row..=end_row {
1042 if let Some(aligned_row) = composite.alignment.rows.get(row) {
1043 if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1044 if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1045 if !text.is_empty() {
1046 text.push('\n');
1047 }
1048 let line_str = String::from_utf8_lossy(&line_bytes);
1050 let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1051 text.push_str(line_trimmed);
1052 }
1053 }
1054 }
1055 }
1056 text
1057 };
1058
1059 if !text.is_empty() {
1060 self.clipboard.copy(text);
1061 }
1062
1063 }
1065
1066 pub(crate) fn handle_create_composite_buffer(
1072 &mut self,
1073 name: String,
1074 mode: String,
1075 layout_config: fresh_core::api::CompositeLayoutConfig,
1076 source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1077 hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1078 initial_focus_hunk: Option<usize>,
1079 _request_id: Option<u64>,
1080 ) {
1081 use crate::model::composite_buffer::{
1082 CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1083 };
1084
1085 let layout = match layout_config.layout_type.as_str() {
1087 "stacked" => CompositeLayout::Stacked {
1088 spacing: layout_config.spacing.unwrap_or(1),
1089 },
1090 "unified" => CompositeLayout::Unified,
1091 _ => CompositeLayout::SideBySide {
1092 ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1093 show_separator: layout_config.show_separator,
1094 },
1095 };
1096
1097 let sources: Vec<SourcePane> = source_configs
1099 .into_iter()
1100 .map(|src| {
1101 let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1102 if let Some(style_config) = src.style {
1103 let gutter_style = match style_config.gutter_style.as_deref() {
1104 Some("diff-markers") => GutterStyle::DiffMarkers,
1105 Some("both") => GutterStyle::Both,
1106 Some("none") => GutterStyle::None,
1107 _ => GutterStyle::LineNumbers,
1108 };
1109 let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1111 pane.style = PaneStyle {
1112 add_bg: style_config.add_bg.map(to_tuple),
1113 remove_bg: style_config.remove_bg.map(to_tuple),
1114 modify_bg: style_config.modify_bg.map(to_tuple),
1115 gutter_style,
1116 };
1117 }
1118 pane
1119 })
1120 .collect();
1121
1122 let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1124
1125 if let Some(hunk_configs) = hunks {
1127 let diff_hunks: Vec<DiffHunk> = hunk_configs
1128 .into_iter()
1129 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1130 .collect();
1131
1132 let old_line_count = self
1134 .buffers
1135 .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[0].buffer_id)
1136 .and_then(|s| s.buffer.line_count())
1137 .unwrap_or(0);
1138 let new_line_count = self
1139 .buffers
1140 .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[1].buffer_id)
1141 .and_then(|s| s.buffer.line_count())
1142 .unwrap_or(0);
1143
1144 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1145 self.set_composite_alignment(buffer_id, alignment);
1146 }
1147
1148 if initial_focus_hunk.is_some() {
1150 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
1151 composite.initial_focus_hunk = initial_focus_hunk;
1152 }
1153 }
1154
1155 tracing::info!(
1156 "Created composite buffer '{}' with mode '{}' (id={:?})",
1157 name,
1158 mode,
1159 buffer_id
1160 );
1161
1162 if let Some(req_id) = _request_id {
1164 let result = buffer_id.0.to_string();
1166 self.plugin_manager
1167 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1168 tracing::info!(
1169 "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1170 req_id
1171 );
1172 }
1173 }
1174
1175 pub(crate) fn handle_update_composite_alignment(
1177 &mut self,
1178 buffer_id: BufferId,
1179 hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1180 ) {
1181 use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1182
1183 if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1184 let diff_hunks: Vec<DiffHunk> = hunk_configs
1185 .into_iter()
1186 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1187 .collect();
1188
1189 let old_line_count = self
1191 .buffers
1192 .get(&composite.sources[0].buffer_id)
1193 .and_then(|s| s.buffer.line_count())
1194 .unwrap_or(0);
1195 let new_line_count = self
1196 .buffers
1197 .get(&composite.sources[1].buffer_id)
1198 .and_then(|s| s.buffer.line_count())
1199 .unwrap_or(0);
1200
1201 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1202 self.set_composite_alignment(buffer_id, alignment);
1203 }
1204 }
1205
1206 pub(crate) fn handle_composite_click(
1208 &mut self,
1209 col: u16,
1210 row: u16,
1211 split_id: LeafId,
1212 buffer_id: BufferId,
1213 content_rect: ratatui::layout::Rect,
1214 ) -> AnyhowResult<()> {
1215 let pane_idx =
1217 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1218 let mut x = content_rect.x;
1219 let mut found_pane = 0;
1220 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1221 if col >= x && col < x + width {
1222 found_pane = i;
1223 break;
1224 }
1225 x += width + 1; }
1227 found_pane
1228 } else {
1229 0
1230 };
1231
1232 let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1235
1236 let (pane_start_x, left_column) =
1238 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1239 let mut x = content_rect.x;
1240 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1241 if i == pane_idx {
1242 break;
1243 }
1244 x += width + 1;
1245 }
1246 let left_col = view_state
1247 .pane_viewports
1248 .get(pane_idx)
1249 .map(|vp| vp.left_column)
1250 .unwrap_or(0);
1251 (x, left_col)
1252 } else {
1253 (content_rect.x, 0)
1254 };
1255 let gutter_width = 4; let visual_col = col
1257 .saturating_sub(pane_start_x)
1258 .saturating_sub(gutter_width) as usize;
1259 let click_col = left_column + visual_col;
1261
1262 let display_row =
1264 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id)) {
1265 view_state.scroll_row + content_row
1266 } else {
1267 content_row
1268 };
1269
1270 let line_length = if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1271 composite
1272 .alignment
1273 .get_row(display_row)
1274 .and_then(|row| row.get_pane_line(pane_idx))
1275 .and_then(|line_ref| {
1276 let source = composite.sources.get(pane_idx)?;
1277 self.buffers
1278 .get(&source.buffer_id)?
1279 .buffer
1280 .get_line(line_ref.line)
1281 })
1282 .map(|bytes| {
1283 let s = String::from_utf8_lossy(&bytes);
1284 let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1286 trimmed.graphemes(true).count()
1287 })
1288 .unwrap_or(0)
1289 } else {
1290 0
1291 };
1292
1293 let clamped_col = click_col.min(line_length);
1295
1296 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
1298 composite.active_pane = pane_idx;
1299 }
1300
1301 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1303 view_state.focused_pane = pane_idx;
1304 view_state.cursor_row = display_row;
1305 view_state.cursor_column = clamped_col;
1306 view_state.sticky_column = clamped_col;
1307
1308 view_state.clear_selection();
1310 }
1311
1312 self.mouse_state.dragging_text_selection = false; self.mouse_state.drag_selection_split = Some(split_id);
1315
1316 self.sync_editor_cursor_from_composite(split_id, buffer_id);
1318
1319 Ok(())
1320 }
1321}