//! Two-tone bevels: the light model that says a thing can be manipulated. //! //! A raised control is lit from the top left, so its top and left edges carry //! [`makeover_tui::Theme::bevel_light`] and its bottom and right edges [`makeover_tui::Theme::bevel_dark`]. //! Swapping the pair recesses it, which is what a pressed button and a text well //! are. One rule, applied without exception, so that a reader who learns it on a //! button already knows what a scrollbar trough is telling them. //! //! The light source does not flip with the theme's polarity. A dark theme is lit //! from the same corner as a light one, because a bevel that reverses between //! modes stops being a rule that transfers and becomes a per-theme detail to //! memorize. //! //! # Why two passes //! //! ratatui's `Block` holds one `border_style` for every side, so a bevel cannot //! be expressed as a single block: the geometry is available (a border `Set` //! addresses all eight sides independently, and `QuadrantOutside` is already the //! half-cell outline this wants) but the two tones are not. So the edges are //! drawn as two blocks into one `Rect`, one owning the lit sides and one the //! shaded, and the corners where they meet are painted afterwards. //! //! Half-blocks rather than box-drawing because the aspect ratio works out: a //! cell is roughly twice as tall as it is wide, so a half-block along the top and //! a half-cell column down the side are about the same number of pixels, and the //! bevel reads as even thickness rather than as a heavy top. //! //! # Where the painting lives //! //! Not here. `makeover-tui` is the family's terminal renderer for the same light //! model, and it carries the fidelity measurements (across 31 themes, a bevel //! loses an edge into its face on all of them at sixteen colours) and a glyph //! fallback for that case. It also renders //! [`makeover_tui::makeover_layout`]'s description, which is what lets a control //! light the same way in a terminal and in an egui window. //! //! What stays here is a `Widget` that speaks [`Theme`] rather than a palette, so //! an Alloy caller does not assemble one per frame. //! //! use makeover_tui::makeover_layout::Bevel as BevelKind; use makeover_tui::{Fidelity, paint_bevel}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; use crate::theme::{Theme, fidelity}; /// Which way a surface is lit, which is to say what it is. /// /// Per the Alloy repo's docs/DESIGN-LANGUAGE.md the presence of an edge is /// itself the affordance: a bordered thing is a control and a flush thing is /// data. [`Elevation`] makes that a physical claim rather than a convention, /// and adds the two states a flat border could not express. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Elevation { /// Flush on its surface. Data panels and tabular regions, which carry the /// color and so must not compete with chrome for it. Flush, /// Lit from the top left. Buttons, tabs, chips, the frame of a pane. Raised, /// Lit from the bottom right, the inversion of [`Raised`](Elevation::Raised). /// Text fields, list wells, progress troughs, scrollbar tracks. Sunken, } impl Elevation { /// The description's name for this elevation, or `None` where there is no /// edge to draw. /// /// Pressed is not a variant of its own: a pressed control is a raised one /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this /// idiom is cheap. One swap gives every interactive widget a correct pressed /// state, with no per-widget special case. /// /// # Sunken is `Inset`, not `Depth::Sunken` /// /// The names collide across the two crates and mean opposite things. /// [`Elevation::Sunken`] here is an *inverted bevel* — a text field, a list /// well, a scrollbar trough — which `makeover-layout` calls /// [`Bevel::Inset`](makeover_tui::makeover_layout::Bevel::Inset) and reaches /// through `Depth::Well`. Its own `Depth::Sunken` is a surface set back by /// colour alone with explicitly no edge, which is a different claim and not /// this one. Mapping by name would silently strip the bevel off every text /// field in the console and leave a flat tint. const fn kind(self) -> Option { match self { Elevation::Flush => None, Elevation::Raised => Some(BevelKind::Raised), Elevation::Sunken => Some(BevelKind::Inset), } } } /// A bevel drawn around `area`. /// /// Draws only the edge. The caller fills the interior, which keeps this /// composable with whatever widget is inside and means a bevel can be laid over /// content that is already rendered. pub struct Bevel<'a> { theme: &'a Theme, elevation: Elevation, fidelity: Option, } impl<'a> Bevel<'a> { pub fn new(theme: &'a Theme, elevation: Elevation) -> Self { Self { theme, elevation, fidelity: None, } } /// Draw for a terminal of a stated colour depth instead of the detected one. /// /// Detection is right for an application, which is why it is the default. /// This exists for the two cases it cannot serve: a caller that already /// quantised its palette and knows the answer, and a test, which must not /// render differently on the machine that runs it. #[must_use] pub fn fidelity(mut self, fidelity: Fidelity) -> Self { self.fidelity = Some(fidelity); self } } impl Widget for Bevel<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let Some(kind) = self.elevation.kind() else { return; }; // Under two cells in either direction the two edges would land in one // cell and the light source would be a guess. `paint_bevel` declines // the same case; checking here too keeps this readable as the rule it // is rather than as a fact about somebody else's function. if area.width < 2 || area.height < 2 { return; } let palette = self.theme.palette(self.fidelity.unwrap_or_else(fidelity)); paint_bevel(buf, area, kind, &palette); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; fn theme() -> Theme { crate::theme::test_theme(crate::theme::Mode::Light) } // Fidelity is pinned rather than detected: these assert on glyphs, and at // Ansi16 the renderer correctly draws a different set. A test that passed // or failed on the `TERM` of whoever ran it would be measuring the machine. fn render(elevation: Elevation, w: u16, h: u16) -> Buffer { let area = Rect::new(0, 0, w, h); let mut buf = Buffer::empty(area); Bevel::new(&theme(), elevation) .fidelity(Fidelity::TrueColor) .render(area, &mut buf); buf } fn glyphs(buf: &Buffer, area: Rect) -> Vec { (area.y..area.bottom()) .map(|y| { (area.x..area.right()) .map(|x| buf[(x, y)].symbol()) .collect() }) .collect() } #[test] fn a_raised_bevel_draws_the_outline_and_leaves_the_middle_alone() { let buf = render(Elevation::Raised, 5, 4); assert_eq!( glyphs(&buf, Rect::new(0, 0, 5, 4)), vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"], ); } // The lit corner is top left and the shaded one bottom right, on a light // theme and on a dark one alike. #[test] fn raised_is_lit_from_the_top_left() { let buf = render(Elevation::Raised, 4, 3); let t = theme(); assert_eq!(buf[(0u16, 0u16)].fg, t.makeover.bevel_light); assert_eq!(buf[(1u16, 0u16)].fg, t.makeover.bevel_light); assert_eq!(buf[(0u16, 1u16)].fg, t.makeover.bevel_light); assert_eq!(buf[(3u16, 2u16)].fg, t.makeover.bevel_dark); assert_eq!(buf[(2u16, 2u16)].fg, t.makeover.bevel_dark); assert_eq!(buf[(3u16, 1u16)].fg, t.makeover.bevel_dark); } // Sunken is the same drawing with the two tones exchanged. Asserted against // raised rather than against literals, because the property that matters is // that they are inverses: that is what makes a pressed state one swap. #[test] fn sunken_is_raised_with_the_tones_exchanged() { let (raised, sunken) = ( render(Elevation::Raised, 4, 3), render(Elevation::Sunken, 4, 3), ); let area = Rect::new(0, 0, 4, 3); assert_eq!(glyphs(&raised, area), glyphs(&sunken, area)); let t = theme(); let swap = |c: Color| match c { c if c == t.makeover.bevel_light => t.makeover.bevel_dark, c if c == t.makeover.bevel_dark => t.makeover.bevel_light, other => other, }; for y in area.y..area.bottom() { for x in area.x..area.right() { assert_eq!(swap(raised[(x, y)].fg), sunken[(x, y)].fg, "fg at {x},{y}"); assert_eq!(swap(raised[(x, y)].bg), sunken[(x, y)].bg, "bg at {x},{y}"); } } } // Where light meets shadow, both tones share the cell. #[test] fn the_transition_corners_carry_both_tones() { let buf = render(Elevation::Raised, 4, 3); let t = theme(); let top_right = &buf[(3u16, 0u16)]; assert_eq!(top_right.fg, t.makeover.bevel_light); assert_eq!(top_right.bg, t.makeover.bevel_dark); let bottom_left = &buf[(0u16, 2u16)]; assert_eq!(bottom_left.fg, t.makeover.bevel_dark); assert_eq!(bottom_left.bg, t.makeover.bevel_light); } #[test] fn flush_draws_nothing() { let buf = render(Elevation::Flush, 4, 3); assert_eq!( glyphs(&buf, Rect::new(0, 0, 4, 3)), vec![" ", " ", " "] ); } // A one-cell-tall or one-cell-wide area cannot hold two opposing edges, so // the light source would have to be guessed. It draws nothing instead. #[test] fn an_area_too_small_to_have_two_sides_is_left_alone() { for (w, h) in [(1, 4), (4, 1), (1, 1)] { let buf = render(Elevation::Raised, w, h); let area = Rect::new(0, 0, w, h); let blank: Vec = (0..h).map(|_| " ".repeat(w as usize)).collect(); assert_eq!(glyphs(&buf, area), blank, "{w}x{h}"); } } }