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}