Skip to main content

max / makeover-tui

Own terminal quantisation: Theme::for_terminal and Quantize, release 0.7.0 Left in a consumer, quantisation is family knowledge held privately: which colours are measured against the page and which against the surface they sit on, why the bevel pair must not go through the contrast-seeking path, and why 256 resolves to makeover's fixed region instead of the whole table. All of it is the renderer's, and Theme is non_exhaustive, so a consumer could not have rebuilt a quantised copy anyway. Quantize is public because a consumer carrying tokens of its own still has to land them in the same palette by the same rules.
Author: Max Johnson <me@maxj.phd> · 2026-08-01 13:13 UTC
Signed with PGP, not checked
Commit: d67380b7c993a0c0241e37e6e6d0974564a28ebc
Parent: fa2b506
3 files changed, +201 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.6.0"
3 + version = "0.7.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
M src/lib.rs +1 -1
@@ -93,7 +93,7 @@
93 93 pub mod theme;
94 94
95 95 #[cfg(feature = "theme")]
96 - pub use theme::{Mode, Theme, ThemeError};
96 + pub use theme::{Mode, Quantize, Theme, ThemeError};
97 97
98 98 /// How many colours the terminal can actually show.
99 99 ///
M src/theme.rs +199
@@ -198,6 +198,75 @@
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,6 +292,83 @@
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,6 +424,59 @@
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