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}