| 198 |
198 |
|
}
|
| 199 |
199 |
|
|
| 200 |
200 |
|
// ============================================================================
|
|
201 |
+ |
// Low-color terminals
|
|
202 |
+ |
// ============================================================================
|
|
203 |
+ |
|
|
204 |
+ |
/// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
|
|
205 |
+ |
/// arrangement the Linux console and most emulators start from.
|
|
206 |
+ |
///
|
|
207 |
+ |
/// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
|
|
208 |
+ |
/// rather than white, which is the entry a themed surface usually lands on, and
|
|
209 |
+ |
/// index 15 is the true white.
|
|
210 |
+ |
///
|
|
211 |
+ |
/// Emulators let the user repaint all sixteen, so this is the standard
|
|
212 |
+ |
/// arrangement rather than a promise about any one terminal. The Linux console
|
|
213 |
+ |
/// keeps it, which is the case that matters: a console app cannot fall back to
|
|
214 |
+ |
/// 24-bit color there.
|
|
215 |
+ |
pub const ANSI_16: [Rgb; 16] = [
|
|
216 |
+ |
Rgb { r: 0x00, g: 0x00, b: 0x00 },
|
|
217 |
+ |
Rgb { r: 0xaa, g: 0x00, b: 0x00 },
|
|
218 |
+ |
Rgb { r: 0x00, g: 0xaa, b: 0x00 },
|
|
219 |
+ |
Rgb { r: 0xaa, g: 0x55, b: 0x00 },
|
|
220 |
+ |
Rgb { r: 0x00, g: 0x00, b: 0xaa },
|
|
221 |
+ |
Rgb { r: 0xaa, g: 0x00, b: 0xaa },
|
|
222 |
+ |
Rgb { r: 0x00, g: 0xaa, b: 0xaa },
|
|
223 |
+ |
Rgb { r: 0xaa, g: 0xaa, b: 0xaa },
|
|
224 |
+ |
Rgb { r: 0x55, g: 0x55, b: 0x55 },
|
|
225 |
+ |
Rgb { r: 0xff, g: 0x55, b: 0x55 },
|
|
226 |
+ |
Rgb { r: 0x55, g: 0xff, b: 0x55 },
|
|
227 |
+ |
Rgb { r: 0xff, g: 0xff, b: 0x55 },
|
|
228 |
+ |
Rgb { r: 0x55, g: 0x55, b: 0xff },
|
|
229 |
+ |
Rgb { r: 0xff, g: 0x55, b: 0xff },
|
|
230 |
+ |
Rgb { r: 0x55, g: 0xff, b: 0xff },
|
|
231 |
+ |
Rgb { r: 0xff, g: 0xff, b: 0xff },
|
|
232 |
+ |
];
|
|
233 |
+ |
|
|
234 |
+ |
/// The contrast ratio two colors must clear to read as separate areas.
|
|
235 |
+ |
///
|
|
236 |
+ |
/// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
|
|
237 |
+ |
/// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
|
|
238 |
+ |
/// text can ask for more by checking [`wcag_contrast`] itself.
|
|
239 |
+ |
pub const DISTINCT: f32 = 3.0;
|
|
240 |
+ |
|
|
241 |
+ |
/// Perceptual distance between two colors, for choosing the closest of a set.
|
|
242 |
+ |
fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
|
|
243 |
+ |
let (x, y) = (a.to_oklab(), b.to_oklab());
|
|
244 |
+ |
((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
|
|
245 |
+ |
}
|
|
246 |
+ |
|
|
247 |
+ |
/// Index of the entry in `palette` that looks most like `c`.
|
|
248 |
+ |
///
|
|
249 |
+ |
/// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
|
|
250 |
+ |
/// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
|
|
251 |
+ |
/// nearest match computed in it picks visibly wrong entries in the mid tones.
|
|
252 |
+ |
///
|
|
253 |
+ |
/// # Panics
|
|
254 |
+ |
///
|
|
255 |
+ |
/// If `palette` is empty.
|
|
256 |
+ |
pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
|
|
257 |
+ |
assert!(!palette.is_empty(), "a palette needs at least one color");
|
|
258 |
+ |
let mut best = 0;
|
|
259 |
+ |
let mut best_distance = f32::INFINITY;
|
|
260 |
+ |
for (index, entry) in palette.iter().enumerate() {
|
|
261 |
+ |
let distance = oklab_distance(c, *entry);
|
|
262 |
+ |
if distance < best_distance {
|
|
263 |
+ |
best = index;
|
|
264 |
+ |
best_distance = distance;
|
|
265 |
+ |
}
|
|
266 |
+ |
}
|
|
267 |
+ |
best
|
|
268 |
+ |
}
|
|
269 |
+ |
|
|
270 |
+ |
/// Index of the entry in `palette` closest to `fg` that still reads against
|
|
271 |
+ |
/// `bg`.
|
|
272 |
+ |
///
|
|
273 |
+ |
/// [`quantize`] answers about one color at a time, and two colors that differ
|
|
274 |
+ |
/// can quantize to the same entry: a themed page and a border drawn on it are
|
|
275 |
+ |
/// often a few steps apart in a 24-bit theme and land together on a 16-color
|
|
276 |
+ |
/// terminal, leaving one flat area where there was a frame. Alloy's console
|
|
277 |
+ |
/// showed exactly this, and it is not a contrived pairing: a light page and the
|
|
278 |
+ |
/// mid-tone border derived from it both land on index 7.
|
|
279 |
+ |
///
|
|
280 |
+ |
/// So the background is quantized first, because what the border must be
|
|
281 |
+ |
/// distinguished from is the entry the terminal will actually paint, not the
|
|
282 |
+ |
/// color the theme asked for. Then the nearest entry to `fg` clearing
|
|
283 |
+ |
/// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
|
|
284 |
+ |
/// furthest does: at that point the palette cannot honor the design, and the
|
|
285 |
+ |
/// most legible approximation beats the closest invisible one.
|
|
286 |
+ |
///
|
|
287 |
+ |
/// Only for colors whose whole job is to be told apart from their background.
|
|
288 |
+ |
/// Applied to every token it would push a deliberately quiet one until it
|
|
289 |
+ |
/// shouted.
|
|
290 |
+ |
///
|
|
291 |
+ |
/// # Panics
|
|
292 |
+ |
///
|
|
293 |
+ |
/// If `palette` is empty.
|
|
294 |
+ |
pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
|
|
295 |
+ |
assert!(!palette.is_empty(), "a palette needs at least one color");
|
|
296 |
+ |
let shown = palette[quantize(bg, palette)];
|
|
297 |
+ |
|
|
298 |
+ |
let mut order: Vec<usize> = (0..palette.len()).collect();
|
|
299 |
+ |
order.sort_by(|a, b| {
|
|
300 |
+ |
oklab_distance(fg, palette[*a])
|
|
301 |
+ |
.total_cmp(&oklab_distance(fg, palette[*b]))
|
|
302 |
+ |
});
|
|
303 |
+ |
|
|
304 |
+ |
order
|
|
305 |
+ |
.iter()
|
|
306 |
+ |
.copied()
|
|
307 |
+ |
.find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
|
|
308 |
+ |
.unwrap_or_else(|| {
|
|
309 |
+ |
order
|
|
310 |
+ |
.iter()
|
|
311 |
+ |
.copied()
|
|
312 |
+ |
.max_by(|a, b| {
|
|
313 |
+ |
wcag_contrast(palette[*a], shown)
|
|
314 |
+ |
.total_cmp(&wcag_contrast(palette[*b], shown))
|
|
315 |
+ |
})
|
|
316 |
+ |
.expect("the palette is not empty")
|
|
317 |
+ |
})
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
// ============================================================================
|
| 201 |
321 |
|
// Intent resolution
|
| 202 |
322 |
|
// ============================================================================
|
| 203 |
323 |
|
|
| 664 |
784 |
|
assert!(validate_theme_id("theme.toml").is_err());
|
| 665 |
785 |
|
}
|
| 666 |
786 |
|
|
|
787 |
+ |
// ---- low-color terminals ----
|
|
788 |
+ |
|
|
789 |
+ |
#[test]
|
|
790 |
+ |
fn the_ansi_palette_is_sixteen_distinct_colors() {
|
|
791 |
+ |
let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
|
|
792 |
+ |
seen.sort_unstable();
|
|
793 |
+ |
seen.dedup();
|
|
794 |
+ |
assert_eq!(seen.len(), 16);
|
|
795 |
+ |
}
|
|
796 |
+ |
|
|
797 |
+ |
#[test]
|
|
798 |
+ |
fn quantize_picks_the_obvious_entry() {
|
|
799 |
+ |
let black = Rgb { r: 0, g: 0, b: 0 };
|
|
800 |
+ |
let white = Rgb { r: 255, g: 255, b: 255 };
|
|
801 |
+ |
assert_eq!(quantize(black, &ANSI_16), 0);
|
|
802 |
+ |
assert_eq!(quantize(white, &ANSI_16), 15);
|
|
803 |
+ |
}
|
|
804 |
+ |
|
|
805 |
+ |
// Nearest-entry quantization is per-color, so two colors a theme keeps
|
|
806 |
+ |
// apart can arrive as one. These two are both closest to the palette's
|
|
807 |
+ |
// light gray, and a border drawn in one on a page painted the other is not
|
|
808 |
+ |
// drawn at all.
|
|
809 |
+ |
#[test]
|
|
810 |
+ |
fn two_colors_can_quantize_to_one_entry() {
|
|
811 |
+ |
let page = Rgb::from_hex("#a8a8a8").unwrap();
|
|
812 |
+ |
let border = Rgb::from_hex("#b4b4b4").unwrap();
|
|
813 |
+ |
|
|
814 |
+ |
assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
|
|
815 |
+ |
assert!(quantize_against(border, page, &ANSI_16) != quantize(page, &ANSI_16));
|
|
816 |
+ |
}
|
|
817 |
+ |
|
|
818 |
+ |
#[test]
|
|
819 |
+ |
fn quantize_against_keeps_the_border_off_the_page() {
|
|
820 |
+ |
let page = Rgb::from_hex("#e4ded6").unwrap();
|
|
821 |
+ |
let border = Rgb::from_hex("#7f786d").unwrap();
|
|
822 |
+ |
|
|
823 |
+ |
let shown_page = ANSI_16[quantize(page, &ANSI_16)];
|
|
824 |
+ |
let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
|
|
825 |
+ |
|
|
826 |
+ |
assert!(
|
|
827 |
+ |
wcag_contrast(shown_border, shown_page) >= DISTINCT,
|
|
828 |
+ |
"border {} on page {} is {:.2}:1",
|
|
829 |
+ |
shown_border.to_hex(),
|
|
830 |
+ |
shown_page.to_hex(),
|
|
831 |
+ |
wcag_contrast(shown_border, shown_page)
|
|
832 |
+ |
);
|
|
833 |
+ |
}
|
|
834 |
+ |
|
|
835 |
+ |
// A color that already reads against its background is left where it is,
|
|
836 |
+ |
// so this can be applied without redesigning what already worked.
|
|
837 |
+ |
#[test]
|
|
838 |
+ |
fn quantize_against_leaves_a_readable_color_alone() {
|
|
839 |
+ |
let page = Rgb::from_hex("#e4ded6").unwrap();
|
|
840 |
+ |
let text = Rgb::from_hex("#1a1816").unwrap();
|
|
841 |
+ |
|
|
842 |
+ |
assert_eq!(
|
|
843 |
+ |
quantize_against(text, page, &ANSI_16),
|
|
844 |
+ |
quantize(text, &ANSI_16)
|
|
845 |
+ |
);
|
|
846 |
+ |
}
|
|
847 |
+ |
|
|
848 |
+ |
// With nothing in the palette to satisfy the request, the most legible
|
|
849 |
+ |
// entry is the answer. Returning the nearest one would return the
|
|
850 |
+ |
// background itself, which is the failure this function exists to avoid.
|
|
851 |
+ |
#[test]
|
|
852 |
+ |
fn an_impossible_palette_gets_the_most_legible_entry() {
|
|
853 |
+ |
let page = Rgb::from_hex("#ffffff").unwrap();
|
|
854 |
+ |
let border = Rgb::from_hex("#fefefe").unwrap();
|
|
855 |
+ |
let palette = [
|
|
856 |
+ |
Rgb::from_hex("#ffffff").unwrap(),
|
|
857 |
+ |
Rgb::from_hex("#fdfdfd").unwrap(),
|
|
858 |
+ |
];
|
|
859 |
+ |
|
|
860 |
+ |
let chosen = palette[quantize_against(border, page, &palette)];
|
|
861 |
+ |
assert_eq!(chosen.to_hex(), "#fdfdfd");
|
|
862 |
+ |
}
|
|
863 |
+ |
|
| 667 |
864 |
|
// ---- meta ----
|
| 668 |
865 |
|
|
| 669 |
866 |
|
#[test]
|