max / makeover-tui
| 1 | //! The terminal renderer for [`makeover_layout`]. |
| 2 | //! |
| 3 | //! <!-- wiki: makeover-tui --> |
| 4 | //! |
| 5 | //! Named for the target and not for ratatui, the same way |
| 6 | //! `makeover-immediate` is named for the mode and not for egui. |
| 7 | //! |
| 8 | //! # What a terminal actually costs you |
| 9 | //! |
| 10 | //! Not colour. That was the original assumption here and it is wrong on any |
| 11 | //! terminal built this decade. Measured across the 31 shipped themes |
| 12 | //! (`makeover`'s `well_fidelity` example): |
| 13 | //! |
| 14 | //! | | ANSI-16 | ANSI-256 | truecolor | |
| 15 | //! |---|---|---|---| |
| 16 | //! | a well collapses onto its face | 18/31 | 4/31 | 2/31 | |
| 17 | //! | at least one bevel edge vanishes into its face | 31/31 | 4/31 | 0 | |
| 18 | //! |
| 19 | //! The threshold is 256, not 24-bit, and the two failures that survive at |
| 20 | //! truecolor are not terminal failures at all: they are the themes whose |
| 21 | //! raised surface is already white, so the lightening clamps and the well |
| 22 | //! lands exactly on its face. Those render identically in a browser. |
| 23 | //! `makeover`'s own `well_is_distinct_from_its_face` test already names them. |
| 24 | //! |
| 25 | //! **What a terminal costs is geometry, and no amount of colour fixes it.** |
| 26 | //! An edge occupies a whole cell on each side. A cell is roughly 8x17 pixels, |
| 27 | //! so a one-pixel bevel becomes something an order of magnitude heavier, which |
| 28 | //! is why [`frame`] hands back a shrunk [`Rect`] instead of pretending the |
| 29 | //! region survived intact. There is nowhere to put a corner radius, so |
| 30 | //! `radius_control` and `radius_container` mean the same thing here. A fill |
| 31 | //! can only begin and end on a cell boundary. |
| 32 | //! |
| 33 | //! That is the constraint worth designing against. It does not improve, it is |
| 34 | //! not detectable, and it applies equally to the best terminal ever written. |
| 35 | //! |
| 36 | //! What it does not mean is that the shape inside the cell stops mattering. |
| 37 | //! Half of a cell is still addressable, and a bevel drawn in half-blocks reads |
| 38 | //! as a lit edge where the same bevel in box-drawing reads as a line: `─` and |
| 39 | //! `│` are one stroke through the middle, identical on all four sides, saying |
| 40 | //! nothing about where the light is. Half-blocks also make the two corners |
| 41 | //! where light meets shadow expressible, since a glyph that fills half a cell |
| 42 | //! leaves the other half to the second tone. |
| 43 | //! |
| 44 | //! # Where fidelity does matter |
| 45 | //! |
| 46 | //! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well |
| 47 | //! cannot be filled distinctly on most themes *and* a bevel loses an edge on |
| 48 | //! every one of them, so a raised card and a well both read as a single-tone |
| 49 | //! box. Colour cannot carry the distinction, so [`frame`] carries it with the |
| 50 | //! glyphs instead. |
| 51 | //! |
| 52 | //! Above that, colour carries it and the glyph fallback never fires. |
| 53 | //! |
| 54 | //! [`Palette::shows`] is worth reading correctly in light of the numbers: it |
| 55 | //! is **not** a low-colour workaround. It is a correctness check that a fill |
| 56 | //! will be visible against what is behind it, and at truecolor it fires on |
| 57 | //! exactly the two clamping themes, which is precisely when it should. |
| 58 | //! |
| 59 | //! # 0.13.0: a modal, and two cues four ports were about to each invent |
| 60 | //! |
| 61 | //! [`Depth::Overlay`](makeover_layout::Depth::Overlay) arrives in |
| 62 | //! makeover-layout 0.14.0 and needed nothing here: [`Palette::fill`] has |
| 63 | //! answered `Fill::Overlay` since this crate had a palette, so what was missing |
| 64 | //! was the route from a description rather than the drawing. A test asserts it, |
| 65 | //! because a route nothing exercises is one a refactor can quietly lose. |
| 66 | //! |
| 67 | //! [`Theme::selection_on`] and [`Theme::focus_ring`] are the other half, and |
| 68 | //! both are DERIVED rather than authored. Every consumer measured did selection |
| 69 | //! with `REVERSED`, for want of an on-accent foreground; every one that wanted |
| 70 | //! a focus ring either spent makeover's `border-strong` on it, which is a |
| 71 | //! divider at 1.63:1 on Akari Dawn, or derived its own the way `alloy_tui` |
| 72 | //! does. Four terminal ports were each about to answer that separately. |
| 73 | //! |
| 74 | //! Derived, not authored, because the direction is one-way. An authored key can |
| 75 | //! fall back to a derivation and break no theme on disk; a key this crate |
| 76 | //! started requiring would break every theme that lacks it. So the theme format |
| 77 | //! does not change and nothing on disk grows, and the promotion stays available |
| 78 | //! for a theme that ever needs to tune either. |
| 79 | //! |
| 80 | //! # 0.14.0: the first structural widget |
| 81 | //! |
| 82 | //! [`table`] is the first thing here that draws content rather than a surface, |
| 83 | //! and it exists because 14 call sites across `mnw-cli` and `viewer` were |
| 84 | //! already drawing one. `mnw-cli` had written the mapping layer by hand |
| 85 | //! (`src/tui/widgets.rs`: a muted bold header, a selected row carried by the |
| 86 | //! background alone) and `viewer` had written a smaller one, which is two |
| 87 | //! answers to a question this crate is supposed to answer once. |
| 88 | //! |
| 89 | //! It is a mapping layer over [`ratatui::widgets::Table`] rather than a table |
| 90 | //! implementation, because ratatui already lays tracks out, draws a header, |
| 91 | //! highlights a row and scrolls. What it has no answer for is content |
| 92 | //! measurement and narrowing, and those are what the module is. |
| 93 | //! |
| 94 | //! # 0.19.0: `widget` is [`piece`], because the word went to the description |
| 95 | //! |
| 96 | //! `makeover-layout` 0.20.0 added `Region::Widget`, the third tier between a |
| 97 | //! primitive and `Region::Bespoke`: a named assembly of primitives that each |
| 98 | //! renderer draws its own way. That is host-agnostic and sits *above* every |
| 99 | //! renderer. |
| 100 | //! |
| 101 | //! This crate's `widget` module is the opposite end of the same stack — |
| 102 | //! renderer-local, the answer to what a meter looks like in cells, taking a |
| 103 | //! description plus what only a terminal knows. Two different things wearing |
| 104 | //! one word, and the collision would have been worst exactly here, in a crate |
| 105 | //! that has to implement the tier. |
| 106 | //! |
| 107 | //! So this half moved and the description's half kept the word. That direction |
| 108 | //! is not arbitrary: a second or third party naming a widget is naming the |
| 109 | //! layout kind, and nothing outside this tree ever needed a word for a drawing |
| 110 | //! routine. `WidgetStyle` is `PieceStyle`. |
| 111 | //! |
| 112 | //! # 0.27.0: a bar for a bounded number, and an option that is not offered yet |
| 113 | //! |
| 114 | //! `makeover-layout` 0.28.0's form findings, at the renderer that has the bar |
| 115 | //! already. A [`makeover_layout::FieldKind::Range`] is drawn as [`piece::meter`]'s |
| 116 | //! cells with its two ends read out either side, because the ends are what the |
| 117 | //! question means and a terminal is where it would be easiest to quietly show a |
| 118 | //! figure instead. An unbounded range falls back to the text path rather than |
| 119 | //! to bounds this crate invented. |
| 120 | //! |
| 121 | //! `Choice::unavailable` is the one place the three-tone convention's muted is |
| 122 | //! the truth rather than the lie it warns about: that option will not answer, |
| 123 | //! and the reason it will not now sits on its row. |
| 124 | //! |
| 125 | //! `Choice::detail` (makeover-layout 0.39.0) takes a row of its own under the |
| 126 | //! option, inset by the width of the mark and muted for the same reason: the |
| 127 | //! line is not a thing to press. This is the host with the most room of the |
| 128 | //! three -- a browser's `<select>` has to run the line into its option's text |
| 129 | //! and a terminal does not, so it does not. |
| 130 | //! |
| 131 | //! `Field::placeholder` on a chooser, the third finding, is already answered |
| 132 | //! here and needed nothing: this renderer draws every option of a select at |
| 133 | //! once, so an unanswered one is a list with no mark against any row rather |
| 134 | //! than an empty box with nothing in it. |
| 135 | //! |
| 136 | //! # 0.33.0: an interval is one line |
| 137 | //! |
| 138 | //! `makeover-layout` 0.34.0's [`makeover_layout::FieldKind::Interval`], drawn |
| 139 | //! as the low end, the word `to`, and the high end. One line because it is one |
| 140 | //! question: two rows would read as two questions, which is exactly what the |
| 141 | //! kind exists to stop the description saying, and a terminal has no |
| 142 | //! side-by-side boxes to fall back on. |
| 143 | //! |
| 144 | //! - **An open end draws the bound it falls back to**, muted, because that is |
| 145 | //! where the axis ends rather than a value anybody set. With no bound to fall |
| 146 | //! back on the end stays blank, which is [`piece::field`]'s standing position |
| 147 | //! on a value this crate would have to invent. |
| 148 | //! - **The word rather than a dash.** A dash between two numbers is a minus |
| 149 | //! sign to anyone reading a signed axis, and half the measured axes are |
| 150 | //! signed: audiofiles filters loudness in dBFS. |
| 151 | //! - The unit rides on each end, through the same `measured` the typed path |
| 152 | //! uses, so `90 BPM to 130 BPM` reads without the label being consulted. |
| 153 | //! |
| 154 | //! [`piece::Held::Between`] is the second value, for the reason the description |
| 155 | //! states both names: a separator inside one string would make this crate own a |
| 156 | //! delimiter either end could contain. |
| 157 | //! |
| 158 | //! # 0.32.0: a number reads with its unit |
| 159 | //! |
| 160 | //! `makeover-layout` 0.33.0's `Field::unit`, drawn on the value rather than in |
| 161 | //! the label. A terminal has one line the eye is on -- the number -- and the |
| 162 | //! label is a line above it, so `0.05 s` is the reading and `Attack (s)` two |
| 163 | //! rows up is not. A range shows it after the readout beside the bar; a typed |
| 164 | //! number after the value. Every other kind ignores it, and which those are is |
| 165 | //! `FieldKind::measurable`'s answer rather than a `matches!` kept here. |
| 166 | //! |
| 167 | //! # 0.31.0: the bar fills along the curve |
| 168 | //! |
| 169 | //! `makeover-layout` 0.32.0's [`Curve`](makeover_layout::Curve). Where a value |
| 170 | //! sits on the bar is the curve's answer now, not its proportion of the extent. |
| 171 | //! Under `Curve::Linear` those are the same number, so every range drawn before |
| 172 | //! this is unchanged; under a constant ratio they are not, and a bar filled |
| 173 | //! linearly would put an envelope's whole useful half inside its first cell. |
| 174 | //! |
| 175 | //! The two ends beside the bar do not move: they are `f(0)` and `f(1)`, which |
| 176 | //! is what they always were and is now what the description calls them. |
| 177 | //! |
| 178 | //! # The correction this renderer forced |
| 179 | //! |
| 180 | //! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning |
| 181 | //! `Page` for `Well` so a consumer without `surface-well` had something to |
| 182 | //! use. That is an answer for a renderer that can always paint a colour. Here |
| 183 | //! it is actively wrong: page *is* the surface a well is usually cut into, so |
| 184 | //! falling back to it produces the exact invisibility the fallback was meant |
| 185 | //! to avoid. |
| 186 | //! |
| 187 | //! Substituting one intent for another is renderer policy, not description. |
| 188 | //! The fallback moved out of the description and into |
| 189 | //! `makeover-immediate`, where it belongs, which is the first thing a second |
| 190 | //! renderer was built to find. |
| 191 | |
| 192 | |
| 193 | |
| 194 | use ; |
| 195 | use Buffer; |
| 196 | use Rect; |
| 197 | use Color; |
| 198 | |
| 199 | /// The description this crate renders, re-exported. |
| 200 | /// |
| 201 | /// Every entry point here takes a type from it, so a consumer would otherwise |
| 202 | /// have to depend on the description separately and keep two version |
| 203 | /// requirements in step to name the argument it is already being handed. |
| 204 | pub use makeover_layout; |
| 205 | |
| 206 | /// A loaded makeover theme, resolved to the colours ratatui draws with. |
| 207 | /// |
| 208 | /// Behind the `theme` feature: it is the only thing here that needs `makeover` |
| 209 | /// itself, and that crate embeds the shipped theme files. A consumer that wants |
| 210 | /// [`frame`] and nothing else should not carry them. |
| 211 | |
| 212 | |
| 213 | |
| 214 | |
| 215 | pub use ; |
| 216 | |
| 217 | /// Columns, narrowing, cell parts and the sort caret, over ratatui's own |
| 218 | /// [`Table`](ratatui::widgets::Table). |
| 219 | /// |
| 220 | /// Not feature-gated. It needs no theme: [`TableStyle`](table::TableStyle) |
| 221 | /// carries the tones, and a caller with a loaded theme gets them from |
| 222 | /// `TableStyle::from_theme` instead of supplying them. |
| 223 | |
| 224 | |
| 225 | /// Word wrapping that answers a height and a drawing from the same measurement. |
| 226 | /// |
| 227 | /// The half ratatui's `Paragraph` leaves out. A flow layout asks for rows at a |
| 228 | /// width and then draws into the rect it was given, and if the two disagree by |
| 229 | /// one row a node draws over the one under it. |
| 230 | |
| 231 | |
| 232 | /// A meter, a badge, a control, a figure and a form field. |
| 233 | /// |
| 234 | /// The pieces below the level [`table`] works at. Not feature-gated, on |
| 235 | /// [`table`]'s footing: [`PieceStyle`](piece::PieceStyle) carries the tones, |
| 236 | /// and a caller with a loaded theme reaches for `PieceStyle::from_theme`. |
| 237 | |
| 238 | |
| 239 | /// How many colours the terminal can actually show. |
| 240 | /// |
| 241 | /// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour |
| 242 | /// separates a raised surface from a well on every shipped theme, and the |
| 243 | /// glyph fallback below never fires. Recorded rather than inferred, because a |
| 244 | /// caller that quantised its palette knows the answer and this crate cannot |
| 245 | /// recover it from the colours afterwards. |
| 246 | |
| 247 | |
| 248 | /// Sixteen colours. Depth cannot be carried by colour: a well collapses |
| 249 | /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31. |
| 250 | Ansi16, |
| 251 | /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes. |
| 252 | Ansi256, |
| 253 | /// 24-bit. The only failures left belong to the theme, not the terminal. |
| 254 | |
| 255 | TrueColor, |
| 256 | |
| 257 | |
| 258 | |
| 259 | /// Read the terminal's own claim, from `COLORTERM` then `TERM`. |
| 260 | /// |
| 261 | /// Deliberately credulous, and the fall-through is where that is decided. |
| 262 | /// An unrecognised `TERM` is assumed capable, because the two wrong answers |
| 263 | /// do not cost the same: guessing [`TrueColor`](Self::TrueColor) on a |
| 264 | /// limited terminal costs some fidelity, and guessing |
| 265 | /// [`Ansi16`](Self::Ansi16) on a capable one throws away colour the user |
| 266 | /// paid for — and, for a caller that quantises its palette off this answer, |
| 267 | /// throws away the whole theme. `COLORTERM` is routinely stripped by ssh |
| 268 | /// and by multiplexers, so an unrecognised name is the common case rather |
| 269 | /// than the exotic one: `foot`, `xterm` and `screen` all land here. |
| 270 | /// |
| 271 | /// So sixteen colours is reached by naming the terminals that really have |
| 272 | /// them. The list is short and it does not grow: these are the fixed |
| 273 | /// consoles, and `TERM=linux` is the case this exists for — the Linux |
| 274 | /// virtual console, which is what an installer and a machine with no |
| 275 | /// desktop draw on. |
| 276 | |
| 277 | |
| 278 | Selffrom_env |
| 279 | &var.unwrap_or_default, |
| 280 | &var.unwrap_or_default, |
| 281 | |
| 282 | |
| 283 | |
| 284 | /// [`detect`](Self::detect) with the environment passed in, so the decision |
| 285 | /// can be tested without mutating a process-wide variable from a parallel |
| 286 | /// test. |
| 287 | |
| 288 | |
| 289 | if colorterm.contains || colorterm.contains |
| 290 | return SelfTrueColor; |
| 291 | |
| 292 | match term |
| 293 | "linux" | "vt100" | "vt220" | "ansi" | "dumb" => SelfAnsi16, |
| 294 | _ if term.contains || term.contains => SelfAnsi256, |
| 295 | _ => SelfTrueColor, |
| 296 | |
| 297 | |
| 298 | |
| 299 | /// Whether colour alone can tell a raised surface from a well here. |
| 300 | |
| 301 | pub const |
| 302 | !matches! |
| 303 | |
| 304 | |
| 305 | |
| 306 | /// The resolved colours this renderer needs. |
| 307 | /// |
| 308 | /// Supply them already quantised to whatever the terminal can show. That is |
| 309 | /// what makes [`Palette::shows`] a plain inequality rather than a colour-space |
| 310 | /// calculation: by the time a colour reaches here, the question of what the |
| 311 | /// terminal will actually paint has been answered. |
| 312 | |
| 313 | |
| 314 | /// `surface-page`. |
| 315 | pub page: Color, |
| 316 | /// `surface-raised`. |
| 317 | pub raised: Color, |
| 318 | /// `surface-overlay`. |
| 319 | pub overlay: Color, |
| 320 | /// `surface-well`, absent on makeover before 2.3.0. |
| 321 | pub well: , |
| 322 | /// `bevel-light`. |
| 323 | pub bevel_light: Color, |
| 324 | /// `bevel-dark`. |
| 325 | pub bevel_dark: Color, |
| 326 | /// What the terminal can show. Defaults to [`Fidelity::TrueColor`]. |
| 327 | pub fidelity: Fidelity, |
| 328 | |
| 329 | |
| 330 | |
| 331 | /// Resolve a surface intent, or `None` where this renderer has no colour |
| 332 | /// for it. |
| 333 | /// |
| 334 | /// No substitution happens here. A missing intent stays missing, and |
| 335 | /// [`frame`] answers it with structure instead of with a different colour. |
| 336 | /// That rule is what lets the wildcard below be a real answer rather than |
| 337 | /// a hole: [`Fill`] is `#[non_exhaustive]` from `makeover-layout` 0.4.0 |
| 338 | /// onward, so the description can name a surface this renderer has not |
| 339 | /// learned to paint, and saying so is better than failing to build. |
| 340 | |
| 341 | pub const |
| 342 | match fill |
| 343 | Page => Some, |
| 344 | Raised => Some, |
| 345 | Overlay => Some, |
| 346 | Well => self.well, |
| 347 | // Includes Fill::Sunken, which this renderer has no tone for: a |
| 348 | // terminal cell has one background, so a surface set back by |
| 349 | // colour alone is not a thing it can say. The chosen tab is drawn |
| 350 | // forward instead. |
| 351 | _ => None, |
| 352 | |
| 353 | |
| 354 | |
| 355 | /// Resolve a bevel edge intent. |
| 356 | |
| 357 | pub const |
| 358 | match edge |
| 359 | Light => self.bevel_light, |
| 360 | Dark => self.bevel_dark, |
| 361 | |
| 362 | |
| 363 | |
| 364 | /// Whether painting `fill` over `behind` would show anything. |
| 365 | /// |
| 366 | /// The whole of the terminal's problem in one predicate. On a truecolor |
| 367 | /// terminal this is almost always true; in sixteen colours it is false |
| 368 | /// often enough that a design relying on fills is a design that vanishes. |
| 369 | |
| 370 | |
| 371 | fill != behind |
| 372 | |
| 373 | |
| 374 | /// Whether this palette can express a bevel as two distinct edges. |
| 375 | /// |
| 376 | /// Measured, this is the wrong thing to worry about: the two edge colours |
| 377 | /// never quantise onto each other, at any depth, on any shipped theme. |
| 378 | /// What does happen is an edge vanishing into the *face* it is drawn on, |
| 379 | /// on every theme at sixteen colours. Kept because a hand-built palette |
| 380 | /// can still collide, and cheap to ask. |
| 381 | |
| 382 | |
| 383 | self.bevel_light != self.bevel_dark |
| 384 | |
| 385 | |
| 386 | /// Whether depth has to be carried by glyphs rather than by colour. |
| 387 | /// |
| 388 | /// True when the terminal cannot separate the two surfaces, which is the |
| 389 | /// sixteen-colour case and nothing else. |
| 390 | |
| 391 | pub const |
| 392 | !self.fidelity.separates_depth |
| 393 | |
| 394 | |
| 395 | |
| 396 | /// The characters a frame's edges and corners are drawn with. |
| 397 | /// |
| 398 | /// Per side rather than per axis, because the set that reads best as a bevel |
| 399 | /// does not use the same glyph on opposite sides: a half-block edge is only |
| 400 | /// half a cell, and which half it occupies is what says where the edge is. |
| 401 | /// Box-drawing sets fill `top`/`bottom` and `left`/`right` with the same |
| 402 | /// character and lose nothing by it. |
| 403 | /// |
| 404 | /// Three sets. [`BEVEL`] is what a terminal that can show two tones gets. The |
| 405 | /// other two exist because at sixteen colours the glyphs are the only thing |
| 406 | /// left to carry depth: a well cannot be filled distinctly and a bevel loses |
| 407 | /// an edge, so a raised card and a well would otherwise be the same |
| 408 | /// single-tone box. A doubled line reads as standing off the page and a light |
| 409 | /// one as cut into it, which is the same claim the fill and the bevel make in |
| 410 | /// colour. |
| 411 | |
| 412 | pub |
| 413 | pub(crate) top: &'static str, |
| 414 | pub(crate) bottom: &'static str, |
| 415 | pub(crate) left: &'static str, |
| 416 | pub(crate) right: &'static str, |
| 417 | pub(crate) top_left: &'static str, |
| 418 | pub(crate) top_right: &'static str, |
| 419 | pub(crate) bottom_left: &'static str, |
| 420 | pub(crate) bottom_right: &'static str, |
| 421 | /// Whether the two corners where light meets shadow carry both tones in |
| 422 | /// one cell, foreground over background. |
| 423 | /// |
| 424 | /// Only a half-cell glyph can: it already divides the cell, so the split |
| 425 | /// costs nothing and the corner reads as a transition rather than as one |
| 426 | /// edge overrunning the other. A box-drawing corner is a single stroke |
| 427 | /// with no such division, so those sets say `false` and both shared |
| 428 | /// corners go to dark — see [`paint_bevel_with`] for why that particular |
| 429 | /// fallback and not the other one. |
| 430 | pub(crate) split_corners: bool, |
| 431 | |
| 432 | |
| 433 | /// Half-blocks, which is what a bevel actually wants. |
| 434 | /// |
| 435 | /// A cell is roughly 8x17 device pixels, so a half-block along the top and a |
| 436 | /// half-cell column down the side are about the same number of pixels and the |
| 437 | /// edge reads as even thickness. Box-drawing cannot do that: `─` and `│` are |
| 438 | /// both a thin stroke through the middle of the cell, identical on all four |
| 439 | /// sides, which draws a *line* rather than a lit edge and gives up the light |
| 440 | /// model that makes a bevel legible. |
| 441 | /// |
| 442 | /// Adopted from `alloy_tui`, which reached this independently and got there |
| 443 | /// first (2026-07-26, two days before this crate existed). |
| 444 | pub const BEVEL: GlyphSet = GlyphSet |
| 445 | top: "▀", |
| 446 | bottom: "▄", |
| 447 | left: "▌", |
| 448 | right: "▐", |
| 449 | top_left: "▛", |
| 450 | // The two shared corners are the split ones: an upper half continues the |
| 451 | // lit top edge while the lower half starts the shaded right edge, and the |
| 452 | // mirror of that at bottom left. |
| 453 | top_right: "▀", |
| 454 | bottom_left: "▄", |
| 455 | bottom_right: "▟", |
| 456 | split_corners: true, |
| 457 | ; |
| 458 | |
| 459 | pub const LIGHT: GlyphSet = GlyphSet |
| 460 | top: "─", |
| 461 | bottom: "─", |
| 462 | left: "│", |
| 463 | right: "│", |
| 464 | top_left: "┌", |
| 465 | top_right: "┐", |
| 466 | bottom_left: "└", |
| 467 | bottom_right: "┘", |
| 468 | split_corners: false, |
| 469 | ; |
| 470 | |
| 471 | pub const DOUBLE: GlyphSet = GlyphSet |
| 472 | top: "═", |
| 473 | bottom: "═", |
| 474 | left: "║", |
| 475 | right: "║", |
| 476 | top_left: "╔", |
| 477 | top_right: "╗", |
| 478 | bottom_left: "╚", |
| 479 | bottom_right: "╝", |
| 480 | split_corners: false, |
| 481 | ; |
| 482 | |
| 483 | /// Paint a two-tone edge around the outside of `area`. |
| 484 | /// |
| 485 | /// Light takes the top and left, dark the bottom and right. What happens at |
| 486 | /// the two corners where they meet depends on what the terminal can show. |
| 487 | /// Above sixteen colours the edge is drawn in half-blocks and those corners |
| 488 | /// carry both tones, one per half-cell. At sixteen it is box-drawing, whose |
| 489 | /// single stroke has no half to give, so both shared corners go to dark. |
| 490 | /// |
| 491 | /// Costs a cell on each side, which a pixel renderer's bevel does not. Use the |
| 492 | /// [`Rect`] returned by [`frame`] rather than assuming the area is intact. |
| 493 | |
| 494 | paint_bevel_with; |
| 495 | |
| 496 | |
| 497 | /// Whether the activity mark is lit, this far into a wait. |
| 498 | /// |
| 499 | /// The one place the blink's phase is worked out, so that `piece::activity` can |
| 500 | /// stay a pure drawing and this crate can still hold no clock: the caller says |
| 501 | /// how long the wait has run and gets back which of the two cells to draw. |
| 502 | /// |
| 503 | /// The cadence is `makeover_timing::Cadence::Activity`, a half-period, and is |
| 504 | /// deliberately not a number chosen here. Three renderers draw this mark and |
| 505 | /// one of them is a browser running it off a CSS custom property; a terminal |
| 506 | /// that picked its own would be a second heartbeat for one wait. |
| 507 | /// |
| 508 | /// **`reduced` returns a lit mark, always.** A reader asking for less motion has |
| 509 | /// asked for the movement to stop, not for the information to go away, which is |
| 510 | /// the whole argument on `makeover_timing::activity_blink`. A terminal has no |
| 511 | /// `prefers-reduced-motion` to read, so the preference arrives as a bool from |
| 512 | /// whatever the host asked its own platform. |
| 513 | /// |
| 514 | /// ``` |
| 515 | /// use makeover_tui::activity_lit; |
| 516 | /// use std::time::Duration; |
| 517 | /// |
| 518 | /// assert!(activity_lit(Duration::from_millis(0), false)); |
| 519 | /// assert!(!activity_lit(Duration::from_millis(600), false)); |
| 520 | /// assert!(activity_lit(Duration::from_millis(1100), false)); |
| 521 | /// // Motion off: lit, and it stays lit. |
| 522 | /// assert!(activity_lit(Duration::from_millis(600), true)); |
| 523 | /// ``` |
| 524 | |
| 525 | |
| 526 | let Some = activity_blink else |
| 527 | return true; |
| 528 | ; |
| 529 | let half = half.as_millis; |
| 530 | // A cadence of zero would divide by nothing, which is the one value the |
| 531 | // token cannot mean. Lit and still is the same answer reduced motion gets. |
| 532 | if half == 0 |
| 533 | return true; |
| 534 | |
| 535 | .is_multiple_of |
| 536 | |
| 537 | |
| 538 | /// Which glyphs to draw with, given what the terminal can show. |
| 539 | /// |
| 540 | /// Above sixteen colours the two tones are available and [`BEVEL`] renders |
| 541 | /// them as light. At sixteen the tones collapse, so the box-drawing sets carry |
| 542 | /// the distinction in weight instead, and `depth` picks which: a doubled frame |
| 543 | /// for a raised card and a light one for everything else. `None` means the |
| 544 | /// caller is drawing a bevel with no depth behind it, which is never the |
| 545 | /// doubled case. |
| 546 | |
| 547 | if !palette.needs_glyph_depth |
| 548 | return BEVEL; |
| 549 | |
| 550 | match depth |
| 551 | Some => DOUBLE, |
| 552 | _ => LIGHT, |
| 553 | |
| 554 | |
| 555 | |
| 556 | |
| 557 | if area.width < 2 || area.height < 2 |
| 558 | return; |
| 559 | |
| 560 | let = bevel.edges; |
| 561 | let light = palette.edge; |
| 562 | let dark = palette.edge; |
| 563 | |
| 564 | let = ; |
| 565 | let = ; |
| 566 | |
| 567 | // Light first: top edge and left edge, corners included. |
| 568 | for x in x0..=x1 |
| 569 | buf.set_symbol.set_fg; |
| 570 | |
| 571 | for y in y0..=y1 |
| 572 | buf.set_symbol.set_fg; |
| 573 | |
| 574 | // Dark second, so on a set without split corners the two shared ones land |
| 575 | // on it by draw order alone. |
| 576 | for x in x0..=x1 |
| 577 | buf.set_symbol.set_fg; |
| 578 | |
| 579 | for y in y0..=y1 |
| 580 | buf.set_symbol.set_fg; |
| 581 | |
| 582 | |
| 583 | buf.set_symbol.set_fg; |
| 584 | buf.set_symbol.set_fg; |
| 585 | |
| 586 | if set.split_corners |
| 587 | // Where light meets shadow, both tones share the cell: the half the |
| 588 | // glyph fills is the foreground and the half it leaves is the |
| 589 | // background, so the corner is a transition rather than one edge |
| 590 | // overrunning the other. |
| 591 | buf |
| 592 | .set_symbol |
| 593 | .set_fg |
| 594 | .set_bg; |
| 595 | buf |
| 596 | .set_symbol |
| 597 | .set_fg |
| 598 | .set_bg; |
| 599 | else |
| 600 | // Both shared corners to dark. Not arbitrary: it is the same rule |
| 601 | // `makeover-immediate` produces by drawing its dark polyline second, |
| 602 | // so a control does not change which corner is lit when it moves |
| 603 | // between a terminal and a window. A single-stroke corner has no half |
| 604 | // to give the other tone, so this is the only rule available to these |
| 605 | // sets anyway. |
| 606 | buf.set_symbol.set_fg; |
| 607 | buf.set_symbol.set_fg; |
| 608 | |
| 609 | |
| 610 | |
| 611 | /// Draw a region at a given [`Depth`] and return the area left for content. |
| 612 | /// |
| 613 | /// The fill is painted only when it would be visible against what is already |
| 614 | /// in the buffer. Everything else is the edge, which is why a well still reads |
| 615 | /// as a well on a terminal that cannot colour one. |
| 616 | |
| 617 | if area.is_empty |
| 618 | return area; |
| 619 | |
| 620 | let behind = buf.bg; |
| 621 | |
| 622 | if let Some = depth.fill.and_then |
| 623 | && shows |
| 624 | |
| 625 | for y in area.top..area.bottom |
| 626 | for x in area.left..area.right |
| 627 | buf.set_bg; |
| 628 | |
| 629 | |
| 630 | |
| 631 | |
| 632 | match depth.bevel |
| 633 | Some if area.width >= 2 && area.height >= 2 => |
| 634 | // Colour separates raised from well wherever it can. Where it |
| 635 | // cannot, the glyphs do, and only then: a doubled frame on every |
| 636 | // terminal would be shouting. |
| 637 | let set = set_for; |
| 638 | paint_bevel_with; |
| 639 | new |
| 640 | |
| 641 | _ => area, |
| 642 | |
| 643 | |
| 644 | |
| 645 | |
| 646 | |
| 647 | use *; |
| 648 | |
| 649 | |
| 650 | Palette |
| 651 | page: Indexed, |
| 652 | raised: Indexed, |
| 653 | overlay: Indexed, |
| 654 | well, |
| 655 | bevel_light: Indexed, |
| 656 | bevel_dark: Indexed, |
| 657 | fidelity: TrueColor, |
| 658 | |
| 659 | |
| 660 | |
| 661 | |
| 662 | empty |
| 663 | |
| 664 | |
| 665 | |
| 666 | |
| 667 | // The 18-of-31 case: no surface-well token at all. |
| 668 | let p = palette; |
| 669 | let mut buf = buffer; |
| 670 | frame; |
| 671 | // No fill was available, but the region still reads as recessed. |
| 672 | assert_eq!; |
| 673 | assert_eq!; |
| 674 | |
| 675 | |
| 676 | |
| 677 | |
| 678 | let p = palette; |
| 679 | let mut buf = buffer; |
| 680 | // Everything behind is already page-coloured, and the well quantised |
| 681 | // onto it. Painting it would be a no-op that hides the real problem. |
| 682 | for y in 0..4 |
| 683 | for x in 0..6 |
| 684 | buf.set_bg; |
| 685 | |
| 686 | |
| 687 | frame; |
| 688 | assert!; |
| 689 | // The edge is what carries the meaning here. |
| 690 | assert_eq!; |
| 691 | |
| 692 | |
| 693 | |
| 694 | |
| 695 | let p = palette; |
| 696 | let mut buf = buffer; |
| 697 | frame; |
| 698 | assert_eq!; |
| 699 | |
| 700 | |
| 701 | |
| 702 | |
| 703 | // makeover-layout 0.14.0's Depth::Overlay, and the wiring under it was |
| 704 | // already here: `Palette::fill` has answered `Fill::Overlay` since this |
| 705 | // crate had a palette. So this asserts the route rather than building |
| 706 | // one, and it is the assertion that would catch the route being lost. |
| 707 | let p = palette; |
| 708 | let mut buf = buffer; |
| 709 | let inner = frame; |
| 710 | |
| 711 | assert_eq!; |
| 712 | // A surface over the page is separated by the lift and by what sits |
| 713 | // behind it, so it takes no edge -- and with no edge drawn, nothing is |
| 714 | // given up to one: the content area is the whole region. |
| 715 | assert_eq!; |
| 716 | assert_eq!; |
| 717 | |
| 718 | |
| 719 | |
| 720 | |
| 721 | let p = palette; |
| 722 | let mut buf = buffer; |
| 723 | paint_bevel; |
| 724 | assert_eq!; // top-left |
| 725 | assert_eq!; // top edge |
| 726 | assert_eq!; // left edge |
| 727 | assert_eq!; // bottom-right |
| 728 | assert_eq!; // bottom edge |
| 729 | assert_eq!; // right edge |
| 730 | |
| 731 | |
| 732 | // Half-cell glyphs divide the cell already, so the corner where light |
| 733 | // meets shadow can hold both rather than picking one. |
| 734 | |
| 735 | |
| 736 | let p = palette; |
| 737 | let mut buf = buffer; |
| 738 | paint_bevel; |
| 739 | let top_right = &buf; |
| 740 | assert_eq!; |
| 741 | assert_eq!; |
| 742 | let bottom_left = &buf; |
| 743 | assert_eq!; |
| 744 | assert_eq!; |
| 745 | |
| 746 | |
| 747 | // A single-stroke corner has no half to give the second tone, so the |
| 748 | // box-drawing sets keep the old rule: both shared corners to dark, which |
| 749 | // is what makeover-immediate produces by drawing its dark polyline second. |
| 750 | // Changing that would move the lit corner between a terminal and a window. |
| 751 | |
| 752 | |
| 753 | let p = Palette |
| 754 | fidelity: Ansi16, |
| 755 | ..palette |
| 756 | ; |
| 757 | let mut buf = buffer; |
| 758 | paint_bevel; |
| 759 | assert_eq!; |
| 760 | assert_eq!; |
| 761 | assert_eq!; |
| 762 | assert_eq!; |
| 763 | |
| 764 | |
| 765 | // The whole outline, as a reader sees it. Asserted as glyphs because the |
| 766 | // shape is the point: an even-weight edge on all four sides, which is what |
| 767 | // box-drawing could not give. |
| 768 | |
| 769 | |
| 770 | let p = palette; |
| 771 | let mut buf = empty; |
| 772 | paint_bevel; |
| 773 | let rows: = |
| 774 | .map |
| 775 | .collect; |
| 776 | assert_eq!; |
| 777 | |
| 778 | |
| 779 | |
| 780 | |
| 781 | let p = palette; |
| 782 | let mut buf = buffer; |
| 783 | paint_bevel; |
| 784 | assert_eq!; |
| 785 | |
| 786 | |
| 787 | |
| 788 | |
| 789 | // Not a failure: one box is still a boundary. The palette says so |
| 790 | // rather than the renderer pretending otherwise. |
| 791 | let flat = Palette |
| 792 | bevel_dark: Indexed, |
| 793 | ..palette |
| 794 | ; |
| 795 | assert!; |
| 796 | assert!; |
| 797 | |
| 798 | |
| 799 | |
| 800 | |
| 801 | let p = palette; |
| 802 | let mut buf = buffer; |
| 803 | let inner = frame; |
| 804 | assert_eq!; |
| 805 | // Flat takes no cells, because it draws no edge. |
| 806 | let same = frame; |
| 807 | assert_eq!; |
| 808 | |
| 809 | |
| 810 | |
| 811 | |
| 812 | // Colour cannot separate raised from well here: the fill collapses on |
| 813 | // most themes and an edge vanishes on all of them. The frame has to |
| 814 | // say it some other way or the two become the same box. |
| 815 | let p = Palette |
| 816 | fidelity: Ansi16, |
| 817 | ..palette |
| 818 | ; |
| 819 | assert!; |
| 820 | let mut raised = buffer; |
| 821 | frame; |
| 822 | let mut well = buffer; |
| 823 | frame; |
| 824 | assert_eq!; |
| 825 | assert_eq!; |
| 826 | assert_ne!; |
| 827 | |
| 828 | |
| 829 | |
| 830 | |
| 831 | // The doubled fallback must not fire where colour already works, or |
| 832 | // every modern terminal gets a heavier frame it did not need. What it |
| 833 | // gets instead is the half-block bevel. |
| 834 | for f in |
| 835 | let p = Palette |
| 836 | fidelity: f, |
| 837 | ..palette |
| 838 | ; |
| 839 | assert!; |
| 840 | let mut buf = buffer; |
| 841 | frame; |
| 842 | assert_eq! |
| 843 | buf.symbol, |
| 844 | BEVEL.top_left, |
| 845 | "{f:?} got a heavier frame" |
| 846 | ; |
| 847 | assert_ne!; |
| 848 | |
| 849 | |
| 850 | |
| 851 | // Raised and well are both bevels and differ only in which way they are |
| 852 | // lit, so above sixteen colours they draw the same glyphs and the tones |
| 853 | // carry the difference. That is exactly what stops holding at Ansi16, and |
| 854 | // why the doubled set exists. |
| 855 | |
| 856 | |
| 857 | let p = palette; |
| 858 | let mut raised = buffer; |
| 859 | frame; |
| 860 | let mut well = buffer; |
| 861 | frame; |
| 862 | assert_eq!; |
| 863 | assert_eq!; |
| 864 | assert_eq!; |
| 865 | |
| 866 | |
| 867 | |
| 868 | |
| 869 | assert!; |
| 870 | assert!; |
| 871 | assert!; |
| 872 | assert!; |
| 873 | |
| 874 | |
| 875 | // Sixteen colours is reached by naming a console, never by failing to |
| 876 | // recognise a terminal. `COLORTERM` is stripped by ssh and by every |
| 877 | // multiplexer, so an unrecognised name carries no evidence at all, and a |
| 878 | // caller quantising its palette off this answer would flatten a whole theme |
| 879 | // on the strength of it. |
| 880 | |
| 881 | |
| 882 | let f = from_env; |
| 883 | assert_eq!; |
| 884 | assert_eq!; |
| 885 | assert_eq!; |
| 886 | assert_eq!; |
| 887 | |
| 888 | |
| 889 | |
| 890 | |
| 891 | let f = from_env; |
| 892 | assert_eq!; |
| 893 | assert_eq!; |
| 894 | assert_eq!; |
| 895 | |
| 896 | |
| 897 | |
| 898 | |
| 899 | let f = from_env; |
| 900 | assert_eq!; |
| 901 | assert_eq!; |
| 902 | assert_eq!; |
| 903 | // And a claim of 24-bit beats the name, which is only ever a floor. |
| 904 | assert_eq!; |
| 905 | assert_eq!; |
| 906 | |
| 907 | |
| 908 | |
| 909 | |
| 910 | let p = palette; |
| 911 | let mut buf = buffer; |
| 912 | let inner = frame; |
| 913 | assert_eq!; |
| 914 | |
| 915 | |
| 916 |