makeover-layout 0.46.1

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
use crate::Intent;

/// 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.
///
/// # The two corners that belong to both edges
///
/// Top-right and bottom-left are where the lit run meets the shaded one, and
/// the description's claim is that they belong to *both*. How a renderer says
/// that is its own business, because the answer is bounded by resolution and
/// not by taste:
///
/// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner
///   to one tone thickens that edge by a cell and reads as one run overrunning
///   the other. A half-cell glyph divides the cell already, so `makeover-tui`
///   splits it and recovers real information. Its box-drawing fallback cannot:
///   a single stroke has no half to give, so there both corners go to dark.
/// - A pixel bevel is a one-point stroke by default, which makes the corner a
///   one-point square. There is nothing to divide — a diagonal seam across one
///   point is sub-pixel, and antialiasing renders it as the blend a mitred join
///   already produces. So `makeover-immediate` mitres and is *not* diverging;
///   it is the same rule at a resolution where the split degenerates.
///
/// Stated here so the difference reads as a decision rather than as drift. A
/// renderer with room to divide the corner should; one without should mitre or
/// pick the shaded tone, and neither is a bug.
#[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.
///
/// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new
/// member is additive rather than breaking. The vocabulary exists to grow and
/// the renderers exist to disagree about how much of it they answer, so growth
/// must not be a lockstep event. The renderer's wildcard is not a hole:
/// [`Fill`] is resolved through a fallible lookup, and a missing intent is
/// answered with structure rather than with a substituted colour.
///
/// [`Sunken`]: Fill::Sunken
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
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,
    /// A surface set back from the one it sits on, by colour and nothing else.
    ///
    /// Not a well. A well is a hole with an edge, and the two are authored in
    /// opposite directions: `makeover` derives `surface-well` by inverting
    /// against the theme's own content colour, while `surface-sunken` is
    /// authored and free to sit darker than raised (goingson's does). Naming
    /// only the well left the recessed-with-no-edge surface unsayable, which is
    /// what an unchosen tab is: it recedes so the chosen one can come forward,
    /// and it carries no bevel of its own.
    Sunken,
}

// 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",
            Self::Sunken => "surface-sunken",
        }
    }
}

/// 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.
/// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same
/// release: a depth this renderer has no drawing for should cost it a
/// wildcard arm, not a compile error and a wait on someone else's publish.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
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,
    /// Set back from what it sits on, by colour alone. No edge.
    ///
    /// The one member carrying a fill without a bevel, so a renderer cannot
    /// assume the two arrive together. That is deliberate and it is still the
    /// pairing rule: both halves come off the same `Depth`, so they cannot
    /// disagree, and here one half is legitimately absent.
    ///
    /// Distinct from [`Depth::Flat`], which has no fill either and inherits.
    /// Recessed and level-with are different claims, and only one of them
    /// needs a colour.
    Sunken,
    /// A surface sitting *over* the page rather than in it. A modal, a popover,
    /// a menu.
    ///
    /// Takes elevation and no bevel: a surface overlaying the page is lifted
    /// off it, and a surface in the page is cut into it. That is the same
    /// pairing rule the rest of the enum holds, applied to the one case where
    /// the separation is not an edge at all — the lift and the scrim behind it
    /// are already saying where the surface is.
    ///
    /// Every renderer already has the surface: `makeover-tui` carries
    /// `Palette::overlay`, `makeover-immediate` `Palette::elevation`, and
    /// `makeover-webview` emits `--elevation-overlay`. This variant is the
    /// route from a description to any of them, which is why it is one variant
    /// rather than a feature.
    Overlay,
}

impl Depth {
    /// The edge this depth is drawn with, if it has one.
    #[must_use]
    pub const fn bevel(self) -> Option<Bevel> {
        match self {
            // Sunken joins Flat here, for the opposite reason: Flat has no edge
            // because nothing separates it from its surroundings, and Sunken has
            // none because its colour is already doing the separating.
            Self::Flat | Self::Sunken => None,
            // A third reason to have no edge, which is why it gets its own arm
            // rather than joining the two above: an overlay is separated by the
            // lift and by the scrim behind it, so an edge would be a second
            // answer to a question already answered.
            Self::Overlay => 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),
            Self::Sunken => Some(Fill::Sunken),
            Self::Overlay => Some(Fill::Overlay),
        }
    }

    /// Pressing a raised region reads as a well, and nothing else moves.
    ///
    /// [`Depth::Overlay`] is untouched along with the rest: an overlay is a
    /// surface, not a control, so there is nothing there to press.
    #[must_use]
    pub const fn pressed(self) -> Self {
        match self {
            Self::Raised => Self::Well,
            other => other,
        }
    }
}

/// An interaction state a region can be in, beside whatever [`Depth`] it is.
///
/// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`]
/// and a disabled field is still a [`Depth::Well`], so folding either member
/// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for
/// something that is not a depth, and would leave disabled-button and
/// disabled-field sharing one variant that cannot tell them apart.
///
/// # Why hover and pressed are not members
///
/// The line is whether every renderer has the state to express, not whether CSS
/// does. Hover is renderer policy and `makeover-webview` says so in its own
/// header: a terminal and an immediate-mode painter have no pointer hovering
/// over anything, and pressed already arrives through [`Bevel::pressed`] and
/// [`Depth::pressed`], where it belongs, because pressing is a depth inversion
/// rather than a separate condition.
///
/// Focus and disabled are different in kind. A TUI has a focused widget and a
/// greyed-out one; so does egui. Both were unsayable here, so all three webview
/// consumers supplied them from outside the primitive by out-specifying rules
/// they did not own: goingson alone carries 19 of them, and the MNW server
/// another 21. That is the divergence this crate exists to end, arriving one
/// layer down.
///
/// # The principle this encodes
///
/// A primitive owns every state it implies. A renderer that emits a hover rule
/// for a thing owes disabled and the capability answer for that same thing,
/// because anything less exports the completion work to N consumers who will
/// each do it differently.
///
/// Focus is not on that list and is not on this axis. It is the renderer's,
/// decided after the description; see the crate header, "Reach,
/// focus and the focus ring", for the three terms and who owns each.
///
/// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth
/// must not be a lockstep event across the three renderers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum State {
    /// Present, visible, and not answering.
    ///
    /// Not the same as absent, and deliberately not a [`Fill`]: a disabled
    /// control keeps the surface it always had and stops responding, so what
    /// changes is its content and its interactivity rather than what it is.
    Disabled,
}

impl State {
    /// Whether a region in this state stops answering the pointer.
    ///
    /// Stated in the description rather than left to each renderer, on the same
    /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an
    /// immediate-mode renderer resolves it per call site, so leaving it unsaid
    /// means resolving it once per consumer and disagreeing.
    #[must_use]
    pub const fn suppresses_interaction(self) -> bool {
        // A match rather than a bare `true`, so a member added to this
        // `#[non_exhaustive]` axis has to answer the question rather than
        // inheriting an answer.
        match self {
            Self::Disabled => true,
        }
    }
}

impl Intent for State {
    fn token(self) -> &'static str {
        match self {
            // Reusing the muted content intent rather than minting a
            // `disabled` colour. Disabled is a reduction and not a status, and
            // `makeover-webview`'s progress rules already record the reading
            // that `content-muted` is what disabled looks like.
            Self::Disabled => "content-muted",
        }
    }
}