//! 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 //! [`Theme::bevel_light`] and its bottom and right edges [`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. //! //! use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::symbols::border; use ratatui::widgets::{Block, BorderType, Borders, Widget}; use crate::theme::Theme; /// Which way a surface is lit, which is to say what it is. /// /// Per 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 tones for the lit-side and shaded-side passes. /// /// 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. fn edges(self, theme: &Theme) -> Option<(Color, Color)> { match self { Elevation::Flush => None, Elevation::Raised => Some((theme.bevel_light, theme.bevel_dark)), Elevation::Sunken => Some((theme.bevel_dark, theme.bevel_light)), } } } /// 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, } impl<'a> Bevel<'a> { pub fn new(theme: &'a Theme, elevation: Elevation) -> Self { Self { theme, elevation } } } impl Widget for Bevel<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let Some((lit, shaded)) = self.elevation.edges(self.theme) else { return; }; // Under two cells in either direction the two edges would land in one // cell and the light source would be a guess. Draw nothing rather than // something misleading. if area.width < 2 || area.height < 2 { return; } // `Replace` so an edge never merges with a neighbouring box-drawing // character. The default strategy would try to combine them into a // junction glyph, which for a half-block is a shape from a different // alphabet. let side = |borders: Borders, color: Color| { Block::new() .borders(borders) .border_type(BorderType::QuadrantOutside) .border_style(Style::default().fg(color)) .merge_borders(ratatui::symbols::merge::MergeStrategy::Replace) }; // Pass one draws the lit sides and, because it owns both of them, the // corner between them. Pass two does the same for the shaded sides. side(Borders::TOP | Borders::LEFT, lit).render(area, buf); side(Borders::BOTTOM | Borders::RIGHT, shaded).render(area, buf); // The remaining two corners are where light meets shadow, and no single // side owns them: each was painted by whichever pass ran last over it. // Repaint them as half-and-half, so the transition reads as a corner // rather than as one edge overrunning the other. let right = area.x + area.width - 1; let bottom = area.y + area.height - 1; buf[(right, area.y)] .set_symbol(border::QUADRANT_TOP_HALF) .set_fg(lit) .set_bg(shaded); buf[(area.x, bottom)] .set_symbol(border::QUADRANT_BOTTOM_HALF) .set_fg(shaded) .set_bg(lit); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; fn theme() -> Theme { crate::theme::Theme { mode: crate::theme::Mode::Light, surface_page: Color::Rgb(0, 0, 0), surface_raised: Color::Rgb(1, 1, 1), surface_sunken: Color::Rgb(2, 2, 2), surface_overlay: Color::Rgb(3, 3, 3), content_primary: Color::Rgb(4, 4, 4), content_secondary: Color::Rgb(5, 5, 5), content_muted: Color::Rgb(6, 6, 6), action_primary: Color::Rgb(7, 7, 7), status_danger: Color::Rgb(8, 8, 8), status_success: Color::Rgb(9, 9, 9), status_warning: Color::Rgb(10, 10, 10), status_info: Color::Rgb(11, 11, 11), line_border: Color::Rgb(12, 12, 12), border_subtle: Color::Rgb(13, 13, 13), border_strong: Color::Rgb(14, 14, 14), bevel_light: Color::Rgb(16, 16, 16), bevel_dark: Color::Rgb(17, 17, 17), category: [Color::Rgb(15, 15, 15); 6], } } 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).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.bevel_light); assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light); assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light); assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark); assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark); assert_eq!(buf[(3u16, 1u16)].fg, t.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.bevel_light => t.bevel_dark, c if c == t.bevel_dark => t.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.bevel_light); assert_eq!(top_right.bg, t.bevel_dark); let bottom_left = &buf[(0u16, 2u16)]; assert_eq!(bottom_left.fg, t.bevel_dark); assert_eq!(bottom_left.bg, t.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}"); } } }