use crate::error::DotmaxError;
use crate::grid::BrailleGrid;
use crossterm::{
cursor::MoveTo,
execute,
terminal::{
disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
LeaveAlternateScreen,
},
};
use ratatui::{
backend::CrosstermBackend,
text::{Line, Span},
widgets::Paragraph,
Terminal,
};
use std::io::{self, Stdout};
use tracing::{debug, error, info, instrument};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalType {
WindowsTerminal,
Wsl,
WindowsConsole,
MacOsTerminal,
LinuxNative,
Unknown,
}
impl TerminalType {
#[must_use]
pub fn detect() -> Self {
if std::env::var("WSL_DISTRO_NAME").is_ok() {
return Self::Wsl;
}
if std::env::var("WT_SESSION").is_ok() {
#[cfg(target_os = "windows")]
{
if std::env::var("PSModulePath").is_ok() {
return Self::WindowsConsole;
}
}
return Self::WindowsTerminal;
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
if term_program == "Apple_Terminal" {
return Self::MacOsTerminal;
}
}
#[cfg(target_os = "windows")]
{
return Self::WindowsConsole;
}
#[cfg(target_os = "macos")]
{
return Self::MacOsTerminal;
}
#[cfg(target_os = "linux")]
{
Self::LinuxNative
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Self::Unknown
}
}
#[must_use]
pub const fn viewport_height_offset(self, reported_height: u16) -> u16 {
match self {
Self::Wsl | Self::WindowsConsole => {
if reported_height > 20 {
12 } else {
0 }
}
Self::WindowsTerminal | Self::MacOsTerminal | Self::LinuxNative | Self::Unknown => 0,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::WindowsTerminal => "Windows Terminal",
Self::Wsl => "WSL",
Self::WindowsConsole => "Windows Console (PowerShell/cmd)",
Self::MacOsTerminal => "macOS Terminal.app",
Self::LinuxNative => "Linux Native Terminal",
Self::Unknown => "Unknown Terminal",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalCapabilities {
pub supports_color: bool,
pub supports_truecolor: bool,
pub supports_unicode: bool,
pub terminal_type: TerminalType,
}
impl Default for TerminalCapabilities {
fn default() -> Self {
Self {
supports_color: true,
supports_truecolor: true,
supports_unicode: true,
terminal_type: TerminalType::detect(),
}
}
}
pub trait TerminalBackend {
fn size(&self) -> Result<(u16, u16), DotmaxError>;
fn render(&mut self, content: &str) -> Result<(), DotmaxError>;
fn clear(&mut self) -> Result<(), DotmaxError>;
fn capabilities(&self) -> TerminalCapabilities;
}
pub struct TerminalRenderer {
terminal: Terminal<CrosstermBackend<Stdout>>,
#[allow(dead_code)] last_size: (u16, u16),
terminal_type: TerminalType,
first_render: bool,
}
impl TerminalRenderer {
#[instrument]
pub fn new() -> Result<Self, DotmaxError> {
let mut stdout = io::stdout();
let (width, height) = crossterm::terminal::size()?;
debug!(width = width, height = height, "Detected terminal size");
if width < 40 || height < 12 {
error!(
width = width,
height = height,
min_width = 40,
min_height = 12,
"Terminal too small: {}×{} (minimum 40×12 required)",
width,
height
);
return Err(DotmaxError::TerminalBackend(format!(
"Terminal too small: {width}×{height} (minimum 40×12 required)"
)));
}
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = Self::restore_terminal();
original_hook(panic_info);
}));
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let terminal_type = TerminalType::detect();
info!(
width = width,
height = height,
terminal_type = terminal_type.name(),
"Terminal renderer initialized successfully with terminal type detection"
);
Ok(Self {
terminal,
last_size: (width, height),
terminal_type,
first_render: true,
})
}
#[instrument(skip(self, grid))]
pub fn render(&mut self, grid: &BrailleGrid) -> Result<(), DotmaxError> {
let (grid_width, grid_height) = grid.dimensions();
debug!(
grid_width = grid_width,
grid_height = grid_height,
total_cells = grid_width * grid_height,
"Rendering BrailleGrid to terminal"
);
if self.first_render {
self.terminal.clear()?;
self.first_render = false;
}
let unicode_grid = grid.to_unicode_grid();
self.terminal.draw(|frame| {
let area = frame.area();
debug!(
area_width = area.width,
area_height = area.height,
terminal_type = self.terminal_type.name(),
grid_height = grid.height(),
"Rendering area vs grid size"
);
let max_lines = area.height as usize;
if unicode_grid.len() > max_lines {
debug!(
grid_lines = unicode_grid.len(),
area_lines = max_lines,
overflow = unicode_grid.len() - max_lines,
"WARNING: Grid is larger than rendering area, truncating to fit"
);
}
let lines: Vec<Line> = unicode_grid
.iter()
.take(max_lines) .enumerate()
.map(|(y, row)| {
let spans: Vec<Span> = row
.iter()
.enumerate()
.map(|(x, &ch)| {
grid.get_color(x, y).map_or_else(
|| Span::raw(ch.to_string()),
|color| {
Span::styled(
ch.to_string(),
ratatui::style::Style::default().fg(
ratatui::style::Color::Rgb(color.r, color.g, color.b),
),
)
},
)
})
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines).scroll((0, 0));
frame.render_widget(paragraph, area);
})?;
Ok(())
}
pub fn clear(&mut self) -> Result<(), DotmaxError> {
self.terminal.clear()?;
Ok(())
}
#[instrument(skip(self))]
pub fn get_terminal_size(&self) -> Result<(u16, u16), DotmaxError> {
let size = self.terminal.size()?;
let offset = self.terminal_type.viewport_height_offset(size.height);
let viewport_height = size.height.saturating_sub(offset);
debug!(
terminal_type = self.terminal_type.name(),
buffer_width = size.width,
buffer_height = size.height,
viewport_width = size.width,
viewport_height = viewport_height,
offset = offset,
"Terminal size query (returning viewport dimensions for grid sizing)"
);
Ok((size.width, viewport_height))
}
pub fn cleanup(&mut self) -> Result<(), DotmaxError> {
Self::restore_terminal()
}
fn restore_terminal() -> Result<(), DotmaxError> {
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}
#[must_use]
pub fn capabilities(&self) -> TerminalCapabilities {
TerminalCapabilities {
terminal_type: self.terminal_type,
..TerminalCapabilities::default()
}
}
}
impl Drop for TerminalRenderer {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_type_viewport_offset_windows_terminal() {
let term_type = TerminalType::WindowsTerminal;
assert_eq!(term_type.viewport_height_offset(10), 0);
assert_eq!(term_type.viewport_height_offset(24), 0);
assert_eq!(term_type.viewport_height_offset(50), 0);
assert_eq!(term_type.viewport_height_offset(100), 0);
}
#[test]
fn test_terminal_type_viewport_offset_wsl() {
let term_type = TerminalType::Wsl;
assert_eq!(
term_type.viewport_height_offset(10),
0,
"Small terminal no offset"
);
assert_eq!(
term_type.viewport_height_offset(20),
0,
"Exactly 20 rows no offset"
);
assert_eq!(
term_type.viewport_height_offset(21),
12,
"21+ rows get 12 offset"
);
assert_eq!(
term_type.viewport_height_offset(53),
12,
"Standard WSL terminal"
);
assert_eq!(term_type.viewport_height_offset(100), 12);
}
#[test]
fn test_terminal_type_viewport_offset_windows_console() {
let term_type = TerminalType::WindowsConsole;
assert_eq!(
term_type.viewport_height_offset(10),
0,
"Small terminal no offset"
);
assert_eq!(
term_type.viewport_height_offset(20),
0,
"Exactly 20 rows no offset"
);
assert_eq!(
term_type.viewport_height_offset(21),
12,
"21+ rows get 12 offset"
);
assert_eq!(
term_type.viewport_height_offset(74),
12,
"PowerShell terminal"
);
assert_eq!(term_type.viewport_height_offset(100), 12);
}
#[test]
fn test_terminal_type_viewport_offset_macos() {
let term_type = TerminalType::MacOsTerminal;
assert_eq!(term_type.viewport_height_offset(10), 0);
assert_eq!(term_type.viewport_height_offset(24), 0);
assert_eq!(term_type.viewport_height_offset(50), 0);
}
#[test]
fn test_terminal_type_viewport_offset_linux_native() {
let term_type = TerminalType::LinuxNative;
assert_eq!(term_type.viewport_height_offset(10), 0);
assert_eq!(term_type.viewport_height_offset(24), 0);
assert_eq!(term_type.viewport_height_offset(50), 0);
}
#[test]
fn test_terminal_type_viewport_offset_unknown() {
let term_type = TerminalType::Unknown;
assert_eq!(term_type.viewport_height_offset(10), 0);
assert_eq!(term_type.viewport_height_offset(24), 0);
assert_eq!(term_type.viewport_height_offset(50), 0);
}
#[test]
fn test_terminal_type_name() {
assert_eq!(TerminalType::WindowsTerminal.name(), "Windows Terminal");
assert_eq!(TerminalType::Wsl.name(), "WSL");
assert_eq!(
TerminalType::WindowsConsole.name(),
"Windows Console (PowerShell/cmd)"
);
assert_eq!(TerminalType::MacOsTerminal.name(), "macOS Terminal.app");
assert_eq!(TerminalType::LinuxNative.name(), "Linux Native Terminal");
assert_eq!(TerminalType::Unknown.name(), "Unknown Terminal");
}
#[test]
fn test_terminal_type_edge_cases() {
let wsl = TerminalType::Wsl;
let windows_console = TerminalType::WindowsConsole;
assert_eq!(
wsl.viewport_height_offset(0),
0,
"Zero height should not panic"
);
assert_eq!(
wsl.viewport_height_offset(1),
0,
"Single row should have no offset"
);
assert_eq!(windows_console.viewport_height_offset(0), 0);
assert_eq!(windows_console.viewport_height_offset(1), 0);
assert_eq!(
wsl.viewport_height_offset(u16::MAX),
12,
"WSL gets 12 offset for large terminals"
);
assert_eq!(
windows_console.viewport_height_offset(u16::MAX),
12,
"Windows Console gets 12 offset"
);
}
#[test]
fn test_terminal_type_saturating_sub() {
let wsl = TerminalType::Wsl;
let offset = wsl.viewport_height_offset(25);
let adjusted = 25u16.saturating_sub(offset);
assert_eq!(adjusted, 13, "25 - 12 = 13");
let small_height = 1u16;
let offset = wsl.viewport_height_offset(small_height);
let adjusted = small_height.saturating_sub(offset);
assert_eq!(adjusted, 1, "1 - 0 = 1 (no offset for small terminal)");
}
#[test]
fn test_terminal_capabilities_default() {
let caps = TerminalCapabilities::default();
assert!(caps.supports_color);
assert!(caps.supports_truecolor);
assert!(caps.supports_unicode);
match caps.terminal_type {
TerminalType::WindowsTerminal
| TerminalType::Wsl
| TerminalType::WindowsConsole
| TerminalType::MacOsTerminal
| TerminalType::LinuxNative
| TerminalType::Unknown => {
}
}
}
#[test]
fn test_terminal_capabilities_includes_terminal_type() {
let caps = TerminalCapabilities::default();
let _ = caps.terminal_type;
let _ = caps.terminal_type.name();
}
macro_rules! require_terminal {
() => {
match TerminalRenderer::new() {
Ok(r) => r,
Err(_) => {
return;
}
}
};
}
#[test]
fn test_terminal_renderer_creation() {
let _renderer = require_terminal!();
}
#[test]
fn test_terminal_dimensions() {
let renderer = require_terminal!();
let (width, height) = renderer.get_terminal_size().expect("Failed to get size");
assert!(width >= 40, "Terminal width should be at least 40");
assert!(height >= 12, "Terminal height should be at least 12");
}
#[test]
fn test_terminal_cleanup() {
let mut renderer = require_terminal!();
let result = renderer.cleanup();
assert!(result.is_ok(), "Cleanup should succeed: {:?}", result.err());
}
#[test]
fn test_render_braille_grid() {
let mut renderer = require_terminal!();
let mut grid = BrailleGrid::new(10, 10).expect("Failed to create grid");
grid.set_dot(5, 5).expect("Failed to set dot");
grid.set_dot(6, 6).expect("Failed to set dot");
let result = renderer.render(&grid);
assert!(result.is_ok(), "Render should succeed: {:?}", result.err());
}
#[test]
fn test_clear_terminal() {
let mut renderer = require_terminal!();
let result = renderer.clear();
assert!(result.is_ok(), "Clear should succeed: {:?}", result.err());
}
#[test]
fn test_get_capabilities() {
let renderer = require_terminal!();
let caps = renderer.capabilities();
assert!(caps.supports_unicode);
}
}