makeover-layout 0.1.0

The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition.
Documentation
//! The renderer-agnostic half of the make-family design system.
//!
//! <!-- wiki: makeover-layout -->
//!
//! `makeover` answers *what colour*, and varies by theme. `makeover-geometry`
//! answers *how much space*, and varies by density and surface. This crate
//! answers *what the thing is*, and varies by nothing.
//!
//! # The deferral rule
//!
//! A description names intents and relationships, never values. Say
//! [`Fill::Raised`], never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is
//! left once colour and spacing are deferred is **composition**: which edges
//! are lit, what inverts on press, what nests in what.
//!
//! The constraint that shapes all of it: a renderer that can only paint
//! rectangles has to be able to express the result. egui has no
//! `box-shadow: inset` and one stroke per widget with no per-side control; a
//! terminal has box-drawing characters and one cell of resolution, and cannot
//! draw a two-tone lit edge at all. A description that assumes per-side edges
//! is a CSS description wearing a neutral name. So this crate names the
//! *intent* — this region is a well — and each renderer chooses an expression
//! it can actually produce, including dropping half of one.
//!
//! # Scope of this first cut
//!
//! Depth only: the bevel and the surfaces it shapes. That much is settled,
//! and settled the hard way — the vocabulary here was read off audiofiles'
//! `ui::theme` and `ui::widgets`, which are the only implementation written
//! by a consumer with no CSS, then checked against both webview apps. All
//! three agreed once Balanced Breakfast's fills were corrected.
//!
//! Deliberately absent, because each is a naming decision rather than a
//! transcription: badge versus chip, toast versus banner, the list row's
//! parts, heading levels, segmented controls, and whether a description names
//! loading state at all. Those are tracked as subtasks of the extraction task
//! and land as they are settled. Guessing at them now is how a description
//! becomes a framework.

#![forbid(unsafe_code)]

/// A colour intent this crate refers to but never resolves.
///
/// The string is the token name `makeover` publishes, so a renderer can look
/// it up without this crate knowing what colour came back.
pub trait Intent {
    /// The `makeover` intent token this resolves against.
    fn token(self) -> &'static str;
}

/// Which way the light falls across a two-tone edge.
///
/// The whole content of a bevel, once colour and thickness are deferred. The
/// light is always assumed to come from the top left: every consumer measured
/// agreed on that and none of them ever varied it, so it is an invariant here
/// rather than a parameter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Bevel {
    /// Lit from the top left: light on top and left, dark on bottom and right.
    Raised,
    /// The same edge inverted, which is also the pressed state of anything
    /// that draws itself [`Bevel::Raised`].
    Inset,
}

impl Bevel {
    /// The edge intents, as `(top_left, bottom_right)`.
    ///
    /// Split out from any painting because the inversion *is* the idea, and
    /// it is the one part every renderer implements identically.
    #[must_use]
    pub const fn edges(self) -> (Edge, Edge) {
        match self {
            Self::Raised => (Edge::Light, Edge::Dark),
            Self::Inset => (Edge::Dark, Edge::Light),
        }
    }

    /// Pressing inverts. A raised control reads as inset while held.
    ///
    /// Stated here rather than left to each consumer because a cascade can
    /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
    /// resolves this per call site, eighteen times.
    #[must_use]
    pub const fn pressed(self) -> Self {
        match self {
            Self::Raised => Self::Inset,
            Self::Inset => Self::Raised,
        }
    }
}

/// One side of a bevel, named by the intent it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Edge {
    /// The lit side.
    Light,
    /// The shadowed side.
    Dark,
}

impl Intent for Edge {
    fn token(self) -> &'static str {
        match self {
            Self::Light => "bevel-light",
            Self::Dark => "bevel-dark",
        }
    }
}

/// A surface intent a region is filled with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Fill {
    /// The page behind everything.
    Page,
    /// A surface lifted off the page: cards, controls, menus, toasts.
    Raised,
    /// A surface floating above the page rather than resting on it.
    Overlay,
    /// The inside of a well.
    Well,
}

// No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
// to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`,
// had something to paint. makeover-tui found that wrong within a day: page is
// the surface a well is usually cut into, so on a terminal that substitution
// produces exactly the invisibility it was meant to prevent, and the right
// answer there is a drawn edge rather than a different colour.
//
// Substituting one intent for another is renderer policy. The description says
// what the region is and stops.

impl Intent for Fill {
    fn token(self) -> &'static str {
        match self {
            Self::Page => "surface-page",
            Self::Raised => "surface-raised",
            Self::Overlay => "surface-overlay",
            Self::Well => "surface-well",
        }
    }
}

/// How a region sits relative to the surface behind it.
///
/// Fill and bevel are named together because naming them apart is what let
/// them disagree. Every consumer measured had at least one region carrying a
/// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
/// and recorded the bug in its doc comment, and Balanced Breakfast still had
/// twelve of them a year later. A single name for the pair makes that
/// unrepresentable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Depth {
    /// Level with its surroundings. No edge.
    Flat,
    /// A card laid on the panel it sits in.
    Raised,
    /// A hole in the panel, with content down inside it. For anything the
    /// user looks *into*: a table body, a tag tree, a text field.
    Well,
}

impl Depth {
    /// The edge this depth is drawn with, if it has one.
    #[must_use]
    pub const fn bevel(self) -> Option<Bevel> {
        match self {
            Self::Flat => None,
            Self::Raised => Some(Bevel::Raised),
            Self::Well => Some(Bevel::Inset),
        }
    }

    /// The surface this depth is filled with.
    ///
    /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
    /// which is the difference between level-with and painted-the-same-colour.
    #[must_use]
    pub const fn fill(self) -> Option<Fill> {
        match self {
            Self::Flat => None,
            Self::Raised => Some(Fill::Raised),
            Self::Well => Some(Fill::Well),
        }
    }

    /// Pressing a raised region reads as a well, and nothing else moves.
    #[must_use]
    pub const fn pressed(self) -> Self {
        match self {
            Self::Raised => Self::Well,
            other => other,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inset_is_raised_with_the_light_moved() {
        let (rl, rd) = Bevel::Raised.edges();
        let (il, id) = Bevel::Inset.edges();
        assert_eq!((rl, rd), (Edge::Light, Edge::Dark));
        assert_eq!((il, id), (rd, rl));
    }

    #[test]
    fn pressing_twice_is_a_no_op() {
        for b in [Bevel::Raised, Bevel::Inset] {
            assert_eq!(b.pressed().pressed(), b);
        }
    }

    #[test]
    fn a_raised_region_is_never_filled_with_a_recessed_surface() {
        // The bug this vocabulary exists to make unrepresentable.
        assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
        assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised));
        assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset));
        assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
    }

    #[test]
    fn flat_has_neither_edge_nor_fill() {
        assert_eq!(Depth::Flat.bevel(), None);
        assert_eq!(Depth::Flat.fill(), None);
    }

    #[test]
    fn pressing_a_card_makes_a_well() {
        assert_eq!(Depth::Raised.pressed(), Depth::Well);
        assert_eq!(
            Depth::Raised.pressed().bevel(),
            Depth::Raised.bevel().map(Bevel::pressed)
        );
        // Only raised regions respond to being pressed.
        assert_eq!(Depth::Flat.pressed(), Depth::Flat);
        assert_eq!(Depth::Well.pressed(), Depth::Well);
    }

    #[test]
    fn intents_name_makeover_tokens_and_nothing_else() {
        assert_eq!(Edge::Light.token(), "bevel-light");
        assert_eq!(Edge::Dark.token(), "bevel-dark");
        assert_eq!(Fill::Raised.token(), "surface-raised");
        assert_eq!(Fill::Well.token(), "surface-well");
        // No value ever leaves this crate.
        for t in [Edge::Light.token(), Edge::Dark.token()] {
            assert!(!t.starts_with('#'), "{t} looks like a value");
        }
    }
}