term39 1.5.1

A modern, retro-styled terminal multiplexer with a classic MS-DOS aesthetic
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
use super::charset::Charset;
use super::color_utils;
use super::theme::Theme;
use crossterm::{
    QueueableCommand, cursor,
    style::{Color, SetBackgroundColor, SetForegroundColor},
};
use std::io::{self, Write};

/// Represents a single cell in the terminal buffer
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cell {
    pub character: char,
    pub fg_color: Color,
    pub bg_color: Color,
}

impl Cell {
    /// Create a new cell with automatic contrast checking.
    /// If the contrast ratio between foreground and background is too low,
    /// the colors will be automatically adjusted to ensure readability.
    /// Uses WCAG 2.1 AA standard (4.5:1 contrast ratio for normal text).
    pub fn new(character: char, fg_color: Color, bg_color: Color) -> Self {
        // Ensure minimum contrast ratio of 4.5:1 (WCAG AA level)
        let (adjusted_fg, adjusted_bg) = color_utils::ensure_contrast(fg_color, bg_color, 4.5);

        Self {
            character,
            fg_color: adjusted_fg,
            bg_color: adjusted_bg,
        }
    }

    /// Create a new cell without contrast checking.
    /// Use this for special effects like shadows where low contrast is intentional.
    pub fn new_unchecked(character: char, fg_color: Color, bg_color: Color) -> Self {
        Self {
            character,
            fg_color,
            bg_color,
        }
    }

    /// Create a new cell with inverted colors (for selection highlighting)
    pub fn inverted(&self) -> Self {
        Self {
            character: self.character,
            fg_color: self.bg_color,
            bg_color: self.fg_color,
        }
    }
}

impl Default for Cell {
    fn default() -> Self {
        Self {
            character: ' ',
            fg_color: Color::White,
            bg_color: Color::Black, // Neutral default that works across all themes
        }
    }
}

/// Double-buffered video memory for efficient rendering
pub struct VideoBuffer {
    width: u16,
    height: u16,
    front_buffer: Vec<Cell>,
    back_buffer: Vec<Cell>,
    /// TTY cursor position (for raw mouse input mode)
    /// When set, the cell at this position will be rendered with inverted colors
    tty_cursor: Option<(u16, u16)>,
    /// Track dirty rows for optimized rendering
    /// Only rows marked dirty need to be processed during present()
    dirty_rows: Vec<bool>,
}

impl VideoBuffer {
    pub fn new(width: u16, height: u16) -> Self {
        // Use checked arithmetic to prevent overflow, with a reasonable fallback
        let size = (width as usize).checked_mul(height as usize).unwrap_or(0);
        let default_cell = Cell::default();

        Self {
            width,
            height,
            front_buffer: vec![default_cell; size],
            back_buffer: vec![default_cell; size],
            tty_cursor: None,
            // All rows dirty initially to ensure first frame renders completely
            dirty_rows: vec![true; height as usize],
        }
    }

    /// Get index for x, y coordinates
    /// Uses checked arithmetic to prevent integer overflow
    fn index(&self, x: u16, y: u16) -> Option<usize> {
        if x < self.width && y < self.height {
            // Use checked arithmetic to prevent overflow
            let row_offset = (y as usize).checked_mul(self.width as usize)?;
            row_offset.checked_add(x as usize)
        } else {
            None
        }
    }

    /// Get cell at position from back buffer
    pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
        self.index(x, y).map(|i| &self.back_buffer[i])
    }

    /// Get cell at position from front buffer (what's currently displayed)
    pub fn get_front(&self, x: u16, y: u16) -> Option<&Cell> {
        self.index(x, y).map(|i| &self.front_buffer[i])
    }

    /// Set cell at position in back buffer
    /// Only marks row dirty if cell actually changed
    pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
        if let Some(i) = self.index(x, y) {
            // Only update and mark dirty if cell actually changed
            if self.back_buffer[i] != cell {
                self.back_buffer[i] = cell;
                if (y as usize) < self.dirty_rows.len() {
                    self.dirty_rows[y as usize] = true;
                }
            }
        }
    }

    /// Clear back buffer with a specific cell
    #[allow(dead_code)]
    pub fn clear(&mut self, cell: Cell) {
        for c in &mut self.back_buffer {
            *c = cell;
        }
        // Mark all rows dirty after clear
        self.mark_all_dirty();
    }

    /// Mark all rows as dirty (for full refresh scenarios)
    pub fn mark_all_dirty(&mut self) {
        for dirty in &mut self.dirty_rows {
            *dirty = true;
        }
    }

    /// Get buffer dimensions
    pub fn dimensions(&self) -> (u16, u16) {
        (self.width, self.height)
    }

    /// Set TTY cursor position for raw mouse input mode
    /// The cell at this position will be rendered with inverted colors
    pub fn set_tty_cursor(&mut self, col: u16, row: u16) {
        self.tty_cursor = Some((col, row));
    }

    /// Clear TTY cursor (hide it)
    pub fn clear_tty_cursor(&mut self) {
        self.tty_cursor = None;
    }

    /// Get current TTY cursor position
    #[allow(dead_code)]
    pub fn get_tty_cursor(&self) -> Option<(u16, u16)> {
        self.tty_cursor
    }

    /// Apply shadow overlay to all cells in the back buffer
    /// This is an optimized version that directly modifies the buffer
    /// without the overhead of get/set methods
    pub fn apply_fullscreen_shadow(&mut self, shadow_fg: Color, shadow_bg: Color) {
        for cell in &mut self.back_buffer {
            cell.fg_color = shadow_fg;
            cell.bg_color = shadow_bg;
        }
        // Mark all rows dirty since shadow affects entire screen
        self.mark_all_dirty();
    }

    /// Present back buffer to screen, only updating changed cells
    /// Uses queued commands for batched I/O - significantly reduces syscalls
    /// Optimized with run-length encoding for consecutive cells
    /// Skips rows that haven't been marked dirty for additional performance
    pub fn present(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
        // Hide cursor at the START of rendering to prevent any cursor flicker
        // This ensures the cursor stays hidden even if PTY output or other
        // operations between frames affected cursor state
        stdout.queue(cursor::Hide)?;

        let mut current_fg = Color::Reset;
        let mut current_bg = Color::Reset;

        // Buffer for accumulating consecutive characters with same colors
        // Pre-allocate with reasonable capacity to avoid reallocations
        let mut run_buffer = String::with_capacity(256);
        let mut run_start_x: u16 = 0;
        let mut run_y: u16 = 0;
        let mut run_char_count: u16 = 0; // Track character count separately for O(1) access
        let mut in_run = false;

        // Extract cursor position once to avoid is_some_and() call per cell
        let cursor_pos = self.tty_cursor;

        for y in 0..self.height {
            // Skip rows that haven't changed (dirty row optimization)
            if (y as usize) < self.dirty_rows.len() && !self.dirty_rows[y as usize] {
                continue;
            }

            // Calculate row start index once per row
            let row_start = (y as usize) * (self.width as usize);

            for x in 0..self.width {
                let idx = row_start + (x as usize);

                // Safety: we know idx is valid because we control the loop bounds
                if idx >= self.front_buffer.len() {
                    continue;
                }

                let front_cell = &self.front_buffer[idx];
                let back_cell = &self.back_buffer[idx];

                // Check if this cell is under the TTY cursor - if so, invert colors
                let is_cursor = cursor_pos.is_some_and(|(cx, cy)| cx == x && cy == y);
                let display_cell = if is_cursor {
                    back_cell.inverted()
                } else {
                    *back_cell
                };

                // Only update if cell changed (compare with inverted if cursor)
                if front_cell != &display_cell {
                    // Check if we can extend the current run
                    // Cell must be immediately adjacent (same row, next column) with same colors
                    let can_extend = in_run
                        && y == run_y
                        && x == run_start_x + run_char_count
                        && display_cell.fg_color == current_fg
                        && display_cell.bg_color == current_bg;

                    if can_extend {
                        // Extend the current run
                        run_buffer.push(display_cell.character);
                        run_char_count += 1;
                    } else {
                        // Flush previous run if any
                        if in_run && !run_buffer.is_empty() {
                            stdout.queue(cursor::MoveTo(run_start_x, run_y))?;
                            stdout.write_all(run_buffer.as_bytes())?;
                            run_buffer.clear();
                        }

                        // Update colors if needed
                        if display_cell.fg_color != current_fg {
                            stdout.queue(SetForegroundColor(display_cell.fg_color))?;
                            current_fg = display_cell.fg_color;
                        }
                        if display_cell.bg_color != current_bg {
                            stdout.queue(SetBackgroundColor(display_cell.bg_color))?;
                            current_bg = display_cell.bg_color;
                        }

                        // Start new run
                        run_start_x = x;
                        run_y = y;
                        run_buffer.push(display_cell.character);
                        run_char_count = 1;
                        in_run = true;
                    }
                }
            }

            // Flush run at end of each row (can't span rows)
            if in_run && !run_buffer.is_empty() {
                stdout.queue(cursor::MoveTo(run_start_x, run_y))?;
                stdout.write_all(run_buffer.as_bytes())?;
                run_buffer.clear();
                run_char_count = 0;
                in_run = false;
            }
        }

        // Clear dirty flags after processing all rows
        for dirty in &mut self.dirty_rows {
            *dirty = false;
        }

        // Update front buffer to reflect what's actually displayed
        // Optimized: bulk copy + single-point cursor inversion (avoids per-cell division/modulo)
        self.front_buffer.copy_from_slice(&self.back_buffer);

        // Apply cursor inversion at single location if needed
        if let Some((cx, cy)) = cursor_pos {
            let cursor_idx = (cy as usize) * (self.width as usize) + (cx as usize);
            if cursor_idx < self.front_buffer.len() {
                self.front_buffer[cursor_idx] = self.back_buffer[cursor_idx].inverted();
            }
        }

        // Hide cursor after rendering to prevent it from being visible or affecting PTY output
        // Even hidden cursors have a position, so we also move it to (0, 0)
        stdout.queue(cursor::MoveTo(0, 0))?;
        stdout.queue(cursor::Hide)?;

        // Flush all queued commands at once - single syscall
        stdout.flush()?;

        Ok(())
    }

    /// Save a rectangular region from the front buffer
    #[allow(dead_code)]
    pub fn save_region(&self, x: u16, y: u16, width: u16, height: u16) -> Vec<Cell> {
        let mut saved = Vec::with_capacity((width as usize) * (height as usize));

        for dy in 0..height {
            for dx in 0..width {
                let cell_x = x + dx;
                let cell_y = y + dy;

                if let Some(cell) = self.get_front(cell_x, cell_y) {
                    saved.push(*cell);
                } else {
                    saved.push(Cell::default());
                }
            }
        }

        saved
    }

    /// Restore a rectangular region to the back buffer
    #[allow(dead_code)]
    pub fn restore_region(&mut self, x: u16, y: u16, width: u16, height: u16, saved: &[Cell]) {
        let mut idx = 0;

        for dy in 0..height {
            for dx in 0..width {
                if idx < saved.len() {
                    let cell_x = x + dx;
                    let cell_y = y + dy;
                    self.set(cell_x, cell_y, saved[idx]);
                    idx += 1;
                }
            }
        }
    }
}

/// Render a shadow for a rectangular region
/// Draws a 2-cell shadow on the right side and 1-cell shadow on the bottom of the given region
/// Instead of drawing with a shadow character, this preserves the existing character
/// and modifies its colors to create a "shadowed" effect (black bg, dark grey fg)
pub fn render_shadow(
    buffer: &mut VideoBuffer,
    x: u16,
    y: u16,
    width: u16,
    height: u16,
    _charset: &Charset,
    theme: &Theme,
) {
    let shadow_fg = theme.window_shadow_color;
    let shadow_bg = Color::Black;
    let (buffer_width, buffer_height) = buffer.dimensions();

    // Pre-compute shadow boundaries
    let right_shadow_x1 = x + width;
    let right_shadow_x2 = x + width + 1;
    let bottom_shadow_y = y + height;

    // Right shadow (2 cells wide to the right)
    // Process both columns together for better cache locality
    for dy in 1..=height {
        let shadow_y = y + dy;
        if shadow_y >= buffer_height {
            continue;
        }

        // First column of right shadow
        if right_shadow_x1 < buffer_width {
            if let Some(existing_cell) = buffer.get(right_shadow_x1, shadow_y) {
                let shadowed_cell =
                    Cell::new_unchecked(existing_cell.character, shadow_fg, shadow_bg);
                buffer.set(right_shadow_x1, shadow_y, shadowed_cell);
            }
        }

        // Second column of right shadow
        if right_shadow_x2 < buffer_width {
            if let Some(existing_cell) = buffer.get(right_shadow_x2, shadow_y) {
                let shadowed_cell =
                    Cell::new_unchecked(existing_cell.character, shadow_fg, shadow_bg);
                buffer.set(right_shadow_x2, shadow_y, shadowed_cell);
            }
        }
    }

    // Bottom shadow (1 cell down)
    if bottom_shadow_y < buffer_height {
        // Calculate the shadow end position, clamped to buffer width
        let shadow_end = (x + width + 1).min(buffer_width);

        for shadow_x in (x + 1)..shadow_end {
            // Get existing cell and preserve its character
            if let Some(existing_cell) = buffer.get(shadow_x, bottom_shadow_y) {
                let shadowed_cell =
                    Cell::new_unchecked(existing_cell.character, shadow_fg, shadow_bg);
                buffer.set(shadow_x, bottom_shadow_y, shadowed_cell);
            }
        }
    }
}

/// Render a full-screen shadow overlay (for modal dialogs)
/// This shadows the entire screen to indicate that only the modal dialog is interactive
/// Preserves existing characters and only modifies colors (black bg, dark grey fg)
pub fn render_fullscreen_shadow(buffer: &mut VideoBuffer, theme: &Theme) {
    let shadow_fg = theme.window_shadow_color;
    let shadow_bg = Color::Black;

    // Use the optimized method that directly modifies the buffer
    buffer.apply_fullscreen_shadow(shadow_fg, shadow_bg);
}