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 set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) {
144 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
145 composite.set_alignment(alignment);
146 }
147 }
148
149 pub fn close_composite_buffer(&mut self, buffer_id: BufferId) {
151 self.composite_buffers.remove(&buffer_id);
152 self.buffer_metadata.remove(&buffer_id);
153 self.composite_view_states
154 .retain(|(_, bid), _| *bid != buffer_id);
155 }
156
157 pub fn composite_focus_next(&mut self, split_id: LeafId, buffer_id: BufferId) {
159 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
160 composite.focus_next();
161 }
162 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
163 view_state.focus_next_pane();
164 }
165 }
166
167 pub fn composite_focus_prev(&mut self, split_id: LeafId, buffer_id: BufferId) {
169 if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) {
170 composite.focus_prev();
171 }
172 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
173 view_state.focus_prev_pane();
174 }
175 }
176
177 fn get_composite_viewport_height(&self, split_id: LeafId) -> usize {
182 const COMPOSITE_HEADER_HEIGHT: u16 = 1;
183 const DEFAULT_VIEWPORT_HEIGHT: usize = 24;
184
185 self.buffers
186 .splits()
187 .map(|(_, vs)| vs)
188 .expect("window must have a populated split layout")
189 .get(&split_id)
190 .map(|vs| vs.viewport.height.saturating_sub(COMPOSITE_HEADER_HEIGHT) as usize)
191 .unwrap_or(DEFAULT_VIEWPORT_HEIGHT)
192 }
193
194 fn sync_editor_cursor_from_composite(&mut self, split_id: LeafId, buffer_id: BufferId) {
200 let (cursor_row, cursor_column, focused_pane) = self
201 .composite_view_states
202 .get(&(split_id, buffer_id))
203 .map(|vs| (vs.cursor_row, vs.cursor_column, vs.focused_pane))
204 .unwrap_or((0, 0, 0));
205
206 let display_line = self
207 .composite_buffers
208 .get(&buffer_id)
209 .and_then(|composite| composite.alignment.get_row(cursor_row))
210 .and_then(|row| row.get_pane_line(focused_pane))
211 .map(|line_ref| line_ref.line)
212 .unwrap_or(cursor_row);
213
214 if let Some(state) = self.buffers.get_mut(&buffer_id) {
215 state.primary_cursor_line_number =
216 crate::model::buffer::LineNumber::Absolute(display_line);
217 }
218
219 let active_split = self
220 .buffers
221 .splits()
222 .map(|(mgr, _)| mgr)
223 .expect("window must have a populated split layout")
224 .active_split();
225 if let Some((_, vs_map)) = self.buffers.splits_mut() {
226 if let Some(view_state) = vs_map.get_mut(&active_split) {
227 view_state.cursors.primary_mut().position = cursor_column;
228 }
229 }
230 }
231
232 pub fn composite_next_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
237 let viewport_height = self.get_composite_viewport_height(split_id);
238 let moved = if let (Some(composite), Some(view_state)) = (
239 self.composite_buffers.get(&buffer_id),
240 self.composite_view_states.get_mut(&(split_id, buffer_id)),
241 ) {
242 if let Some(next_row) = composite.alignment.next_hunk_row(view_state.cursor_row) {
243 view_state.cursor_row = next_row;
244 let context_above = viewport_height / 3;
245 view_state.scroll_row = next_row.saturating_sub(context_above);
246 true
247 } else {
248 false
249 }
250 } else {
251 false
252 };
253 if moved {
254 self.sync_editor_cursor_from_composite(split_id, buffer_id);
255 }
256 moved
257 }
258
259 pub fn composite_prev_hunk(&mut self, split_id: LeafId, buffer_id: BufferId) -> bool {
262 let viewport_height = self.get_composite_viewport_height(split_id);
263 let moved = if let (Some(composite), Some(view_state)) = (
264 self.composite_buffers.get(&buffer_id),
265 self.composite_view_states.get_mut(&(split_id, buffer_id)),
266 ) {
267 if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.cursor_row) {
268 view_state.cursor_row = prev_row;
269 let context_above = viewport_height / 3;
270 view_state.scroll_row = prev_row.saturating_sub(context_above);
271 true
272 } else {
273 false
274 }
275 } else {
276 false
277 };
278 if moved {
279 self.sync_editor_cursor_from_composite(split_id, buffer_id);
280 }
281 moved
282 }
283
284 pub fn composite_next_hunk_active(&mut self, buffer_id: BufferId) -> bool {
288 let split_id = self
289 .buffers
290 .splits()
291 .map(|(mgr, _)| mgr)
292 .expect("window must have a populated split layout")
293 .active_split();
294 self.composite_next_hunk(split_id, buffer_id)
295 }
296
297 pub fn composite_prev_hunk_active(&mut self, buffer_id: BufferId) -> bool {
299 let split_id = self
300 .buffers
301 .splits()
302 .map(|(mgr, _)| mgr)
303 .expect("window must have a populated split layout")
304 .active_split();
305 self.composite_prev_hunk(split_id, buffer_id)
306 }
307
308 pub fn composite_scroll(&mut self, split_id: LeafId, buffer_id: BufferId, delta: isize) {
312 if let (Some(composite), Some(view_state)) = (
313 self.composite_buffers.get(&buffer_id),
314 self.composite_view_states.get_mut(&(split_id, buffer_id)),
315 ) {
316 let max_row = composite.row_count().saturating_sub(1);
317 view_state.scroll(delta, max_row);
318 }
319 }
320
321 pub fn composite_scroll_to(&mut self, split_id: LeafId, buffer_id: BufferId, row: usize) {
323 if let (Some(composite), Some(view_state)) = (
324 self.composite_buffers.get(&buffer_id),
325 self.composite_view_states.get_mut(&(split_id, buffer_id)),
326 ) {
327 let max_row = composite.row_count().saturating_sub(1);
328 view_state.set_scroll_row(row, max_row);
329 }
330 }
331}
332
333impl Editor {
334 pub fn flush_layout(&mut self) {
347 use crate::view::composite_view::CompositeViewState;
348
349 let visible = self
350 .windows
351 .get(&self.active_window)
352 .and_then(|w| w.buffers.splits())
353 .map(|(mgr, _)| mgr)
354 .expect("active window must have a populated split layout")
355 .get_visible_buffers(ratatui::layout::Rect {
356 x: 0,
357 y: 0,
358 width: self.terminal_width,
359 height: self.terminal_height,
360 });
361
362 for (split_id, buffer_id, _area) in &visible {
363 if let Some(composite) = self.active_window().composite_buffers.get(buffer_id) {
365 let pane_count = composite.pane_count();
366 self.active_window_mut()
367 .composite_view_states
368 .entry((*split_id, *buffer_id))
369 .or_insert_with(|| CompositeViewState::new(*buffer_id, pane_count));
370 }
371 }
372 }
373
374 pub fn get_composite_view_state(
389 &mut self,
390 split_id: LeafId,
391 buffer_id: BufferId,
392 ) -> Option<&mut CompositeViewState> {
393 if !self
394 .active_window()
395 .composite_buffers
396 .contains_key(&buffer_id)
397 {
398 return None;
399 }
400
401 let pane_count = self
402 .active_window()
403 .composite_buffers
404 .get(&buffer_id)?
405 .pane_count();
406
407 Some(
408 self.active_window_mut()
409 .composite_view_states
410 .entry((split_id, buffer_id))
411 .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)),
412 )
413 }
414
415 pub fn create_composite_buffer(
426 &mut self,
427 name: String,
428 mode: String,
429 layout: CompositeLayout,
430 sources: Vec<SourcePane>,
431 ) -> BufferId {
432 let buffer_id = self.alloc_buffer_id();
433
434 let composite =
435 CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources);
436 self.active_window_mut()
437 .composite_buffers
438 .insert(buffer_id, composite);
439
440 let mut metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true);
444 metadata.hidden_from_tabs = false;
445 self.active_window_mut()
446 .buffer_metadata
447 .insert(buffer_id, metadata);
448
449 let mut state = crate::state::EditorState::new(
452 80,
453 24,
454 crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
455 std::sync::Arc::clone(&self.authority.filesystem),
456 );
457 state.is_composite_buffer = true;
458 state.editing_disabled = true;
459 state.mode = mode;
460 self.windows
461 .get_mut(&self.active_window)
462 .map(|w| &mut w.buffers)
463 .expect("active window present")
464 .insert(buffer_id, state);
465 self.active_window_mut()
467 .event_logs
468 .insert(buffer_id, crate::model::event::EventLog::new());
469
470 let split_id = self
472 .windows
473 .get(&self.active_window)
474 .and_then(|w| w.buffers.splits())
475 .map(|(mgr, _)| mgr)
476 .expect("active window must have a populated split layout")
477 .active_split();
478 if let Some(view_state) = self
479 .windows
480 .get_mut(&self.active_window)
481 .and_then(|w| w.split_view_states_mut())
482 .expect("active window must have a populated split layout")
483 .get_mut(&split_id)
484 {
485 view_state.add_buffer(buffer_id);
486 }
487
488 buffer_id
489 }
490
491 fn get_cursor_line_info(&self, split_id: LeafId, buffer_id: BufferId) -> CursorLineInfo {
502 let composite = self.active_window().composite_buffers.get(&buffer_id);
503 let view_state = self
504 .active_window()
505 .composite_view_states
506 .get(&(split_id, buffer_id));
507
508 if let (Some(composite), Some(view_state)) = (composite, view_state) {
509 let pane_line = composite
510 .alignment
511 .get_row(view_state.cursor_row)
512 .and_then(|row| row.get_pane_line(view_state.focused_pane));
513
514 tracing::debug!(
515 "get_cursor_line_info: cursor_row={}, focused_pane={}, pane_line={:?}",
516 view_state.cursor_row,
517 view_state.focused_pane,
518 pane_line
519 );
520
521 let line_bytes = pane_line.and_then(|line_ref| {
522 let source = composite.sources.get(view_state.focused_pane)?;
523 self.windows
524 .get(&self.active_window)
525 .map(|w| &w.buffers)
526 .expect("active window present")
527 .get(&source.buffer_id)?
528 .buffer
529 .get_line(line_ref.line)
530 });
531
532 let content = line_bytes
533 .as_ref()
534 .map(|b| {
535 let s = String::from_utf8_lossy(b).to_string();
536 s.trim_end_matches('\n').trim_end_matches('\r').to_string()
538 })
539 .unwrap_or_default();
540 let length = content.graphemes(true).count();
541 let pane_width = view_state
542 .pane_widths
543 .get(view_state.focused_pane)
544 .copied()
545 .unwrap_or(40) as usize;
546
547 CursorLineInfo {
548 content,
549 length,
550 pane_width,
551 }
552 } else {
553 CursorLineInfo {
554 content: String::new(),
555 length: 0,
556 pane_width: 40,
557 }
558 }
559 }
560
561 fn apply_cursor_movement(
563 &mut self,
564 split_id: LeafId,
565 buffer_id: BufferId,
566 movement: CursorMovement,
567 line_info: &CursorLineInfo,
568 viewport_height: usize,
569 ) {
570 let max_row = self
571 .active_window_mut()
572 .composite_buffers
573 .get(&buffer_id)
574 .map(|c| c.row_count().saturating_sub(1))
575 .unwrap_or(0);
576
577 let is_vertical = matches!(movement, CursorMovement::Up | CursorMovement::Down);
578 let mut wrapped_to_new_line = false;
579
580 let win = self.active_window_mut();
582 let composite = win.composite_buffers.get(&buffer_id);
583
584 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id)) {
585 match movement {
586 CursorMovement::Down => {
587 view_state.move_cursor_down(max_row, viewport_height);
588 }
589 CursorMovement::Up => {
590 view_state.move_cursor_up(viewport_height);
591 }
592 CursorMovement::Left => {
593 if view_state.cursor_column > 0 {
594 view_state.move_cursor_left();
595 } else if view_state.cursor_row > 0 {
596 if let Some(composite) = composite {
598 let focused_pane = view_state.focused_pane;
599 let mut target_row = view_state.cursor_row - 1;
600 while target_row > 0 {
601 if let Some(row) = composite.alignment.get_row(target_row) {
602 if row.get_pane_line(focused_pane).is_some() {
603 break;
604 }
605 }
606 target_row -= 1;
607 }
608 if let Some(row) = composite.alignment.get_row(target_row) {
610 if row.get_pane_line(focused_pane).is_some() {
611 view_state.cursor_row = target_row;
612 if view_state.cursor_row < view_state.scroll_row {
613 view_state.scroll_row = view_state.cursor_row;
614 }
615 wrapped_to_new_line = true;
616 }
617 }
618 }
619 }
620 }
621 CursorMovement::Right => {
622 if view_state.cursor_column < line_info.length {
623 view_state.move_cursor_right(line_info.length, line_info.pane_width);
624 } else if view_state.cursor_row < max_row {
625 if let Some(composite) = composite {
627 let focused_pane = view_state.focused_pane;
628 let mut target_row = view_state.cursor_row + 1;
629 while target_row < max_row {
630 if let Some(row) = composite.alignment.get_row(target_row) {
631 if row.get_pane_line(focused_pane).is_some() {
632 break;
633 }
634 }
635 target_row += 1;
636 }
637 if let Some(row) = composite.alignment.get_row(target_row) {
639 if row.get_pane_line(focused_pane).is_some() {
640 view_state.cursor_row = target_row;
641 view_state.cursor_column = 0;
642 view_state.sticky_column = 0;
643 if view_state.cursor_row
644 >= view_state.scroll_row + viewport_height
645 {
646 view_state.scroll_row = view_state
647 .cursor_row
648 .saturating_sub(viewport_height - 1);
649 }
650 for viewport in &mut view_state.pane_viewports {
652 viewport.left_column = 0;
653 }
654 }
655 }
656 }
657 }
658 }
659 CursorMovement::LineStart => {
660 view_state.move_cursor_to_line_start();
661 }
662 CursorMovement::LineEnd => {
663 view_state.move_cursor_to_line_end(line_info.length, line_info.pane_width);
664 }
665 CursorMovement::WordLeft => {
666 let new_col =
667 find_word_boundary_left(&line_info.content, view_state.cursor_column);
668 if new_col < view_state.cursor_column {
669 view_state.cursor_column = new_col;
670 view_state.sticky_column = new_col;
671 let current_left = view_state
673 .pane_viewports
674 .get(view_state.focused_pane)
675 .map(|v| v.left_column)
676 .unwrap_or(0);
677 if view_state.cursor_column < current_left {
678 for viewport in &mut view_state.pane_viewports {
679 viewport.left_column = view_state.cursor_column;
680 }
681 }
682 } else if view_state.cursor_row > 0 {
683 if let Some(composite) = composite {
685 let focused_pane = view_state.focused_pane;
686 let mut target_row = view_state.cursor_row - 1;
687 while target_row > 0 {
688 if let Some(row) = composite.alignment.get_row(target_row) {
689 if row.get_pane_line(focused_pane).is_some() {
690 break;
691 }
692 }
693 target_row -= 1;
694 }
695 if let Some(row) = composite.alignment.get_row(target_row) {
697 if row.get_pane_line(focused_pane).is_some() {
698 view_state.cursor_row = target_row;
699 if view_state.cursor_row < view_state.scroll_row {
700 view_state.scroll_row = view_state.cursor_row;
701 }
702 wrapped_to_new_line = true;
703 }
704 }
705 }
706 }
707 }
708 CursorMovement::WordRight => {
709 let new_col = find_word_boundary_right(
710 &line_info.content,
711 view_state.cursor_column,
712 line_info.length,
713 );
714 if new_col > view_state.cursor_column {
715 view_state.cursor_column = new_col;
716 view_state.sticky_column = new_col;
717 let visible_width = line_info.pane_width.saturating_sub(4);
719 let current_left = view_state
720 .pane_viewports
721 .get(view_state.focused_pane)
722 .map(|v| v.left_column)
723 .unwrap_or(0);
724 if visible_width > 0
725 && view_state.cursor_column >= current_left + visible_width
726 {
727 let new_left = view_state
728 .cursor_column
729 .saturating_sub(visible_width.saturating_sub(1));
730 for viewport in &mut view_state.pane_viewports {
731 viewport.left_column = new_left;
732 }
733 }
734 } else if view_state.cursor_row < max_row {
735 if let Some(composite) = composite {
737 let focused_pane = view_state.focused_pane;
738 let mut target_row = view_state.cursor_row + 1;
739 while target_row < max_row {
740 if let Some(row) = composite.alignment.get_row(target_row) {
741 if row.get_pane_line(focused_pane).is_some() {
742 break;
743 }
744 }
745 target_row += 1;
746 }
747 if let Some(row) = composite.alignment.get_row(target_row) {
749 if row.get_pane_line(focused_pane).is_some() {
750 view_state.cursor_row = target_row;
751 view_state.cursor_column = 0;
752 view_state.sticky_column = 0;
753 if view_state.cursor_row
754 >= view_state.scroll_row + viewport_height
755 {
756 view_state.scroll_row = view_state
757 .cursor_row
758 .saturating_sub(viewport_height - 1);
759 }
760 for viewport in &mut view_state.pane_viewports {
762 viewport.left_column = 0;
763 }
764 }
765 }
766 }
767 }
768 }
769 CursorMovement::WordEnd => {
770 let new_col = find_word_end_right(
771 &line_info.content,
772 view_state.cursor_column,
773 line_info.length,
774 );
775 if new_col > view_state.cursor_column {
776 view_state.cursor_column = new_col;
777 view_state.sticky_column = new_col;
778 let visible_width = line_info.pane_width.saturating_sub(4);
780 let current_left = view_state
781 .pane_viewports
782 .get(view_state.focused_pane)
783 .map(|v| v.left_column)
784 .unwrap_or(0);
785 if visible_width > 0
786 && view_state.cursor_column >= current_left + visible_width
787 {
788 let new_left = view_state
789 .cursor_column
790 .saturating_sub(visible_width.saturating_sub(1));
791 for viewport in &mut view_state.pane_viewports {
792 viewport.left_column = new_left;
793 }
794 }
795 } else if view_state.cursor_row < max_row {
796 if let Some(composite) = composite {
798 let focused_pane = view_state.focused_pane;
799 let mut target_row = view_state.cursor_row + 1;
800 while target_row < max_row {
801 if let Some(row) = composite.alignment.get_row(target_row) {
802 if row.get_pane_line(focused_pane).is_some() {
803 break;
804 }
805 }
806 target_row += 1;
807 }
808 if let Some(row) = composite.alignment.get_row(target_row) {
810 if row.get_pane_line(focused_pane).is_some() {
811 view_state.cursor_row = target_row;
812 view_state.cursor_column = 0;
813 view_state.sticky_column = 0;
814 if view_state.cursor_row
815 >= view_state.scroll_row + viewport_height
816 {
817 view_state.scroll_row = view_state
818 .cursor_row
819 .saturating_sub(viewport_height - 1);
820 }
821 for viewport in &mut view_state.pane_viewports {
823 viewport.left_column = 0;
824 }
825 }
826 }
827 }
828 }
829 }
830 }
831 }
832
833 if is_vertical || wrapped_to_new_line {
835 let new_line_info = self.get_cursor_line_info(split_id, buffer_id);
836 if let Some(view_state) = self
837 .active_window_mut()
838 .composite_view_states
839 .get_mut(&(split_id, buffer_id))
840 {
841 if wrapped_to_new_line
842 && matches!(movement, CursorMovement::Left | CursorMovement::WordLeft)
843 {
844 tracing::debug!(
846 "Wrap left to row {}, setting column to line length {}",
847 view_state.cursor_row,
848 new_line_info.length
849 );
850 view_state.cursor_column = new_line_info.length;
851 view_state.sticky_column = new_line_info.length;
852 let visible_width = new_line_info.pane_width.saturating_sub(4);
854 if visible_width > 0 && view_state.cursor_column >= visible_width {
855 let new_left = view_state
856 .cursor_column
857 .saturating_sub(visible_width.saturating_sub(1));
858 for viewport in &mut view_state.pane_viewports {
859 viewport.left_column = new_left;
860 }
861 }
862 } else {
863 view_state.clamp_cursor_to_line(new_line_info.length);
864 }
865 }
866 }
867 }
868
869 fn handle_cursor_movement_action(
871 &mut self,
872 split_id: LeafId,
873 buffer_id: BufferId,
874 movement: CursorMovement,
875 extend_selection: bool,
876 ) -> Option<bool> {
877 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
878
879 let line_info = self.get_cursor_line_info(split_id, buffer_id);
880
881 if extend_selection {
882 if let Some(view_state) = self
884 .active_window_mut()
885 .composite_view_states
886 .get_mut(&(split_id, buffer_id))
887 {
888 if !view_state.visual_mode {
889 view_state.start_visual_selection();
890 }
891 }
892 } else {
893 if let Some(view_state) = self
895 .active_window_mut()
896 .composite_view_states
897 .get_mut(&(split_id, buffer_id))
898 {
899 if view_state.visual_mode {
900 view_state.clear_selection();
901 }
902 }
903 }
904
905 self.apply_cursor_movement(split_id, buffer_id, movement, &line_info, viewport_height);
906 self.active_window_mut()
907 .sync_editor_cursor_from_composite(split_id, buffer_id);
908
909 Some(true)
910 }
911
912 pub fn handle_composite_action(
918 &mut self,
919 buffer_id: BufferId,
920 action: &crate::input::keybindings::Action,
921 ) -> Option<bool> {
922 use crate::input::keybindings::Action;
923
924 let split_id = self
925 .windows
926 .get(&self.active_window)
927 .and_then(|w| w.buffers.splits())
928 .map(|(mgr, _)| mgr)
929 .expect("active window must have a populated split layout")
930 .active_split();
931
932 let _composite = self.active_window().composite_buffers.get(&buffer_id)?;
934 let _view_state = self
935 .active_window()
936 .composite_view_states
937 .get(&(split_id, buffer_id))?;
938
939 match action {
940 Action::InsertTab => {
942 self.active_window_mut()
943 .composite_focus_next(split_id, buffer_id);
944 Some(true)
945 }
946
947 Action::Copy => {
949 self.handle_composite_copy(split_id, buffer_id);
950 Some(true)
951 }
952
953 Action::MoveDown => {
955 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, false)
956 }
957 Action::MoveUp => {
958 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, false)
959 }
960 Action::MoveLeft => {
961 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
962 }
963 Action::MoveRight => self.handle_cursor_movement_action(
964 split_id,
965 buffer_id,
966 CursorMovement::Right,
967 false,
968 ),
969 Action::MoveLineStart | Action::SmartHome => self.handle_cursor_movement_action(
970 split_id,
971 buffer_id,
972 CursorMovement::LineStart,
973 false,
974 ),
975 Action::MoveLineEnd => self.handle_cursor_movement_action(
976 split_id,
977 buffer_id,
978 CursorMovement::LineEnd,
979 false,
980 ),
981 Action::MoveWordLeft => self.handle_cursor_movement_action(
982 split_id,
983 buffer_id,
984 CursorMovement::WordLeft,
985 false,
986 ),
987 Action::MoveWordRight => self.handle_cursor_movement_action(
988 split_id,
989 buffer_id,
990 CursorMovement::WordRight,
991 false,
992 ),
993 Action::MoveWordEnd | Action::ViMoveWordEnd => self.handle_cursor_movement_action(
994 split_id,
995 buffer_id,
996 CursorMovement::WordEnd,
997 false,
998 ),
999 Action::MoveLeftInLine => {
1000 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, false)
1001 }
1002 Action::MoveRightInLine => self.handle_cursor_movement_action(
1003 split_id,
1004 buffer_id,
1005 CursorMovement::Right,
1006 false,
1007 ),
1008
1009 Action::SelectDown => {
1011 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Down, true)
1012 }
1013 Action::SelectUp => {
1014 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Up, true)
1015 }
1016 Action::SelectLeft => {
1017 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Left, true)
1018 }
1019 Action::SelectRight => {
1020 self.handle_cursor_movement_action(split_id, buffer_id, CursorMovement::Right, true)
1021 }
1022 Action::SelectLineStart => self.handle_cursor_movement_action(
1023 split_id,
1024 buffer_id,
1025 CursorMovement::LineStart,
1026 true,
1027 ),
1028 Action::SelectLineEnd => self.handle_cursor_movement_action(
1029 split_id,
1030 buffer_id,
1031 CursorMovement::LineEnd,
1032 true,
1033 ),
1034 Action::SelectWordLeft => self.handle_cursor_movement_action(
1035 split_id,
1036 buffer_id,
1037 CursorMovement::WordLeft,
1038 true,
1039 ),
1040 Action::SelectWordRight => self.handle_cursor_movement_action(
1041 split_id,
1042 buffer_id,
1043 CursorMovement::WordRight,
1044 true,
1045 ),
1046 Action::SelectWordEnd | Action::ViSelectWordEnd => self.handle_cursor_movement_action(
1047 split_id,
1048 buffer_id,
1049 CursorMovement::WordEnd,
1050 true,
1051 ),
1052
1053 Action::MovePageDown | Action::MovePageUp => {
1055 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1056 let win = self.active_window_mut();
1057 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1058 {
1059 if matches!(action, Action::MovePageDown) {
1060 if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1061 let max_row = composite.row_count().saturating_sub(1);
1062 view_state.page_down(viewport_height, max_row);
1063 view_state.cursor_row = view_state.scroll_row;
1064 }
1065 } else {
1066 view_state.page_up(viewport_height);
1067 view_state.cursor_row = view_state.scroll_row;
1068 }
1069 }
1070 self.active_window_mut()
1071 .sync_editor_cursor_from_composite(split_id, buffer_id);
1072 Some(true)
1073 }
1074
1075 Action::MoveDocumentStart | Action::MoveDocumentEnd => {
1077 let viewport_height = self.active_window().get_composite_viewport_height(split_id);
1078 let win = self.active_window_mut();
1079 if let Some(view_state) = win.composite_view_states.get_mut(&(split_id, buffer_id))
1080 {
1081 if matches!(action, Action::MoveDocumentStart) {
1082 view_state.move_cursor_to_top();
1083 } else if let Some(composite) = win.composite_buffers.get(&buffer_id) {
1084 let max_row = composite.row_count().saturating_sub(1);
1085 view_state.move_cursor_to_bottom(max_row, viewport_height);
1086 }
1087 }
1088 self.active_window_mut()
1089 .sync_editor_cursor_from_composite(split_id, buffer_id);
1090 Some(true)
1091 }
1092
1093 Action::ScrollDown | Action::ScrollUp => {
1095 let delta = if matches!(action, Action::ScrollDown) {
1096 1
1097 } else {
1098 -1
1099 };
1100 self.active_window_mut()
1101 .composite_scroll(split_id, buffer_id, delta);
1102 Some(true)
1103 }
1104
1105 _ => None,
1107 }
1108 }
1109
1110 fn handle_composite_copy(&mut self, split_id: LeafId, buffer_id: BufferId) {
1112 let text = {
1113 let composite = match self.active_window().composite_buffers.get(&buffer_id) {
1114 Some(c) => c,
1115 None => return,
1116 };
1117 let view_state = match self
1118 .active_window()
1119 .composite_view_states
1120 .get(&(split_id, buffer_id))
1121 {
1122 Some(vs) => vs,
1123 None => return,
1124 };
1125
1126 let (start_row, end_row) = match view_state.selection_row_range() {
1127 Some(range) => range,
1128 None => return,
1129 };
1130
1131 let source = match composite.sources.get(view_state.focused_pane) {
1132 Some(s) => s,
1133 None => return,
1134 };
1135
1136 let source_state = match self
1137 .windows
1138 .get(&self.active_window)
1139 .map(|w| &w.buffers)
1140 .expect("active window present")
1141 .get(&source.buffer_id)
1142 {
1143 Some(s) => s,
1144 None => return,
1145 };
1146
1147 let mut text = String::new();
1149 for row in start_row..=end_row {
1150 if let Some(aligned_row) = composite.alignment.rows.get(row) {
1151 if let Some(line_ref) = aligned_row.get_pane_line(view_state.focused_pane) {
1152 if let Some(line_bytes) = source_state.buffer.get_line(line_ref.line) {
1153 if !text.is_empty() {
1154 text.push('\n');
1155 }
1156 let line_str = String::from_utf8_lossy(&line_bytes);
1158 let line_trimmed = line_str.trim_end_matches(&['\n', '\r'][..]);
1159 text.push_str(line_trimmed);
1160 }
1161 }
1162 }
1163 }
1164 text
1165 };
1166
1167 if !text.is_empty() {
1168 self.clipboard.copy(text);
1169 }
1170
1171 }
1173
1174 pub(crate) fn handle_create_composite_buffer(
1180 &mut self,
1181 name: String,
1182 mode: String,
1183 layout_config: fresh_core::api::CompositeLayoutConfig,
1184 source_configs: Vec<fresh_core::api::CompositeSourceConfig>,
1185 hunks: Option<Vec<fresh_core::api::CompositeHunk>>,
1186 initial_focus_hunk: Option<usize>,
1187 _request_id: Option<u64>,
1188 ) {
1189 use crate::model::composite_buffer::{
1190 CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane,
1191 };
1192
1193 let layout = match layout_config.layout_type.as_str() {
1195 "stacked" => CompositeLayout::Stacked {
1196 spacing: layout_config.spacing.unwrap_or(1),
1197 },
1198 "unified" => CompositeLayout::Unified,
1199 _ => CompositeLayout::SideBySide {
1200 ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]),
1201 show_separator: layout_config.show_separator,
1202 },
1203 };
1204
1205 let sources: Vec<SourcePane> = source_configs
1207 .into_iter()
1208 .map(|src| {
1209 let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable);
1210 if let Some(style_config) = src.style {
1211 let gutter_style = match style_config.gutter_style.as_deref() {
1212 Some("diff-markers") => GutterStyle::DiffMarkers,
1213 Some("both") => GutterStyle::Both,
1214 Some("none") => GutterStyle::None,
1215 _ => GutterStyle::LineNumbers,
1216 };
1217 let to_tuple = |arr: [u8; 3]| (arr[0], arr[1], arr[2]);
1219 pane.style = PaneStyle {
1220 add_bg: style_config.add_bg.map(to_tuple),
1221 remove_bg: style_config.remove_bg.map(to_tuple),
1222 modify_bg: style_config.modify_bg.map(to_tuple),
1223 gutter_style,
1224 };
1225 }
1226 pane
1227 })
1228 .collect();
1229
1230 let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources);
1232
1233 if let Some(hunk_configs) = hunks {
1235 let diff_hunks: Vec<DiffHunk> = hunk_configs
1236 .into_iter()
1237 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1238 .collect();
1239
1240 let old_line_count = self
1242 .buffers()
1243 .get(
1244 &self
1245 .active_window()
1246 .composite_buffers
1247 .get(&buffer_id)
1248 .unwrap()
1249 .sources[0]
1250 .buffer_id,
1251 )
1252 .and_then(|s| s.buffer.line_count())
1253 .unwrap_or(0);
1254 let new_line_count = self
1255 .buffers()
1256 .get(
1257 &self
1258 .active_window()
1259 .composite_buffers
1260 .get(&buffer_id)
1261 .unwrap()
1262 .sources[1]
1263 .buffer_id,
1264 )
1265 .and_then(|s| s.buffer.line_count())
1266 .unwrap_or(0);
1267
1268 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1269 self.active_window_mut()
1270 .set_composite_alignment(buffer_id, alignment);
1271 }
1272
1273 if initial_focus_hunk.is_some() {
1275 if let Some(composite) = self
1276 .active_window_mut()
1277 .composite_buffers
1278 .get_mut(&buffer_id)
1279 {
1280 composite.initial_focus_hunk = initial_focus_hunk;
1281 }
1282 }
1283
1284 tracing::info!(
1285 "Created composite buffer '{}' with mode '{}' (id={:?})",
1286 name,
1287 mode,
1288 buffer_id
1289 );
1290
1291 if let Some(req_id) = _request_id {
1293 let result = buffer_id.0.to_string();
1295 self.plugin_manager
1296 .read()
1297 .unwrap()
1298 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), result);
1299 tracing::info!(
1300 "CreateCompositeBuffer: resolve_callback sent for request_id={}",
1301 req_id
1302 );
1303 }
1304 }
1305
1306 pub(crate) fn handle_update_composite_alignment(
1308 &mut self,
1309 buffer_id: BufferId,
1310 hunk_configs: Vec<fresh_core::api::CompositeHunk>,
1311 ) {
1312 use crate::model::composite_buffer::{DiffHunk, LineAlignment};
1313
1314 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1315 let diff_hunks: Vec<DiffHunk> = hunk_configs
1316 .into_iter()
1317 .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count))
1318 .collect();
1319
1320 let old_line_count = self
1322 .buffers()
1323 .get(&composite.sources[0].buffer_id)
1324 .and_then(|s| s.buffer.line_count())
1325 .unwrap_or(0);
1326 let new_line_count = self
1327 .buffers()
1328 .get(&composite.sources[1].buffer_id)
1329 .and_then(|s| s.buffer.line_count())
1330 .unwrap_or(0);
1331
1332 let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count);
1333 self.active_window_mut()
1334 .set_composite_alignment(buffer_id, alignment);
1335 }
1336 }
1337
1338 pub(crate) fn handle_composite_click(
1340 &mut self,
1341 col: u16,
1342 row: u16,
1343 split_id: LeafId,
1344 buffer_id: BufferId,
1345 content_rect: ratatui::layout::Rect,
1346 ) -> AnyhowResult<()> {
1347 let pane_idx = if let Some(view_state) = self
1349 .active_window()
1350 .composite_view_states
1351 .get(&(split_id, buffer_id))
1352 {
1353 let mut x = content_rect.x;
1354 let mut found_pane = 0;
1355 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1356 if col >= x && col < x + width {
1357 found_pane = i;
1358 break;
1359 }
1360 x += width + 1; }
1362 found_pane
1363 } else {
1364 0
1365 };
1366
1367 let content_row = row.saturating_sub(content_rect.y).saturating_sub(1) as usize;
1370
1371 let (pane_start_x, left_column) = if let Some(view_state) = self
1373 .active_window()
1374 .composite_view_states
1375 .get(&(split_id, buffer_id))
1376 {
1377 let mut x = content_rect.x;
1378 for (i, &width) in view_state.pane_widths.iter().enumerate() {
1379 if i == pane_idx {
1380 break;
1381 }
1382 x += width + 1;
1383 }
1384 let left_col = view_state
1385 .pane_viewports
1386 .get(pane_idx)
1387 .map(|vp| vp.left_column)
1388 .unwrap_or(0);
1389 (x, left_col)
1390 } else {
1391 (content_rect.x, 0)
1392 };
1393 let gutter_width = 4; let visual_col = col
1395 .saturating_sub(pane_start_x)
1396 .saturating_sub(gutter_width) as usize;
1397 let click_col = left_column + visual_col;
1399
1400 let display_row = if let Some(view_state) = self
1402 .active_window()
1403 .composite_view_states
1404 .get(&(split_id, buffer_id))
1405 {
1406 view_state.scroll_row + content_row
1407 } else {
1408 content_row
1409 };
1410
1411 let line_length =
1412 if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1413 composite
1414 .alignment
1415 .get_row(display_row)
1416 .and_then(|row| row.get_pane_line(pane_idx))
1417 .and_then(|line_ref| {
1418 let source = composite.sources.get(pane_idx)?;
1419 self.windows
1420 .get(&self.active_window)
1421 .map(|w| &w.buffers)
1422 .expect("active window present")
1423 .get(&source.buffer_id)?
1424 .buffer
1425 .get_line(line_ref.line)
1426 })
1427 .map(|bytes| {
1428 let s = String::from_utf8_lossy(&bytes);
1429 let trimmed = s.trim_end_matches('\n').trim_end_matches('\r');
1431 trimmed.graphemes(true).count()
1432 })
1433 .unwrap_or(0)
1434 } else {
1435 0
1436 };
1437
1438 let clamped_col = click_col.min(line_length);
1440
1441 if let Some(composite) = self
1443 .active_window_mut()
1444 .composite_buffers
1445 .get_mut(&buffer_id)
1446 {
1447 composite.active_pane = pane_idx;
1448 }
1449
1450 if let Some(view_state) = self
1452 .active_window_mut()
1453 .composite_view_states
1454 .get_mut(&(split_id, buffer_id))
1455 {
1456 view_state.focused_pane = pane_idx;
1457 view_state.cursor_row = display_row;
1458 view_state.cursor_column = clamped_col;
1459 view_state.sticky_column = clamped_col;
1460
1461 view_state.clear_selection();
1463 }
1464
1465 self.active_window_mut().mouse_state.dragging_text_selection = false; self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1468
1469 self.active_window_mut()
1471 .sync_editor_cursor_from_composite(split_id, buffer_id);
1472
1473 Ok(())
1474 }
1475}