Skip to main content

minui/term/
capabilities.rs

1//! Terminal capability detection and configurable fallbacks.
2//!
3//! This module provides a small, pragmatic capability model so MinUI can degrade gracefully
4//! across terminals (truecolor vs 256-color vs 16-color) without pushing editor logic into
5//! widgets or applications.
6//!
7//! Design goals:
8//! - Best-effort autodetection (mostly via environment variables)
9//! - Simple, explicit override path (app/framework decides)
10//! - Central place to "downgrade" requested colors to what the terminal likely supports
11//!
12//! Non-goals (for now):
13//! - Full terminfo/terminfo-db probing
14//! - Perfect detection of all terminal quirks
15//! - IME / grapheme shaping (handled elsewhere)
16
17use crate::{Color, ColorPair};
18
19/// Color fidelity supported by the terminal.
20///
21/// This represents what we *believe* the terminal can display.
22/// It is not a guarantee—terminals can lie, and remote/SSH environments vary.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ColorSupport {
25    /// Only the basic 16 ANSI colors should be assumed.
26    Ansi16,
27    /// The 256-color ANSI palette is supported.
28    Ansi256,
29    /// 24-bit "truecolor" RGB is supported.
30    Truecolor,
31}
32
33impl ColorSupport {
34    /// Returns the maximum fidelity between `self` and `other`.
35    pub fn max(self, other: Self) -> Self {
36        use ColorSupport::*;
37        match (self, other) {
38            (Truecolor, _) | (_, Truecolor) => Truecolor,
39            (Ansi256, _) | (_, Ansi256) => Ansi256,
40            _ => Ansi16,
41        }
42    }
43
44    /// Returns the minimum fidelity between `self` and `other`.
45    pub fn min(self, other: Self) -> Self {
46        use ColorSupport::*;
47        match (self, other) {
48            (Ansi16, _) | (_, Ansi16) => Ansi16,
49            (Ansi256, _) | (_, Ansi256) => Ansi256,
50            _ => Truecolor,
51        }
52    }
53}
54
55/// Terminal capabilities that influence how MinUI should render.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct TerminalCapabilities {
58    /// Color fidelity supported by the terminal.
59    pub color_support: ColorSupport,
60
61    /// Whether bracketed paste mode is expected to work.
62    ///
63    /// Note: MinUI enables bracketed paste when initializing `TerminalWindow`.
64    /// This flag is purely informational for apps that want to display/adjust UI.
65    pub bracketed_paste: bool,
66
67    /// Whether mouse capture/reporting is expected to work.
68    ///
69    /// Note: MinUI enables mouse capture when initializing `TerminalWindow`.
70    /// This flag is informational, as terminals can still vary.
71    pub mouse: bool,
72
73    /// Whether changing the cursor style (block/bar/underline) is likely supported.
74    ///
75    /// MinUI does not currently expose cursor styling as a stable public API.
76    pub cursor_style: bool,
77}
78
79impl Default for TerminalCapabilities {
80    fn default() -> Self {
81        Self {
82            color_support: ColorSupport::Ansi16,
83            bracketed_paste: true,
84            mouse: true,
85            cursor_style: false,
86        }
87    }
88}
89
90impl TerminalCapabilities {
91    /// Best-effort autodetection using environment variables.
92    ///
93    /// This intentionally avoids heavy dependencies and terminfo parsing.
94    ///
95    /// Heuristics:
96    /// - `COLORTERM=truecolor` or `COLORTERM=24bit` => Truecolor
97    /// - `TERM` contains `-truecolor` => Truecolor
98    /// - `TERM` contains `256color` => Ansi256
99    /// - otherwise => Ansi16
100    ///
101    /// Notes:
102    /// - Many terminals do support truecolor even when `TERM` doesn't advertise it.
103    /// - Remote shells and tmux/screen can mask capabilities unless configured.
104    pub fn detect() -> Self {
105        let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
106        let colorterm = std::env::var("COLORTERM")
107            .unwrap_or_default()
108            .to_lowercase();
109
110        let mut caps = TerminalCapabilities::default();
111
112        // Color support
113        if colorterm.contains("truecolor")
114            || colorterm.contains("24bit")
115            || term.contains("truecolor")
116        {
117            caps.color_support = ColorSupport::Truecolor;
118        } else if term.contains("256color") {
119            caps.color_support = ColorSupport::Ansi256;
120        } else {
121            caps.color_support = ColorSupport::Ansi16;
122        }
123
124        // Mouse/bracketed paste/cursor style are hard to detect reliably without probing.
125        // We leave these at conservative defaults (mouse + bracketed paste true, cursor style false).
126        caps
127    }
128
129    /// Returns a copy of `pair` with colors downgraded to this terminal's capabilities.
130    ///
131    /// This is the main "policy hook" for MinUI rendering: widgets/apps can request rich
132    /// colors, and MinUI will degrade to what the terminal can likely display.
133    pub fn downgrade_pair(self, pair: ColorPair) -> ColorPair {
134        ColorPair {
135            fg: self.downgrade_color(pair.fg),
136            bg: self.downgrade_color(pair.bg),
137        }
138    }
139
140    /// Downgrade a single `Color` to match `self.color_support`.
141    ///
142    /// Rules:
143    /// - `Reset`/`Transparent` are preserved
144    /// - Named ANSI colors are preserved
145    /// - `AnsiValue(n)` is preserved unless target is `Ansi16` (then approximated)
146    /// - `Rgb{..}` is preserved only for `Truecolor`; otherwise approximated
147    pub fn downgrade_color(self, c: Color) -> Color {
148        match c {
149            Color::Reset | Color::Transparent => c,
150            // Already-safe named colors
151            Color::Black
152            | Color::Red
153            | Color::Green
154            | Color::Yellow
155            | Color::Blue
156            | Color::Magenta
157            | Color::Cyan
158            | Color::White
159            | Color::DarkGray
160            | Color::LightRed
161            | Color::LightGreen
162            | Color::LightYellow
163            | Color::LightBlue
164            | Color::LightMagenta
165            | Color::LightCyan
166            | Color::LightGray => c,
167
168            Color::AnsiValue(n) => match self.color_support {
169                ColorSupport::Ansi16 => ansi256_to_ansi16(n),
170                ColorSupport::Ansi256 | ColorSupport::Truecolor => Color::AnsiValue(n),
171            },
172
173            Color::Rgb { r, g, b } => match self.color_support {
174                ColorSupport::Truecolor => Color::Rgb { r, g, b },
175                ColorSupport::Ansi256 => Color::AnsiValue(rgb_to_ansi256(r, g, b)),
176                ColorSupport::Ansi16 => rgb_to_ansi16(r, g, b),
177            },
178        }
179    }
180}
181
182/// Convert RGB to an ANSI 256-color palette index.
183///
184/// Uses the common 6x6x6 color cube mapping (16..231) plus grayscale (232..255).
185pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
186    // Grayscale detection: if r==g==b map into grayscale ramp.
187    if r == g && g == b {
188        // 24 grayscale steps from 232..255
189        // Clamp to [0..23]
190        let v = r as u16;
191        let idx = ((v.saturating_sub(8)) * 24 / (255 - 8)).min(23) as u8;
192        return 232 + idx;
193    }
194
195    // Map to 6-level cube.
196    let r6 = (r as u16 * 5 / 255) as u8;
197    let g6 = (g as u16 * 5 / 255) as u8;
198    let b6 = (b as u16 * 5 / 255) as u8;
199
200    16 + 36 * r6 + 6 * g6 + b6
201}
202
203/// Convert an ANSI256 palette index to a best-effort ANSI16 color.
204pub fn ansi256_to_ansi16(n: u8) -> Color {
205    // Common approximation: map to nearest of the basic 16 using rough buckets.
206    // This is intentionally simple; the goal is "not ugly", not perfect.
207    match n {
208        0..=7 => match n {
209            0 => Color::Black,
210            1 => Color::Red,
211            2 => Color::Green,
212            3 => Color::Yellow,
213            4 => Color::Blue,
214            5 => Color::Magenta,
215            6 => Color::Cyan,
216            _ => Color::White,
217        },
218        8..=15 => match n {
219            8 => Color::DarkGray,
220            9 => Color::LightRed,
221            10 => Color::LightGreen,
222            11 => Color::LightYellow,
223            12 => Color::LightBlue,
224            13 => Color::LightMagenta,
225            14 => Color::LightCyan,
226            _ => Color::LightGray,
227        },
228        // Color cube / grayscale -> approximate by brightness and dominant channel.
229        _ => {
230            // Convert to RGB approximation then map to ansi16.
231            let (r, g, b) = ansi256_to_rgb(n);
232            rgb_to_ansi16(r, g, b)
233        }
234    }
235}
236
237/// Convert RGB to a best-effort ANSI16 color.
238pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
239    // Luma-ish brightness
240    let brightness = (r as u16 + g as u16 + b as u16) / 3;
241
242    // Identify dominant channel(s)
243    let max = r.max(g).max(b);
244    let min = r.min(g).min(b);
245
246    // Near-gray: map to gray/white/black
247    if max.saturating_sub(min) < 20 {
248        return match brightness {
249            0..=30 => Color::Black,
250            31..=90 => Color::DarkGray,
251            91..=180 => Color::LightGray,
252            _ => Color::White,
253        };
254    }
255
256    // Dominant hue
257    let bright = brightness >= 140;
258
259    let base = if r >= g && r >= b {
260        if g >= b {
261            Color::Yellow
262        } else {
263            Color::Magenta
264        }
265    } else if g >= r && g >= b {
266        if r >= b { Color::Yellow } else { Color::Green }
267    } else {
268        if r >= g { Color::Magenta } else { Color::Cyan }
269    };
270
271    // Map to bright variants where it makes sense.
272    if !bright {
273        return match base {
274            Color::Red => Color::Red,
275            Color::Green => Color::Green,
276            Color::Yellow => Color::Yellow,
277            Color::Blue => Color::Blue,
278            Color::Magenta => Color::Magenta,
279            Color::Cyan => Color::Cyan,
280            _ => base,
281        };
282    }
283
284    match base {
285        Color::Red => Color::LightRed,
286        Color::Green => Color::LightGreen,
287        Color::Yellow => Color::LightYellow,
288        Color::Blue => Color::LightBlue,
289        Color::Magenta => Color::LightMagenta,
290        Color::Cyan => Color::LightCyan,
291        _ => base,
292    }
293}
294
295/// Convert an ANSI256 palette index to an approximate RGB triple.
296pub fn ansi256_to_rgb(n: u8) -> (u8, u8, u8) {
297    match n {
298        0..=15 => {
299            // Approximate standard 16 colors (not exact, but close enough for mapping).
300            const BASIC: [(u8, u8, u8); 16] = [
301                (0, 0, 0),
302                (205, 49, 49),
303                (13, 188, 121),
304                (229, 229, 16),
305                (36, 114, 200),
306                (188, 63, 188),
307                (17, 168, 205),
308                (229, 229, 229),
309                (102, 102, 102),
310                (241, 76, 76),
311                (35, 209, 139),
312                (245, 245, 67),
313                (59, 142, 234),
314                (214, 112, 214),
315                (41, 184, 219),
316                (255, 255, 255),
317            ];
318            BASIC[n as usize]
319        }
320        16..=231 => {
321            let idx = n - 16;
322            let r = idx / 36;
323            let g = (idx % 36) / 6;
324            let b = idx % 6;
325
326            // 6 levels: 0, 95, 135, 175, 215, 255
327            let to_level = |v: u8| match v {
328                0 => 0,
329                1 => 95,
330                2 => 135,
331                3 => 175,
332                4 => 215,
333                _ => 255,
334            };
335
336            (to_level(r), to_level(g), to_level(b))
337        }
338        232..=255 => {
339            // 24 grayscale steps: 8 + 10*i
340            let i = n - 232;
341            let v = 8 + 10 * i;
342            (v, v, v)
343        }
344    }
345}