Skip to main content

max / makeover-tui

0.38.0: the theme picker, grouped and marked makeover-layout 0.38.0's FieldKind::Theme (518c650a, ruled by Max on 70028e00). Drawn as the radio group is rather than as a closed control, because a terminal has no closed control: the list is on screen already, so the group headings cost a row each and buy the structure the description now carries. Same walk the other two renderers do -- the run of one variant is the group, a heading opens whenever the variant changes -- which is what keeps three renderers from disagreeing about where a group starts. The heading takes the muted intent, which is the one place muted is the truth rather than the lie: a heading will not answer, exactly as an unavailable option will not. field_height counts the headings by walking the variants rather than by assuming three. A machine with only dark themes installed draws one heading, and reserving three would leave two blank rows under every picker.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01AAbx8dxVmuVeyKbKoRrUL2
Author: Max Johnson <me@maxj.phd> · 2026-08-28 19:09 UTC
Signed with PGP, not checked
Commit: bf1935331bdfc690d1dac4ca62fc114f507d5843
Parent: 15bea95
2 files changed, +146 insertions, -3 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.37.0"
3 + version = "0.38.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"
@@ -19,7 +19,7 @@
19 19 # compile against an API added in a later one -- which is what `Depth::Overlay`
20 20 # would do here. The rest of the suite has pinned this way since
21 21 # makeover-webview found it the hard way.
22 - makeover-layout = "0.37.0"
22 + makeover-layout = "0.38.0"
23 23 # The cadence the activity mark blinks at, and the motion-off seam beside it.
24 24 # Taken rather than chosen here: `activity_lit` is this crate's only use of it,
25 25 # and the whole point is that the number is not this crate's to pick.
M src/piece.rs +144 -1
@@ -50,7 +50,9 @@
50 50 //! nothing here places anything relative to anything else, because the moment
51 51 //! it did it would be a layout engine with one consumer's flow baked into it.
52 52
53 - use makeover_layout::{Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
53 + use makeover_layout::{
54 + Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token, Tone,
55 + };
54 56 use ratatui::buffer::Buffer;
55 57 use ratatui::layout::Rect;
56 58 use ratatui::style::{Modifier, Style};
@@ -572,6 +574,24 @@
572 574 // the source is the text, and drawing it as text is honest.
573 575 kind if kind.multiline() => 3,
574 576 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
577 + // A row per theme, a row per group heading, and a row for the follow
578 + // entry when there is one. The headings are counted by walking the
579 + // variants rather than by assuming three, because a machine with only
580 + // dark themes installed draws one heading and reserving three would
581 + // leave two blank rows under every picker.
582 + kind if kind.offers_themes() => {
583 + let mut variants = 0u16;
584 + let mut open: Option<ThemeVariant> = None;
585 + for theme in field.themes {
586 + if open != Some(theme.variant) {
587 + variants = variants.saturating_add(1);
588 + open = Some(theme.variant);
589 + }
590 + }
591 + let rows = u16::try_from(field.themes.len()).unwrap_or(u16::MAX);
592 + rows.saturating_add(variants)
593 + .saturating_add(u16::from(field.follows.is_some()))
594 + }
575 595 _ => 1,
576 596 };
577 597 let note = message_of(style, field).map_or(0, |(text, _)| text::height(text, width));
@@ -638,6 +658,64 @@
638 658 let line = interval_line(style, field, held, well);
639 659 text::draw_line(&line, below(area, used), buf)
640 660 }
661 + // The grouping comes out of the order, not out of a group list:
662 + // `Field::themes` arrives sorted by variant, so the run of one variant
663 + // is the group and a heading opens whenever the variant changes. Same
664 + // walk the other two renderers do, which is what keeps three renderers
665 + // from disagreeing about where a group starts.
666 + //
667 + // Drawn as the radio group above rather than as a closed control,
668 + // because a terminal has no closed control: the list is already on
669 + // screen and always was, so the group headings cost a row each and buy
670 + // the structure the description finally carries.
671 + kind if kind.offers_themes() => {
672 + let mut rows = 0;
673 + if let Some(follow) = field.follows {
674 + // First, and under no heading. It names no theme and sits in no
675 + // variant, so a heading over it would be inventing a fourth
676 + // variant for one row.
677 + let chosen = held.text() == follow.value;
678 + let (mark, painted) = if chosen {
679 + ("(*)", well)
680 + } else {
681 + ("( )", style.secondary)
682 + };
683 + rows += text::draw(
684 + &format!("{mark} {}", follow.label),
685 + painted,
686 + below(area, used + rows),
687 + buf,
688 + );
689 + }
690 + let mut open: Option<ThemeVariant> = None;
691 + for theme in field.themes {
692 + if open != Some(theme.variant) {
693 + // Muted, which is the one place it is the truth rather than
694 + // the lie: a heading will not answer, exactly as an
695 + // unavailable option will not.
696 + rows += text::draw(
697 + theme.variant.heading(),
698 + style.muted,
699 + below(area, used + rows),
700 + buf,
701 + );
702 + open = Some(theme.variant);
703 + }
704 + let chosen = held.text() == theme.id;
705 + let (mark, painted) = if chosen {
706 + ("(*)", well)
707 + } else {
708 + ("( )", style.secondary)
709 + };
710 + rows += text::draw(
711 + &format!("{mark} {} [{}]", theme.name, theme.contrast.badge()),
712 + painted,
713 + below(area, used + rows),
714 + buf,
715 + );
716 + }
717 + rows
718 + }
641 719 kind if kind.offers_options() => {
642 720 let mut rows = 0;
643 721 for choice in field.options {
@@ -1532,4 +1610,69 @@
1532 1610 );
1533 1611 assert!(empty.spans[0].content.starts_with('-'));
1534 1612 }
1613 +
1614 + #[test]
1615 + fn a_theme_picker_heads_each_group_and_marks_each_tier() {
1616 + const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
1617 + makeover_layout::ThemeChoice::new(
1618 + "goingson",
1619 + "GoingsOn",
1620 + ThemeVariant::Light,
1621 + makeover_layout::Contrast::High,
1622 + ),
1623 + makeover_layout::ThemeChoice::new(
1624 + "carbonfox",
1625 + "Carbonfox",
1626 + ThemeVariant::Dark,
1627 + makeover_layout::Contrast::Standard,
1628 + ),
1629 + ];
1630 + let style = style();
1631 + let field_ = Field::theme("theme", "Theme", THEMES)
1632 + .following(makeover_layout::Choice::new("system", "Follow System"));
1633 + let mut buf = buffer(32, 8);
1634 + field(
1635 + &style,
1636 + &field_,
1637 + Held::Text("carbonfox"),
1638 + false,
1639 + buf.area,
1640 + &mut buf,
1641 + );
1642 +
1643 + let rows = rows(&buf);
1644 + assert_eq!(rows[1], "( ) Follow System");
1645 + assert_eq!(rows[2], "Light");
1646 + assert_eq!(rows[3], "( ) GoingsOn [AA]");
1647 + assert_eq!(rows[4], "Dark");
1648 + assert_eq!(rows[5], "(*) Carbonfox [OK]");
1649 + }
1650 +
1651 + #[test]
1652 + fn a_theme_picker_asks_for_the_rows_it_draws() {
1653 + // Label, follow, two headings, two themes. A height that counted the
1654 + // themes alone would clip the last group off every picker.
1655 + const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
1656 + makeover_layout::ThemeChoice::new(
1657 + "goingson",
1658 + "GoingsOn",
1659 + ThemeVariant::Light,
1660 + makeover_layout::Contrast::High,
1661 + ),
1662 + makeover_layout::ThemeChoice::new(
1663 + "carbonfox",
1664 + "Carbonfox",
1665 + ThemeVariant::Dark,
1666 + makeover_layout::Contrast::Standard,
1667 + ),
1668 + ];
1669 + let style = style();
1670 + let field_ = Field::theme("theme", "Theme", THEMES)
1671 + .following(makeover_layout::Choice::new("system", "Follow System"));
1672 + assert_eq!(field_height(&style, &field_, 32), 6);
1673 +
1674 + // One variant, no follow row: one heading, not three.
1675 + let one = Field::theme("theme", "Theme", &THEMES[..1]);
1676 + assert_eq!(field_height(&style, &one, 32), 3);
1677 + }
1535 1678 }