dotmax/
render.rs

1//! Terminal rendering module
2//!
3//! Provides terminal rendering abstractions for braille grids using ratatui and crossterm.
4//! Extracted and adapted from crabmusic/src/rendering/mod.rs
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use dotmax::{BrailleGrid, TerminalRenderer};
10//!
11//! let mut renderer = TerminalRenderer::new().expect("Failed to initialize terminal");
12//! let mut grid = BrailleGrid::new(80, 24).expect("Failed to create grid");
13//!
14//! // Set some dots
15//! grid.set_dot(10, 10).expect("Failed to set dot");
16//!
17//! // Render to terminal
18//! renderer.render(&grid).expect("Failed to render");
19//!
20//! // Clean up
21//! renderer.cleanup().expect("Failed to cleanup");
22//! ```
23
24use crate::error::DotmaxError;
25use crate::grid::BrailleGrid;
26use crossterm::{
27    cursor::MoveTo,
28    execute,
29    terminal::{
30        disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
31        LeaveAlternateScreen,
32    },
33};
34use ratatui::{
35    backend::CrosstermBackend,
36    text::{Line, Span},
37    widgets::Paragraph,
38    Terminal,
39};
40use std::io::{self, Stdout};
41
42// Tracing for structured logging (Story 2.7)
43use tracing::{debug, error, info, instrument};
44
45// ============================================================================
46// Error Handling - Extends DotmaxError for terminal operations
47// ============================================================================
48
49// DotmaxError::Terminal automatically converts from std::io::Error via #[from]
50// in src/grid.rs:41
51
52// ============================================================================
53// Terminal Capabilities Detection
54// ============================================================================
55
56/// Terminal type detection for viewport handling
57///
58/// Different terminals report their dimensions differently:
59/// - Some report the visible viewport size (what the user sees)
60/// - Others report the buffer size (which can be larger, enabling scrollback)
61///
62/// This enum categorizes terminals based on their reporting behavior.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TerminalType {
65    /// Windows Terminal - reports viewport size correctly
66    WindowsTerminal,
67    /// WSL (Windows Subsystem for Linux) - may report buffer size
68    Wsl,
69    /// `PowerShell` / cmd - reports buffer size (not viewport)
70    WindowsConsole,
71    /// macOS Terminal.app
72    MacOsTerminal,
73    /// Ubuntu native terminal (gnome-terminal, etc.)
74    LinuxNative,
75    /// Unknown terminal - use conservative defaults
76    Unknown,
77}
78
79impl TerminalType {
80    /// Detect the terminal type from environment variables
81    ///
82    /// Uses environment variables to identify the terminal emulator:
83    /// - `WT_SESSION`: Windows Terminal
84    /// - `WSL_DISTRO_NAME`: WSL environment
85    /// - `TERM_PROGRAM`: macOS Terminal.app or other
86    /// - Platform detection for fallback
87    #[must_use]
88    pub fn detect() -> Self {
89        // Check for WSL first (highest priority)
90        // WSL reports buffer size even when running in Windows Terminal
91        if std::env::var("WSL_DISTRO_NAME").is_ok() {
92            return Self::Wsl;
93        }
94
95        // Check for Windows Terminal with PowerShell
96        // PowerShell in Windows Terminal also reports buffer size
97        if std::env::var("WT_SESSION").is_ok() {
98            // Check if we're running in PowerShell
99            #[cfg(target_os = "windows")]
100            {
101                if std::env::var("PSModulePath").is_ok() {
102                    // This is PowerShell running in Windows Terminal
103                    return Self::WindowsConsole;
104                }
105            }
106            return Self::WindowsTerminal;
107        }
108
109        // Check for macOS Terminal.app
110        if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
111            if term_program == "Apple_Terminal" {
112                return Self::MacOsTerminal;
113            }
114        }
115
116        // Platform-based detection
117        #[cfg(target_os = "windows")]
118        {
119            return Self::WindowsConsole;
120        }
121
122        #[cfg(target_os = "macos")]
123        {
124            return Self::MacOsTerminal;
125        }
126
127        #[cfg(target_os = "linux")]
128        {
129            // If we're on Linux but not WSL, it's native Linux
130            Self::LinuxNative
131        }
132
133        #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
134        {
135            Self::Unknown
136        }
137    }
138
139    /// Calculate viewport height offset based on terminal type
140    ///
141    /// Returns the number of rows to subtract from the reported terminal size
142    /// to get the actual visible viewport. This compensates for terminals that
143    /// report buffer size instead of viewport size.
144    ///
145    /// # Arguments
146    /// * `reported_height` - The height reported by the terminal
147    ///
148    /// # Returns
149    /// The number of rows to subtract to get the visible viewport height
150    #[must_use]
151    pub const fn viewport_height_offset(self, reported_height: u16) -> u16 {
152        match self {
153            // WSL and Windows Console both report buffer size, not viewport size
154            // Empirically tested: -12 offset works correctly for both
155            Self::Wsl | Self::WindowsConsole => {
156                if reported_height > 20 {
157                    12 // Viewport is typically 12 rows smaller than buffer
158                } else {
159                    0 // Small terminals, don't apply offset
160                }
161            }
162
163            // All other terminals report viewport size correctly
164            Self::WindowsTerminal | Self::MacOsTerminal | Self::LinuxNative | Self::Unknown => 0,
165        }
166    }
167
168    /// Get a human-readable name for this terminal type
169    #[must_use]
170    pub const fn name(self) -> &'static str {
171        match self {
172            Self::WindowsTerminal => "Windows Terminal",
173            Self::Wsl => "WSL",
174            Self::WindowsConsole => "Windows Console (PowerShell/cmd)",
175            Self::MacOsTerminal => "macOS Terminal.app",
176            Self::LinuxNative => "Linux Native Terminal",
177            Self::Unknown => "Unknown Terminal",
178        }
179    }
180}
181
182/// Terminal capabilities information
183///
184/// Provides information about what features the terminal supports.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct TerminalCapabilities {
187    /// Whether the terminal supports basic ANSI colors (16 colors)
188    pub supports_color: bool,
189    /// Whether the terminal supports true color (24-bit RGB)
190    pub supports_truecolor: bool,
191    /// Whether the terminal supports Unicode braille characters (U+2800-U+28FF)
192    pub supports_unicode: bool,
193    /// The detected terminal type (for viewport detection)
194    pub terminal_type: TerminalType,
195}
196
197impl Default for TerminalCapabilities {
198    fn default() -> Self {
199        // Modern terminals typically support all these features
200        // Crossterm handles platform differences automatically
201        Self {
202            supports_color: true,
203            supports_truecolor: true,
204            supports_unicode: true,
205            terminal_type: TerminalType::detect(),
206        }
207    }
208}
209
210// ============================================================================
211// Terminal Backend Trait - ADR 0004
212// ============================================================================
213
214/// Terminal backend abstraction
215///
216/// This trait abstracts terminal I/O operations to enable testing with mock
217/// backends and reduce lock-in to specific terminal libraries.
218///
219/// Implements ADR 0004: Terminal Backend Abstraction via Trait
220pub trait TerminalBackend {
221    /// Get the current terminal size in character cells
222    ///
223    /// # Returns
224    /// A tuple of (width, height) in characters
225    ///
226    /// # Errors
227    /// Returns `DotmaxError::Terminal` if querying size fails
228    fn size(&self) -> Result<(u16, u16), DotmaxError>;
229
230    /// Render content to the terminal
231    ///
232    /// # Arguments
233    /// * `content` - The content to render (typically braille characters)
234    ///
235    /// # Errors
236    /// Returns `DotmaxError::Terminal` if rendering fails
237    fn render(&mut self, content: &str) -> Result<(), DotmaxError>;
238
239    /// Clear the terminal display
240    ///
241    /// # Errors
242    /// Returns `DotmaxError::Terminal` if clearing fails
243    fn clear(&mut self) -> Result<(), DotmaxError>;
244
245    /// Get terminal capabilities
246    ///
247    /// # Returns
248    /// Information about terminal features (color, unicode support)
249    fn capabilities(&self) -> TerminalCapabilities;
250}
251
252// ============================================================================
253// Terminal Renderer - Main Implementation
254// ============================================================================
255
256/// Terminal renderer for braille grids
257///
258/// Manages terminal state and renders `BrailleGrid` to the terminal display.
259/// Uses ratatui and crossterm for cross-platform terminal manipulation.
260///
261/// Extracted and adapted from crabmusic/src/rendering/mod.rs:56-377
262///
263/// # Examples
264///
265/// ```no_run
266/// use dotmax::{BrailleGrid, TerminalRenderer};
267///
268/// let mut renderer = TerminalRenderer::new().expect("Failed to initialize terminal");
269/// let grid = BrailleGrid::new(80, 24).expect("Failed to create grid");
270/// renderer.render(&grid).expect("Failed to render");
271/// renderer.cleanup().expect("Failed to cleanup terminal");
272/// ```
273pub struct TerminalRenderer {
274    terminal: Terminal<CrosstermBackend<Stdout>>,
275    #[allow(dead_code)] // Reserved for future resize detection (Story 2.5)
276    last_size: (u16, u16),
277    /// Detected terminal type for viewport handling
278    terminal_type: TerminalType,
279    /// Whether this is the first render (clear needed) or subsequent (skip clear for performance)
280    first_render: bool,
281}
282
283impl TerminalRenderer {
284    /// Initialize a new terminal renderer
285    ///
286    /// Sets up the terminal in raw mode and alternate screen.
287    /// Extracted from crabmusic/src/rendering/mod.rs:81-117
288    ///
289    /// # Returns
290    /// A new `TerminalRenderer` instance
291    ///
292    /// # Errors
293    /// Returns `DotmaxError::Terminal` if terminal setup fails
294    /// Returns `DotmaxError::TerminalBackend` if terminal is too small (min 40×12)
295    ///
296    /// # Examples
297    ///
298    /// ```no_run
299    /// use dotmax::TerminalRenderer;
300    ///
301    /// let renderer = TerminalRenderer::new().expect("Failed to initialize terminal");
302    /// ```
303    #[instrument]
304    pub fn new() -> Result<Self, DotmaxError> {
305        let mut stdout = io::stdout();
306
307        // Check terminal size (minimum 40×12 for basic functionality)
308        // Extracted from crabmusic/src/rendering/mod.rs:85-93
309        let (width, height) = crossterm::terminal::size()?;
310
311        debug!(width = width, height = height, "Detected terminal size");
312
313        if width < 40 || height < 12 {
314            error!(
315                width = width,
316                height = height,
317                min_width = 40,
318                min_height = 12,
319                "Terminal too small: {}×{} (minimum 40×12 required)",
320                width,
321                height
322            );
323            return Err(DotmaxError::TerminalBackend(format!(
324                "Terminal too small: {width}×{height} (minimum 40×12 required)"
325            )));
326        }
327
328        // Enter raw mode
329        // Extracted from crabmusic/src/rendering/mod.rs:95-96
330        enable_raw_mode()?;
331
332        // Enter alternate screen
333        // Extracted from crabmusic/src/rendering/mod.rs:98-99
334        execute!(stdout, EnterAlternateScreen)?;
335
336        // Story 2.8: Fix cursor position after entering alternate screen
337        // In WSL/Windows Terminal, the cursor may not start at (0,0)
338        // Explicitly clear screen and move cursor to top-left
339        execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?;
340
341        // Set up panic handler to restore terminal
342        // Extracted from crabmusic/src/rendering/mod.rs:101-106
343        let original_hook = std::panic::take_hook();
344        std::panic::set_hook(Box::new(move |panic_info| {
345            let _ = Self::restore_terminal();
346            original_hook(panic_info);
347        }));
348
349        // Create Ratatui terminal
350        // Extracted from crabmusic/src/rendering/mod.rs:108-110
351        let backend = CrosstermBackend::new(stdout);
352        let terminal = Terminal::new(backend)?;
353
354        // Detect terminal type for viewport handling (Story 2.8)
355        let terminal_type = TerminalType::detect();
356        info!(
357            width = width,
358            height = height,
359            terminal_type = terminal_type.name(),
360            "Terminal renderer initialized successfully with terminal type detection"
361        );
362
363        Ok(Self {
364            terminal,
365            last_size: (width, height),
366            terminal_type,
367            first_render: true,
368        })
369    }
370
371    /// Render a braille grid to the terminal
372    ///
373    /// Uses Ratatui's Frame API to efficiently render the grid.
374    /// Ratatui handles differential rendering automatically.
375    ///
376    /// Extracted and adapted from crabmusic/src/rendering/mod.rs:202-254
377    /// Adapted to use `BrailleGrid::to_unicode_grid()` from Story 2.2
378    ///
379    /// # Arguments
380    /// * `grid` - The braille grid to render
381    ///
382    /// # Errors
383    /// Returns `DotmaxError::Terminal` if rendering fails
384    ///
385    /// # Examples
386    ///
387    /// ```no_run
388    /// use dotmax::{BrailleGrid, TerminalRenderer};
389    ///
390    /// let mut renderer = TerminalRenderer::new().expect("Failed to initialize");
391    /// let grid = BrailleGrid::new(80, 24).expect("Failed to create grid");
392    /// renderer.render(&grid).expect("Failed to render");
393    /// ```
394    #[instrument(skip(self, grid))]
395    pub fn render(&mut self, grid: &BrailleGrid) -> Result<(), DotmaxError> {
396        let (grid_width, grid_height) = grid.dimensions();
397        debug!(
398            grid_width = grid_width,
399            grid_height = grid_height,
400            total_cells = grid_width * grid_height,
401            "Rendering BrailleGrid to terminal"
402        );
403
404        // ISSUE #2 FIX: Clear the terminal buffer on FIRST render only to ensure
405        // ratatui's differential rendering has a clean baseline.
406        // Skip clear on subsequent renders to avoid flashing during video playback.
407        if self.first_render {
408            self.terminal.clear()?;
409            self.first_render = false;
410        }
411
412        // Convert grid to Unicode characters using Story 2.2 functionality
413        let unicode_grid = grid.to_unicode_grid();
414
415        self.terminal.draw(|frame| {
416            let area = frame.area();
417
418            // DEBUG: Log the actual rendering area
419            debug!(
420                area_width = area.width,
421                area_height = area.height,
422                terminal_type = self.terminal_type.name(),
423                grid_height = grid.height(),
424                "Rendering area vs grid size"
425            );
426
427            // CRITICAL BUG FIX: Grid may be larger than the rendering area
428            // We need to render ONLY the lines that fit, starting from the BEGINNING
429            let max_lines = area.height as usize;
430
431            if unicode_grid.len() > max_lines {
432                debug!(
433                    grid_lines = unicode_grid.len(),
434                    area_lines = max_lines,
435                    overflow = unicode_grid.len() - max_lines,
436                    "WARNING: Grid is larger than rendering area, truncating to fit"
437                );
438            }
439
440            // Convert Unicode grid to Ratatui Lines with colors (Story 2.6)
441            // Extracted from crabmusic/src/rendering/mod.rs:207-246
442            // Enhanced to support per-cell colors
443            // ONLY RENDER LINES THAT FIT IN THE AREA
444            let lines: Vec<Line> = unicode_grid
445                .iter()
446                .take(max_lines) // Critical: only render what fits
447                .enumerate()
448                .map(|(y, row)| {
449                    let spans: Vec<Span> = row
450                        .iter()
451                        .enumerate()
452                        .map(|(x, &ch)| {
453                            // Check if cell has color assigned and apply color if present
454                            grid.get_color(x, y).map_or_else(
455                                || Span::raw(ch.to_string()),
456                                |color| {
457                                    Span::styled(
458                                        ch.to_string(),
459                                        ratatui::style::Style::default().fg(
460                                            ratatui::style::Color::Rgb(color.r, color.g, color.b),
461                                        ),
462                                    )
463                                },
464                            )
465                        })
466                        .collect();
467                    Line::from(spans)
468                })
469                .collect();
470
471            // Create paragraph widget
472            // CRITICAL: Ensure paragraph starts from the TOP (scroll = 0)
473            let paragraph = Paragraph::new(lines).scroll((0, 0)); // Explicitly set scroll to (0, 0) to start from top
474
475            // Render to frame
476            frame.render_widget(paragraph, area);
477        })?;
478
479        Ok(())
480    }
481
482    /// Clear the terminal display
483    ///
484    /// # Errors
485    /// Returns `DotmaxError::Terminal` if clearing fails
486    pub fn clear(&mut self) -> Result<(), DotmaxError> {
487        self.terminal.clear()?;
488        Ok(())
489    }
490
491    /// Get the current terminal dimensions
492    ///
493    /// Extracted from crabmusic/src/rendering/mod.rs:282-285
494    ///
495    /// # Returns
496    /// A tuple of (width, height) in characters
497    ///
498    /// # Errors
499    /// Returns `DotmaxError::Terminal` if querying terminal size fails
500    ///
501    /// # Examples
502    ///
503    /// ```no_run
504    /// use dotmax::TerminalRenderer;
505    ///
506    /// let renderer = TerminalRenderer::new().expect("Failed to initialize terminal");
507    /// let (width, height) = renderer.get_terminal_size().unwrap();
508    /// assert!(width >= 40);
509    /// assert!(height >= 12);
510    /// ```
511    #[instrument(skip(self))]
512    pub fn get_terminal_size(&self) -> Result<(u16, u16), DotmaxError> {
513        let size = self.terminal.size()?;
514
515        // Story 2.8: Return the viewport size (not buffer size)
516        // The offset is applied during rendering in render(), not here
517        // This ensures that grid sizing matches the actual visible viewport
518        let offset = self.terminal_type.viewport_height_offset(size.height);
519        let viewport_height = size.height.saturating_sub(offset);
520
521        debug!(
522            terminal_type = self.terminal_type.name(),
523            buffer_width = size.width,
524            buffer_height = size.height,
525            viewport_width = size.width,
526            viewport_height = viewport_height,
527            offset = offset,
528            "Terminal size query (returning viewport dimensions for grid sizing)"
529        );
530
531        Ok((size.width, viewport_height))
532    }
533
534    /// Clean up and restore terminal state
535    ///
536    /// Should be called before the application exits to restore the terminal
537    /// to its original state.
538    ///
539    /// Extracted from crabmusic/src/rendering/mod.rs:263-265
540    ///
541    /// # Errors
542    /// Returns `DotmaxError::Terminal` if cleanup fails
543    pub fn cleanup(&mut self) -> Result<(), DotmaxError> {
544        Self::restore_terminal()
545    }
546
547    /// Restore terminal to original state (static for panic handler)
548    ///
549    /// Extracted from crabmusic/src/rendering/mod.rs:358-369
550    fn restore_terminal() -> Result<(), DotmaxError> {
551        let mut stdout = io::stdout();
552
553        // Leave alternate screen
554        execute!(stdout, LeaveAlternateScreen)?;
555
556        // Disable raw mode
557        disable_raw_mode()?;
558
559        Ok(())
560    }
561
562    /// Get terminal capabilities
563    ///
564    /// Returns information about terminal features including detected terminal type.
565    ///
566    /// # Returns
567    /// Terminal capabilities information
568    #[must_use]
569    pub fn capabilities(&self) -> TerminalCapabilities {
570        TerminalCapabilities {
571            terminal_type: self.terminal_type,
572            ..TerminalCapabilities::default()
573        }
574    }
575}
576
577impl Drop for TerminalRenderer {
578    /// Ensure terminal is cleaned up even if `cleanup()` wasn't called
579    ///
580    /// Extracted from crabmusic/src/rendering/mod.rs:372-377
581    fn drop(&mut self) {
582        let _ = self.cleanup();
583    }
584}
585
586// ============================================================================
587// Tests
588// ============================================================================
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    // ============================================================================
595    // Story 2.8: Terminal Type Detection Tests
596    // ============================================================================
597
598    #[test]
599    fn test_terminal_type_viewport_offset_windows_terminal() {
600        let term_type = TerminalType::WindowsTerminal;
601
602        // Windows Terminal reports viewport correctly - no offset needed
603        assert_eq!(term_type.viewport_height_offset(10), 0);
604        assert_eq!(term_type.viewport_height_offset(24), 0);
605        assert_eq!(term_type.viewport_height_offset(50), 0);
606        assert_eq!(term_type.viewport_height_offset(100), 0);
607    }
608
609    #[test]
610    fn test_terminal_type_viewport_offset_wsl() {
611        let term_type = TerminalType::Wsl;
612
613        // WSL applies 12-row offset (empirically tested)
614        assert_eq!(
615            term_type.viewport_height_offset(10),
616            0,
617            "Small terminal no offset"
618        );
619        assert_eq!(
620            term_type.viewport_height_offset(20),
621            0,
622            "Exactly 20 rows no offset"
623        );
624        assert_eq!(
625            term_type.viewport_height_offset(21),
626            12,
627            "21+ rows get 12 offset"
628        );
629        assert_eq!(
630            term_type.viewport_height_offset(53),
631            12,
632            "Standard WSL terminal"
633        );
634        assert_eq!(term_type.viewport_height_offset(100), 12);
635    }
636
637    #[test]
638    fn test_terminal_type_viewport_offset_windows_console() {
639        let term_type = TerminalType::WindowsConsole;
640
641        // Windows Console - applies 12-row offset (same as WSL)
642        assert_eq!(
643            term_type.viewport_height_offset(10),
644            0,
645            "Small terminal no offset"
646        );
647        assert_eq!(
648            term_type.viewport_height_offset(20),
649            0,
650            "Exactly 20 rows no offset"
651        );
652        assert_eq!(
653            term_type.viewport_height_offset(21),
654            12,
655            "21+ rows get 12 offset"
656        );
657        assert_eq!(
658            term_type.viewport_height_offset(74),
659            12,
660            "PowerShell terminal"
661        );
662        assert_eq!(term_type.viewport_height_offset(100), 12);
663    }
664
665    #[test]
666    fn test_terminal_type_viewport_offset_macos() {
667        let term_type = TerminalType::MacOsTerminal;
668
669        // macOS Terminal reports viewport correctly - no offset needed
670        assert_eq!(term_type.viewport_height_offset(10), 0);
671        assert_eq!(term_type.viewport_height_offset(24), 0);
672        assert_eq!(term_type.viewport_height_offset(50), 0);
673    }
674
675    #[test]
676    fn test_terminal_type_viewport_offset_linux_native() {
677        let term_type = TerminalType::LinuxNative;
678
679        // Linux native terminals report viewport correctly - no offset needed
680        assert_eq!(term_type.viewport_height_offset(10), 0);
681        assert_eq!(term_type.viewport_height_offset(24), 0);
682        assert_eq!(term_type.viewport_height_offset(50), 0);
683    }
684
685    #[test]
686    fn test_terminal_type_viewport_offset_unknown() {
687        let term_type = TerminalType::Unknown;
688
689        // Unknown terminals use conservative approach - no offset
690        assert_eq!(term_type.viewport_height_offset(10), 0);
691        assert_eq!(term_type.viewport_height_offset(24), 0);
692        assert_eq!(term_type.viewport_height_offset(50), 0);
693    }
694
695    #[test]
696    fn test_terminal_type_name() {
697        assert_eq!(TerminalType::WindowsTerminal.name(), "Windows Terminal");
698        assert_eq!(TerminalType::Wsl.name(), "WSL");
699        assert_eq!(
700            TerminalType::WindowsConsole.name(),
701            "Windows Console (PowerShell/cmd)"
702        );
703        assert_eq!(TerminalType::MacOsTerminal.name(), "macOS Terminal.app");
704        assert_eq!(TerminalType::LinuxNative.name(), "Linux Native Terminal");
705        assert_eq!(TerminalType::Unknown.name(), "Unknown Terminal");
706    }
707
708    #[test]
709    fn test_terminal_type_edge_cases() {
710        // Test edge cases for offset calculation
711        let wsl = TerminalType::Wsl;
712        let windows_console = TerminalType::WindowsConsole;
713
714        // Boundary testing
715        assert_eq!(
716            wsl.viewport_height_offset(0),
717            0,
718            "Zero height should not panic"
719        );
720        assert_eq!(
721            wsl.viewport_height_offset(1),
722            0,
723            "Single row should have no offset"
724        );
725
726        assert_eq!(windows_console.viewport_height_offset(0), 0);
727        assert_eq!(windows_console.viewport_height_offset(1), 0);
728
729        // Maximum values
730        assert_eq!(
731            wsl.viewport_height_offset(u16::MAX),
732            12,
733            "WSL gets 12 offset for large terminals"
734        );
735        assert_eq!(
736            windows_console.viewport_height_offset(u16::MAX),
737            12,
738            "Windows Console gets 12 offset"
739        );
740    }
741
742    #[test]
743    fn test_terminal_type_saturating_sub() {
744        // Verify that offset doesn't cause underflow
745        let wsl = TerminalType::Wsl;
746        let offset = wsl.viewport_height_offset(25);
747        let adjusted = 25u16.saturating_sub(offset);
748        assert_eq!(adjusted, 13, "25 - 12 = 13");
749
750        // Test that saturating_sub prevents underflow
751        let small_height = 1u16;
752        let offset = wsl.viewport_height_offset(small_height);
753        let adjusted = small_height.saturating_sub(offset);
754        assert_eq!(adjusted, 1, "1 - 0 = 1 (no offset for small terminal)");
755    }
756
757    #[test]
758    fn test_terminal_capabilities_default() {
759        let caps = TerminalCapabilities::default();
760        assert!(caps.supports_color);
761        assert!(caps.supports_truecolor);
762        assert!(caps.supports_unicode);
763
764        // Story 2.8: Verify terminal type is detected
765        // The actual type will depend on the test environment
766        // Just verify it's one of the valid types
767        match caps.terminal_type {
768            TerminalType::WindowsTerminal
769            | TerminalType::Wsl
770            | TerminalType::WindowsConsole
771            | TerminalType::MacOsTerminal
772            | TerminalType::LinuxNative
773            | TerminalType::Unknown => {
774                // Valid terminal type detected
775            }
776        }
777    }
778
779    #[test]
780    fn test_terminal_capabilities_includes_terminal_type() {
781        let caps = TerminalCapabilities::default();
782
783        // Verify terminal_type field exists and is accessible
784        let _ = caps.terminal_type;
785        let _ = caps.terminal_type.name();
786    }
787
788    /// Helper macro to skip tests that require a terminal when running in CI/test harness
789    /// Returns early if no terminal is available (e.g., when stdout is captured)
790    macro_rules! require_terminal {
791        () => {
792            match TerminalRenderer::new() {
793                Ok(r) => r,
794                Err(_) => {
795                    // No terminal available (e.g., running in test harness with captured stdout)
796                    // Skip this test gracefully
797                    return;
798                }
799            }
800        };
801    }
802
803    #[test]
804    fn test_terminal_renderer_creation() {
805        let _renderer = require_terminal!();
806        // If we get here, terminal creation succeeded
807    }
808
809    #[test]
810    fn test_terminal_dimensions() {
811        let renderer = require_terminal!();
812        let (width, height) = renderer.get_terminal_size().expect("Failed to get size");
813        assert!(width >= 40, "Terminal width should be at least 40");
814        assert!(height >= 12, "Terminal height should be at least 12");
815    }
816
817    #[test]
818    fn test_terminal_cleanup() {
819        let mut renderer = require_terminal!();
820        let result = renderer.cleanup();
821        assert!(result.is_ok(), "Cleanup should succeed: {:?}", result.err());
822    }
823
824    #[test]
825    fn test_render_braille_grid() {
826        let mut renderer = require_terminal!();
827        let mut grid = BrailleGrid::new(10, 10).expect("Failed to create grid");
828
829        // Set a test pattern
830        grid.set_dot(5, 5).expect("Failed to set dot");
831        grid.set_dot(6, 6).expect("Failed to set dot");
832
833        // Render should succeed
834        let result = renderer.render(&grid);
835        assert!(result.is_ok(), "Render should succeed: {:?}", result.err());
836    }
837
838    #[test]
839    fn test_clear_terminal() {
840        let mut renderer = require_terminal!();
841        let result = renderer.clear();
842        assert!(result.is_ok(), "Clear should succeed: {:?}", result.err());
843    }
844
845    #[test]
846    fn test_get_capabilities() {
847        let renderer = require_terminal!();
848        let caps = renderer.capabilities();
849        // Should return default capabilities
850        assert!(caps.supports_unicode);
851    }
852}