fresh-editor 0.1.96

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! View state for composite buffers
//!
//! Manages viewport, cursor, and focus state for composite buffer rendering.

use crate::model::cursor::Cursors;
use crate::model::event::BufferId;
use ratatui::layout::Rect;

/// View state for a composite buffer in a split
#[derive(Debug, Clone)]
pub struct CompositeViewState {
    /// The composite buffer being displayed
    pub composite_id: BufferId,

    /// Independent viewport per pane
    pub pane_viewports: Vec<PaneViewport>,

    /// Which pane has focus (0-indexed)
    pub focused_pane: usize,

    /// Single scroll position (display row)
    /// All panes scroll together via alignment
    pub scroll_row: usize,

    /// Current cursor row (for navigation highlighting)
    pub cursor_row: usize,

    /// Current cursor column within the focused pane
    pub cursor_column: usize,

    /// Desired column for vertical navigation (sticky column)
    /// When moving up/down, the cursor tries to return to this column
    pub sticky_column: usize,

    /// Cursor positions per pane (for editing)
    pub pane_cursors: Vec<Cursors>,

    /// Width of each pane (computed during render)
    pub pane_widths: Vec<u16>,

    /// Whether visual selection mode is active
    pub visual_mode: bool,

    /// Selection anchor row (where selection started)
    pub selection_anchor_row: usize,

    /// Selection anchor column (where selection started)
    pub selection_anchor_column: usize,
}

impl CompositeViewState {
    /// Create a new composite view state for the given buffer
    pub fn new(composite_id: BufferId, pane_count: usize) -> Self {
        Self {
            composite_id,
            pane_viewports: (0..pane_count).map(|_| PaneViewport::default()).collect(),
            focused_pane: 0,
            scroll_row: 0,
            cursor_row: 0,
            cursor_column: 0,
            sticky_column: 0,
            pane_cursors: (0..pane_count).map(|_| Cursors::new()).collect(),
            pane_widths: vec![0; pane_count],
            visual_mode: false,
            selection_anchor_row: 0,
            selection_anchor_column: 0,
        }
    }

    /// Start visual selection at current cursor position
    pub fn start_visual_selection(&mut self) {
        self.visual_mode = true;
        self.selection_anchor_row = self.cursor_row;
        self.selection_anchor_column = self.cursor_column;
    }

    /// Clear visual selection
    pub fn clear_selection(&mut self) {
        self.visual_mode = false;
    }

    /// Get selection row range (start_row, end_row) inclusive
    /// Returns None if not in visual mode
    pub fn selection_row_range(&self) -> Option<(usize, usize)> {
        if !self.visual_mode {
            return None;
        }
        let start = self.selection_anchor_row.min(self.cursor_row);
        let end = self.selection_anchor_row.max(self.cursor_row);
        Some((start, end))
    }

    /// Check if a row is within the selection
    pub fn is_row_selected(&self, row: usize) -> bool {
        if !self.visual_mode {
            return false;
        }
        let (start, end) = self.selection_row_range().unwrap();
        row >= start && row <= end
    }

    /// Get the column range that is selected for a given row
    /// Returns (start_col, end_col) where end_col is exclusive
    /// Returns None if row is not in selection
    pub fn selection_column_range(&self, row: usize) -> Option<(usize, usize)> {
        if !self.visual_mode {
            return None;
        }

        let (start_row, end_row) = self.selection_row_range()?;
        if row < start_row || row > end_row {
            return None;
        }

        // Determine which position is "start" and which is "end"
        let (sel_start_row, sel_start_col, sel_end_row, sel_end_col) = if self.selection_anchor_row
            < self.cursor_row
            || (self.selection_anchor_row == self.cursor_row
                && self.selection_anchor_column <= self.cursor_column)
        {
            (
                self.selection_anchor_row,
                self.selection_anchor_column,
                self.cursor_row,
                self.cursor_column,
            )
        } else {
            (
                self.cursor_row,
                self.cursor_column,
                self.selection_anchor_row,
                self.selection_anchor_column,
            )
        };

        // For multi-row selection:
        // - First row: from start_col to end of line (usize::MAX)
        // - Middle rows: entire line (0 to usize::MAX)
        // - Last row: from 0 to end_col
        // For single-row selection: from start_col to end_col
        if sel_start_row == sel_end_row {
            // Single row selection
            Some((sel_start_col, sel_end_col))
        } else if row == sel_start_row {
            // First row of multi-row selection
            Some((sel_start_col, usize::MAX))
        } else if row == sel_end_row {
            // Last row of multi-row selection
            Some((0, sel_end_col))
        } else {
            // Middle row - entire line selected
            Some((0, usize::MAX))
        }
    }

    /// Move cursor down, auto-scrolling if needed
    pub fn move_cursor_down(&mut self, max_row: usize, viewport_height: usize) {
        if self.cursor_row < max_row {
            self.cursor_row += 1;
            // Auto-scroll if cursor goes below viewport
            if self.cursor_row >= self.scroll_row + viewport_height {
                self.scroll_row = self.cursor_row.saturating_sub(viewport_height - 1);
            }
        }
    }

    /// Move cursor up, auto-scrolling if needed
    pub fn move_cursor_up(&mut self) {
        if self.cursor_row > 0 {
            self.cursor_row -= 1;
            // Auto-scroll if cursor goes above viewport
            if self.cursor_row < self.scroll_row {
                self.scroll_row = self.cursor_row;
            }
        }
    }

    /// Move cursor to top
    pub fn move_cursor_to_top(&mut self) {
        self.cursor_row = 0;
        self.scroll_row = 0;
    }

    /// Move cursor to bottom
    pub fn move_cursor_to_bottom(&mut self, max_row: usize, viewport_height: usize) {
        self.cursor_row = max_row;
        self.scroll_row = max_row.saturating_sub(viewport_height.saturating_sub(1));
    }

    /// Move cursor left by one column
    pub fn move_cursor_left(&mut self) {
        if self.cursor_column > 0 {
            self.cursor_column -= 1;
            self.sticky_column = self.cursor_column;
            // Auto-scroll horizontally all panes together
            let current_left = self
                .pane_viewports
                .get(self.focused_pane)
                .map(|v| v.left_column)
                .unwrap_or(0);
            if self.cursor_column < current_left {
                for viewport in &mut self.pane_viewports {
                    viewport.left_column = self.cursor_column;
                }
            }
        }
    }

    /// Move cursor right by one column
    pub fn move_cursor_right(&mut self, max_column: usize, pane_width: usize) {
        if self.cursor_column < max_column {
            self.cursor_column += 1;
            self.sticky_column = self.cursor_column;
            // Auto-scroll horizontally all panes together
            let visible_width = pane_width.saturating_sub(4); // minus gutter
            let current_left = self
                .pane_viewports
                .get(self.focused_pane)
                .map(|v| v.left_column)
                .unwrap_or(0);
            if visible_width > 0 && self.cursor_column >= current_left + visible_width {
                let new_left = self
                    .cursor_column
                    .saturating_sub(visible_width.saturating_sub(1));
                for viewport in &mut self.pane_viewports {
                    viewport.left_column = new_left;
                }
            }
        }
    }

    /// Move cursor to start of line
    pub fn move_cursor_to_line_start(&mut self) {
        self.cursor_column = 0;
        self.sticky_column = 0;
        // Reset horizontal scroll for all panes
        for viewport in &mut self.pane_viewports {
            viewport.left_column = 0;
        }
    }

    /// Move cursor to end of line
    pub fn move_cursor_to_line_end(&mut self, line_length: usize, pane_width: usize) {
        self.cursor_column = line_length;
        self.sticky_column = line_length;
        // Auto-scroll all panes to show cursor
        let visible_width = pane_width.saturating_sub(4); // minus gutter
        let current_left = self
            .pane_viewports
            .get(self.focused_pane)
            .map(|v| v.left_column)
            .unwrap_or(0);
        if visible_width > 0 && self.cursor_column >= current_left + visible_width {
            let new_left = self
                .cursor_column
                .saturating_sub(visible_width.saturating_sub(1));
            for viewport in &mut self.pane_viewports {
                viewport.left_column = new_left;
            }
        }
    }

    /// Clamp cursor column to line length, using sticky column if possible
    /// Call this after vertical movement to adjust cursor to new line's length
    pub fn clamp_cursor_to_line(&mut self, line_length: usize) {
        // Try to use sticky column, but clamp to line length
        self.cursor_column = self.sticky_column.min(line_length);
    }

    /// Scroll all panes together by delta lines
    pub fn scroll(&mut self, delta: isize, max_row: usize) {
        if delta >= 0 {
            self.scroll_row = self.scroll_row.saturating_add(delta as usize).min(max_row);
        } else {
            self.scroll_row = self.scroll_row.saturating_sub(delta.unsigned_abs());
        }
    }

    /// Set scroll to a specific row
    pub fn set_scroll_row(&mut self, row: usize, max_row: usize) {
        self.scroll_row = row.min(max_row);
    }

    /// Scroll to top
    pub fn scroll_to_top(&mut self) {
        self.scroll_row = 0;
    }

    /// Scroll to bottom
    pub fn scroll_to_bottom(&mut self, total_rows: usize, viewport_height: usize) {
        self.scroll_row = total_rows.saturating_sub(viewport_height);
    }

    /// Page down
    pub fn page_down(&mut self, viewport_height: usize, max_row: usize) {
        self.scroll_row = self.scroll_row.saturating_add(viewport_height).min(max_row);
    }

    /// Page up
    pub fn page_up(&mut self, viewport_height: usize) {
        self.scroll_row = self.scroll_row.saturating_sub(viewport_height);
    }

    /// Switch focus to the next pane
    pub fn focus_next_pane(&mut self) {
        if !self.pane_viewports.is_empty() {
            self.focused_pane = (self.focused_pane + 1) % self.pane_viewports.len();
        }
    }

    /// Switch focus to the previous pane
    pub fn focus_prev_pane(&mut self) {
        let count = self.pane_viewports.len();
        if count > 0 {
            self.focused_pane = (self.focused_pane + count - 1) % count;
        }
    }

    /// Set focus to a specific pane
    pub fn set_focused_pane(&mut self, pane_index: usize) {
        if pane_index < self.pane_viewports.len() {
            self.focused_pane = pane_index;
        }
    }

    /// Get the viewport for a specific pane
    pub fn get_pane_viewport(&self, pane_index: usize) -> Option<&PaneViewport> {
        self.pane_viewports.get(pane_index)
    }

    /// Get mutable viewport for a specific pane
    pub fn get_pane_viewport_mut(&mut self, pane_index: usize) -> Option<&mut PaneViewport> {
        self.pane_viewports.get_mut(pane_index)
    }

    /// Get the cursor for a specific pane
    pub fn get_pane_cursor(&self, pane_index: usize) -> Option<&Cursors> {
        self.pane_cursors.get(pane_index)
    }

    /// Get mutable cursor for a specific pane
    pub fn get_pane_cursor_mut(&mut self, pane_index: usize) -> Option<&mut Cursors> {
        self.pane_cursors.get_mut(pane_index)
    }

    /// Get the focused pane's cursor
    pub fn focused_cursor(&self) -> Option<&Cursors> {
        self.pane_cursors.get(self.focused_pane)
    }

    /// Get mutable reference to the focused pane's cursor
    pub fn focused_cursor_mut(&mut self) -> Option<&mut Cursors> {
        self.pane_cursors.get_mut(self.focused_pane)
    }

    /// Update pane widths based on layout ratios and total width
    pub fn update_pane_widths(&mut self, total_width: u16, ratios: &[f32], separator_width: u16) {
        let separator_count = if self.pane_viewports.len() > 1 {
            self.pane_viewports.len() - 1
        } else {
            0
        };
        let available_width = total_width.saturating_sub(separator_count as u16 * separator_width);

        self.pane_widths.clear();
        for ratio in ratios {
            let width = (available_width as f32 * ratio).round() as u16;
            self.pane_widths.push(width);
        }

        // Adjust last pane to account for rounding
        let total: u16 = self.pane_widths.iter().sum();
        if total < available_width {
            if let Some(last) = self.pane_widths.last_mut() {
                *last += available_width - total;
            }
        } else if total > available_width {
            if let Some(last) = self.pane_widths.last_mut() {
                *last = last.saturating_sub(total - available_width);
            }
        }
    }

    /// Compute rects for each pane given the total area
    pub fn compute_pane_rects(&self, area: Rect, separator_width: u16) -> Vec<Rect> {
        let mut rects = Vec::with_capacity(self.pane_widths.len());
        let mut x = area.x;

        for (i, &width) in self.pane_widths.iter().enumerate() {
            rects.push(Rect {
                x,
                y: area.y,
                width,
                height: area.height,
            });
            x += width;
            if i < self.pane_widths.len() - 1 {
                x += separator_width;
            }
        }

        rects
    }
}

/// Viewport state for a single pane within a composite
#[derive(Debug, Clone, Default)]
pub struct PaneViewport {
    /// Computed rect for this pane (set during render)
    pub rect: Rect,
    /// Horizontal scroll offset for this pane
    pub left_column: usize,
}

impl PaneViewport {
    /// Create a new pane viewport
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the rect for this pane
    pub fn set_rect(&mut self, rect: Rect) {
        self.rect = rect;
    }

    /// Scroll horizontally
    pub fn scroll_horizontal(&mut self, delta: isize, max_column: usize) {
        if delta >= 0 {
            self.left_column = self
                .left_column
                .saturating_add(delta as usize)
                .min(max_column);
        } else {
            self.left_column = self.left_column.saturating_sub(delta.unsigned_abs());
        }
    }

    /// Reset horizontal scroll
    pub fn reset_horizontal_scroll(&mut self) {
        self.left_column = 0;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_composite_view_scroll() {
        let mut view = CompositeViewState::new(BufferId(1), 2);
        assert_eq!(view.scroll_row, 0);

        view.scroll(10, 100);
        assert_eq!(view.scroll_row, 10);

        view.scroll(-5, 100);
        assert_eq!(view.scroll_row, 5);

        view.scroll(-10, 100);
        assert_eq!(view.scroll_row, 0); // Doesn't go negative
    }

    #[test]
    fn test_composite_view_focus() {
        let mut view = CompositeViewState::new(BufferId(1), 3);
        assert_eq!(view.focused_pane, 0);

        view.focus_next_pane();
        assert_eq!(view.focused_pane, 1);

        view.focus_next_pane();
        assert_eq!(view.focused_pane, 2);

        view.focus_next_pane();
        assert_eq!(view.focused_pane, 0); // Wraps around

        view.focus_prev_pane();
        assert_eq!(view.focused_pane, 2);
    }

    #[test]
    fn test_pane_width_calculation() {
        let mut view = CompositeViewState::new(BufferId(1), 2);
        view.update_pane_widths(100, &[0.5, 0.5], 1);

        assert_eq!(view.pane_widths.len(), 2);
        // 100 - 1 (separator) = 99, 99 * 0.5 = 49.5 ≈ 50
        assert!(view.pane_widths[0] + view.pane_widths[1] == 99);
    }

    #[test]
    fn test_compute_pane_rects() {
        let mut view = CompositeViewState::new(BufferId(1), 2);
        view.update_pane_widths(101, &[0.5, 0.5], 1);

        let area = Rect {
            x: 0,
            y: 0,
            width: 101,
            height: 50,
        };
        let rects = view.compute_pane_rects(area, 1);

        assert_eq!(rects.len(), 2);
        assert_eq!(rects[0].x, 0);
        assert_eq!(rects[1].x, rects[0].width + 1); // After separator
        assert_eq!(rects[0].height, 50);
        assert_eq!(rects[1].height, 50);
    }
}