Skip to main content

max / makeover-tui

0.4.0: Draw the bevel in half-blocks, and split the shared corners alloy_tui reached the same light model independently on 2026-07-26, two days before this crate existed, and drew it better. Take its rendering. Box-drawing was the wrong shape for a bevel. A cell is roughly 8x17 device pixels, so a half-block along the top and a half-cell column down the side land at comparable weight and read as an even edge; a box-drawing frame is one thin stroke through the middle of the cell, identical on all four sides, saying nothing about where the light is. The crate already argued that what a terminal costs you is geometry, then conceded the point in its own glyphs. Half-cells also make the two corners where light meets shadow expressible. A glyph filling half a cell leaves the other half to the second tone, so top-right and bottom-left now carry both rather than picking one. The box-drawing sets keep the old corner rule, and not as a compromise: a single stroke has no half to give, so both shared corners to dark is the only rule available to them. That happens to be the rule makeover-immediate produces by drawing its dark polyline second, so the cross-renderer agreement this crate documented is intact wherever it was ever expressible. Whether the pixel renderer should also split is now the only open half of that question. GlyphSet grows from per-axis to per-side, since the bevel set uses a different glyph on opposite sides. Glyph selection moves into set_for, which also fixes paint_bevel: it hardcoded the box-drawing set and ignored fidelity, so a caller drawing a bevel directly got the sixteen-colour fallback on a truecolor terminal. makeover_layout is re-exported. Every entry point here takes a type from it, so a consumer otherwise carries a second dependency and two version requirements to name an argument it is already being handed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 17:23 UTC
Signed with PGP, not checked
Commit: 8d69fa89d6dbaac32381729b3d9abb0e3dfd89a0
Parent: c46e5aa
2 files changed, +206 insertions, -38 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.3.0"
3 + version = "0.4.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
M src/lib.rs +205 -37
@@ -33,6 +33,14 @@
33 33 //! That is the constraint worth designing against. It does not improve, it is
34 34 //! not detectable, and it applies equally to the best terminal ever written.
35 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 + //!
36 44 //! # Where fidelity does matter
37 45 //!
38 46 //! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well
@@ -69,6 +77,13 @@
69 77 use ratatui::layout::Rect;
70 78 use ratatui::style::Color;
71 79
80 + /// The description this crate renders, re-exported.
81 + ///
82 + /// Every entry point here takes a type from it, so a consumer would otherwise
83 + /// have to depend on the description separately and keep two version
84 + /// requirements in step to name the argument it is already being handed.
85 + pub use makeover_layout;
86 +
72 87 /// How many colours the terminal can actually show.
73 88 ///
74 89 /// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
@@ -207,53 +222,123 @@
207 222 }
208 223 }
209 224
210 - /// Box-drawing characters for a frame's edges and corners.
225 + /// The characters a frame's edges and corners are drawn with.
211 226 ///
212 - /// Two sets, because at sixteen colours the glyphs are the only thing left to
213 - /// carry depth: a well cannot be filled distinctly and a bevel loses an edge,
214 - /// so a raised card and a well would otherwise be the same single-tone box.
215 - /// A doubled line reads as standing off the page and a light one as cut into
216 - /// it, which is the same claim the fill and the bevel make in colour.
227 + /// Per side rather than per axis, because the set that reads best as a bevel
228 + /// does not use the same glyph on opposite sides: a half-block edge is only
229 + /// half a cell, and which half it occupies is what says where the edge is.
230 + /// Box-drawing sets fill `top`/`bottom` and `left`/`right` with the same
231 + /// character and lose nothing by it.
232 + ///
233 + /// Three sets. [`BEVEL`] is what a terminal that can show two tones gets. The
234 + /// other two exist because at sixteen colours the glyphs are the only thing
235 + /// left to carry depth: a well cannot be filled distinctly and a bevel loses
236 + /// an edge, so a raised card and a well would otherwise be the same
237 + /// single-tone box. A doubled line reads as standing off the page and a light
238 + /// one as cut into it, which is the same claim the fill and the bevel make in
239 + /// colour.
217 240 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
218 241 pub(crate) struct GlyphSet {
219 - pub(crate) horizontal: &'static str,
220 - pub(crate) vertical: &'static str,
242 + pub(crate) top: &'static str,
243 + pub(crate) bottom: &'static str,
244 + pub(crate) left: &'static str,
245 + pub(crate) right: &'static str,
221 246 pub(crate) top_left: &'static str,
222 247 pub(crate) top_right: &'static str,
223 248 pub(crate) bottom_left: &'static str,
224 249 pub(crate) bottom_right: &'static str,
250 + /// Whether the two corners where light meets shadow carry both tones in
251 + /// one cell, foreground over background.
252 + ///
253 + /// Only a half-cell glyph can: it already divides the cell, so the split
254 + /// costs nothing and the corner reads as a transition rather than as one
255 + /// edge overrunning the other. A box-drawing corner is a single stroke
256 + /// with no such division, so those sets say `false` and both shared
257 + /// corners go to dark — see [`paint_bevel_with`] for why that particular
258 + /// fallback and not the other one.
259 + pub(crate) split_corners: bool,
225 260 }
226 261
262 + /// Half-blocks, which is what a bevel actually wants.
263 + ///
264 + /// A cell is roughly 8x17 device pixels, so a half-block along the top and a
265 + /// half-cell column down the side are about the same number of pixels and the
266 + /// edge reads as even thickness. Box-drawing cannot do that: `─` and `│` are
267 + /// both a thin stroke through the middle of the cell, identical on all four
268 + /// sides, which draws a *line* rather than a lit edge and gives up the light
269 + /// model that makes a bevel legible.
270 + ///
271 + /// Adopted from `alloy_tui`, which reached this independently and got there
272 + /// first (2026-07-26, two days before this crate existed).
273 + pub(crate) const BEVEL: GlyphSet = GlyphSet {
274 + top: "▀",
275 + bottom: "▄",
276 + left: "▌",
277 + right: "▐",
278 + top_left: "▛",
279 + // The two shared corners are the split ones: an upper half continues the
280 + // lit top edge while the lower half starts the shaded right edge, and the
281 + // mirror of that at bottom left.
282 + top_right: "▀",
283 + bottom_left: "▄",
284 + bottom_right: "▟",
285 + split_corners: true,
286 + };
287 +
227 288 pub(crate) const LIGHT: GlyphSet = GlyphSet {
228 - horizontal: "─",
229 - vertical: "│",
289 + top: "─",
290 + bottom: "─",
291 + left: "│",
292 + right: "│",
230 293 top_left: "┌",
231 294 top_right: "┐",
232 295 bottom_left: "└",
233 296 bottom_right: "┘",
297 + split_corners: false,
234 298 };
235 299
236 300 pub(crate) const DOUBLE: GlyphSet = GlyphSet {
237 - horizontal: "═",
238 - vertical: "║",
301 + top: "═",
302 + bottom: "═",
303 + left: "║",
304 + right: "║",
239 305 top_left: "╔",
240 306 top_right: "╗",
241 307 bottom_left: "╚",
242 308 bottom_right: "╝",
309 + split_corners: false,
243 310 };
244 311
245 312 /// Paint a two-tone edge around the outside of `area`.
246 313 ///
247 - /// Light takes the top and left, dark the bottom and right, and the two shared
248 - /// corners go to dark. That corner rule is not arbitrary: it is the same one
249 - /// `makeover-immediate` produces by drawing its dark polyline second, so a
250 - /// control does not change which corner is lit when it moves between a
251 - /// terminal and a window.
314 + /// Light takes the top and left, dark the bottom and right. What happens at
315 + /// the two corners where they meet depends on what the terminal can show.
316 + /// Above sixteen colours the edge is drawn in half-blocks and those corners
317 + /// carry both tones, one per half-cell. At sixteen it is box-drawing, whose
318 + /// single stroke has no half to give, so both shared corners go to dark.
252 319 ///
253 320 /// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
254 321 /// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
255 322 pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
256 - paint_bevel_with(buf, area, bevel, palette, LIGHT);
323 + paint_bevel_with(buf, area, bevel, palette, set_for(palette, None));
324 + }
325 +
326 + /// Which glyphs to draw with, given what the terminal can show.
327 + ///
328 + /// Above sixteen colours the two tones are available and [`BEVEL`] renders
329 + /// them as light. At sixteen the tones collapse, so the box-drawing sets carry
330 + /// the distinction in weight instead, and `depth` picks which: a doubled frame
331 + /// for a raised card and a light one for everything else. `None` means the
332 + /// caller is drawing a bevel with no depth behind it, which is never the
333 + /// doubled case.
334 + fn set_for(palette: &Palette, depth: Option<Depth>) -> GlyphSet {
335 + if !palette.needs_glyph_depth() {
336 + return BEVEL;
337 + }
338 + match depth {
339 + Some(Depth::Raised) => DOUBLE,
340 + _ => LIGHT,
341 + }
257 342 }
258 343
259 344 fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
@@ -269,23 +354,46 @@
269 354
270 355 // Light first: top edge and left edge, corners included.
271 356 for x in x0..=x1 {
272 - buf[(x, y0)].set_symbol(set.horizontal).set_fg(light);
357 + buf[(x, y0)].set_symbol(set.top).set_fg(light);
273 358 }
274 359 for y in y0..=y1 {
275 - buf[(x0, y)].set_symbol(set.vertical).set_fg(light);
360 + buf[(x0, y)].set_symbol(set.left).set_fg(light);
276 361 }
277 - // Dark second, so the two shared corners land on it.
362 + // Dark second, so on a set without split corners the two shared ones land
363 + // on it by draw order alone.
278 364 for x in x0..=x1 {
279 - buf[(x, y1)].set_symbol(set.horizontal).set_fg(dark);
365 + buf[(x, y1)].set_symbol(set.bottom).set_fg(dark);
280 366 }
281 367 for y in y0..=y1 {
282 - buf[(x1, y)].set_symbol(set.vertical).set_fg(dark);
368 + buf[(x1, y)].set_symbol(set.right).set_fg(dark);
283 369 }
284 370
285 371 buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
286 - buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
287 - buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
288 372 buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);
373 +
374 + if set.split_corners {
375 + // Where light meets shadow, both tones share the cell: the half the
376 + // glyph fills is the foreground and the half it leaves is the
377 + // background, so the corner is a transition rather than one edge
378 + // overrunning the other.
379 + buf[(x1, y0)]
380 + .set_symbol(set.top_right)
381 + .set_fg(light)
382 + .set_bg(dark);
383 + buf[(x0, y1)]
384 + .set_symbol(set.bottom_left)
385 + .set_fg(dark)
386 + .set_bg(light);
387 + } else {
388 + // Both shared corners to dark. Not arbitrary: it is the same rule
389 + // `makeover-immediate` produces by drawing its dark polyline second,
390 + // so a control does not change which corner is lit when it moves
391 + // between a terminal and a window. A single-stroke corner has no half
392 + // to give the other tone, so this is the only rule available to these
393 + // sets anyway.
394 + buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
395 + buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
396 + }
289 397 }
290 398
291 399 /// Draw a region at a given [`Depth`] and return the area left for content.
@@ -314,10 +422,7 @@
314 422 // Colour separates raised from well wherever it can. Where it
315 423 // cannot, the glyphs do, and only then: a doubled frame on every
316 424 // terminal would be shouting.
317 - let set = match (palette.needs_glyph_depth(), depth) {
318 - (true, Depth::Raised) => DOUBLE,
319 - _ => LIGHT,
320 - };
425 + let set = set_for(palette, Some(depth));
321 426 paint_bevel_with(buf, area, bevel, palette, set);
322 427 Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
323 428 }
@@ -352,7 +457,7 @@
352 457 let mut buf = buffer();
353 458 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
354 459 // No fill was available, but the region still reads as recessed.
355 - assert_eq!(buf[(0, 0)].symbol(), LIGHT.top_left);
460 + assert_eq!(buf[(0, 0)].symbol(), BEVEL.top_left);
356 461 assert_eq!(buf[(0, 0)].bg, Color::Reset);
357 462 }
358 463
@@ -370,7 +475,7 @@
370 475 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
371 476 assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
372 477 // The edge is what carries the meaning here.
373 - assert_eq!(buf[(5, 3)].symbol(), LIGHT.bottom_right);
478 + assert_eq!(buf[(5, 3)].symbol(), BEVEL.bottom_right);
374 479 }
375 480
376 481 #[test]
@@ -382,7 +487,7 @@
382 487 }
383 488
384 489 #[test]
385 - fn the_light_falls_from_the_top_left_and_corners_go_dark() {
490 + fn the_light_falls_from_the_top_left() {
386 491 let p = palette(None);
387 492 let mut buf = buffer();
388 493 paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
@@ -390,12 +495,57 @@
390 495 assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
391 496 assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
392 497 assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
393 - // The two shared corners go to dark, matching what the immediate
394 - // renderer produces by drawing its dark polyline second.
498 + assert_eq!(buf[(3, 3)].fg, p.bevel_dark); // bottom edge
499 + assert_eq!(buf[(5, 2)].fg, p.bevel_dark); // right edge
500 + }
501 +
502 + // Half-cell glyphs divide the cell already, so the corner where light
503 + // meets shadow can hold both rather than picking one.
504 + #[test]
505 + fn the_shared_corners_carry_both_tones_when_the_glyph_can_split() {
506 + let p = palette(None);
507 + let mut buf = buffer();
508 + paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
509 + let top_right = &buf[(5, 0)];
510 + assert_eq!(top_right.fg, p.bevel_light);
511 + assert_eq!(top_right.bg, p.bevel_dark);
512 + let bottom_left = &buf[(0, 3)];
513 + assert_eq!(bottom_left.fg, p.bevel_dark);
514 + assert_eq!(bottom_left.bg, p.bevel_light);
515 + }
516 +
517 + // A single-stroke corner has no half to give the second tone, so the
518 + // box-drawing sets keep the old rule: both shared corners to dark, which
519 + // is what makeover-immediate produces by drawing its dark polyline second.
520 + // Changing that would move the lit corner between a terminal and a window.
521 + #[test]
522 + fn box_drawing_corners_stay_dark_and_match_the_immediate_renderer() {
523 + let p = Palette {
524 + fidelity: Fidelity::Ansi16,
525 + ..palette(None)
526 + };
527 + let mut buf = buffer();
528 + paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
529 + assert_eq!(buf[(5, 0)].symbol(), LIGHT.top_right);
395 530 assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
531 + assert_eq!(buf[(5, 0)].bg, Color::Reset, "a stroke has no second tone");
396 532 assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
397 533 }
398 534
535 + // The whole outline, as a reader sees it. Asserted as glyphs because the
536 + // shape is the point: an even-weight edge on all four sides, which is what
537 + // box-drawing could not give.
538 + #[test]
539 + fn a_bevel_draws_an_even_outline_and_leaves_the_middle_alone() {
540 + let p = palette(None);
541 + let mut buf = Buffer::empty(Rect::new(0, 0, 5, 4));
542 + paint_bevel(&mut buf, Rect::new(0, 0, 5, 4), Bevel::Raised, &p);
543 + let rows: Vec<String> = (0..4)
544 + .map(|y| (0..5).map(|x| buf[(x, y)].symbol()).collect())
545 + .collect();
546 + assert_eq!(rows, vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"]);
547 + }
548 +
399 549 #[test]
400 550 fn pressing_swaps_the_lit_side() {
401 551 let p = palette(None);
@@ -448,8 +598,9 @@
448 598
449 599 #[test]
450 600 fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
451 - // The fallback must not fire where colour already works, or every
452 - // modern terminal gets a doubled frame it did not need.
601 + // The doubled fallback must not fire where colour already works, or
602 + // every modern terminal gets a heavier frame it did not need. What it
603 + // gets instead is the half-block bevel.
453 604 for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
454 605 let p = Palette {
455 606 fidelity: f,
@@ -460,12 +611,29 @@
460 611 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
461 612 assert_eq!(
462 613 buf[(0, 0)].symbol(),
463 - LIGHT.top_left,
614 + BEVEL.top_left,
464 615 "{f:?} got a heavier frame"
465 616 );
617 + assert_ne!(buf[(0, 0)].symbol(), DOUBLE.top_left);
466 618 }
467 619 }
468 620
621 + // Raised and well are both bevels and differ only in which way they are
622 + // lit, so above sixteen colours they draw the same glyphs and the tones
623 + // carry the difference. That is exactly what stops holding at Ansi16, and
624 + // why the doubled set exists.
625 + #[test]
626 + fn colour_alone_separates_raised_from_well_where_it_can() {
627 + let p = palette(Some(Color::Indexed(4)));
628 + let mut raised = buffer();
629 + frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
630 + let mut well = buffer();
631 + frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
632 + assert_eq!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
633 + assert_eq!(raised[(0, 0)].fg, p.bevel_light);
634 + assert_eq!(well[(0, 0)].fg, p.bevel_dark);
635 + }
636 +
469 637 #[test]
470 638 fn detection_defaults_generously_and_only_downgrades_on_evidence() {
471 639 assert!(Fidelity::default().separates_depth());