max / alloy_tui
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+674 insertions,
-31 deletions
| @@ -3,7 +3,8 @@ | |||
| 3 | 3 | version = "2.0.0" | |
| 4 | 4 | description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings." | |
| 5 | 5 | edition = "2024" | |
| 6 | - | rust-version = "1.86" | |
| 6 | + | # 1.88 is ratatui 0.30.2's floor, and 0.30.2 is where `Block::shadow` lands. | |
| 7 | + | rust-version = "1.88" | |
| 7 | 8 | license = "MIT" | |
| 8 | 9 | repository = "https://makenot.work/git/max/alloy_tui" | |
| 9 | 10 | authors = ["Max Johnson <me@maxj.phd>"] | |
| @@ -12,6 +13,12 @@ | |||
| 12 | 13 | ratatui = "0.30" | |
| 13 | 14 | makeover = "2.1.0" | |
| 14 | 15 | ||
| 16 | + | # TEMPORARY. The bevel intents and the 256-color palette are in makeover's tree | |
| 17 | + | # and not yet on crates.io. Remove this the moment makeover publishes them, and | |
| 18 | + | # raise the requirement above to that release. | |
| 19 | + | [patch.crates-io] | |
| 20 | + | makeover = { path = "../makeover" } | |
| 21 | + | ||
| 15 | 22 | [lints.rust] | |
| 16 | 23 | unused = "warn" | |
| 17 | 24 | unreachable_pub = "warn" |
| @@ -120,6 +120,8 @@ | |||
| 120 | 120 | line_border: Color::Rgb(12, 12, 12), | |
| 121 | 121 | border_subtle: Color::Rgb(13, 13, 13), | |
| 122 | 122 | border_strong: Color::Rgb(14, 14, 14), | |
| 123 | + | bevel_light: Color::Rgb(16, 16, 16), | |
| 124 | + | bevel_dark: Color::Rgb(17, 17, 17), | |
| 123 | 125 | category: [Color::Rgb(15, 15, 15); 6], | |
| 124 | 126 | } | |
| 125 | 127 | } |
| @@ -16,6 +16,7 @@ | |||
| 16 | 16 | //! | |
| 17 | 17 | //! <!-- wiki: alloy-console --> | |
| 18 | 18 | ||
| 19 | + | pub mod bevel; | |
| 19 | 20 | pub mod connector; | |
| 20 | 21 | pub mod cursor; | |
| 21 | 22 | pub mod focus; | |
| @@ -27,6 +28,7 @@ | |||
| 27 | 28 | pub mod theme; | |
| 28 | 29 | pub mod widgets; | |
| 29 | 30 | ||
| 31 | + | pub use bevel::{Bevel, Elevation}; | |
| 30 | 32 | pub use connector::AlloyConnector; | |
| 31 | 33 | pub use cursor::Cursor; | |
| 32 | 34 | pub use focus::FocusRing; |
| @@ -49,6 +49,17 @@ | |||
| 49 | 49 | pub border_subtle: Color, | |
| 50 | 50 | pub border_strong: Color, | |
| 51 | 51 | ||
| 52 | + | /// The lit and shadowed edges of a raised surface, from makeover. | |
| 53 | + | /// | |
| 54 | + | /// A control is lit from the top left, so its top and left edges take | |
| 55 | + | /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the | |
| 56 | + | /// two recesses it, which is what a pressed state and a text well are. The | |
| 57 | + | /// light source does not flip with the theme's polarity — a dark theme is lit | |
| 58 | + | /// from the same corner, or the rule stops transferring between widgets, | |
| 59 | + | /// which is the whole reason to have one. | |
| 60 | + | pub bevel_light: Color, | |
| 61 | + | pub bevel_dark: Color, | |
| 62 | + | ||
| 52 | 63 | pub category: [Color; 6], | |
| 53 | 64 | } | |
| 54 | 65 | ||
| @@ -84,10 +95,30 @@ | |||
| 84 | 95 | }) | |
| 85 | 96 | }; | |
| 86 | 97 | ||
| 98 | + | // The bevel pair is makeover's, so that a console, a webview and an egui | |
| 99 | + | // app light a raised surface the same way. Read through `resolve` rather | |
| 100 | + | // than recomputed here, which is the point of it living in the crate. | |
| 101 | + | let resolved = makeover::resolve(theme); | |
| 102 | + | let intent = |key: &'static str| -> Result<Rgb, ThemeError> { | |
| 103 | + | let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?; | |
| 104 | + | Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { | |
| 105 | + | key, | |
| 106 | + | value: hex.to_string(), | |
| 107 | + | }) | |
| 108 | + | }; | |
| 109 | + | ||
| 87 | 110 | let surface_page = get("surface.page")?; | |
| 88 | 111 | let content_primary = get("content.primary")?; | |
| 89 | 112 | let line_border = get("line.border")?; | |
| 90 | 113 | ||
| 114 | + | // These two stay local, and deliberately, though makeover also emits a | |
| 115 | + | // `border-strong`. Its version is a fixed 5% darkening of the authored | |
| 116 | + | // border, which is a slightly firmer divider; this one is pulled most of | |
| 117 | + | // the way to the text color because Alloy spends it on the focus ring, | |
| 118 | + | // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md | |
| 119 | + | // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at | |
| 120 | + | // 3.27:1 and 1.63:1, so they are different tokens wearing one name and | |
| 121 | + | // adopting the shared one would take focus to half the required floor. | |
| 91 | 122 | let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60); | |
| 92 | 123 | let border_strong = mix_linear_srgb(line_border, content_primary, 0.65); | |
| 93 | 124 | ||
| @@ -120,6 +151,9 @@ | |||
| 120 | 151 | border_subtle: rgb(border_subtle), | |
| 121 | 152 | border_strong: rgb(border_strong), | |
| 122 | 153 | ||
| 154 | + | bevel_light: rgb(intent("bevel-light")?), | |
| 155 | + | bevel_dark: rgb(intent("bevel-dark")?), | |
| 156 | + | ||
| 123 | 157 | category: [ | |
| 124 | 158 | rgb(get("category.one")?), | |
| 125 | 159 | rgb(get("category.two")?), | |
| @@ -141,88 +175,143 @@ | |||
| 141 | 175 | pub enum ColorDepth { | |
| 142 | 176 | /// 24-bit. Theme colors are sent as authored. | |
| 143 | 177 | Full, | |
| 178 | + | /// The xterm 256-color table, addressed by index. | |
| 179 | + | /// | |
| 180 | + | /// Enough to keep a two-tone bevel: both Akari themes put the two edges and | |
| 181 | + | /// the face they surround on three separate entries here, where sixteen | |
| 182 | + | /// colors has nothing between a face and its neighbour and one edge lands | |
| 183 | + | /// back on the face. | |
| 184 | + | Ansi256, | |
| 144 | 185 | /// The sixteen ANSI colors, addressed by index. | |
| 145 | 186 | Ansi16, | |
| 146 | 187 | } | |
| 147 | 188 | ||
| 189 | + | impl ColorDepth { | |
| 190 | + | /// The palette to quantize into, and what to add to an index in it to get | |
| 191 | + | /// the number the terminal wants. | |
| 192 | + | /// | |
| 193 | + | /// 256 resolves to makeover's fixed region rather than the whole table: the | |
| 194 | + | /// low sixteen are repaintable in every emulator, so a match landing there | |
| 195 | + | /// is a match against a color the user may have moved out from under it. | |
| 196 | + | fn palette(self) -> Option<(&'static [makeover::Rgb], usize)> { | |
| 197 | + | match self { | |
| 198 | + | ColorDepth::Full => None, | |
| 199 | + | ColorDepth::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)), | |
| 200 | + | ColorDepth::Ansi16 => Some((&makeover::ANSI_16, 0)), | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 148 | 205 | /// What the environment says the terminal can show. | |
| 149 | 206 | /// | |
| 150 | 207 | /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color, | |
| 151 | 208 | /// and `TERM=linux` is the case this exists for: the Linux virtual console, | |
| 152 | - | /// which is what an installer and a machine with no desktop draw on. Anything | |
| 153 | - | /// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing | |
| 154 | - | /// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity; | |
| 155 | - | /// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color | |
| 209 | + | /// which is what an installer and a machine with no desktop draw on. | |
| 210 | + | /// | |
| 211 | + | /// A `TERM` ending in `-256color` and no `COLORTERM` is the terminal saying what | |
| 212 | + | /// it has. Taking it at its word beats the old behavior of calling it | |
| 213 | + | /// [`Full`](ColorDepth::Full) and sending 24-bit for it to approximate, because | |
| 214 | + | /// its approximation is per-color and collapses tones the theme keeps apart, | |
| 215 | + | /// which is the same failure that cost the console its frame on the VT. | |
| 216 | + | /// | |
| 217 | + | /// Everything else is assumed to manage 24-bit, which is the safer wrong answer: | |
| 218 | + | /// guessing [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity, | |
| 219 | + | /// and guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color | |
| 156 | 220 | /// the user paid for. | |
| 157 | 221 | pub fn detect_color_depth() -> ColorDepth { | |
| 158 | - | let colorterm = std::env::var("COLORTERM").unwrap_or_default(); | |
| 222 | + | depth_from_env( | |
| 223 | + | &std::env::var("COLORTERM").unwrap_or_default(), | |
| 224 | + | &std::env::var("TERM").unwrap_or_default(), | |
| 225 | + | ) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// [`detect_color_depth`] with the environment passed in, so the decision can be | |
| 229 | + | /// tested without mutating a process-wide variable from a parallel test. | |
| 230 | + | fn depth_from_env(colorterm: &str, term: &str) -> ColorDepth { | |
| 159 | 231 | if colorterm == "truecolor" || colorterm == "24bit" { | |
| 160 | 232 | return ColorDepth::Full; | |
| 161 | 233 | } | |
| 162 | - | match std::env::var("TERM").unwrap_or_default().as_str() { | |
| 234 | + | match term { | |
| 163 | 235 | "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16, | |
| 236 | + | _ if term.ends_with("-256color") => ColorDepth::Ansi256, | |
| 164 | 237 | _ => ColorDepth::Full, | |
| 165 | 238 | } | |
| 166 | 239 | } | |
| 167 | 240 | ||
| 168 | 241 | /// The palette entry for `c`, as an index the terminal will not reinterpret. | |
| 169 | - | fn indexed(c: Color) -> Color { | |
| 242 | + | fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color { | |
| 170 | 243 | match c { | |
| 171 | 244 | Color::Rgb(r, g, b) => { | |
| 172 | - | Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8) | |
| 245 | + | Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8) | |
| 173 | 246 | } | |
| 174 | 247 | other => other, | |
| 175 | 248 | } | |
| 176 | 249 | } | |
| 177 | 250 | ||
| 178 | 251 | /// As [`indexed`], but guaranteed to stay legible against `on`. | |
| 179 | - | fn indexed_against(c: Color, on: Color) -> Color { | |
| 252 | + | /// | |
| 253 | + | /// Only for a color whose job is to be told apart from a known background. It | |
| 254 | + | /// answers "nearest entry that still contrasts with `on`" and has no notion of | |
| 255 | + | /// which side of `on` the answer should fall, so a pair of colors that must also | |
| 256 | + | /// stay apart from *each other* is the one thing it must not be used for: both | |
| 257 | + | /// are pushed onto the same contrasting entry. That is why the bevel edges go | |
| 258 | + | /// through [`indexed`]. | |
| 259 | + | fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color { | |
| 180 | 260 | match (c, on) { | |
| 181 | - | (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => { | |
| 182 | - | Color::Indexed(makeover::quantize_against( | |
| 261 | + | (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed( | |
| 262 | + | (makeover::quantize_against( | |
| 183 | 263 | Rgb { r, g, b }, | |
| 184 | 264 | Rgb { | |
| 185 | 265 | r: br, | |
| 186 | 266 | g: bg, | |
| 187 | 267 | b: bb, | |
| 188 | 268 | }, | |
| 189 | - | &makeover::ANSI_16, | |
| 190 | - | ) as u8) | |
| 191 | - | } | |
| 192 | - | _ => indexed(c), | |
| 269 | + | palette, | |
| 270 | + | ) + offset) as u8, | |
| 271 | + | ), | |
| 272 | + | _ => indexed(c, palette, offset), | |
| 193 | 273 | } | |
| 194 | 274 | } | |
| 195 | 275 | ||
| 196 | 276 | impl Theme { | |
| 197 | 277 | /// This theme as the terminal can actually draw it. | |
| 198 | 278 | /// | |
| 199 | - | /// At [`ColorDepth::Full`] the theme is returned untouched. At | |
| 200 | - | /// [`ColorDepth::Ansi16`] every color becomes a palette index, which is the | |
| 201 | - | /// point: left as 24-bit, the terminal approximates them itself, and its | |
| 202 | - | /// approximation collapses tones that the theme keeps apart. Alloy's | |
| 203 | - | /// console lost its frame that way, drawing a border in a color the Linux | |
| 204 | - | /// console could not distinguish from the page behind it. | |
| 279 | + | /// At [`ColorDepth::Full`] the theme is returned untouched. Otherwise every | |
| 280 | + | /// color becomes a palette index, which is the point: left as 24-bit, the | |
| 281 | + | /// terminal approximates them itself, and its approximation collapses tones | |
| 282 | + | /// that the theme keeps apart. Alloy's console lost its frame that way, | |
| 283 | + | /// drawing a border in a color the Linux console could not distinguish from | |
| 284 | + | /// the page behind it. | |
| 205 | 285 | /// | |
| 206 | 286 | /// Anything that has to be seen against the page is quantized against it | |
| 207 | 287 | /// rather than on its own, so a border stays a border and text stays | |
| 208 | 288 | /// readable. The surfaces themselves are quantized plainly: they are what | |
| 209 | 289 | /// the others are measured against. | |
| 290 | + | /// | |
| 291 | + | /// The bevel edges are quantized plainly too, for a different reason. They | |
| 292 | + | /// are measured against the raised surface they surround rather than against | |
| 293 | + | /// the page, and running them through [`indexed_against`] would push both | |
| 294 | + | /// onto the same entry and invert the bevel on one side. At | |
| 295 | + | /// [`ColorDepth::Ansi16`] the palette cannot hold the pair at all and one | |
| 296 | + | /// edge lands back on its face, which is a property of sixteen colors rather | |
| 297 | + | /// than something this can fix: a caller drawing there should spend the edge | |
| 298 | + | /// that survives on a single-tone shadow. | |
| 210 | 299 | #[must_use] | |
| 211 | 300 | pub fn for_terminal(self, depth: ColorDepth) -> Theme { | |
| 212 | - | if depth == ColorDepth::Full { | |
| 301 | + | let Some((palette, offset)) = depth.palette() else { | |
| 213 | 302 | return self; | |
| 214 | - | } | |
| 303 | + | }; | |
| 215 | 304 | ||
| 216 | - | let page = indexed(self.surface_page); | |
| 217 | - | let on_page = |c: Color| indexed_against(c, self.surface_page); | |
| 305 | + | let plain = |c: Color| indexed(c, palette, offset); | |
| 306 | + | let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset); | |
| 218 | 307 | ||
| 219 | 308 | Theme { | |
| 220 | 309 | mode: self.mode, | |
| 221 | 310 | ||
| 222 | - | surface_page: page, | |
| 223 | - | surface_raised: indexed(self.surface_raised), | |
| 224 | - | surface_sunken: indexed(self.surface_sunken), | |
| 225 | - | surface_overlay: indexed(self.surface_overlay), | |
| 311 | + | surface_page: plain(self.surface_page), | |
| 312 | + | surface_raised: plain(self.surface_raised), | |
| 313 | + | surface_sunken: plain(self.surface_sunken), | |
| 314 | + | surface_overlay: plain(self.surface_overlay), | |
| 226 | 315 | ||
| 227 | 316 | content_primary: on_page(self.content_primary), | |
| 228 | 317 | content_secondary: on_page(self.content_secondary), | |
| @@ -239,6 +328,9 @@ | |||
| 239 | 328 | border_subtle: on_page(self.border_subtle), | |
| 240 | 329 | border_strong: on_page(self.border_strong), | |
| 241 | 330 | ||
| 331 | + | bevel_light: plain(self.bevel_light), | |
| 332 | + | bevel_dark: plain(self.bevel_dark), | |
| 333 | + | ||
| 242 | 334 | category: self.category.map(on_page), | |
| 243 | 335 | } | |
| 244 | 336 | } | |
| @@ -337,6 +429,11 @@ | |||
| 337 | 429 | line_border: Color::Rgb(0xca, 0xbe, 0xae), | |
| 338 | 430 | border_subtle: Color::Rgb(0xda, 0xd2, 0xc7), | |
| 339 | 431 | border_strong: Color::Rgb(0x7f, 0x78, 0x6d), | |
| 432 | + | // As makeover derives them from Akari Dawn's real raised surface, | |
| 433 | + | // #ede7de, which is a step above the page this fixture flattens | |
| 434 | + | // every surface onto. | |
| 435 | + | bevel_light: Color::Rgb(0xff, 0xfe, 0xf5), | |
| 436 | + | bevel_dark: Color::Rgb(0xb3, 0xad, 0xa5), | |
| 340 | 437 | category: [Color::Rgb(0x8a, 0x45, 0x30); 6], | |
| 341 | 438 | } | |
| 342 | 439 | } | |
| @@ -363,6 +460,62 @@ | |||
| 363 | 460 | } | |
| 364 | 461 | } | |
| 365 | 462 | ||
| 463 | + | // 256 colors is the shallowest depth that can hold a bevel: the two edges | |
| 464 | + | // and the face they surround have to reach three separate entries. | |
| 465 | + | #[test] | |
| 466 | + | fn a_256_color_terminal_keeps_both_bevel_edges() { | |
| 467 | + | let theme = akari_dawn().for_terminal(ColorDepth::Ansi256); | |
| 468 | + | assert_ne!(theme.bevel_light, theme.surface_raised); | |
| 469 | + | assert_ne!(theme.bevel_dark, theme.surface_raised); | |
| 470 | + | assert_ne!(theme.bevel_light, theme.bevel_dark); | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // And sixteen cannot. Asserted rather than left implicit so that a caller | |
| 474 | + | // reading this knows to spend the surviving edge on a single-tone shadow | |
| 475 | + | // instead of drawing a bevel that resolves on two sides. | |
| 476 | + | #[test] | |
| 477 | + | fn a_sixteen_color_terminal_loses_one_bevel_edge() { | |
| 478 | + | let theme = akari_dawn().for_terminal(ColorDepth::Ansi16); | |
| 479 | + | let light_survives = theme.bevel_light != theme.surface_raised; | |
| 480 | + | let dark_survives = theme.bevel_dark != theme.surface_raised; | |
| 481 | + | assert!( | |
| 482 | + | light_survives != dark_survives, | |
| 483 | + | "expected exactly one edge to survive, light {light_survives} dark {dark_survives}" | |
| 484 | + | ); | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // The indices handed to a 256-color terminal have to be the ones it paints, | |
| 488 | + | // and quantizing against the fixed region returns an index into that region. | |
| 489 | + | // Forgetting the offset would silently address the repaintable low sixteen. | |
| 490 | + | #[test] | |
| 491 | + | fn the_256_indices_land_outside_the_repaintable_low_sixteen() { | |
| 492 | + | let theme = akari_dawn().for_terminal(ColorDepth::Ansi256); | |
| 493 | + | for color in [ | |
| 494 | + | theme.surface_page, | |
| 495 | + | theme.content_primary, | |
| 496 | + | theme.border_strong, | |
| 497 | + | theme.bevel_light, | |
| 498 | + | theme.bevel_dark, | |
| 499 | + | ] { | |
| 500 | + | let Color::Indexed(i) = color else { | |
| 501 | + | panic!("{color:?} is not an index") | |
| 502 | + | }; | |
| 503 | + | assert!(i >= 16, "index {i} is in the repaintable range"); | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | #[test] | |
| 508 | + | fn a_256_color_term_is_detected_from_its_name() { | |
| 509 | + | let depth = depth_from_env; | |
| 510 | + | assert_eq!(depth("", "xterm-256color"), ColorDepth::Ansi256); | |
| 511 | + | assert_eq!(depth("", "screen-256color"), ColorDepth::Ansi256); | |
| 512 | + | // A terminal claiming 24-bit is believed over its name. | |
| 513 | + | assert_eq!(depth("truecolor", "xterm-256color"), ColorDepth::Full); | |
| 514 | + | // The VT is still the VT. | |
| 515 | + | assert_eq!(depth("", "linux"), ColorDepth::Ansi16); | |
| 516 | + | assert_eq!(depth("", "foot"), ColorDepth::Full); | |
| 517 | + | } | |
| 518 | + | ||
| 366 | 519 | // The bug, as a test: the installer's frame drew in border_strong on | |
| 367 | 520 | // surface_page and could not be seen. | |
| 368 | 521 | #[test] |
| @@ -12,8 +12,9 @@ | |||
| 12 | 12 | use ratatui::layout::Rect; | |
| 13 | 13 | use ratatui::style::{Color, Style}; | |
| 14 | 14 | use ratatui::text::{Line, Span}; | |
| 15 | - | use ratatui::widgets::{Block, Borders, Paragraph, Widget}; | |
| 15 | + | use ratatui::widgets::{Block, Borders, Paragraph, Shadow, Widget}; | |
| 16 | 16 | ||
| 17 | + | use crate::bevel::{Bevel, Elevation}; | |
| 17 | 18 | use crate::input::TextField; | |
| 18 | 19 | use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style}; | |
| 19 | 20 | use crate::text; | |
| @@ -362,6 +363,119 @@ | |||
| 362 | 363 | } | |
| 363 | 364 | } | |
| 364 | 365 | ||
| 366 | + | /// A push button: a raised surface with a label on it. | |
| 367 | + | /// | |
| 368 | + | /// The first widget here whose affordance is physical rather than conventional. | |
| 369 | + | /// Everything else in this crate announces interactivity by carrying a border, | |
| 370 | + | /// which the reader has to be told about; a beveled surface says it without | |
| 371 | + | /// being told, and says the same thing on a widget nobody has seen before. | |
| 372 | + | /// | |
| 373 | + | /// Pressed is the same button drawn [`Elevation::Sunken`], which is the whole | |
| 374 | + | /// economy of the idiom: no `:active` special case, no second set of tones, one | |
| 375 | + | /// swap. Disabled keeps its bevel and drops the label to `content_muted`, so an | |
| 376 | + | /// unavailable action stays in place and keeps teaching the layout rather than | |
| 377 | + | /// vanishing from it. | |
| 378 | + | pub struct AlloyButton<'a> { | |
| 379 | + | theme: &'a Theme, | |
| 380 | + | label: &'a str, | |
| 381 | + | pressed: bool, | |
| 382 | + | disabled: bool, | |
| 383 | + | primary: bool, | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | impl<'a> AlloyButton<'a> { | |
| 387 | + | pub fn new(theme: &'a Theme, label: &'a str) -> Self { | |
| 388 | + | Self { | |
| 389 | + | theme, | |
| 390 | + | label, | |
| 391 | + | pressed: false, | |
| 392 | + | disabled: false, | |
| 393 | + | primary: false, | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[must_use] | |
| 398 | + | pub fn pressed(mut self, pressed: bool) -> Self { | |
| 399 | + | self.pressed = pressed; | |
| 400 | + | self | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | #[must_use] | |
| 404 | + | pub fn disabled(mut self, disabled: bool) -> Self { | |
| 405 | + | self.disabled = disabled; | |
| 406 | + | self | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// The pane's one primary action, drawn at inverted polarity. | |
| 410 | + | /// | |
| 411 | + | /// Per docs/DESIGN-LANGUAGE.md this is how weight is added without spending | |
| 412 | + | /// an accent on chrome, and a pane never shows more than one. The bevel is | |
| 413 | + | /// unchanged: a primary button is lit like every other, it is only filled | |
| 414 | + | /// differently. | |
| 415 | + | #[must_use] | |
| 416 | + | pub fn primary(mut self, primary: bool) -> Self { | |
| 417 | + | self.primary = primary; | |
| 418 | + | self | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | impl Widget for AlloyButton<'_> { | |
| 423 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 424 | + | if area.height == 0 || area.width == 0 { | |
| 425 | + | return; | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | let elevation = if self.pressed { | |
| 429 | + | Elevation::Sunken | |
| 430 | + | } else { | |
| 431 | + | Elevation::Raised | |
| 432 | + | }; | |
| 433 | + | ||
| 434 | + | // A pressed control sits on the recessed surface, so the fill moves with | |
| 435 | + | // the light rather than staying put under an inverted bevel. | |
| 436 | + | let face = if self.pressed { | |
| 437 | + | self.theme.surface_sunken | |
| 438 | + | } else { | |
| 439 | + | self.theme.surface_raised | |
| 440 | + | }; | |
| 441 | + | let (bg, fg) = if self.primary { | |
| 442 | + | (self.theme.content_primary, self.theme.surface_raised) | |
| 443 | + | } else { | |
| 444 | + | (face, self.theme.content_primary) | |
| 445 | + | }; | |
| 446 | + | let fg = if self.disabled { | |
| 447 | + | self.theme.content_muted | |
| 448 | + | } else { | |
| 449 | + | fg | |
| 450 | + | }; | |
| 451 | + | ||
| 452 | + | Paragraph::new("") | |
| 453 | + | .style(Style::default().bg(bg)) | |
| 454 | + | .render(area, buf); | |
| 455 | + | Bevel::new(self.theme, elevation).render(area, buf); | |
| 456 | + | ||
| 457 | + | // The label goes on the middle row, inside the edge. On a button too | |
| 458 | + | // short to have an inside, it takes the whole area and the bevel is | |
| 459 | + | // simply what fits around it. | |
| 460 | + | let label_area = if area.height >= 3 && area.width >= 3 { | |
| 461 | + | Rect { | |
| 462 | + | x: area.x + 1, | |
| 463 | + | y: area.y + area.height / 2, | |
| 464 | + | width: area.width - 2, | |
| 465 | + | height: 1, | |
| 466 | + | } | |
| 467 | + | } else { | |
| 468 | + | Rect { height: 1, ..area } | |
| 469 | + | }; | |
| 470 | + | Paragraph::new(Line::from(Span::styled( | |
| 471 | + | self.label, | |
| 472 | + | Style::default().bg(bg).fg(fg), | |
| 473 | + | ))) | |
| 474 | + | .centered() | |
| 475 | + | .render(label_area, buf); | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | ||
| 365 | 479 | /// A centered confirmation modal, drawn over the view that raised it. | |
| 366 | 480 | /// | |
| 367 | 481 | /// Confirmation is design-system chrome rather than per-view furniture: every | |
| @@ -410,6 +524,7 @@ | |||
| 410 | 524 | .borders(Borders::ALL) | |
| 411 | 525 | .border_style(Style::default().fg(self.theme.border_strong)) | |
| 412 | 526 | .style(base) | |
| 527 | + | .shadow(floating_shadow(self.theme)) | |
| 413 | 528 | .title(format!(" {} ", self.title)); | |
| 414 | 529 | let inner = block.inner(area); | |
| 415 | 530 | block.render(area, buf); | |
| @@ -683,6 +798,24 @@ | |||
| 683 | 798 | } | |
| 684 | 799 | } | |
| 685 | 800 | ||
| 801 | + | /// The drop shadow under a surface that floats above the page. | |
| 802 | + | /// | |
| 803 | + | /// The one place this design system draws a shadow, and the exception is | |
| 804 | + | /// deliberate: a bevel says *raised by one step*, which is the wrong claim for a | |
| 805 | + | /// modal. A shadow says *detached from the page underneath*, and a reader needs | |
| 806 | + | /// to know the thing behind is still there and still theirs to return to. | |
| 807 | + | /// | |
| 808 | + | /// Shade characters rather than a dimmed background, because a terminal has no | |
| 809 | + | /// blur and a solid offset block reads as a second window rather than as a | |
| 810 | + | /// shadow. | |
| 811 | + | pub(crate) fn floating_shadow(theme: &Theme) -> Shadow { | |
| 812 | + | Shadow::medium_shade().style( | |
| 813 | + | Style::default() | |
| 814 | + | .fg(theme.border_strong) | |
| 815 | + | .bg(theme.surface_page), | |
| 816 | + | ) | |
| 817 | + | } | |
| 818 | + | ||
| 686 | 819 | /// The swatch a color field paints beside its hex. | |
| 687 | 820 | const SWATCH: &str = "██"; | |
| 688 | 821 | ||
| @@ -1033,6 +1166,7 @@ | |||
| 1033 | 1166 | .borders(Borders::ALL) | |
| 1034 | 1167 | .border_style(Style::default().fg(self.theme.border_strong)) | |
| 1035 | 1168 | .style(base) | |
| 1169 | + | .shadow(floating_shadow(self.theme)) | |
| 1036 | 1170 | .title(format!(" {} ", self.title)); | |
| 1037 | 1171 | let inner = block.inner(area); | |
| 1038 | 1172 | block.render(area, buf); | |
| @@ -1243,6 +1377,8 @@ | |||
| 1243 | 1377 | line_border: Color::Rgb(12, 12, 12), | |
| 1244 | 1378 | border_subtle: Color::Rgb(13, 13, 13), | |
| 1245 | 1379 | border_strong: Color::Rgb(14, 14, 14), | |
| 1380 | + | bevel_light: Color::Rgb(16, 16, 16), | |
| 1381 | + | bevel_dark: Color::Rgb(17, 17, 17), | |
| 1246 | 1382 | category: [Color::Rgb(15, 15, 15); 6], | |
| 1247 | 1383 | } | |
| 1248 | 1384 | } | |
| @@ -1811,4 +1947,85 @@ | |||
| 1811 | 1947 | "oldest entry must have scrolled off" | |
| 1812 | 1948 | ); | |
| 1813 | 1949 | } | |
| 1950 | + | ||
| 1951 | + | // ---- button ---- | |
| 1952 | + | ||
| 1953 | + | fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) { | |
| 1954 | + | let area = Rect::new(0, 0, w, h); | |
| 1955 | + | let mut buf = Buffer::empty(area); | |
| 1956 | + | button.render(area, &mut buf); | |
| 1957 | + | (buf, area) | |
| 1958 | + | } | |
| 1959 | + | ||
| 1960 | + | fn rows(buf: &Buffer, area: Rect) -> Vec<String> { | |
| 1961 | + | (area.y..area.bottom()) | |
| 1962 | + | .map(|y| { | |
| 1963 | + | (area.x..area.right()) | |
| 1964 | + | .map(|x| buf[(x, y)].symbol()) | |
| 1965 | + | .collect::<String>() | |
| 1966 | + | }) | |
| 1967 | + | .collect() | |
| 1968 | + | } | |
| 1969 | + | ||
| 1970 | + | #[test] | |
| 1971 | + | fn a_button_is_a_beveled_surface_with_a_centered_label() { | |
| 1972 | + | let theme = theme(); | |
| 1973 | + | let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3); | |
| 1974 | + | assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); | |
| 1975 | + | } | |
| 1976 | + | ||
| 1977 | + | // The pressed state is the same button lit from the other corner. Asserted | |
| 1978 | + | // as a relationship rather than against literals, because that is what | |
| 1979 | + | // makes it one swap instead of a second widget. | |
| 1980 | + | #[test] | |
| 1981 | + | fn pressing_a_button_inverts_its_light_and_recesses_its_face() { | |
| 1982 | + | let theme = theme(); | |
| 1983 | + | let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3); | |
| 1984 | + | let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3); | |
| 1985 | + | ||
| 1986 | + | assert_eq!(rows(&up, area), rows(&down, area)); | |
| 1987 | + | assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light); | |
| 1988 | + | assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark); | |
| 1989 | + | assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised); | |
| 1990 | + | assert_eq!(down[(3u16, 1u16)].bg, theme.surface_sunken); | |
| 1991 | + | } | |
| 1992 | + | ||
| 1993 | + | // Dimmed and still there, so the layout keeps teaching itself. | |
| 1994 | + | #[test] | |
| 1995 | + | fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() { | |
| 1996 | + | let theme = theme(); | |
| 1997 | + | let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3); | |
| 1998 | + | assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); | |
| 1999 | + | assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light); | |
| 2000 | + | assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted); | |
| 2001 | + | } | |
| 2002 | + | ||
| 2003 | + | #[test] | |
| 2004 | + | fn a_primary_button_inverts_polarity_without_touching_the_bevel() { | |
| 2005 | + | let theme = theme(); | |
| 2006 | + | let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3); | |
| 2007 | + | assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary); | |
| 2008 | + | assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised); | |
| 2009 | + | assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light); | |
| 2010 | + | } | |
| 2011 | + | ||
| 2012 | + | // ---- floating surfaces ---- | |
| 2013 | + | ||
| 2014 | + | // The shadow lands outside the modal, one cell down and right, so the page | |
| 2015 | + | // has to be bigger than the modal for it to exist at all. | |
| 2016 | + | #[test] | |
| 2017 | + | fn a_modal_casts_a_shadow_onto_the_page_behind_it() { | |
| 2018 | + | let theme = theme(); | |
| 2019 | + | let page = Rect::new(0, 0, 24, 8); | |
| 2020 | + | let modal = Rect::new(2, 1, 18, 5); | |
| 2021 | + | let mut buf = Buffer::empty(page); | |
| 2022 | + | AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf); | |
| 2023 | + | ||
| 2024 | + | // Directly under the modal's bottom edge, offset one to the right. | |
| 2025 | + | let below = &buf[(3u16, 6u16)]; | |
| 2026 | + | assert_eq!(below.symbol(), "▒"); | |
| 2027 | + | assert_eq!(below.fg, theme.border_strong); | |
| 2028 | + | // The page well away from the modal is untouched. | |
| 2029 | + | assert_eq!(buf[(23u16, 7u16)].symbol(), " "); | |
| 2030 | + | } | |
| 1814 | 2031 | } |
| @@ -1,0 +1,262 @@ | |||
| 1 | + | //! Two-tone bevels: the light model that says a thing can be manipulated. | |
| 2 | + | //! | |
| 3 | + | //! A raised control is lit from the top left, so its top and left edges carry | |
| 4 | + | //! [`Theme::bevel_light`] and its bottom and right edges [`Theme::bevel_dark`]. | |
| 5 | + | //! Swapping the pair recesses it, which is what a pressed button and a text well | |
| 6 | + | //! are. One rule, applied without exception, so that a reader who learns it on a | |
| 7 | + | //! button already knows what a scrollbar trough is telling them. | |
| 8 | + | //! | |
| 9 | + | //! The light source does not flip with the theme's polarity. A dark theme is lit | |
| 10 | + | //! from the same corner as a light one, because a bevel that reverses between | |
| 11 | + | //! modes stops being a rule that transfers and becomes a per-theme detail to | |
| 12 | + | //! memorize. | |
| 13 | + | //! | |
| 14 | + | //! # Why two passes | |
| 15 | + | //! | |
| 16 | + | //! ratatui's `Block` holds one `border_style` for every side, so a bevel cannot | |
| 17 | + | //! be expressed as a single block: the geometry is available (a border `Set` | |
| 18 | + | //! addresses all eight sides independently, and `QuadrantOutside` is already the | |
| 19 | + | //! half-cell outline this wants) but the two tones are not. So the edges are | |
| 20 | + | //! drawn as two blocks into one `Rect`, one owning the lit sides and one the | |
| 21 | + | //! shaded, and the corners where they meet are painted afterwards. | |
| 22 | + | //! | |
| 23 | + | //! Half-blocks rather than box-drawing because the aspect ratio works out: a | |
| 24 | + | //! cell is roughly twice as tall as it is wide, so a half-block along the top and | |
| 25 | + | //! a half-cell column down the side are about the same number of pixels, and the | |
| 26 | + | //! bevel reads as even thickness rather than as a heavy top. | |
| 27 | + | //! | |
| 28 | + | //! <!-- wiki: alloy-console --> | |
| 29 | + | ||
| 30 | + | use ratatui::buffer::Buffer; | |
| 31 | + | use ratatui::layout::Rect; | |
| 32 | + | use ratatui::style::{Color, Style}; | |
| 33 | + | use ratatui::symbols::border; | |
| 34 | + | use ratatui::widgets::{Block, BorderType, Borders, Widget}; | |
| 35 | + | ||
| 36 | + | use crate::theme::Theme; | |
| 37 | + | ||
| 38 | + | /// Which way a surface is lit, which is to say what it is. | |
| 39 | + | /// | |
| 40 | + | /// Per docs/DESIGN-LANGUAGE.md the presence of an edge is itself the affordance: | |
| 41 | + | /// a bordered thing is a control and a flush thing is data. [`Elevation`] makes | |
| 42 | + | /// that a physical claim rather than a convention, and adds the two states a | |
| 43 | + | /// flat border could not express. | |
| 44 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 45 | + | pub enum Elevation { | |
| 46 | + | /// Flush on its surface. Data panels and tabular regions, which carry the | |
| 47 | + | /// color and so must not compete with chrome for it. | |
| 48 | + | Flush, | |
| 49 | + | /// Lit from the top left. Buttons, tabs, chips, the frame of a pane. | |
| 50 | + | Raised, | |
| 51 | + | /// Lit from the bottom right, the inversion of [`Raised`](Elevation::Raised). | |
| 52 | + | /// Text fields, list wells, progress troughs, scrollbar tracks. | |
| 53 | + | Sunken, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl Elevation { | |
| 57 | + | /// The tones for the lit-side and shaded-side passes. | |
| 58 | + | /// | |
| 59 | + | /// Pressed is not a variant of its own: a pressed control is a raised one | |
| 60 | + | /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this | |
| 61 | + | /// idiom is cheap. One swap gives every interactive widget a correct pressed | |
| 62 | + | /// state, with no per-widget special case. | |
| 63 | + | fn edges(self, theme: &Theme) -> Option<(Color, Color)> { | |
| 64 | + | match self { | |
| 65 | + | Elevation::Flush => None, | |
| 66 | + | Elevation::Raised => Some((theme.bevel_light, theme.bevel_dark)), | |
| 67 | + | Elevation::Sunken => Some((theme.bevel_dark, theme.bevel_light)), | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// A bevel drawn around `area`. | |
| 73 | + | /// | |
| 74 | + | /// Draws only the edge. The caller fills the interior, which keeps this | |
| 75 | + | /// composable with whatever widget is inside and means a bevel can be laid over | |
| 76 | + | /// content that is already rendered. | |
| 77 | + | pub struct Bevel<'a> { | |
| 78 | + | theme: &'a Theme, | |
| 79 | + | elevation: Elevation, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl<'a> Bevel<'a> { | |
| 83 | + | pub fn new(theme: &'a Theme, elevation: Elevation) -> Self { | |
| 84 | + | Self { theme, elevation } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | impl Widget for Bevel<'_> { | |
| 89 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 90 | + | let Some((lit, shaded)) = self.elevation.edges(self.theme) else { | |
| 91 | + | return; | |
| 92 | + | }; | |
| 93 | + | // Under two cells in either direction the two edges would land in one | |
| 94 | + | // cell and the light source would be a guess. Draw nothing rather than | |
| 95 | + | // something misleading. | |
| 96 | + | if area.width < 2 || area.height < 2 { | |
| 97 | + | return; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // `Replace` so an edge never merges with a neighbouring box-drawing | |
| 101 | + | // character. The default strategy would try to combine them into a | |
| 102 | + | // junction glyph, which for a half-block is a shape from a different | |
| 103 | + | // alphabet. | |
| 104 | + | let side = |borders: Borders, color: Color| { | |
| 105 | + | Block::new() | |
| 106 | + | .borders(borders) | |
| 107 | + | .border_type(BorderType::QuadrantOutside) | |
| 108 | + | .border_style(Style::default().fg(color)) | |
| 109 | + | .merge_borders(ratatui::symbols::merge::MergeStrategy::Replace) | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | // Pass one draws the lit sides and, because it owns both of them, the | |
| 113 | + | // corner between them. Pass two does the same for the shaded sides. | |
| 114 | + | side(Borders::TOP | Borders::LEFT, lit).render(area, buf); | |
| 115 | + | side(Borders::BOTTOM | Borders::RIGHT, shaded).render(area, buf); | |
| 116 | + | ||
| 117 | + | // The remaining two corners are where light meets shadow, and no single | |
| 118 | + | // side owns them: each was painted by whichever pass ran last over it. | |
| 119 | + | // Repaint them as half-and-half, so the transition reads as a corner | |
| 120 | + | // rather than as one edge overrunning the other. | |
| 121 | + | let right = area.x + area.width - 1; | |
| 122 | + | let bottom = area.y + area.height - 1; | |
| 123 | + | buf[(right, area.y)] | |
| 124 | + | .set_symbol(border::QUADRANT_TOP_HALF) | |
| 125 | + | .set_fg(lit) | |
| 126 | + | .set_bg(shaded); | |
| 127 | + | buf[(area.x, bottom)] | |
| 128 | + | .set_symbol(border::QUADRANT_BOTTOM_HALF) | |
| 129 | + | .set_fg(shaded) | |
| 130 | + | .set_bg(lit); | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[cfg(test)] | |
| 135 | + | mod tests { | |
| 136 | + | use super::*; | |
| 137 | + | use ratatui::style::Color; | |
| 138 | + | ||
| 139 | + | fn theme() -> Theme { | |
| 140 | + | crate::theme::Theme { | |
| 141 | + | mode: crate::theme::Mode::Light, | |
| 142 | + | surface_page: Color::Rgb(0, 0, 0), | |
| 143 | + | surface_raised: Color::Rgb(1, 1, 1), | |
| 144 | + | surface_sunken: Color::Rgb(2, 2, 2), | |
| 145 | + | surface_overlay: Color::Rgb(3, 3, 3), | |
| 146 | + | content_primary: Color::Rgb(4, 4, 4), | |
| 147 | + | content_secondary: Color::Rgb(5, 5, 5), | |
| 148 | + | content_muted: Color::Rgb(6, 6, 6), | |
| 149 | + | action_primary: Color::Rgb(7, 7, 7), | |
| 150 | + | status_danger: Color::Rgb(8, 8, 8), | |
| 151 | + | status_success: Color::Rgb(9, 9, 9), | |
| 152 | + | status_warning: Color::Rgb(10, 10, 10), | |
| 153 | + | status_info: Color::Rgb(11, 11, 11), | |
| 154 | + | line_border: Color::Rgb(12, 12, 12), | |
| 155 | + | border_subtle: Color::Rgb(13, 13, 13), | |
| 156 | + | border_strong: Color::Rgb(14, 14, 14), | |
| 157 | + | bevel_light: Color::Rgb(16, 16, 16), | |
| 158 | + | bevel_dark: Color::Rgb(17, 17, 17), | |
| 159 | + | category: [Color::Rgb(15, 15, 15); 6], | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | fn render(elevation: Elevation, w: u16, h: u16) -> Buffer { | |
| 164 | + | let area = Rect::new(0, 0, w, h); | |
| 165 | + | let mut buf = Buffer::empty(area); | |
| 166 | + | Bevel::new(&theme(), elevation).render(area, &mut buf); | |
| 167 | + | buf | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | fn glyphs(buf: &Buffer, area: Rect) -> Vec<String> { | |
| 171 | + | (area.y..area.bottom()) | |
| 172 | + | .map(|y| { | |
| 173 | + | (area.x..area.right()) | |
| 174 | + | .map(|x| buf[(x, y)].symbol()) | |
| 175 | + | .collect() | |
| 176 | + | }) | |
| 177 | + | .collect() | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn a_raised_bevel_draws_the_outline_and_leaves_the_middle_alone() { | |
| 182 | + | let buf = render(Elevation::Raised, 5, 4); | |
| 183 | + | assert_eq!( | |
| 184 | + | glyphs(&buf, Rect::new(0, 0, 5, 4)), | |
| 185 | + | vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"], | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // The lit corner is top left and the shaded one bottom right, on a light | |
| 190 | + | // theme and on a dark one alike. | |
| 191 | + | #[test] | |
| 192 | + | fn raised_is_lit_from_the_top_left() { | |
| 193 | + | let buf = render(Elevation::Raised, 4, 3); | |
| 194 | + | let t = theme(); | |
| 195 | + | assert_eq!(buf[(0u16, 0u16)].fg, t.bevel_light); | |
| 196 | + | assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light); | |
| 197 | + | assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light); | |
| 198 | + | assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark); | |
| 199 | + | assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark); | |
| 200 | + | assert_eq!(buf[(3u16, 1u16)].fg, t.bevel_dark); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // Sunken is the same drawing with the two tones exchanged. Asserted against | |
| 204 | + | // raised rather than against literals, because the property that matters is | |
| 205 | + | // that they are inverses: that is what makes a pressed state one swap. | |
| 206 | + | #[test] | |
| 207 | + | fn sunken_is_raised_with_the_tones_exchanged() { | |
| 208 | + | let (raised, sunken) = ( | |
| 209 | + | render(Elevation::Raised, 4, 3), | |
| 210 | + | render(Elevation::Sunken, 4, 3), | |
| 211 | + | ); | |
| 212 | + | let area = Rect::new(0, 0, 4, 3); | |
| 213 | + | assert_eq!(glyphs(&raised, area), glyphs(&sunken, area)); | |
| 214 | + | ||
| 215 | + | let t = theme(); | |
| 216 | + | let swap = |c: Color| match c { | |
| 217 | + | c if c == t.bevel_light => t.bevel_dark, | |
| 218 | + | c if c == t.bevel_dark => t.bevel_light, | |
| 219 | + | other => other, | |
| 220 | + | }; | |
| 221 | + | for y in area.y..area.bottom() { | |
| 222 | + | for x in area.x..area.right() { | |
| 223 | + | assert_eq!(swap(raised[(x, y)].fg), sunken[(x, y)].fg, "fg at {x},{y}"); | |
| 224 | + | assert_eq!(swap(raised[(x, y)].bg), sunken[(x, y)].bg, "bg at {x},{y}"); | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // Where light meets shadow, both tones share the cell. | |
| 230 | + | #[test] | |
| 231 | + | fn the_transition_corners_carry_both_tones() { | |
| 232 | + | let buf = render(Elevation::Raised, 4, 3); | |
| 233 | + | let t = theme(); | |
| 234 | + | let top_right = &buf[(3u16, 0u16)]; | |
| 235 | + | assert_eq!(top_right.fg, t.bevel_light); | |
| 236 | + | assert_eq!(top_right.bg, t.bevel_dark); | |
| 237 | + | let bottom_left = &buf[(0u16, 2u16)]; | |
| 238 | + | assert_eq!(bottom_left.fg, t.bevel_dark); | |
| 239 | + | assert_eq!(bottom_left.bg, t.bevel_light); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn flush_draws_nothing() { | |
| 244 | + | let buf = render(Elevation::Flush, 4, 3); | |
| 245 | + | assert_eq!( | |
| 246 | + | glyphs(&buf, Rect::new(0, 0, 4, 3)), | |
| 247 | + | vec![" ", " ", " "] | |
| 248 | + | ); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // A one-cell-tall or one-cell-wide area cannot hold two opposing edges, so | |
| 252 | + | // the light source would have to be guessed. It draws nothing instead. | |
| 253 | + | #[test] | |
| 254 | + | fn an_area_too_small_to_have_two_sides_is_left_alone() { | |
| 255 | + | for (w, h) in [(1, 4), (4, 1), (1, 1)] { | |
| 256 | + | let buf = render(Elevation::Raised, w, h); | |
| 257 | + | let area = Rect::new(0, 0, w, h); | |
| 258 | + | let blank: Vec<String> = (0..h).map(|_| " ".repeat(w as usize)).collect(); | |
| 259 | + | assert_eq!(glyphs(&buf, area), blank, "{w}x{h}"); | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } |