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 { 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 { 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", } } }