Skip to main content

fresh/view/
composite_view.rs

1//! View state for composite buffers
2//!
3//! Manages viewport, cursor, and focus state for composite buffer rendering.
4
5use crate::model::cursor::Cursors;
6use crate::model::event::BufferId;
7use ratatui::layout::Rect;
8
9/// View state for a composite buffer in a split
10#[derive(Debug, Clone)]
11pub struct CompositeViewState {
12    /// The composite buffer being displayed
13    pub composite_id: BufferId,
14
15    /// Independent viewport per pane
16    pub pane_viewports: Vec<PaneViewport>,
17
18    /// Which pane has focus (0-indexed)
19    pub focused_pane: usize,
20
21    /// Single scroll position (display row)
22    /// All panes scroll together via alignment
23    pub scroll_row: usize,
24
25    /// Current cursor row (for navigation highlighting)
26    pub cursor_row: usize,
27
28    /// Current cursor column within the focused pane
29    pub cursor_column: usize,
30
31    /// Desired column for vertical navigation (sticky column)
32    /// When moving up/down, the cursor tries to return to this column
33    pub sticky_column: usize,
34
35    /// Cursor positions per pane (for editing)
36    pub pane_cursors: Vec<Cursors>,
37
38    /// Width of each pane (computed during render)
39    pub pane_widths: Vec<u16>,
40
41    /// Whether visual selection mode is active
42    pub visual_mode: bool,
43
44    /// Selection anchor row (where selection started)
45    pub selection_anchor_row: usize,
46
47    /// Selection anchor column (where selection started)
48    pub selection_anchor_column: usize,
49}
50
51impl CompositeViewState {
52    /// Create a new composite view state for the given buffer
53    pub fn new(composite_id: BufferId, pane_count: usize) -> Self {
54        Self {
55            composite_id,
56            pane_viewports: (0..pane_count).map(|_| PaneViewport::default()).collect(),
57            focused_pane: 0,
58            scroll_row: 0,
59            cursor_row: 0,
60            cursor_column: 0,
61            sticky_column: 0,
62            pane_cursors: (0..pane_count).map(|_| Cursors::new()).collect(),
63            pane_widths: vec![0; pane_count],
64            visual_mode: false,
65            selection_anchor_row: 0,
66            selection_anchor_column: 0,
67        }
68    }
69
70    /// Start visual selection at current cursor position
71    pub fn start_visual_selection(&mut self) {
72        self.visual_mode = true;
73        self.selection_anchor_row = self.cursor_row;
74        self.selection_anchor_column = self.cursor_column;
75    }
76
77    /// Clear visual selection
78    pub fn clear_selection(&mut self) {
79        self.visual_mode = false;
80    }
81
82    /// Get selection row range (start_row, end_row) inclusive
83    /// Returns None if not in visual mode
84    pub fn selection_row_range(&self) -> Option<(usize, usize)> {
85        if !self.visual_mode {
86            return None;
87        }
88        let start = self.selection_anchor_row.min(self.cursor_row);
89        let end = self.selection_anchor_row.max(self.cursor_row);
90        Some((start, end))
91    }
92
93    /// Check if a row is within the selection
94    pub fn is_row_selected(&self, row: usize) -> bool {
95        if !self.visual_mode {
96            return false;
97        }
98        let (start, end) = self.selection_row_range().unwrap();
99        row >= start && row <= end
100    }
101
102    /// Get the column range that is selected for a given row
103    /// Returns (start_col, end_col) where end_col is exclusive
104    /// Returns None if row is not in selection
105    pub fn selection_column_range(&self, row: usize) -> Option<(usize, usize)> {
106        if !self.visual_mode {
107            return None;
108        }
109
110        let (start_row, end_row) = self.selection_row_range()?;
111        if row < start_row || row > end_row {
112            return None;
113        }
114
115        // Determine which position is "start" and which is "end"
116        let (sel_start_row, sel_start_col, sel_end_row, sel_end_col) = if self.selection_anchor_row
117            < self.cursor_row
118            || (self.selection_anchor_row == self.cursor_row
119                && self.selection_anchor_column <= self.cursor_column)
120        {
121            (
122                self.selection_anchor_row,
123                self.selection_anchor_column,
124                self.cursor_row,
125                self.cursor_column,
126            )
127        } else {
128            (
129                self.cursor_row,
130                self.cursor_column,
131                self.selection_anchor_row,
132                self.selection_anchor_column,
133            )
134        };
135
136        // For multi-row selection:
137        // - First row: from start_col to end of line (usize::MAX)
138        // - Middle rows: entire line (0 to usize::MAX)
139        // - Last row: from 0 to end_col
140        // For single-row selection: from start_col to end_col
141        if sel_start_row == sel_end_row {
142            // Single row selection
143            Some((sel_start_col, sel_end_col))
144        } else if row == sel_start_row {
145            // First row of multi-row selection
146            Some((sel_start_col, usize::MAX))
147        } else if row == sel_end_row {
148            // Last row of multi-row selection
149            Some((0, sel_end_col))
150        } else {
151            // Middle row - entire line selected
152            Some((0, usize::MAX))
153        }
154    }
155
156    /// Move cursor down, auto-scrolling if needed.
157    /// Keeps cursor at least SCROLL_MARGIN lines from the bottom edge of the viewport.
158    pub fn move_cursor_down(&mut self, max_row: usize, viewport_height: usize) {
159        const SCROLL_MARGIN: usize = 3;
160        if self.cursor_row < max_row {
161            self.cursor_row += 1;
162            let margin = SCROLL_MARGIN.min(viewport_height.saturating_sub(1) / 2);
163            if self.cursor_row + margin >= self.scroll_row + viewport_height {
164                self.scroll_row += 1;
165            }
166        }
167    }
168
169    /// Move cursor up, auto-scrolling if needed.
170    /// Keeps cursor at least SCROLL_MARGIN lines from the top edge of the viewport.
171    pub fn move_cursor_up(&mut self, viewport_height: usize) {
172        const SCROLL_MARGIN: usize = 3;
173        if self.cursor_row > 0 {
174            self.cursor_row -= 1;
175            let margin = SCROLL_MARGIN.min(viewport_height.saturating_sub(1) / 2);
176            if self.cursor_row < self.scroll_row + margin && self.scroll_row > 0 {
177                self.scroll_row -= 1;
178            }
179        }
180    }
181
182    /// Move cursor to top
183    pub fn move_cursor_to_top(&mut self) {
184        self.cursor_row = 0;
185        self.scroll_row = 0;
186    }
187
188    /// Move cursor to bottom
189    pub fn move_cursor_to_bottom(&mut self, max_row: usize, viewport_height: usize) {
190        self.cursor_row = max_row;
191        self.scroll_row = max_row.saturating_sub(viewport_height.saturating_sub(1));
192    }
193
194    /// Move cursor left by one column
195    pub fn move_cursor_left(&mut self) {
196        if self.cursor_column > 0 {
197            self.cursor_column -= 1;
198            self.sticky_column = self.cursor_column;
199            // Auto-scroll horizontally all panes together
200            let current_left = self
201                .pane_viewports
202                .get(self.focused_pane)
203                .map(|v| v.left_column)
204                .unwrap_or(0);
205            if self.cursor_column < current_left {
206                for viewport in &mut self.pane_viewports {
207                    viewport.left_column = self.cursor_column;
208                }
209            }
210        }
211    }
212
213    /// Move cursor right by one column
214    pub fn move_cursor_right(&mut self, max_column: usize, pane_width: usize) {
215        if self.cursor_column < max_column {
216            self.cursor_column += 1;
217            self.sticky_column = self.cursor_column;
218            // Auto-scroll horizontally all panes together
219            let visible_width = pane_width.saturating_sub(4); // minus gutter
220            let current_left = self
221                .pane_viewports
222                .get(self.focused_pane)
223                .map(|v| v.left_column)
224                .unwrap_or(0);
225            if visible_width > 0 && self.cursor_column >= current_left + visible_width {
226                let new_left = self
227                    .cursor_column
228                    .saturating_sub(visible_width.saturating_sub(1));
229                for viewport in &mut self.pane_viewports {
230                    viewport.left_column = new_left;
231                }
232            }
233        }
234    }
235
236    /// Move cursor to start of line
237    pub fn move_cursor_to_line_start(&mut self) {
238        self.cursor_column = 0;
239        self.sticky_column = 0;
240        // Reset horizontal scroll for all panes
241        for viewport in &mut self.pane_viewports {
242            viewport.left_column = 0;
243        }
244    }
245
246    /// Move cursor to end of line
247    pub fn move_cursor_to_line_end(&mut self, line_length: usize, pane_width: usize) {
248        self.cursor_column = line_length;
249        self.sticky_column = line_length;
250        // Auto-scroll all panes to show cursor
251        let visible_width = pane_width.saturating_sub(4); // minus gutter
252        let current_left = self
253            .pane_viewports
254            .get(self.focused_pane)
255            .map(|v| v.left_column)
256            .unwrap_or(0);
257        if visible_width > 0 && self.cursor_column >= current_left + visible_width {
258            let new_left = self
259                .cursor_column
260                .saturating_sub(visible_width.saturating_sub(1));
261            for viewport in &mut self.pane_viewports {
262                viewport.left_column = new_left;
263            }
264        }
265    }
266
267    /// Clamp cursor column to line length, using sticky column if possible
268    /// Call this after vertical movement to adjust cursor to new line's length
269    pub fn clamp_cursor_to_line(&mut self, line_length: usize) {
270        // Try to use sticky column, but clamp to line length
271        self.cursor_column = self.sticky_column.min(line_length);
272    }
273
274    /// Scroll all panes together by delta lines
275    pub fn scroll(&mut self, delta: isize, max_row: usize) {
276        if delta >= 0 {
277            self.scroll_row = self.scroll_row.saturating_add(delta as usize).min(max_row);
278        } else {
279            self.scroll_row = self.scroll_row.saturating_sub(delta.unsigned_abs());
280        }
281    }
282
283    /// Set scroll to a specific row
284    pub fn set_scroll_row(&mut self, row: usize, max_row: usize) {
285        self.scroll_row = row.min(max_row);
286    }
287
288    /// Scroll to top
289    pub fn scroll_to_top(&mut self) {
290        self.scroll_row = 0;
291    }
292
293    /// Scroll to bottom
294    pub fn scroll_to_bottom(&mut self, total_rows: usize, viewport_height: usize) {
295        self.scroll_row = total_rows.saturating_sub(viewport_height);
296    }
297
298    /// Page down
299    pub fn page_down(&mut self, viewport_height: usize, max_row: usize) {
300        self.scroll_row = self.scroll_row.saturating_add(viewport_height).min(max_row);
301    }
302
303    /// Page up
304    pub fn page_up(&mut self, viewport_height: usize) {
305        self.scroll_row = self.scroll_row.saturating_sub(viewport_height);
306    }
307
308    /// Switch focus to the next pane
309    pub fn focus_next_pane(&mut self) {
310        if !self.pane_viewports.is_empty() {
311            self.focused_pane = (self.focused_pane + 1) % self.pane_viewports.len();
312        }
313    }
314
315    /// Switch focus to the previous pane
316    pub fn focus_prev_pane(&mut self) {
317        let count = self.pane_viewports.len();
318        if count > 0 {
319            self.focused_pane = (self.focused_pane + count - 1) % count;
320        }
321    }
322
323    /// Set focus to a specific pane
324    pub fn set_focused_pane(&mut self, pane_index: usize) {
325        if pane_index < self.pane_viewports.len() {
326            self.focused_pane = pane_index;
327        }
328    }
329
330    /// Get the viewport for a specific pane
331    pub fn get_pane_viewport(&self, pane_index: usize) -> Option<&PaneViewport> {
332        self.pane_viewports.get(pane_index)
333    }
334
335    /// Get mutable viewport for a specific pane
336    pub fn get_pane_viewport_mut(&mut self, pane_index: usize) -> Option<&mut PaneViewport> {
337        self.pane_viewports.get_mut(pane_index)
338    }
339
340    /// Get the cursor for a specific pane
341    pub fn get_pane_cursor(&self, pane_index: usize) -> Option<&Cursors> {
342        self.pane_cursors.get(pane_index)
343    }
344
345    /// Get mutable cursor for a specific pane
346    pub fn get_pane_cursor_mut(&mut self, pane_index: usize) -> Option<&mut Cursors> {
347        self.pane_cursors.get_mut(pane_index)
348    }
349
350    /// Get the focused pane's cursor
351    pub fn focused_cursor(&self) -> Option<&Cursors> {
352        self.pane_cursors.get(self.focused_pane)
353    }
354
355    /// Get mutable reference to the focused pane's cursor
356    pub fn focused_cursor_mut(&mut self) -> Option<&mut Cursors> {
357        self.pane_cursors.get_mut(self.focused_pane)
358    }
359
360    /// Update pane widths based on layout ratios and total width
361    pub fn update_pane_widths(&mut self, total_width: u16, ratios: &[f32], separator_width: u16) {
362        let separator_count = if self.pane_viewports.len() > 1 {
363            self.pane_viewports.len() - 1
364        } else {
365            0
366        };
367        let available_width = total_width.saturating_sub(separator_count as u16 * separator_width);
368
369        self.pane_widths.clear();
370        for ratio in ratios {
371            let width = (available_width as f32 * ratio).round() as u16;
372            self.pane_widths.push(width);
373        }
374
375        // Adjust last pane to account for rounding
376        let total: u16 = self.pane_widths.iter().sum();
377        if total < available_width {
378            if let Some(last) = self.pane_widths.last_mut() {
379                *last += available_width - total;
380            }
381        } else if total > available_width {
382            if let Some(last) = self.pane_widths.last_mut() {
383                *last = last.saturating_sub(total - available_width);
384            }
385        }
386    }
387
388    /// Compute rects for each pane given the total area
389    pub fn compute_pane_rects(&self, area: Rect, separator_width: u16) -> Vec<Rect> {
390        let mut rects = Vec::with_capacity(self.pane_widths.len());
391        let mut x = area.x;
392
393        for (i, &width) in self.pane_widths.iter().enumerate() {
394            rects.push(Rect {
395                x,
396                y: area.y,
397                width,
398                height: area.height,
399            });
400            x += width;
401            if i < self.pane_widths.len() - 1 {
402                x += separator_width;
403            }
404        }
405
406        rects
407    }
408}
409
410/// Viewport state for a single pane within a composite
411#[derive(Debug, Clone, Default)]
412pub struct PaneViewport {
413    /// Computed rect for this pane (set during render)
414    pub rect: Rect,
415    /// Horizontal scroll offset for this pane
416    pub left_column: usize,
417}
418
419impl PaneViewport {
420    /// Create a new pane viewport
421    pub fn new() -> Self {
422        Self::default()
423    }
424
425    /// Set the rect for this pane
426    pub fn set_rect(&mut self, rect: Rect) {
427        self.rect = rect;
428    }
429
430    /// Scroll horizontally
431    pub fn scroll_horizontal(&mut self, delta: isize, max_column: usize) {
432        if delta >= 0 {
433            self.left_column = self
434                .left_column
435                .saturating_add(delta as usize)
436                .min(max_column);
437        } else {
438            self.left_column = self.left_column.saturating_sub(delta.unsigned_abs());
439        }
440    }
441
442    /// Reset horizontal scroll
443    pub fn reset_horizontal_scroll(&mut self) {
444        self.left_column = 0;
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_composite_view_scroll() {
454        let mut view = CompositeViewState::new(BufferId(1), 2);
455        assert_eq!(view.scroll_row, 0);
456
457        view.scroll(10, 100);
458        assert_eq!(view.scroll_row, 10);
459
460        view.scroll(-5, 100);
461        assert_eq!(view.scroll_row, 5);
462
463        view.scroll(-10, 100);
464        assert_eq!(view.scroll_row, 0); // Doesn't go negative
465    }
466
467    #[test]
468    fn test_composite_view_focus() {
469        let mut view = CompositeViewState::new(BufferId(1), 3);
470        assert_eq!(view.focused_pane, 0);
471
472        view.focus_next_pane();
473        assert_eq!(view.focused_pane, 1);
474
475        view.focus_next_pane();
476        assert_eq!(view.focused_pane, 2);
477
478        view.focus_next_pane();
479        assert_eq!(view.focused_pane, 0); // Wraps around
480
481        view.focus_prev_pane();
482        assert_eq!(view.focused_pane, 2);
483    }
484
485    #[test]
486    fn test_pane_width_calculation() {
487        let mut view = CompositeViewState::new(BufferId(1), 2);
488        view.update_pane_widths(100, &[0.5, 0.5], 1);
489
490        assert_eq!(view.pane_widths.len(), 2);
491        // 100 - 1 (separator) = 99, 99 * 0.5 = 49.5 ≈ 50
492        assert!(view.pane_widths[0] + view.pane_widths[1] == 99);
493    }
494
495    #[test]
496    fn test_compute_pane_rects() {
497        let mut view = CompositeViewState::new(BufferId(1), 2);
498        view.update_pane_widths(101, &[0.5, 0.5], 1);
499
500        let area = Rect {
501            x: 0,
502            y: 0,
503            width: 101,
504            height: 50,
505        };
506        let rects = view.compute_pane_rects(area, 1);
507
508        assert_eq!(rects.len(), 2);
509        assert_eq!(rects[0].x, 0);
510        assert_eq!(rects[1].x, rects[0].width + 1); // After separator
511        assert_eq!(rects[0].height, 50);
512        assert_eq!(rects[1].height, 50);
513    }
514}