| 198 |
198 |
|
})
|
| 199 |
199 |
|
}
|
| 200 |
200 |
|
|
|
201 |
+ |
/// This theme as the terminal can actually draw it.
|
|
202 |
+ |
///
|
|
203 |
+ |
/// At [`TrueColor`](crate::Fidelity::TrueColor) the theme is returned
|
|
204 |
+ |
/// untouched. Otherwise every colour becomes a palette index, which is the
|
|
205 |
+ |
/// point: left as 24-bit, the terminal approximates them itself, and its
|
|
206 |
+ |
/// approximation collapses tones the theme keeps apart. Alloy's console lost
|
|
207 |
+ |
/// its frame that way, drawing a border in a colour the Linux console could
|
|
208 |
+ |
/// not tell from the page behind it.
|
|
209 |
+ |
///
|
|
210 |
+ |
/// Anything that has to be seen against the page is quantised against it
|
|
211 |
+ |
/// rather than on its own, so a border stays a border and text stays
|
|
212 |
+ |
/// readable. The surfaces themselves are quantised plainly: they are what
|
|
213 |
+ |
/// the others are measured against.
|
|
214 |
+ |
///
|
|
215 |
+ |
/// The bevel edges are quantised plainly too, for a different reason. They
|
|
216 |
+ |
/// are measured against the raised surface they surround rather than against
|
|
217 |
+ |
/// the page, and running them through [`Quantize::against`] would push both
|
|
218 |
+ |
/// onto the same entry and invert the bevel on one side. At
|
|
219 |
+ |
/// [`Ansi16`](crate::Fidelity::Ansi16) the palette cannot hold the pair at
|
|
220 |
+ |
/// all and one edge lands back on its face, which is a property of sixteen
|
|
221 |
+ |
/// colours rather than something this can fix. A caller drawing there does
|
|
222 |
+ |
/// not have to handle it: [`Theme::palette`] carries the fidelity through,
|
|
223 |
+ |
/// and [`frame`](crate::frame) answers it with glyphs instead of tones.
|
|
224 |
+ |
///
|
|
225 |
+ |
/// A consumer holding tokens of its own quantises them alongside this, with
|
|
226 |
+ |
/// the same [`Quantize`], rather than after the fact.
|
|
227 |
+ |
#[must_use]
|
|
228 |
+ |
pub fn for_terminal(self, fidelity: crate::Fidelity) -> Self {
|
|
229 |
+ |
let Some(q) = Quantize::for_fidelity(fidelity) else {
|
|
230 |
+ |
return self;
|
|
231 |
+ |
};
|
|
232 |
+ |
|
|
233 |
+ |
let plain = |c: Color| q.plain(c);
|
|
234 |
+ |
let on_page = |c: Color| q.against(c, self.surface_page);
|
|
235 |
+ |
|
|
236 |
+ |
Self {
|
|
237 |
+ |
mode: self.mode,
|
|
238 |
+ |
|
|
239 |
+ |
surface_page: plain(self.surface_page),
|
|
240 |
+ |
surface_raised: plain(self.surface_raised),
|
|
241 |
+ |
surface_sunken: plain(self.surface_sunken),
|
|
242 |
+ |
surface_overlay: plain(self.surface_overlay),
|
|
243 |
+ |
// Plainly, like the other surfaces and for the same reason as the
|
|
244 |
+ |
// bevel pair: a well is measured against the raised face it is cut
|
|
245 |
+ |
// into, not against the page, so quantising it against the page
|
|
246 |
+ |
// would push it toward contrast it is not supposed to have.
|
|
247 |
+ |
surface_well: self.surface_well.map(plain),
|
|
248 |
+ |
|
|
249 |
+ |
content_primary: on_page(self.content_primary),
|
|
250 |
+ |
content_secondary: on_page(self.content_secondary),
|
|
251 |
+ |
content_muted: on_page(self.content_muted),
|
|
252 |
+ |
|
|
253 |
+ |
action_primary: on_page(self.action_primary),
|
|
254 |
+ |
|
|
255 |
+ |
status_danger: on_page(self.status_danger),
|
|
256 |
+ |
status_success: on_page(self.status_success),
|
|
257 |
+ |
status_warning: on_page(self.status_warning),
|
|
258 |
+ |
status_info: on_page(self.status_info),
|
|
259 |
+ |
|
|
260 |
+ |
line_border: on_page(self.line_border),
|
|
261 |
+ |
border_strong: on_page(self.border_strong),
|
|
262 |
+ |
|
|
263 |
+ |
bevel_light: plain(self.bevel_light),
|
|
264 |
+ |
bevel_dark: plain(self.bevel_dark),
|
|
265 |
+ |
|
|
266 |
+ |
category: self.category.map(on_page),
|
|
267 |
+ |
}
|
|
268 |
+ |
}
|
|
269 |
+ |
|
| 201 |
270 |
|
/// The depth-painting palette this theme implies, at `fidelity`.
|
| 202 |
271 |
|
///
|
| 203 |
272 |
|
/// The bridge between the two halves of this crate: [`Theme`] is what a
|
| 223 |
292 |
|
Color::Rgb(c.r, c.g, c.b)
|
| 224 |
293 |
|
}
|
| 225 |
294 |
|
|
|
295 |
+ |
/// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for
|
|
296 |
+ |
/// landing a colour in it.
|
|
297 |
+ |
///
|
|
298 |
+ |
/// Public because a consumer carrying tokens of its own has to quantise them the
|
|
299 |
+ |
/// same way this crate quantises the ones it knows about. `alloy_tui` derives a
|
|
300 |
+ |
/// decorative divider and a focus ring from the authored border; those are its
|
|
301 |
+ |
/// tokens, but "a colour that must stay legible against the page is quantised
|
|
302 |
+ |
/// against the page" is not its rule to reinvent.
|
|
303 |
+ |
#[derive(Debug, Clone, Copy)]
|
|
304 |
+ |
pub struct Quantize {
|
|
305 |
+ |
palette: &'static [Rgb],
|
|
306 |
+ |
offset: usize,
|
|
307 |
+ |
}
|
|
308 |
+ |
|
|
309 |
+ |
impl Quantize {
|
|
310 |
+ |
/// The quantiser for `fidelity`, or `None` at
|
|
311 |
+ |
/// [`TrueColor`](crate::Fidelity::TrueColor), where nothing is quantised.
|
|
312 |
+ |
///
|
|
313 |
+ |
/// 256 resolves to makeover's fixed region rather than the whole table: the
|
|
314 |
+ |
/// low sixteen are repaintable in every emulator, so a match landing there
|
|
315 |
+ |
/// is a match against a colour the user may have moved out from under it.
|
|
316 |
+ |
#[must_use]
|
|
317 |
+ |
pub const fn for_fidelity(fidelity: crate::Fidelity) -> Option<Self> {
|
|
318 |
+ |
match fidelity {
|
|
319 |
+ |
crate::Fidelity::TrueColor => None,
|
|
320 |
+ |
crate::Fidelity::Ansi256 => Some(Self {
|
|
321 |
+ |
palette: makeover::ANSI_240,
|
|
322 |
+ |
offset: makeover::ANSI_240_OFFSET,
|
|
323 |
+ |
}),
|
|
324 |
+ |
crate::Fidelity::Ansi16 => Some(Self {
|
|
325 |
+ |
palette: &makeover::ANSI_16,
|
|
326 |
+ |
offset: 0,
|
|
327 |
+ |
}),
|
|
328 |
+ |
}
|
|
329 |
+ |
}
|
|
330 |
+ |
|
|
331 |
+ |
/// The palette entry for `c`, as an index the terminal will not reinterpret.
|
|
332 |
+ |
///
|
|
333 |
+ |
/// For a colour measured against the surface it sits on rather than against
|
|
334 |
+ |
/// the page: the surfaces themselves, and the bevel pair.
|
|
335 |
+ |
#[must_use]
|
|
336 |
+ |
pub fn plain(&self, c: Color) -> Color {
|
|
337 |
+ |
match c {
|
|
338 |
+ |
Color::Rgb(r, g, b) => Color::Indexed(
|
|
339 |
+ |
(makeover::quantize(Rgb { r, g, b }, self.palette) + self.offset) as u8,
|
|
340 |
+ |
),
|
|
341 |
+ |
other => other,
|
|
342 |
+ |
}
|
|
343 |
+ |
}
|
|
344 |
+ |
|
|
345 |
+ |
/// As [`plain`](Self::plain), but guaranteed to stay legible against `on`.
|
|
346 |
+ |
///
|
|
347 |
+ |
/// Only for a colour whose job is to be told apart from a known background.
|
|
348 |
+ |
/// It answers "nearest entry that still contrasts with `on`" and has no
|
|
349 |
+ |
/// notion of which side of `on` the answer should fall, so a pair of colours
|
|
350 |
+ |
/// that must also stay apart from *each other* is the one thing it must not
|
|
351 |
+ |
/// be used for: both get pushed onto the same contrasting entry. That is why
|
|
352 |
+ |
/// the bevel edges go through [`plain`](Self::plain).
|
|
353 |
+ |
#[must_use]
|
|
354 |
+ |
pub fn against(&self, c: Color, on: Color) -> Color {
|
|
355 |
+ |
match (c, on) {
|
|
356 |
+ |
(Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
|
|
357 |
+ |
(makeover::quantize_against(
|
|
358 |
+ |
Rgb { r, g, b },
|
|
359 |
+ |
Rgb {
|
|
360 |
+ |
r: br,
|
|
361 |
+ |
g: bg,
|
|
362 |
+ |
b: bb,
|
|
363 |
+ |
},
|
|
364 |
+ |
self.palette,
|
|
365 |
+ |
) + self.offset) as u8,
|
|
366 |
+ |
),
|
|
367 |
+ |
_ => self.plain(c),
|
|
368 |
+ |
}
|
|
369 |
+ |
}
|
|
370 |
+ |
}
|
|
371 |
+ |
|
| 226 |
372 |
|
#[cfg(test)]
|
| 227 |
373 |
|
mod tests {
|
| 228 |
374 |
|
use super::*;
|
| 278 |
424 |
|
}
|
| 279 |
425 |
|
}
|
| 280 |
426 |
|
|
|
427 |
+ |
#[test]
|
|
428 |
+ |
fn a_capable_terminal_gets_the_theme_as_authored() {
|
|
429 |
+ |
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
|
|
430 |
+ |
let same = theme.for_terminal(crate::Fidelity::TrueColor);
|
|
431 |
+ |
assert_eq!(same.surface_page, theme.surface_page);
|
|
432 |
+ |
assert_eq!(same.content_primary, theme.content_primary);
|
|
433 |
+ |
assert!(matches!(same.surface_page, Color::Rgb(..)));
|
|
434 |
+ |
}
|
|
435 |
+ |
|
|
436 |
+ |
#[test]
|
|
437 |
+ |
fn a_limited_terminal_gets_indices_rather_than_rgb() {
|
|
438 |
+ |
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
|
|
439 |
+ |
for fidelity in [crate::Fidelity::Ansi16, crate::Fidelity::Ansi256] {
|
|
440 |
+ |
let q = theme.for_terminal(fidelity);
|
|
441 |
+ |
assert!(
|
|
442 |
+ |
matches!(q.surface_page, Color::Indexed(_)),
|
|
443 |
+ |
"{fidelity:?} left a surface as rgb"
|
|
444 |
+ |
);
|
|
445 |
+ |
assert!(
|
|
446 |
+ |
matches!(q.content_primary, Color::Indexed(_)),
|
|
447 |
+ |
"{fidelity:?} left content as rgb"
|
|
448 |
+ |
);
|
|
449 |
+ |
}
|
|
450 |
+ |
}
|
|
451 |
+ |
|
|
452 |
+ |
#[test]
|
|
453 |
+ |
fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
|
|
454 |
+ |
// The reason Quantize::for_fidelity resolves 256 to makeover's fixed
|
|
455 |
+ |
// region: an index below 16 is one the user's emulator may have moved.
|
|
456 |
+ |
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
|
|
457 |
+ |
let q = theme.for_terminal(crate::Fidelity::Ansi256);
|
|
458 |
+ |
for (name, c) in [
|
|
459 |
+ |
("surface_page", q.surface_page),
|
|
460 |
+ |
("content_primary", q.content_primary),
|
|
461 |
+ |
("bevel_light", q.bevel_light),
|
|
462 |
+ |
("bevel_dark", q.bevel_dark),
|
|
463 |
+ |
] {
|
|
464 |
+ |
match c {
|
|
465 |
+ |
Color::Indexed(i) => assert!(i >= 16, "{name} landed on repaintable index {i}"),
|
|
466 |
+ |
other => panic!("{name} was not quantised: {other:?}"),
|
|
467 |
+ |
}
|
|
468 |
+ |
}
|
|
469 |
+ |
}
|
|
470 |
+ |
|
|
471 |
+ |
#[test]
|
|
472 |
+ |
fn the_bevel_pair_stays_two_tones_at_256() {
|
|
473 |
+ |
// Quantised plainly rather than against the page, precisely so they do
|
|
474 |
+ |
// not collapse onto one entry and invert the bevel on one side.
|
|
475 |
+ |
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
|
|
476 |
+ |
let q = theme.for_terminal(crate::Fidelity::Ansi256);
|
|
477 |
+ |
assert_ne!(q.bevel_light, q.bevel_dark);
|
|
478 |
+ |
}
|
|
479 |
+ |
|
| 281 |
480 |
|
#[test]
|
| 282 |
481 |
|
fn the_palette_takes_the_well_and_not_the_sunken_surface() {
|
| 283 |
482 |
|
// The substitution this crate deleted from the description, asserted
|