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 let active_split = self
286 .buffers
287 .splits()
288 .map(|(mgr, _)| mgr)
289 .expect("window must have a populated split layout")
290 .active_split();
291 if let Some((_, vs_map)) = self.buffers.splits_mut() {
292 if let Some(view_state) = vs_map.get_mut(&active_split) {
293 view_state.cursors.primary_mut().position = cursor_column;
294 }
295 }
296 }
297
298 pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
303 let viewport_height = self.get_composite_viewport_height(split_id);
304 let moved = if let (Some(composite), Some(view_state)) = (
305 self.composite_buffers.get(&buffer_id),
306 self.composite_view_states.get_mut(&(split_id, buffer_id)),
307 ) {
308 if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
309 view_state.cursor_row = next_row;
310 let context_above = viewport_height / 3;
311 view_state.scroll_row = next_row.saturating_sub(context_above);
312 true
313 } else {
314 false
315 }
316 } else {
317 false
318 };
319 if moved {
320 self.sync_editor_cursor_from_composite(split_id, buffer_id);
321 }
322 moved
323 }
324
325 pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
328 let viewport_height = self.get_composite_viewport_height(split_id);
329 let moved = if let (Some(composite), Some(view_state)) = (
330 self.composite_buffers.get(&buffer_id),
331 self.composite_view_states.get_mut(&(split_id, buffer_id)),
332 ) {
333 if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
334 view_state.cursor_row = prev_row;
335 let context_above = viewport_height / 3;
336 view_state.scroll_row = prev_row.saturating_sub(context_above);
337 true
338 } else {
339 false
340 }
341 } else {
342 false
343 };
344 if moved {
345 self.sync_editor_cursor_from_composite(split_id, buffer_id);
346 }
347 moved
348 }
349
350 pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
354 let split_id = self
355 .buffers
356 .splits()
357 .map(|(mgr, _)| mgr)
358 .expect("window must have a populated split layout")
359 .active_split();
360 self.composite_next_hunk(split_id, buffer_id)
361 }
362
363 pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
365 let split_id = self
366 .buffers
367 .splits()
368 .map(|(mgr, _)| mgr)
369 .expect("window must have a populated split layout")
370 .active_split();
371 self.composite_prev_hunk(split_id, buffer_id)
372 }
373
374 pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
378 if let (Some(composite), Some(view_state)) = (
379 self.composite_buffers.get(&buffer_id),
380 self.composite_view_states.get_mut(&(split_id, buffer_id)),
381 ) {
382 let max_row = composite.row_count().saturating_sub(1);
383 view_state.scroll(delta, max_row);
384 }
385 }
386
387 pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
389 if let (Some(composite), Some(view_state)) = (
390 self.composite_buffers.get(&buffer_id),
391 self.composite_view_states.get_mut(&(split_id, buffer_id)),
392 ) {
393 let max_row = composite.row_count().saturating_sub(1);
394 view_state.set_scroll_row(row, max_row);
395 }
396 }
397}
398
399impl Editor {
400 pub fn flush_layout(&mut self) {
413 use crate::view::composite_view::CompositeViewState;
414
415 let visible = self
416 .windows
417 .get(&self.active_window)
418 .and_then(|w| w.buffers.splits())
419 .map(|(mgr, _)| mgr)
420 .expect("active window must have a populated split layout")
421 .get_visible_buffers(ratatui::layout::Rect {
422 x: 0,
423 y: 0,
424 width: self.terminal_width,
425 height: self.terminal_height,
426 });
427
428 for (split_id, buffer_id, _area) in &visible {
429 if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
431 let pane_count = composite.pane_count();
432 self.active_window_mut()
433 .composite_view_states
434 .entry((*split_id, *buffer_id))
435 .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
436 }
437 }
438 }
439
440 pub fn get_composite_view_state(
455 &mut self,
456 split_id: LeafId,
457 buffer_id: BufferId,
458 ) -> Option<&mut CompositeViewState> {
459 if !self
460 .active_window()
461 .composite_buffers
462 .contains_key(&buffer_id)
463 {
464 return None;
465 }
466
467 let pane_count = self
468 .active_window()
469 .composite_buffers
470 .get(&buffer_id)?
471 .pane_count();
472
473 Some(
474 self.active_window_mut()
475 .composite_view_states
476 .entry((split_id, buffer_id))
477 .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
478 )
479 }
480
481 pub fn create_composite_buffer(
492 &mut self,
493 name: String,
494 mode: String,
495 layout: CompositeLayout,
496 sources: Vec<SourcePane>,
497 ) -> BufferId {
498 let buffer_id = self.alloc_buffer_id();
499
500 let composite =
501 CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
502 self.active_window_mut()
503 .composite_buffers
504 .insert(buffer_id, composite);
505
506 let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
510 metadata.hidden_from_tabs = false;
511 self.active_window_mut()
512 .buffer_metadata
513 .insert(buffer_id, metadata);
514
515 let mut state = crate::state::EditorState::new(
518 80,
519 24,
520 crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
521 std::sync::Arc::clone(&self.authority.filesystem),
522 );
523 state.is_composite_buffer = true;
524 state.editing_disabled = true;
525 state.mode = mode;
526 self.windows
527 .get_mut(&self.active_window)
528 .map(|w| &mut w.buffers)
529 .expect("active window present")
530 .insert(buffer_id, state);
531 self.active_window_mut()
533 .event_logs
534 .insert(buffer_id, crate::model::event::EventLog::new());
535
536 let split_id = self
538 .windows
539 .get(&self.active_window)
540 .and_then(|w| w.buffers.splits())
541 .map(|(mgr, _)| mgr)
542 .expect("active window must have a populated split layout")
543 .active_split();
544 if let Some(view_state) = self
545 .windows
546 .get_mut(&self.active_window)
547 .and_then(|w| w.split_view_states_mut())
548 .expect("active window must have a populated split layout")
549 .get_mut(&split_id)
550 {
551 view_state.add_buffer(buffer_id);
552 }
553
554 buffer_id
555 }
556
557 fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
568 let composite = self.active_window().composite_buffers.get(&buffer_id);
569 let view_state = self
570 .active_window()
571 .composite_view_states
572 .get(&(split_id, buffer_id));
573
574 if let (Some(composite), Some(view_state)) = (composite, view_state) {
575 let pane_line = composite
576 .alignment
577 .get_row(view_state.cursor_row)
578 .and_then(|row| row.get_pane_line(view_state.focused_pane));
579
580 tracing::debug!(
581 "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
582 view_state.cursor_row,
583 view_state.focused_pane,
584 pane_line
585 );
586
587 let line_bytes = pane_line.and_then(|line_ref| {
588 let source = composite.sources.get(view_state.focused_pane)?;
589 self.windows
590 .get(&self.active_window)
591 .map(|w| &w.buffers)
592 .expect("active window present")
593 .get(&source.buffer_id)?
594 .buffer
595 .get_line(line_ref.line)
596 });
597
598 let content = line_bytes
599 .as_ref()
600 .map(|b| {
601 let s = String::from_utf8_lossy(b).to_string();
602 s.trim_end_matches('\n').trim_end_matches('\r').to_string()
604 })
605 .unwrap_or_default();
606 let length = content.graphemes(true).count();
607 let pane_width = view_state
608 .pane_widths
609 .get(view_state.focused_pane)
610 .copied()
611 .unwrap_or(40) as usize;
612
613 CursorLineInfo {
614 content,
615 length,
616 pane_width,
617 }
618 } else {
619 CursorLineInfo {
620 content: String::new(),
621 length: 0,
622 pane_width: 40,
623 }
624 }
625 }
626
627 fn apply_cursor_movement(
629 &mut self,
630 split_id: LeafId,
631 buffer_id: BufferId,
632 movement: CursorMovement,
633 line_info: &CursorLineInfo,
634 viewport_height: usize,
635 ) {
636 let max_row = self
637 .active_window_mut()
638 .composite_buffers
639 .get(&buffer_id)
640 .map(|c| c.row_count().saturating_sub(1))
641 .unwrap_or(0);
642
643 let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
644 let mut wrapped_to_new_line = false;
645
646 let win = self.active_window_mut();
648 let composite = win.composite_buffers.get(&buffer_id);
649
650 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
651 match movement {
652 CursorMovement::Down => {
653 view_state.move_cursor_down(max_row, viewport_height);
654 }
655 CursorMovement::Up => {
656 view_state.move_cursor_up(viewport_height);
657 }
658 CursorMovement::Left => {
659 if view_state.cursor_column > 0 {
660 view_state.move_cursor_left();
661 } else if view_state.cursor_row > 0 {
662 if let Some(composite) = composite {
664 let focused_pane = view_state.focused_pane;
665 let mut target_row = view_state.cursor_row - 1;
666 while target_row > 0 {
667 if let Some(row) = composite.alignment.get_row(target_row) {
668 if row.get_pane_line(focused_pane).is_some() {
669 break;
670 }
671 }
672 target_row -= 1;
673 }
674 if let Some(row) = composite.alignment.get_row(target_row) {
676 if row.get_pane_line(focused_pane).is_some() {
677 view_state.cursor_row = target_row;
678 if view_state.cursor_row < view_state.scroll_row {
679 view_state.scroll_row = view_state.cursor_row;
680 }
681 wrapped_to_new_line = true;
682 }
683 }
684 }
685 }
686 }
687 CursorMovement::Right => {
688 if view_state.cursor_column < line_info.length {
689 view_state.move_cursor_right(line_info.length, line_info.pane_width);
690 } else if view_state.cursor_row < max_row {
691 if let Some(composite) = composite {
693 let focused_pane = view_state.focused_pane;
694 let mut target_row = view_state.cursor_row + 1;
695 while target_row < max_row {
696 if let Some(row) = composite.alignment.get_row(target_row) {
697 if row.get_pane_line(focused_pane).is_some() {
698 break;
699 }
700 }
701 target_row += 1;
702 }
703 if let Some(row) = composite.alignment.get_row(target_row) {
705 if row.get_pane_line(focused_pane).is_some() {
706 view_state.cursor_row = target_row;
707 view_state.cursor_column = 0;
708 view_state.sticky_column = 0;
709 if view_state.cursor_row
710 >= view_state.scroll_row + viewport_height
711 {
712 view_state.scroll_row = view_state
713 .cursor_row
714 .saturating_sub(viewport_height - 1);
715 }
716 for viewport in &mut view_state.pane_viewports {
718 viewport.left_column = 0;
719 }
720 }
721 }
722 }
723 }
724 }
725 CursorMovement::LineStart => {
726 view_state.move_cursor_to_line_start();
727 }
728 CursorMovement::LineEnd => {
729 view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
730 }
731 CursorMovement::WordLeft => {
732 let new_col =
733 find_word_boundary_left(&line_info.content, view_state.cursor_column);
734 if new_col < view_state.cursor_column {
735 view_state.cursor_column = new_col;
736 view_state.sticky_column = new_col;
737 let current_left = view_state
739 .pane_viewports
740 .get(view_state.focused_pane)
741 .map(|v| v.left_column)
742 .unwrap_or(0);
743 if view_state.cursor_column < current_left {
744 for viewport in &mut view_state.pane_viewports {
745 viewport.left_column = view_state.cursor_column;
746 }
747 }
748 } else if view_state.cursor_row > 0 {
749 if let Some(composite) = composite {
751 let focused_pane = view_state.focused_pane;
752 let mut target_row = view_state.cursor_row - 1;
753 while target_row > 0 {
754 if let Some(row) = composite.alignment.get_row(target_row) {
755 if row.get_pane_line(focused_pane).is_some() {
756 break;
757 }
758 }
759 target_row -= 1;
760 }
761 if let Some(row) = composite.alignment.get_row(target_row) {
763 if row.get_pane_line(focused_pane).is_some() {
764 view_state.cursor_row = target_row;
765 if view_state.cursor_row < view_state.scroll_row {
766 view_state.scroll_row = view_state.cursor_row;
767 }
768 wrapped_to_new_line = true;
769 }
770 }
771 }
772 }
773 }
774 CursorMovement::WordRight => {
775 let new_col = find_word_boundary_right(
776 &line_info.content,
777 view_state.cursor_column,
778 line_info.length,
779 );
780 if new_col > view_state.cursor_column {
781 view_state.cursor_column = new_col;
782 view_state.sticky_column = new_col;
783 let visible_width = line_info.pane_width.saturating_sub(4);
785 let current_left = view_state
786 .pane_viewports
787 .get(view_state.focused_pane)
788 .map(|v| v.left_column)
789 .unwrap_or(0);
790 if visible_width > 0
791 && view_state.cursor_column >= current_left + visible_width
792 {
793 let new_left = view_state
794 .cursor_column
795 .saturating_sub(visible_width.saturating_sub(1));
796 for viewport in &mut view_state.pane_viewports {
797 viewport.left_column = new_left;
798 }
799 }
800 } else if view_state.cursor_row < max_row {
801 if let Some(composite) = composite {
803 let focused_pane = view_state.focused_pane;
804 let mut target_row = view_state.cursor_row + 1;
805 while target_row < max_row {
806 if let Some(row) = composite.alignment.get_row(target_row) {
807 if row.get_pane_line(focused_pane).is_some() {
808 break;
809 }
810 }
811 target_row += 1;
812 }
813 if let Some(row) = composite.alignment.get_row(target_row) {
815 if row.get_pane_line(focused_pane).is_some() {
816 view_state.cursor_row = target_row;
817 view_state.cursor_column = 0;
818 view_state.sticky_column = 0;
819 if view_state.cursor_row
820 >= view_state.scroll_row + viewport_height
821 {
822 view_state.scroll_row = view_state
823 .cursor_row
824 .saturating_sub(viewport_height - 1);
825 }
826 for viewport in &mut view_state.pane_viewports {
828 viewport.left_column = 0;
829 }
830 }
831 }
832 }
833 }
834 }
835 CursorMovement::WordEnd => {
836 let new_col = find_word_end_right(
837 &line_info.content,
838 view_state.cursor_column,
839 line_info.length,
840 );
841 if new_col > view_state.cursor_column {
842 view_state.cursor_column = new_col;
843 view_state.sticky_column = new_col;
844 let visible_width = line_info.pane_width.saturating_sub(4);
846 let current_left = view_state
847 .pane_viewports
848 .get(view_state.focused_pane)
849 .map(|v| v.left_column)
850 .unwrap_or(0);
851 if visible_width > 0
852 && view_state.cursor_column >= current_left + visible_width
853 {
854 let new_left = view_state
855 .cursor_column
856 .saturating_sub(visible_width.saturating_sub(1));
857 for viewport in &mut view_state.pane_viewports {
858 viewport.left_column = new_left;
859 }
860 }
861 } else if view_state.cursor_row < max_row {
862 if let Some(composite) = composite {
864 let focused_pane = view_state.focused_pane;
865 let mut target_row = view_state.cursor_row + 1;
866 while target_row < max_row {
867 if let Some(row) = composite.alignment.get_row(target_row) {
868 if row.get_pane_line(focused_pane).is_some() {
869 break;
870 }
871 }
872 target_row += 1;
873 }
874 if let Some(row) = composite.alignment.get_row(target_row) {
876 if row.get_pane_line(focused_pane).is_some() {
877 view_state.cursor_row = target_row;
878 view_state.cursor_column = 0;
879 view_state.sticky_column = 0;
880 if view_state.cursor_row
881 >= view_state.scroll_row + viewport_height
882 {
883 view_state.scroll_row = view_state
884 .cursor_row
885 .saturating_sub(viewport_height - 1);
886 }
887 for viewport in &mut view_state.pane_viewports {
889 viewport.left_column = 0;
890 }
891 }
892 }
893 }
894 }
895 }
896 }
897 }
898
899 if is_vertical || wrapped_to_new_line {
901 let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
902 if let Some(view_state) = self
903 .active_window_mut()
904 .composite_view_states
905 .get_mut(&(split_id, buffer_id))
906 {
907 if wrapped_to_new_line
908 && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
909 {
910 tracing::debug!(
912 "Wrap left to row {}, setting column to line length {}",
913 view_state.cursor_row,
914 new_line_info.length
915 );
916 view_state.cursor_column = new_line_info.length;
917 view_state.sticky_column = new_line_info.length;
918 let visible_width = new_line_info.pane_width.saturating_sub(4);
920 if visible_width > 0 && view_state.cursor_column >= visible_width {
921 let new_left = view_state
922 .cursor_column
923 .saturating_sub(visible_width.saturating_sub(1));
924 for viewport in &mut view_state.pane_viewports {
925 viewport.left_column = new_left;
926 }
927 }
928 } else {
929 view_state.clamp_cursor_to_line(new_line_info.length);
930 }
931 }
932 }
933 }
934
935 fn handle_cursor_movement_action(
937 &mut self,
938 split_id: LeafId,
939 buffer_id: BufferId,
940 movement: CursorMovement,
941 extend_selection: bool,
942 ) -> Option<bool> {
943 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
944
945 let line_info = self.get_cursor_line_info(split_id, buffer_id);
946
947 if extend_selection {
948 if let Some(view_state) = self
950 .active_window_mut()
951 .composite_view_states
952 .get_mut(&(split_id, buffer_id))
953 {
954 if !view_state.visual_mode {
955 view_state.start_visual_selection();
956 }
957 }
958 } else {
959 if let Some(view_state) = self
961 .active_window_mut()
962 .composite_view_states
963 .get_mut(&(split_id, buffer_id))
964 {
965 if view_state.visual_mode {
966 view_state.clear_selection();
967 }
968 }
969 }
970
971 self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
972 self.active_window_mut()
973 .sync_editor_cursor_from_composite(split_id, buffer_id);
974
975 Some(true)
976 }
977
978 pub fn handle_composite_action(
984 &mut self,
985 buffer_id: BufferId,
986 action: &crate::input::keybindings::Action,
987 ) -> Option<bool> {
988 use crate::input::keybindings::Action;
989
990 let split_id = self
991 .windows
992 .get(&self.active_window)
993 .and_then(|w| w.buffers.splits())
994 .map(|(mgr, _)| mgr)
995 .expect("active window must have a populated split layout")
996 .active_split();
997
998 let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
1000 let _view_state = self
1001 .active_window()
1002 .composite_view_states
1003 .get(&(split_id, buffer_id))?;
1004
1005 match action {
1006 Action::InsertTab => {
1008 self.active_window_mut()
1009 .composite_focus_next(split_id, buffer_id);
1010 Some(true)
1011 }
1012
1013 Action::Copy => {
1015 self.handle_composite_copy(split_id, buffer_id);
1016 Some(true)
1017 }
1018
1019 Action::MoveDown => {
1021 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
1022 }
1023 Action::MoveUp => {
1024 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
1025 }
1026 Action::MoveLeft => {
1027 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1028 }
1029 Action::MoveRight => self.handle_cursor_movement_action(
1030 split_id,
1031 buffer_id,
1032 CursorMovement::Right,
1033 false,
1034 ),
1035 Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
1036 split_id,
1037 buffer_id,
1038 CursorMovement::LineStart,
1039 false,
1040 ),
1041 Action::MoveLineEnd => self.handle_cursor_movement_action(
1042 split_id,
1043 buffer_id,
1044 CursorMovement::LineEnd,
1045 false,
1046 ),
1047 Action::MoveWordLeft => self.handle_cursor_movement_action(
1048 split_id,
1049 buffer_id,
1050 CursorMovement::WordLeft,
1051 false,
1052 ),
1053 Action::MoveWordRight => self.handle_cursor_movement_action(
1054 split_id,
1055 buffer_id,
1056 CursorMovement::WordRight,
1057 false,
1058 ),
1059 Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
1060 split_id,
1061 buffer_id,
1062 CursorMovement::WordEnd,
1063 false,
1064 ),
1065 Action::MoveLeftInLine => {
1066 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1067 }
1068 Action::MoveRightInLine => self.handle_cursor_movement_action(
1069 split_id,
1070 buffer_id,
1071 CursorMovement::Right,
1072 false,
1073 ),
1074
1075 Action::SelectDown => {
1077 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1078 }
1079 Action::SelectUp => {
1080 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1081 }
1082 Action::SelectLeft => {
1083 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1084 }
1085 Action::SelectRight => {
1086 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1087 }
1088 Action::SelectLineStart => self.handle_cursor_movement_action(
1089 split_id,
1090 buffer_id,
1091 CursorMovement::LineStart,
1092 true,
1093 ),
1094 Action::SelectLineEnd => self.handle_cursor_movement_action(
1095 split_id,
1096 buffer_id,
1097 CursorMovement::LineEnd,
1098 true,
1099 ),
1100 Action::SelectWordLeft => self.handle_cursor_movement_action(
1101 split_id,
1102 buffer_id,
1103 CursorMovement::WordLeft,
1104 true,
1105 ),
1106 Action::SelectWordRight => self.handle_cursor_movement_action(
1107 split_id,
1108 buffer_id,
1109 CursorMovement::WordRight,
1110 true,
1111 ),
1112 Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1113 split_id,
1114 buffer_id,
1115 CursorMovement::WordEnd,
1116 true,
1117 ),
1118
1119 Action::MovePageDown | Action::MovePageUp => {
1121 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1122 let win = self.active_window_mut();
1123 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1124 {
1125 if matches!(action, Action::MovePageDown) {
1126 if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1127 let max_row = composite.row_count().saturating_sub(1);
1128 view_state.page_down(viewport_height, max_row);
1129 view_state.cursor_row = view_state.scroll_row;
1130 }
1131 } else {
1132 view_state.page_up(viewport_height);
1133 view_state.cursor_row = view_state.scroll_row;
1134 }
1135 }
1136 self.active_window_mut()
1137 .sync_editor_cursor_from_composite(split_id, buffer_id);
1138 Some(true)
1139 }
1140
1141 Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1143 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1144 let win = self.active_window_mut();
1145 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1146 {
1147 if matches!(action, Action::MoveDocumentStart) {
1148 view_state.move_cursor_to_top();
1149 } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1150 let max_row = composite.row_count().saturating_sub(1);
1151 view_state.move_cursor_to_bottom(max_row, viewport_height);
1152 }
1153 }
1154 self.active_window_mut()
1155 .sync_editor_cursor_from_composite(split_id, buffer_id);
1156 Some(true)
1157 }
1158
1159 Action::ScrollDown | Action::ScrollUp => {
1161 let delta = if matches!(action, Action::ScrollDown) {
1162 1
1163 } else {
1164 -1
1165 };
1166 self.active_window_mut()
1167 .composite_scroll(split_id, buffer_id, delta);
1168 Some(true)
1169 }
1170
1171 _ => None,
1173 }
1174 }
1175
1176 fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1178 let text = {
1179 let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1180 Some(c) => c,
1181 None => return,
1182 };
1183 let view_state = match self
1184 .active_window()
1185 .composite_view_states
1186 .get(&(split_id, buffer_id))
1187 {
1188 Some(vs) => vs,
1189 None => return,
1190 };
1191
1192 let (start_row, end_row) = match view_state.selection_row_range() {
1193 Some(range) => range,
1194 None => return,
1195 };
1196
1197 let source = match composite.sources.get(view_state.focused_pane) {
1198 Some(s) => s,
1199 None => return,
1200 };
1201
1202 let source_state = match self
1203 .windows
1204 .get(&self.active_window)
1205 .map(|w| &w.buffers)
1206 .expect("active window present")
1207 .get(&source.buffer_id)
1208 {
1209 Some(s) => s,
1210 None => return,
1211 };
1212
1213 let mut text = String::new();
1215 for row in start_row..=end_row {
1216 if let Some(aligned_row) = composite.alignment.rows.get(row) {
1217 if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1218 if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1219 if !text.is_empty() {
1220 text.push('\n');
1221 }
1222 let line_str = String::from_utf8_lossy(&line_bytes);
1224 let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1225 text.push_str(line_trimmed);
1226 }
1227 }
1228 }
1229 }
1230 text
1231 };
1232
1233 if !text.is_empty() {
1234 self.clipboard.copy(text);
1235 }
1236
1237 }
1239
1240 #[cfg(feature = "plugins")]
1246 pub(crate) fn handle_create_composite_buffer(
1247 &mut self,
1248 name: String,
1249 mode: String,
1250 layout_config: fresh_core::api::CompositeLayoutConfig,
1251 source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1252 hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1253 initial_focus_hunk: Option<usize>,
1254 _request_id: Option<u64>,
1255 ) {
1256 use crate::model::composite_buffer::{
1257 CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1258 };
1259
1260 let layout = match layout_config.layout_type.as_str() {
1262 "stacked" => CompositeLayout::Stacked {
1263 spacing: layout_config.spacing.unwrap_or(1),
1264 },
1265 "unified" => CompositeLayout::Unified,
1266 _ => CompositeLayout::SideBySide {
1267 ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1268 show_separator: layout_config.show_separator,
1269 },
1270 };
1271
1272 let sources: Vec<SourcePane> = source_configs
1274 .into_iter()
1275 .map(|src| {
1276 let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1277 if let Some(style_config) = src.style {
1278 let gutter_style = match style_config.gutter_style.as_deref() {
1279 Some("diff-markers") => GutterStyle::DiffMarkers,
1280 Some("both") => GutterStyle::Both,
1281 Some("none") => GutterStyle::None,
1282 _ => GutterStyle::LineNumbers,
1283 };
1284 let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1286 pane.style = PaneStyle {
1287 add_bg: style_config.add_bg.map(to_tuple),
1288 remove_bg: style_config.remove_bg.map(to_tuple),
1289 modify_bg: style_config.modify_bg.map(to_tuple),
1290 gutter_style,
1291 };
1292 }
1293 pane
1294 })
1295 .collect();
1296
1297 let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1299
1300 if let Some(hunk_configs) = hunks {
1302 let diff_hunks: Vec<DiffHunk> = hunk_configs
1303 .into_iter()
1304 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1305 .collect();
1306
1307 let old_line_count = self
1309 .buffers()
1310 .get(
1311 &self
1312 .active_window()
1313 .composite_buffers
1314 .get(&buffer_id)
1315 .unwrap()
1316 .sources[0]
1317 .buffer_id,
1318 )
1319 .and_then(|s| s.buffer.line_count())
1320 .unwrap_or(0);
1321 let new_line_count = self
1322 .buffers()
1323 .get(
1324 &self
1325 .active_window()
1326 .composite_buffers
1327 .get(&buffer_id)
1328 .unwrap()
1329 .sources[1]
1330 .buffer_id,
1331 )
1332 .and_then(|s| s.buffer.line_count())
1333 .unwrap_or(0);
1334
1335 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1336 self.active_window_mut()
1337 .set_composite_alignment(buffer_id, alignment);
1338 }
1339
1340 if initial_focus_hunk.is_some() {
1342 if let Some(composite) = self
1343 .active_window_mut()
1344 .composite_buffers
1345 .get_mut(&buffer_id)
1346 {
1347 composite.initial_focus_hunk = initial_focus_hunk;
1348 }
1349 }
1350
1351 tracing::info!(
1352 "Created composite buffer '{}' with mode '{}' (id={:?})",
1353 name,
1354 mode,
1355 buffer_id
1356 );
1357
1358 if let Some(req_id) = _request_id {
1360 let result = buffer_id.0.to_string();
1362 self.plugin_manager
1363 .read()
1364 .unwrap()
1365 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1366 tracing::info!(
1367 "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1368 req_id
1369 );
1370 }
1371 }
1372
1373 #[cfg(feature = "plugins")]
1375 pub(crate) fn handle_update_composite_alignment(
1376 &mut self,
1377 buffer_id: BufferId,
1378 hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1379 ) {
1380 use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1381
1382 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1383 let diff_hunks: Vec<DiffHunk> = hunk_configs
1384 .into_iter()
1385 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1386 .collect();
1387
1388 let old_line_count = self
1390 .buffers()
1391 .get(&composite.sources[0].buffer_id)
1392 .and_then(|s| s.buffer.line_count())
1393 .unwrap_or(0);
1394 let new_line_count = self
1395 .buffers()
1396 .get(&composite.sources[1].buffer_id)
1397 .and_then(|s| s.buffer.line_count())
1398 .unwrap_or(0);
1399
1400 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1401 self.active_window_mut()
1402 .set_composite_alignment(buffer_id, alignment);
1403 }
1404 }
1405
1406 pub(crate) fn handle_composite_click(
1408 &mut self,
1409 col: u16,
1410 row: u16,
1411 split_id: LeafId,
1412 buffer_id: BufferId,
1413 content_rect: ratatui::layout::Rect,
1414 ) -> AnyhowResult<()> {
1415 let pane_idx = if let Some(view_state) = self
1417 .active_window()
1418 .composite_view_states
1419 .get(&(split_id, buffer_id))
1420 {
1421 let mut x = content_rect.x;
1422 let mut found_pane = 0;
1423 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1424 if col >= x && col < x + width {
1425 found_pane = i;
1426 break;
1427 }
1428 x += width + 1; }
1430 found_pane
1431 } else {
1432 0
1433 };
1434
1435 let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1438
1439 let (pane_start_x, left_column) = if let Some(view_state) = self
1441 .active_window()
1442 .composite_view_states
1443 .get(&(split_id, buffer_id))
1444 {
1445 let mut x = content_rect.x;
1446 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1447 if i == pane_idx {
1448 break;
1449 }
1450 x += width + 1;
1451 }
1452 let left_col = view_state
1453 .pane_viewports
1454 .get(pane_idx)
1455 .map(|vp| vp.left_column)
1456 .unwrap_or(0);
1457 (x, left_col)
1458 } else {
1459 (content_rect.x, 0)
1460 };
1461 let gutter_width = 4; let visual_col = col
1463 .saturating_sub(pane_start_x)
1464 .saturating_sub(gutter_width) as usize;
1465 let click_col = left_column + visual_col;
1467
1468 let display_row = if let Some(view_state) = self
1470 .active_window()
1471 .composite_view_states
1472 .get(&(split_id, buffer_id))
1473 {
1474 view_state.scroll_row + content_row
1475 } else {
1476 content_row
1477 };
1478
1479 let line_length =
1480 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1481 composite
1482 .alignment
1483 .get_row(display_row)
1484 .and_then(|row| row.get_pane_line(pane_idx))
1485 .and_then(|line_ref| {
1486 let source = composite.sources.get(pane_idx)?;
1487 self.windows
1488 .get(&self.active_window)
1489 .map(|w| &w.buffers)
1490 .expect("active window present")
1491 .get(&source.buffer_id)?
1492 .buffer
1493 .get_line(line_ref.line)
1494 })
1495 .map(|bytes| {
1496 let s = String::from_utf8_lossy(&bytes);
1497 let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1499 trimmed.graphemes(true).count()
1500 })
1501 .unwrap_or(0)
1502 } else {
1503 0
1504 };
1505
1506 let clamped_col = click_col.min(line_length);
1508
1509 if let Some(composite) = self
1511 .active_window_mut()
1512 .composite_buffers
1513 .get_mut(&buffer_id)
1514 {
1515 composite.active_pane = pane_idx;
1516 }
1517
1518 if let Some(view_state) = self
1520 .active_window_mut()
1521 .composite_view_states
1522 .get_mut(&(split_id, buffer_id))
1523 {
1524 view_state.focused_pane = pane_idx;
1525 view_state.cursor_row = display_row;
1526 view_state.cursor_column = clamped_col;
1527 view_state.sticky_column = clamped_col;
1528
1529 view_state.clear_selection();
1531 }
1532
1533 self.active_window_mut().mouse_state.dragging_text_selection = false; self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1536
1537 self.active_window_mut()
1539 .sync_editor_cursor_from_composite(split_id, buffer_id);
1540
1541 Ok(())
1542 }
1543}