Skip to main content

tui/
chart.rs

1//! Chart renderer selector.
2//!
3//! Detects terminal graphics capability once at startup and picks the
4//! highest-fidelity renderer the current terminal supports:
5//!
6//! 1. [`Backend::Kitty`] — writes PNG via kitty's graphics APC. Works
7//!    in kitty, wezterm, ghostty, kitten-wrapped tmux, etc.
8//! 2. [`Backend::Ratatui`] — braille / block-based charting inside
9//!    ratatui's canvas widget. Works everywhere ratatui does.
10//! 3. [`Backend::Text`] — monospace ASCII block renderer, the fallback
11//!    used by the automation CLI.
12
13use std::env;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Backend {
17    Kitty,
18    Ratatui,
19    Text,
20}
21
22/// Decide which chart backend to use given the current process
23/// environment. Honours `NOMISYNC_TUI_CHART` for explicit overrides —
24/// useful for CI recordings and for users whose terminal capability
25/// the heuristics misidentify.
26#[must_use]
27pub fn detect_backend<F>(env_lookup: F) -> Backend
28where
29    F: Fn(&str) -> Option<String>,
30{
31    if let Some(forced) = env_lookup("NOMISYNC_TUI_CHART") {
32        match forced.to_ascii_lowercase().as_str() {
33            "kitty" => return Backend::Kitty,
34            "ratatui" => return Backend::Ratatui,
35            "text" => return Backend::Text,
36            _ => {}
37        }
38    }
39    if supports_kitty(&env_lookup) {
40        return Backend::Kitty;
41    }
42    Backend::Ratatui
43}
44
45fn supports_kitty<F>(env_lookup: &F) -> bool
46where
47    F: Fn(&str) -> Option<String>,
48{
49    if env_lookup("KITTY_WINDOW_ID").is_some() {
50        return true;
51    }
52    let term = env_lookup("TERM").unwrap_or_default();
53    if term.contains("kitty") {
54        return true;
55    }
56    let term_program = env_lookup("TERM_PROGRAM").unwrap_or_default();
57    matches!(term_program.as_str(), "WezTerm" | "ghostty")
58}
59
60/// Convenience wrapper that reads from the real process environment.
61#[must_use]
62pub fn detect_from_env() -> Backend {
63    detect_backend(|name| env::var(name).ok())
64}
65
66/// Capability detection driven by the active [`Transport`]'s
67/// `client_term`. The SSH transport returns the terminal type the
68/// peer announced via `pty-req`; the local transport returns
69/// `$TERM`. Falls back to the daemon's own environment for the
70/// remaining hints (`KITTY_WINDOW_ID`, `TERM_PROGRAM`) — those have
71/// no SSH equivalent today.
72#[must_use]
73pub fn detect_for<T>(transport: &T) -> Backend
74where
75    T: crate::transport::Transport,
76{
77    let client_term = transport.client_term().map(str::to_string);
78    detect_backend(|name| match name {
79        "TERM" => client_term.clone(),
80        other => env::var(other).ok(),
81    })
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use std::collections::HashMap;
88
89    fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
90        let map: HashMap<String, String> = pairs
91            .iter()
92            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
93            .collect();
94        move |k: &str| map.get(k).cloned()
95    }
96
97    #[test]
98    fn plain_xterm_picks_ratatui() {
99        let env = env_from(&[("TERM", "xterm-256color")]);
100        assert_eq!(detect_backend(env), Backend::Ratatui);
101    }
102
103    #[test]
104    fn kitty_window_id_picks_kitty() {
105        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("TERM", "xterm-256color")]);
106        assert_eq!(detect_backend(env), Backend::Kitty);
107    }
108
109    #[test]
110    fn xterm_kitty_term_picks_kitty() {
111        let env = env_from(&[("TERM", "xterm-kitty")]);
112        assert_eq!(detect_backend(env), Backend::Kitty);
113    }
114
115    #[test]
116    fn wezterm_program_picks_kitty() {
117        let env = env_from(&[("TERM", "xterm-256color"), ("TERM_PROGRAM", "WezTerm")]);
118        assert_eq!(detect_backend(env), Backend::Kitty);
119    }
120
121    #[test]
122    fn ghostty_program_picks_kitty() {
123        let env = env_from(&[("TERM_PROGRAM", "ghostty")]);
124        assert_eq!(detect_backend(env), Backend::Kitty);
125    }
126
127    #[test]
128    fn empty_env_falls_back_to_ratatui() {
129        let env = env_from(&[]);
130        assert_eq!(detect_backend(env), Backend::Ratatui);
131    }
132
133    #[test]
134    fn explicit_override_forces_text_backend() {
135        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("NOMISYNC_TUI_CHART", "text")]);
136        assert_eq!(detect_backend(env), Backend::Text);
137    }
138
139    #[test]
140    fn explicit_override_forces_ratatui_backend() {
141        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("NOMISYNC_TUI_CHART", "ratatui")]);
142        assert_eq!(detect_backend(env), Backend::Ratatui);
143    }
144
145    #[test]
146    fn explicit_override_forces_kitty_backend() {
147        let env = env_from(&[("TERM", "xterm"), ("NOMISYNC_TUI_CHART", "kitty")]);
148        assert_eq!(detect_backend(env), Backend::Kitty);
149    }
150}