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

            
13
use std::env;
14

            
15
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
pub 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]
27
9
pub fn detect_backend<F>(env_lookup: F) -> Backend
28
9
where
29
9
    F: Fn(&str) -> Option<String>,
30
{
31
9
    if let Some(forced) = env_lookup("NOMISYNC_TUI_CHART") {
32
3
        match forced.to_ascii_lowercase().as_str() {
33
3
            "kitty" => return Backend::Kitty,
34
2
            "ratatui" => return Backend::Ratatui,
35
1
            "text" => return Backend::Text,
36
            _ => {}
37
        }
38
6
    }
39
6
    if supports_kitty(&env_lookup) {
40
4
        return Backend::Kitty;
41
2
    }
42
2
    Backend::Ratatui
43
9
}
44

            
45
6
fn supports_kitty<F>(env_lookup: &F) -> bool
46
6
where
47
6
    F: Fn(&str) -> Option<String>,
48
{
49
6
    if env_lookup("KITTY_WINDOW_ID").is_some() {
50
1
        return true;
51
5
    }
52
5
    let term = env_lookup("TERM").unwrap_or_default();
53
5
    if term.contains("kitty") {
54
1
        return true;
55
4
    }
56
4
    let term_program = env_lookup("TERM_PROGRAM").unwrap_or_default();
57
4
    matches!(term_program.as_str(), "WezTerm" | "ghostty")
58
6
}
59

            
60
/// Convenience wrapper that reads from the real process environment.
61
#[must_use]
62
pub 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]
73
pub fn detect_for<T>(transport: &T) -> Backend
74
where
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)]
85
mod tests {
86
    use super::*;
87
    use std::collections::HashMap;
88

            
89
9
    fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
90
9
        let map: HashMap<String, String> = pairs
91
9
            .iter()
92
13
            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
93
9
            .collect();
94
24
        move |k: &str| map.get(k).cloned()
95
9
    }
96

            
97
    #[test]
98
1
    fn plain_xterm_picks_ratatui() {
99
1
        let env = env_from(&[("TERM", "xterm-256color")]);
100
1
        assert_eq!(detect_backend(env), Backend::Ratatui);
101
1
    }
102

            
103
    #[test]
104
1
    fn kitty_window_id_picks_kitty() {
105
1
        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("TERM", "xterm-256color")]);
106
1
        assert_eq!(detect_backend(env), Backend::Kitty);
107
1
    }
108

            
109
    #[test]
110
1
    fn xterm_kitty_term_picks_kitty() {
111
1
        let env = env_from(&[("TERM", "xterm-kitty")]);
112
1
        assert_eq!(detect_backend(env), Backend::Kitty);
113
1
    }
114

            
115
    #[test]
116
1
    fn wezterm_program_picks_kitty() {
117
1
        let env = env_from(&[("TERM", "xterm-256color"), ("TERM_PROGRAM", "WezTerm")]);
118
1
        assert_eq!(detect_backend(env), Backend::Kitty);
119
1
    }
120

            
121
    #[test]
122
1
    fn ghostty_program_picks_kitty() {
123
1
        let env = env_from(&[("TERM_PROGRAM", "ghostty")]);
124
1
        assert_eq!(detect_backend(env), Backend::Kitty);
125
1
    }
126

            
127
    #[test]
128
1
    fn empty_env_falls_back_to_ratatui() {
129
1
        let env = env_from(&[]);
130
1
        assert_eq!(detect_backend(env), Backend::Ratatui);
131
1
    }
132

            
133
    #[test]
134
1
    fn explicit_override_forces_text_backend() {
135
1
        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("NOMISYNC_TUI_CHART", "text")]);
136
1
        assert_eq!(detect_backend(env), Backend::Text);
137
1
    }
138

            
139
    #[test]
140
1
    fn explicit_override_forces_ratatui_backend() {
141
1
        let env = env_from(&[("KITTY_WINDOW_ID", "1"), ("NOMISYNC_TUI_CHART", "ratatui")]);
142
1
        assert_eq!(detect_backend(env), Backend::Ratatui);
143
1
    }
144

            
145
    #[test]
146
1
    fn explicit_override_forces_kitty_backend() {
147
1
        let env = env_from(&[("TERM", "xterm"), ("NOMISYNC_TUI_CHART", "kitty")]);
148
1
        assert_eq!(detect_backend(env), Backend::Kitty);
149
1
    }
150
}