use std::io::{self, Stdout};
use crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use crate::error::TuiError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSupport {
NoColor,
Basic,
Colors256,
TrueColor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Breakpoint {
Xs,
Sm,
Md,
Lg,
}
impl Breakpoint {
pub fn from_width(width: u16) -> Self {
match width {
0..=59 => Breakpoint::Xs,
60..=99 => Breakpoint::Sm,
100..=139 => Breakpoint::Md,
_ => Breakpoint::Lg,
}
}
pub fn allows_side_panel(self) -> bool {
self >= Breakpoint::Md
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CapsOverrides {
pub no_color: bool,
pub ascii: bool,
pub no_mouse: bool,
}
#[derive(Debug, Clone)]
pub struct TermCaps {
pub color_support: ColorSupport,
pub unicode: bool,
pub mouse: bool,
pub width: u16,
pub height: u16,
}
impl Default for TermCaps {
fn default() -> Self {
Self {
color_support: ColorSupport::TrueColor,
unicode: true,
mouse: true,
width: 80,
height: 24,
}
}
}
impl TermCaps {
pub fn is_small(&self) -> bool {
self.width < 80 || self.height < 24
}
pub fn breakpoint(&self) -> Breakpoint {
Breakpoint::from_width(self.width)
}
pub fn set_size(&mut self, width: u16, height: u16) {
self.width = width;
self.height = height;
}
}
pub fn detect_terminal_caps() -> TermCaps {
detect_terminal_caps_with(CapsOverrides::default())
}
pub fn detect_terminal_caps_with(overrides: CapsOverrides) -> TermCaps {
let env = |k: &str| std::env::var(k).unwrap_or_default();
let term = env("TERM");
let colorterm = env("COLORTERM");
let term_program = env("TERM_PROGRAM");
let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
let locale = [env("LC_ALL"), env("LC_CTYPE"), env("LANG")]
.into_iter()
.find(|v| !v.is_empty())
.unwrap_or_default();
let color_support = if overrides.no_color || no_color_env {
ColorSupport::NoColor
} else {
detect_color_support(&term, &colorterm, &term_program)
};
let unicode = !overrides.ascii && detect_unicode(&term, &locale);
let mouse = !overrides.no_mouse && detect_mouse(&term);
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
TermCaps {
color_support,
unicode,
mouse,
width,
height,
}
}
const TRUECOLOR_TERMS: &[&str] = &[
"alacritty",
"contour",
"foot",
"ghostty",
"kitty",
"rio",
"wezterm",
];
const TRUECOLOR_PROGRAMS: &[&str] = &["ghostty", "iterm.app", "wezterm", "vscode", "hyper"];
fn detect_color_support(term: &str, colorterm: &str, term_program: &str) -> ColorSupport {
let term = term.to_lowercase();
let colorterm = colorterm.to_lowercase();
let term_program = term_program.to_lowercase();
if term == "dumb" || term.is_empty() {
return ColorSupport::NoColor;
}
if colorterm == "truecolor" || colorterm == "24bit" {
return ColorSupport::TrueColor;
}
if TRUECOLOR_TERMS.iter().any(|t| term.contains(t)) {
return ColorSupport::TrueColor;
}
if TRUECOLOR_PROGRAMS.contains(&term_program.as_str()) {
return ColorSupport::TrueColor;
}
if term.contains("256color") || term.contains("direct") {
return ColorSupport::Colors256;
}
if term.starts_with("nsterm") {
return ColorSupport::Colors256;
}
ColorSupport::Basic
}
fn detect_unicode(term: &str, locale: &str) -> bool {
let term = term.to_lowercase();
if term == "dumb" || term.is_empty() {
return false;
}
if term == "linux" || term.starts_with("vt1") || term.starts_with("vt2") {
return false;
}
let locale = locale.to_lowercase();
if locale.contains("utf-8") || locale.contains("utf8") {
return true;
}
if cfg!(windows) || cfg!(target_os = "macos") {
return true;
}
false
}
fn detect_mouse(term: &str) -> bool {
let term = term.to_lowercase();
!(term.is_empty() || term == "dumb" || term == "linux" || term.starts_with("vt"))
}
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
pub struct TerminalGuard {
pub terminal: Tui,
mouse_enabled: bool,
}
impl TerminalGuard {
pub fn terminal_mut(&mut self) -> &mut Tui {
&mut self.terminal
}
pub fn mouse_enabled(&self) -> bool {
self.mouse_enabled
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = restore_terminal(&mut self.terminal, self.mouse_enabled);
}
}
pub fn init_terminal(mouse: bool) -> Result<TerminalGuard, TuiError> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(e) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(TuiError::Terminal(e));
}
let mouse_enabled = mouse && execute!(stdout, EnableMouseCapture).is_ok();
let backend = CrosstermBackend::new(stdout);
match Terminal::new(backend) {
Ok(terminal) => Ok(TerminalGuard {
terminal,
mouse_enabled,
}),
Err(e) => {
let _ = restore_stdout(mouse_enabled);
let _ = disable_raw_mode();
Err(TuiError::Terminal(e))
}
}
}
pub fn restore_terminal(terminal: &mut Tui, mouse_enabled: bool) -> Result<(), TuiError> {
disable_raw_mode()?;
if mouse_enabled {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
}
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn restore_stdout(mouse_enabled: bool) -> io::Result<()> {
let mut stdout = io::stdout();
if mouse_enabled {
let _ = execute!(stdout, DisableMouseCapture);
}
execute!(stdout, LeaveAlternateScreen)
}
pub fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
DisableMouseCapture,
LeaveAlternateScreen,
cursor::Show
);
original_hook(panic_info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_panic_hook_install() {
install_panic_hook();
}
#[test]
fn test_restore_is_idempotent() {
let _ = disable_raw_mode();
}
#[test]
fn test_terminal_guard_is_send() {
fn assert_send<T: Send>() {}
assert_send::<TerminalGuard>();
}
#[test]
fn test_detect_truecolor() {
assert_eq!(
detect_color_support("xterm-256color", "truecolor", ""),
ColorSupport::TrueColor
);
}
#[test]
fn test_detect_truecolor_24bit() {
assert_eq!(
detect_color_support("xterm", "24bit", ""),
ColorSupport::TrueColor
);
}
#[test]
fn test_detect_truecolor_known_term() {
assert_eq!(
detect_color_support("xterm-kitty", "", ""),
ColorSupport::TrueColor
);
assert_eq!(
detect_color_support("alacritty", "", ""),
ColorSupport::TrueColor
);
}
#[test]
fn test_detect_truecolor_known_program() {
assert_eq!(
detect_color_support("xterm", "", "iTerm.app"),
ColorSupport::TrueColor
);
}
#[test]
fn test_detect_256color() {
assert_eq!(
detect_color_support("xterm-256color", "", ""),
ColorSupport::Colors256
);
assert_eq!(
detect_color_support("screen-256color", "", ""),
ColorSupport::Colors256
);
assert_eq!(
detect_color_support("tmux-256color", "", ""),
ColorSupport::Colors256
);
}
#[test]
fn test_detect_basic_color() {
assert_eq!(detect_color_support("xterm", "", ""), ColorSupport::Basic);
assert_eq!(detect_color_support("linux", "", ""), ColorSupport::Basic);
assert_eq!(detect_color_support("vt220", "", ""), ColorSupport::Basic);
}
#[test]
fn test_detect_no_color_dumb() {
assert_eq!(detect_color_support("dumb", "", ""), ColorSupport::NoColor);
}
#[test]
fn test_detect_no_color_empty() {
assert_eq!(detect_color_support("", "", ""), ColorSupport::NoColor);
}
#[test]
fn test_no_color_override_wins_over_truecolor() {
let caps = detect_terminal_caps_with(CapsOverrides {
no_color: true,
..Default::default()
});
assert_eq!(caps.color_support, ColorSupport::NoColor);
}
#[test]
fn test_detect_unicode_utf8_locale() {
assert!(detect_unicode("xterm-256color", "en_US.UTF-8"));
assert!(detect_unicode("xterm-256color", "fr_FR.utf8"));
}
#[test]
fn test_detect_unicode_dumb_term() {
assert!(!detect_unicode("dumb", "en_US.UTF-8"));
}
#[test]
fn test_detect_unicode_linux_console_is_ascii() {
assert!(!detect_unicode("linux", "en_US.UTF-8"));
assert!(!detect_unicode("vt220", "en_US.UTF-8"));
}
#[test]
#[cfg(not(any(windows, target_os = "macos")))]
fn test_detect_unicode_posix_locale_is_ascii_on_unix() {
assert!(!detect_unicode("xterm-256color", "C"));
assert!(!detect_unicode("xterm-256color", ""));
}
#[test]
#[cfg(any(windows, target_os = "macos"))]
fn test_detect_unicode_assumed_on_windows_and_macos() {
assert!(detect_unicode("xterm-256color", ""));
}
#[test]
fn test_ascii_override_wins() {
let caps = detect_terminal_caps_with(CapsOverrides {
ascii: true,
..Default::default()
});
assert!(!caps.unicode);
}
#[test]
fn test_mouse_disabled_on_console_and_dumb() {
assert!(!detect_mouse("linux"));
assert!(!detect_mouse("dumb"));
assert!(!detect_mouse("vt100"));
assert!(!detect_mouse(""));
}
#[test]
fn test_mouse_enabled_on_normal_terminals() {
assert!(detect_mouse("xterm-256color"));
assert!(detect_mouse("tmux-256color"));
}
#[test]
fn test_no_mouse_override_wins() {
let caps = detect_terminal_caps_with(CapsOverrides {
no_mouse: true,
..Default::default()
});
assert!(!caps.mouse);
}
#[test]
fn test_term_caps_default() {
let caps = TermCaps::default();
assert_eq!(caps.color_support, ColorSupport::TrueColor);
assert!(caps.unicode);
assert!(caps.mouse);
assert_eq!(caps.width, 80);
assert_eq!(caps.height, 24);
}
#[test]
fn test_term_caps_is_small() {
let mut caps = TermCaps::default();
assert!(!caps.is_small());
caps.width = 79;
assert!(caps.is_small());
caps.width = 80;
caps.height = 23;
assert!(caps.is_small());
}
#[test]
fn test_breakpoints() {
assert_eq!(Breakpoint::from_width(40), Breakpoint::Xs);
assert_eq!(Breakpoint::from_width(59), Breakpoint::Xs);
assert_eq!(Breakpoint::from_width(60), Breakpoint::Sm);
assert_eq!(Breakpoint::from_width(80), Breakpoint::Sm);
assert_eq!(Breakpoint::from_width(100), Breakpoint::Md);
assert_eq!(Breakpoint::from_width(139), Breakpoint::Md);
assert_eq!(Breakpoint::from_width(140), Breakpoint::Lg);
assert_eq!(Breakpoint::from_width(400), Breakpoint::Lg);
}
#[test]
fn test_breakpoint_side_panel() {
assert!(!Breakpoint::Xs.allows_side_panel());
assert!(!Breakpoint::Sm.allows_side_panel());
assert!(Breakpoint::Md.allows_side_panel());
assert!(Breakpoint::Lg.allows_side_panel());
}
#[test]
fn test_set_size_updates_breakpoint() {
let mut caps = TermCaps::default();
assert_eq!(caps.breakpoint(), Breakpoint::Sm);
caps.set_size(160, 50);
assert_eq!(caps.breakpoint(), Breakpoint::Lg);
}
}