| 86 |
86 |
|
/// one.
|
| 87 |
87 |
|
pub border_strong: Color,
|
| 88 |
88 |
|
|
|
89 |
+ |
/// The foreground for text sitting on [`action_primary`](Theme::action_primary).
|
|
90 |
+ |
///
|
|
91 |
+ |
/// DERIVED, not authored. Every consumer measured did selection with
|
|
92 |
+ |
/// `REVERSED`, because there was no on-accent foreground to pair with an
|
|
93 |
+ |
/// accent background, and four ports were each about to invent one.
|
|
94 |
+ |
///
|
|
95 |
+ |
/// Chosen between [`surface_page`](Theme::surface_page) and
|
|
96 |
+ |
/// [`content_primary`](Theme::content_primary) by contrast against the
|
|
97 |
+ |
/// accent, rather than mixed: both are colours the theme authored, so a
|
|
98 |
+ |
/// selected row stays inside the theme's own palette instead of landing on
|
|
99 |
+ |
/// a colour that appears nowhere in the file. That is
|
|
100 |
+ |
/// [`Quantize::against`]'s reasoning applied a step earlier — a colour on a
|
|
101 |
+ |
/// colour, answered by measuring rather than by taste.
|
|
102 |
+ |
///
|
|
103 |
+ |
/// A candidate for promotion to an authored theme key if a theme ever needs
|
|
104 |
+ |
/// to tune it. That direction works and the reverse does not: an authored
|
|
105 |
+ |
/// key can fall back to this derivation and break no theme on disk, while a
|
|
106 |
+ |
/// key this crate started requiring would break every theme that has one.
|
|
107 |
+ |
pub selection_on: Color,
|
|
108 |
+ |
/// The colour of a focus ring.
|
|
109 |
+ |
///
|
|
110 |
+ |
/// DERIVED, not authored, and deliberately not
|
|
111 |
+ |
/// [`border_strong`](Theme::border_strong). That is a divider at 5%
|
|
112 |
+ |
/// darkening — see the module header — and spending it here takes a focus
|
|
113 |
+ |
/// ring to half its floor. The cue comes off
|
|
114 |
+ |
/// [`action_primary`](Theme::action_primary) instead, held to AA-UI against
|
|
115 |
+ |
/// the page, which is what `alloy_tui` already derives for itself rather
|
|
116 |
+ |
/// than adopting makeover's border for a job it does not do.
|
|
117 |
+ |
///
|
|
118 |
+ |
/// Promotable to an authored key on the same terms as
|
|
119 |
+ |
/// [`selection_on`](Theme::selection_on).
|
|
120 |
+ |
pub focus_ring: Color,
|
|
121 |
+ |
|
| 89 |
122 |
|
/// The lit and shadowed edges of a raised surface.
|
| 90 |
123 |
|
///
|
| 91 |
124 |
|
/// A control is lit from the top left, so its top and left edges take
|
| 152 |
185 |
|
})
|
| 153 |
186 |
|
};
|
| 154 |
187 |
|
|
|
188 |
+ |
// The two derived cues. Both are computed here rather than in
|
|
189 |
+ |
// `makeover::resolve`, because neither is a colour a webview or an egui
|
|
190 |
+ |
// app needs: CSS has `:focus-visible` with its own accent handling, and
|
|
191 |
+ |
// a browser paints a selection itself. This is the terminal's problem,
|
|
192 |
+ |
// so it is answered where the terminal is drawn.
|
|
193 |
+ |
let accent = authored("action.primary")?;
|
|
194 |
+ |
let page = authored("surface.page")?;
|
|
195 |
+ |
let selection_on = on_accent(accent, page, authored("content.primary")?);
|
|
196 |
+ |
|
| 155 |
197 |
|
let mode = match theme.meta.variant.as_str() {
|
| 156 |
198 |
|
"dark" => Mode::Dark,
|
| 157 |
199 |
|
"high-contrast" => Mode::HighContrast,
|
| 174 |
216 |
|
content_secondary: rgb(authored("content.secondary")?),
|
| 175 |
217 |
|
content_muted: rgb(authored("content.muted")?),
|
| 176 |
218 |
|
|
| 177 |
|
- |
action_primary: rgb(authored("action.primary")?),
|
|
219 |
+ |
action_primary: rgb(accent),
|
|
220 |
+ |
selection_on: rgb(selection_on),
|
|
221 |
+ |
focus_ring: rgb(focus_ring(accent, page)),
|
| 178 |
222 |
|
|
| 179 |
223 |
|
status_danger: rgb(authored("status.danger")?),
|
| 180 |
224 |
|
status_success: rgb(authored("status.success")?),
|
| 251 |
295 |
|
content_muted: on_page(self.content_muted),
|
| 252 |
296 |
|
|
| 253 |
297 |
|
action_primary: on_page(self.action_primary),
|
|
298 |
+ |
// Against the accent, not against the page: this is the one colour
|
|
299 |
+ |
// here whose whole job is to be legible on the accent behind it.
|
|
300 |
+ |
// Quantising it against the page would answer the wrong question
|
|
301 |
+ |
// and hand a selected row unreadable text at Ansi16.
|
|
302 |
+ |
selection_on: q.against(self.selection_on, self.action_primary),
|
|
303 |
+ |
focus_ring: on_page(self.focus_ring),
|
| 254 |
304 |
|
|
| 255 |
305 |
|
status_danger: on_page(self.status_danger),
|
| 256 |
306 |
|
status_success: on_page(self.status_success),
|
| 292 |
342 |
|
Color::Rgb(c.r, c.g, c.b)
|
| 293 |
343 |
|
}
|
| 294 |
344 |
|
|
|
345 |
+ |
/// The AA floor for text against what it sits on.
|
|
346 |
+ |
const TEXT_FLOOR: f32 = 4.5;
|
|
347 |
+ |
|
|
348 |
+ |
/// The AA-UI floor a non-text cue has to clear against what it sits on.
|
|
349 |
+ |
///
|
|
350 |
+ |
/// 3:1 rather than 4.5:1 because a ring is a graphical object and not text.
|
|
351 |
+ |
/// The same number `alloy_tui` holds its own derived ring to.
|
|
352 |
+ |
const UI_FLOOR: f32 = 3.0;
|
|
353 |
+ |
|
|
354 |
+ |
/// The better of the theme's two candidates for text on the accent, or black or
|
|
355 |
+ |
/// white where neither is legible.
|
|
356 |
+ |
///
|
|
357 |
+ |
/// Preferring an authored colour is the point: a selected row should stay inside
|
|
358 |
+ |
/// the palette the theme wrote rather than land on a colour appearing nowhere in
|
|
359 |
+ |
/// the file. But an unreadable selection is worse than an off-palette one, and
|
|
360 |
+ |
/// the two candidates can both fail — ayu-light's page and content both land
|
|
361 |
+ |
/// under 2.5:1 on its accent. So the walk is: page or content by contrast,
|
|
362 |
+ |
/// and `readable_on` only when the winner misses the AA text floor.
|
|
363 |
+ |
///
|
|
364 |
+ |
/// Measured across every bundled theme when this was written: 29 of 31 clear
|
|
365 |
+ |
/// the floor from the theme's own colours, one clears it at 3.99 and 3.36 (the
|
|
366 |
+ |
/// app themes), and ayu-light is the one that needs the backstop.
|
|
367 |
+ |
fn on_accent(accent: Rgb, page: Rgb, content: Rgb) -> Rgb {
|
|
368 |
+ |
let best = if makeover::wcag_contrast(page, accent) >= makeover::wcag_contrast(content, accent)
|
|
369 |
+ |
{
|
|
370 |
+ |
page
|
|
371 |
+ |
} else {
|
|
372 |
+ |
content
|
|
373 |
+ |
};
|
|
374 |
+ |
if makeover::wcag_contrast(best, accent) >= TEXT_FLOOR {
|
|
375 |
+ |
best
|
|
376 |
+ |
} else {
|
|
377 |
+ |
makeover::readable_on(accent)
|
|
378 |
+ |
}
|
|
379 |
+ |
}
|
|
380 |
+ |
|
|
381 |
+ |
/// A ring colour off the accent, pushed away from the page until it is visible.
|
|
382 |
+ |
///
|
|
383 |
+ |
/// The accent itself is the answer on most themes, and the walk only runs where
|
|
384 |
+ |
/// it is not: a theme whose accent sits close to its page has a real focus
|
|
385 |
+ |
/// problem, and returning the accent unchanged there would be a ring nobody can
|
|
386 |
+ |
/// see. Which way to push is decided by the page rather than by the theme's
|
|
387 |
+ |
/// declared variant, because a light theme may carry a dark panel and the
|
|
388 |
+ |
/// question is always "away from *this* surface".
|
|
389 |
+ |
///
|
|
390 |
+ |
/// Steps in 5% and stops at the floor rather than going as far as it can, so
|
|
391 |
+ |
/// the ring stays recognisably the accent on the themes that need the help.
|
|
392 |
+ |
fn focus_ring(accent: Rgb, page: Rgb) -> Rgb {
|
|
393 |
+ |
if makeover::wcag_contrast(accent, page) >= UI_FLOOR {
|
|
394 |
+ |
return accent;
|
|
395 |
+ |
}
|
|
396 |
+ |
|
|
397 |
+ |
// Whether the page is dark, by the same relative-luminance rule the
|
|
398 |
+ |
// contrast ratio is built on: white against it beats black against it.
|
|
399 |
+ |
let page_is_dark = makeover::wcag_contrast(
|
|
400 |
+ |
Rgb {
|
|
401 |
+ |
r: 255,
|
|
402 |
+ |
g: 255,
|
|
403 |
+ |
b: 255,
|
|
404 |
+ |
},
|
|
405 |
+ |
page,
|
|
406 |
+ |
) > makeover::wcag_contrast(Rgb { r: 0, g: 0, b: 0 }, page);
|
|
407 |
+ |
|
|
408 |
+ |
let mut candidate = accent;
|
|
409 |
+ |
for _ in 0..20 {
|
|
410 |
+ |
candidate = if page_is_dark {
|
|
411 |
+ |
makeover::lighten(candidate, 0.05)
|
|
412 |
+ |
} else {
|
|
413 |
+ |
makeover::darken(candidate, 0.05)
|
|
414 |
+ |
};
|
|
415 |
+ |
if makeover::wcag_contrast(candidate, page) >= UI_FLOOR {
|
|
416 |
+ |
return candidate;
|
|
417 |
+ |
}
|
|
418 |
+ |
}
|
|
419 |
+ |
candidate
|
|
420 |
+ |
}
|
|
421 |
+ |
|
| 295 |
422 |
|
/// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for
|
| 296 |
423 |
|
/// landing a colour in it.
|
| 297 |
424 |
|
///
|
| 399 |
526 |
|
}
|
| 400 |
527 |
|
}
|
| 401 |
528 |
|
|
|
529 |
+ |
fn to_rgb(c: Color) -> Rgb {
|
|
530 |
+ |
match c {
|
|
531 |
+ |
Color::Rgb(r, g, b) => Rgb { r, g, b },
|
|
532 |
+ |
other => panic!("expected a resolved colour, got {other:?}"),
|
|
533 |
+ |
}
|
|
534 |
+ |
}
|
|
535 |
+ |
|
|
536 |
+ |
#[test]
|
|
537 |
+ |
fn the_two_derived_cues_are_legible_in_every_bundled_theme() {
|
|
538 |
+ |
// The test that would have caught `border_strong` being spent as a
|
|
539 |
+ |
// focus ring: it lands at 1.63:1 on Akari Dawn, and nothing asked.
|
|
540 |
+ |
// Every theme rather than one, because a derivation that works on the
|
|
541 |
+ |
// theme it was written against says nothing about the other thirty.
|
|
542 |
+ |
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
|
|
543 |
+ |
for meta in makeover::list_themes_from_dirs(&[(dir, false)]) {
|
|
544 |
+ |
let theme = Theme::from_theme(&bundled(&meta.id)).expect("resolves");
|
|
545 |
+ |
|
|
546 |
+ |
// Text on the accent, so the text floor.
|
|
547 |
+ |
let selection =
|
|
548 |
+ |
makeover::wcag_contrast(to_rgb(theme.selection_on), to_rgb(theme.action_primary));
|
|
549 |
+ |
assert!(
|
|
550 |
+ |
selection >= TEXT_FLOOR,
|
|
551 |
+ |
"{}: selection_on is {selection:.2} on the accent",
|
|
552 |
+ |
meta.id
|
|
553 |
+ |
);
|
|
554 |
+ |
|
|
555 |
+ |
// A ring is a graphical object, so the AA-UI floor.
|
|
556 |
+ |
let ring =
|
|
557 |
+ |
makeover::wcag_contrast(to_rgb(theme.focus_ring), to_rgb(theme.surface_page));
|
|
558 |
+ |
assert!(
|
|
559 |
+ |
ring >= UI_FLOOR,
|
|
560 |
+ |
"{}: focus_ring is {ring:.2} on the page",
|
|
561 |
+ |
meta.id
|
|
562 |
+ |
);
|
|
563 |
+ |
|
|
564 |
+ |
// And the ring is not the divider wearing the ring's name, which
|
|
565 |
+ |
// is the whole reason it is derived rather than adopted.
|
|
566 |
+ |
assert_ne!(theme.focus_ring, theme.border_strong, "{}", meta.id);
|
|
567 |
+ |
}
|
|
568 |
+ |
}
|
|
569 |
+ |
|
|
570 |
+ |
#[test]
|
|
571 |
+ |
fn the_derived_cues_are_quantised_for_the_surface_each_sits_on() {
|
|
572 |
+ |
// A colour that has to be told apart from a known background is
|
|
573 |
+ |
// quantised against that background, and the two cues do not share one.
|
|
574 |
+ |
// Left plain, both collapse toward whatever the palette has nearest.
|
|
575 |
+ |
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
|
|
576 |
+ |
let ansi16 = theme.for_terminal(crate::Fidelity::Ansi16);
|
|
577 |
+ |
let q = Quantize::for_fidelity(crate::Fidelity::Ansi16).expect("Ansi16 quantises");
|
|
578 |
+ |
|
|
579 |
+ |
assert_eq!(
|
|
580 |
+ |
ansi16.selection_on,
|
|
581 |
+ |
q.against(theme.selection_on, theme.action_primary)
|
|
582 |
+ |
);
|
|
583 |
+ |
assert_eq!(
|
|
584 |
+ |
ansi16.focus_ring,
|
|
585 |
+ |
q.against(theme.focus_ring, theme.surface_page)
|
|
586 |
+ |
);
|
|
587 |
+ |
}
|
|
588 |
+ |
|
| 402 |
589 |
|
#[test]
|
| 403 |
590 |
|
fn a_missing_intent_names_the_key_it_wanted() {
|
| 404 |
591 |
|
let mut colors = bundled("goingson");
|