| 136 |
136 |
|
Color::Rgb(c.r, c.g, c.b)
|
| 137 |
137 |
|
}
|
| 138 |
138 |
|
|
|
139 |
+ |
/// How much color the terminal being drawn to can actually show.
|
|
140 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
141 |
+ |
pub enum ColorDepth {
|
|
142 |
+ |
/// 24-bit. Theme colors are sent as authored.
|
|
143 |
+ |
Full,
|
|
144 |
+ |
/// The sixteen ANSI colors, addressed by index.
|
|
145 |
+ |
Ansi16,
|
|
146 |
+ |
}
|
|
147 |
+ |
|
|
148 |
+ |
/// What the environment says the terminal can show.
|
|
149 |
+ |
///
|
|
150 |
+ |
/// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
|
|
151 |
+ |
/// and `TERM=linux` is the case this exists for: the Linux virtual console,
|
|
152 |
+ |
/// which is what an installer and a machine with no desktop draw on. Anything
|
|
153 |
+ |
/// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing
|
|
154 |
+ |
/// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity;
|
|
155 |
+ |
/// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
|
|
156 |
+ |
/// the user paid for.
|
|
157 |
+ |
pub fn detect_color_depth() -> ColorDepth {
|
|
158 |
+ |
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
|
159 |
+ |
if colorterm == "truecolor" || colorterm == "24bit" {
|
|
160 |
+ |
return ColorDepth::Full;
|
|
161 |
+ |
}
|
|
162 |
+ |
match std::env::var("TERM").unwrap_or_default().as_str() {
|
|
163 |
+ |
"linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
|
|
164 |
+ |
_ => ColorDepth::Full,
|
|
165 |
+ |
}
|
|
166 |
+ |
}
|
|
167 |
+ |
|
|
168 |
+ |
/// The palette entry for `c`, as an index the terminal will not reinterpret.
|
|
169 |
+ |
fn indexed(c: Color) -> Color {
|
|
170 |
+ |
match c {
|
|
171 |
+ |
Color::Rgb(r, g, b) => {
|
|
172 |
+ |
Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8)
|
|
173 |
+ |
}
|
|
174 |
+ |
other => other,
|
|
175 |
+ |
}
|
|
176 |
+ |
}
|
|
177 |
+ |
|
|
178 |
+ |
/// As [`indexed`], but guaranteed to stay legible against `on`.
|
|
179 |
+ |
fn indexed_against(c: Color, on: Color) -> Color {
|
|
180 |
+ |
match (c, on) {
|
|
181 |
+ |
(Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(makeover::quantize_against(
|
|
182 |
+ |
Rgb { r, g, b },
|
|
183 |
+ |
Rgb { r: br, g: bg, b: bb },
|
|
184 |
+ |
&makeover::ANSI_16,
|
|
185 |
+ |
) as u8),
|
|
186 |
+ |
_ => indexed(c),
|
|
187 |
+ |
}
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
impl Theme {
|
|
191 |
+ |
/// This theme as the terminal can actually draw it.
|
|
192 |
+ |
///
|
|
193 |
+ |
/// At [`ColorDepth::Full`] the theme is returned untouched. At
|
|
194 |
+ |
/// [`ColorDepth::Ansi16`] every color becomes a palette index, which is the
|
|
195 |
+ |
/// point: left as 24-bit, the terminal approximates them itself, and its
|
|
196 |
+ |
/// approximation collapses tones that the theme keeps apart. Alloy's
|
|
197 |
+ |
/// console lost its frame that way, drawing a border in a color the Linux
|
|
198 |
+ |
/// console could not distinguish from the page behind it.
|
|
199 |
+ |
///
|
|
200 |
+ |
/// Anything that has to be seen against the page is quantized against it
|
|
201 |
+ |
/// rather than on its own, so a border stays a border and text stays
|
|
202 |
+ |
/// readable. The surfaces themselves are quantized plainly: they are what
|
|
203 |
+ |
/// the others are measured against.
|
|
204 |
+ |
pub fn for_terminal(self, depth: ColorDepth) -> Theme {
|
|
205 |
+ |
if depth == ColorDepth::Full {
|
|
206 |
+ |
return self;
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
let page = indexed(self.surface_page);
|
|
210 |
+ |
let on_page = |c: Color| indexed_against(c, self.surface_page);
|
|
211 |
+ |
|
|
212 |
+ |
Theme {
|
|
213 |
+ |
mode: self.mode,
|
|
214 |
+ |
|
|
215 |
+ |
surface_page: page,
|
|
216 |
+ |
surface_raised: indexed(self.surface_raised),
|
|
217 |
+ |
surface_sunken: indexed(self.surface_sunken),
|
|
218 |
+ |
surface_overlay: indexed(self.surface_overlay),
|
|
219 |
+ |
|
|
220 |
+ |
content_primary: on_page(self.content_primary),
|
|
221 |
+ |
content_secondary: on_page(self.content_secondary),
|
|
222 |
+ |
content_muted: on_page(self.content_muted),
|
|
223 |
+ |
|
|
224 |
+ |
action_primary: on_page(self.action_primary),
|
|
225 |
+ |
|
|
226 |
+ |
status_danger: on_page(self.status_danger),
|
|
227 |
+ |
status_success: on_page(self.status_success),
|
|
228 |
+ |
status_warning: on_page(self.status_warning),
|
|
229 |
+ |
status_info: on_page(self.status_info),
|
|
230 |
+ |
|
|
231 |
+ |
line_border: on_page(self.line_border),
|
|
232 |
+ |
border_subtle: on_page(self.border_subtle),
|
|
233 |
+ |
border_strong: on_page(self.border_strong),
|
|
234 |
+ |
|
|
235 |
+ |
category: self.category.map(on_page),
|
|
236 |
+ |
}
|
|
237 |
+ |
}
|
|
238 |
+ |
}
|
|
239 |
+ |
|
| 139 |
240 |
|
// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
|
| 140 |
241 |
|
// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
|
| 141 |
242 |
|
// less uniform than OKLab but keeps the derived hex reproducible against the
|
| 193 |
294 |
|
got.r, got.g, got.b
|
| 194 |
295 |
|
);
|
| 195 |
296 |
|
}
|
|
297 |
+ |
|
|
298 |
+ |
// Akari Dawn as far as this matters: the page, the text on it, and the
|
|
299 |
+ |
// strong border derived above.
|
|
300 |
+ |
fn akari_dawn() -> Theme {
|
|
301 |
+ |
let page = Color::Rgb(0xe4, 0xde, 0xd6);
|
|
302 |
+ |
Theme {
|
|
303 |
+ |
mode: Mode::Light,
|
|
304 |
+ |
surface_page: page,
|
|
305 |
+ |
surface_raised: page,
|
|
306 |
+ |
surface_sunken: page,
|
|
307 |
+ |
surface_overlay: page,
|
|
308 |
+ |
content_primary: Color::Rgb(0x1a, 0x18, 0x16),
|
|
309 |
+ |
content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
|
|
310 |
+ |
content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
|
|
311 |
+ |
action_primary: Color::Rgb(0x8a, 0x45, 0x30),
|
|
312 |
+ |
status_danger: Color::Rgb(0x8a, 0x45, 0x30),
|
|
313 |
+ |
status_success: Color::Rgb(0x8a, 0x45, 0x30),
|
|
314 |
+ |
status_warning: Color::Rgb(0x8a, 0x45, 0x30),
|
|
315 |
+ |
status_info: Color::Rgb(0x8a, 0x45, 0x30),
|
|
316 |
+ |
line_border: Color::Rgb(0xca, 0xbe, 0xae),
|
|
317 |
+ |
border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
|
|
318 |
+ |
border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
|
|
319 |
+ |
category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
|
|
320 |
+ |
}
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
#[test]
|
|
324 |
+ |
fn a_capable_terminal_gets_the_theme_as_authored() {
|
|
325 |
+ |
let theme = akari_dawn().for_terminal(ColorDepth::Full);
|
|
326 |
+ |
assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
|
|
327 |
+ |
}
|
|
328 |
+ |
|
|
329 |
+ |
// Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
|
|
330 |
+ |
// the approximating to the terminal, which is where the collapse happened.
|
|
331 |
+ |
#[test]
|
|
332 |
+ |
fn a_sixteen_color_terminal_gets_indices() {
|
|
333 |
+ |
let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
|
|
334 |
+ |
for color in [
|
|
335 |
+ |
theme.surface_page,
|
|
336 |
+ |
theme.content_primary,
|
|
337 |
+ |
theme.border_strong,
|
|
338 |
+ |
theme.border_subtle,
|
|
339 |
+ |
theme.line_border,
|
|
340 |
+ |
] {
|
|
341 |
+ |
assert!(matches!(color, Color::Indexed(_)), "{color:?}");
|
|
342 |
+ |
}
|
|
343 |
+ |
}
|
|
344 |
+ |
|
|
345 |
+ |
// The bug, as a test: the installer's frame drew in border_strong on
|
|
346 |
+ |
// surface_page and could not be seen.
|
|
347 |
+ |
#[test]
|
|
348 |
+ |
fn the_frame_stays_visible_against_the_page() {
|
|
349 |
+ |
let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
|
|
350 |
+ |
assert_ne!(theme.border_strong, theme.surface_page);
|
|
351 |
+ |
assert_ne!(theme.border_subtle, theme.surface_page);
|
|
352 |
+ |
assert_ne!(theme.content_primary, theme.surface_page);
|
|
353 |
+ |
}
|
| 196 |
354 |
|
}
|