Skip to main content

max / quasi

Say a picture: Node::Image, and draw it on both hosts Picture carries the source; makeover_layout::Image carries what the picture is. Act's split, and named Picture rather than Image because the two are in scope together constantly, which is the dodge Tag already makes for Token. quasi-webview writes a figure for a captioned frame and a bare img for one without: wrapping the uncaptioned case would put a grouping element around a group of one. src is attribute-escaped and gets no scheme guard, because quoting is what stops a breakout and javascript: in an img src is a broken picture rather than a script. quasi-tui draws the alt text, which is the terminal's honest answer and the reason alt is not an Option upstream. A decorative picture draws nothing at all -- standing in for a rule with the word "decoration" is worse than the gap. Fit is read and deliberately not honoured: fitting is about a box with proportions and a run of words has none. Not reachable in focus order. src is where the bytes are, not somewhere the reader goes; a picture meant to be clicked is one inside a Link.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 16:04 UTC
Signed with PGP, not checked
Commit: e8acd86701beafe5782745fc780de23c62a34ac6
Parent: 2bcfe8a
10 files changed, +189 insertions, -8 deletions
M Cargo.lock +2 -2
@@ -2305,7 +2305,7 @@
2305 2305
2306 2306 [[package]]
2307 2307 name = "makeover-layout"
2308 - version = "0.20.0"
2308 + version = "0.21.0"
2309 2309
2310 2310 [[package]]
2311 2311 name = "makeover-touch"
@@ -2326,7 +2326,7 @@
2326 2326
2327 2327 [[package]]
2328 2328 name = "makeover-webview"
2329 - version = "0.35.1"
2329 + version = "0.36.0"
2330 2330 dependencies = [
2331 2331 "makeover-geometry",
2332 2332 "makeover-layout",
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.20.0"
16 + makeover-layout = "0.21.0"
@@ -14,7 +14,7 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.2.0" }
17 - makeover-layout = "0.20.0"
17 + makeover-layout = "0.21.0"
18 18 # The depth palette, the theme bridge and the table. Everything else this crate
19 19 # draws is written here first and lifted upstream once a second consumer wants
20 20 # it, which is the order the suite has always moved in: the constrained consumer
@@ -15,8 +15,8 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.2.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.2.0" }
18 - makeover-layout = "0.20.0"
19 - makeover-webview = "0.35.0"
18 + makeover-layout = "0.21.0"
19 + makeover-webview = "0.36.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -198,6 +198,12 @@
198 198 Self::Link { .. } => Containment::Text,
199 199 // One figure on a line. The strip is the collection; this is not.
200 200 Self::Figure(_) => Containment::Text,
201 + // A picture holds nothing. Its alt text and caption are its own
202 + // fields rather than content it contains, the same way a figure's
203 + // caption is: they describe the leaf, and nothing can be nested
204 + // under them. A gallery is a widget assembled out of several of
205 + // these, which is a container's answer and not this one's.
206 + Self::Image(_) => Containment::Text,
201 207 // An act is a label and an address. The address is not content.
202 208 Self::Act(_) => Containment::Text,
203 209 // A notice is a sentence with a tone. It was never allowed to hold
@@ -120,7 +120,7 @@
120 120 pub use crate::router::{Handler, Router};
121 121 pub use crate::screen::{
122 122 Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node,
123 - Part, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
123 + Part, Picture, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
124 124 };
125 125
126 126 #[cfg(test)]
@@ -427,6 +427,68 @@
427 427 }
428 428 }
429 429
430 + /// A picture and where it is.
431 + ///
432 + /// Called `Picture` rather than `Image` because `layout::Image` is the
433 + /// description half and the two are in scope together constantly. The same
434 + /// dodge [`Tag`] makes for `layout::Token`.
435 + ///
436 + /// The split is `layout::Image`'s: [`src`](Self::src) is an address and lives
437 + /// here, everything about what the picture *is* lives there.
438 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
439 + pub struct Picture {
440 + /// Where the picture is. A URL, or whatever the host resolves.
441 + ///
442 + /// Never interpreted here. A renderer escapes it for wherever it is putting
443 + /// it, the way it does every other app-supplied string.
444 + pub src: String,
445 + /// What the picture says, for anything not showing it.
446 + ///
447 + /// Empty means decorative. `layout::Image` carries the argument for why
448 + /// this is a `String` and not an `Option<String>`.
449 + pub alt: String,
450 + /// A visible line under it, where the app wants one.
451 + pub caption: Option<String>,
452 + /// How it sits in the box it is given.
453 + pub fit: layout::Fit,
454 + }
455 +
456 + impl Picture {
457 + /// A picture at a source, carrying its own proportions.
458 + pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
459 + Self {
460 + src: src.into(),
461 + alt: alt.into(),
462 + caption: None,
463 + fit: layout::Fit::Natural,
464 + }
465 + }
466 +
467 + /// A visible line under it.
468 + #[must_use]
469 + pub fn caption(mut self, caption: impl Into<String>) -> Self {
470 + self.caption = Some(caption.into());
471 + self
472 + }
473 +
474 + /// How it sits in its box.
475 + #[must_use]
476 + pub const fn fit(mut self, fit: layout::Fit) -> Self {
477 + self.fit = fit;
478 + self
479 + }
480 +
481 + /// Borrow as the description layer's own type.
482 + #[must_use]
483 + pub fn as_layout(&self) -> layout::Image<'_> {
484 + layout::Image {
485 + alt: &self.alt,
486 + caption: self.caption.as_deref(),
487 + fit: self.fit,
488 + }
489 + }
490 + }
491 +
430 492 /// One figure with a caption, owned.
431 493 ///
432 494 /// The borrowed original is [`layout::Figure`], and everything it says applies:
@@ -2071,6 +2133,16 @@
2071 2133 /// there is nothing for a set to add. A dashboard strip of one is still a
2072 2134 /// strip; a revenue column is not.
2073 2135 Figure(Figure),
2136 + /// A picture, at a source this crate holds and the description does not.
2137 + ///
2138 + /// The [`Act`](Self::Act) split, and `layout::Image`'s own docs carry the
2139 + /// argument: an address is not the description's to hold, so the shape and
2140 + /// the alt text live there and the URL lives here.
2141 + ///
2142 + /// A leaf, so it may sit in a run the way [`Link`](Self::Link) does. What
2143 + /// it may *not* do is stand in for a region: a picture is one thing on the
2144 + /// page, and a gallery of them is a widget assembled out of several.
2145 + Image(Picture),
2074 2146 /// A small labelled thing sitting inside something else.
2075 2147 Token(Tag),
2076 2148 /// Something the app is telling the user, unprompted.
@@ -327,6 +327,10 @@
327 327 | Node::Text { .. }
328 328 | Node::Rich { .. }
329 329 | Node::Figure(_)
330 + // A picture carries no address of its own -- `src` is where the bytes
331 + // are, not somewhere the reader goes -- so there is nothing to stop on.
332 + // A picture that is meant to be clicked is one inside a `Link`.
333 + | Node::Image(_)
330 334 | Node::Notice { .. }
331 335 | Node::Meter(_)
332 336 | Node::Stats { .. } => {}
@@ -6,7 +6,7 @@
6 6
7 7 use makeover_layout as layout;
8 8 use makeover_tui::{piece, table, text};
9 - use quasi_router::{Act, Cell, Cells, Field, Figure, Meter, Node, Part, Row, Tag};
9 + use quasi_router::{Act, Cell, Cells, Field, Figure, Meter, Node, Part, Picture, Row, Tag};
10 10 use ratatui::buffer::Buffer;
11 11 use ratatui::layout::Rect;
12 12 use ratatui::style::{Modifier, Style};
@@ -27,6 +27,7 @@
27 27 Node::Link { text: label, .. } => text::height(label, width),
28 28 Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width),
29 29 Node::Figure(figure) => figure_height(tui, figure, width),
30 + Node::Image(picture) => image_height(picture, width),
30 31 Node::Notice { text: content, .. } => text::height(content, width),
31 32 Node::StandIn { message, act, .. } => {
32 33 text::height(message, width) + act.as_ref().map_or(0, |_| 1)
@@ -129,6 +130,8 @@
129 130
130 131 Node::Figure(figure) => draw_figure(tui, figure, area, buf),
131 132
133 + Node::Image(picture) => draw_image(tui, picture, area, buf),
134 +
132 135 // A banner and a toast are the same rows here. A toast is a message
133 136 // that goes away on its own, which is a clock the description does not
134 137 // carry and the drawing has no way to keep, so the kind is read and
@@ -593,6 +596,41 @@
593 596 piece::figure(tui.style(), &figure.as_layout(), area, buf)
594 597 }
595 598
599 + /// A picture is its alt text here, and a decorative one is nothing.
600 + ///
601 + /// The terminal's honest answer, and the reason `layout::Image::alt` is not an
602 + /// `Option`. There is no graphics protocol in this renderer -- ratatui draws
603 + /// cells -- so what a reader gets is the words the picture stands for. An empty
604 + /// alt is the description saying the picture adds nothing to the text around
605 + /// it, and repeating "image" in its place would be worse than the gap.
606 + ///
607 + /// `Fit` is read and deliberately not honoured, the way `Notice`'s kind is:
608 + /// fitting is about a box with proportions, and a run of words has none.
609 + fn image_height(picture: &Picture, width: u16) -> u16 {
610 + if !picture.as_layout().speaks() {
611 + return 0;
612 + }
613 + text::height(&picture.alt, width)
614 + + picture
615 + .caption
616 + .as_ref()
617 + .map_or(0, |c| text::height(c, width))
618 + }
619 +
620 + fn draw_image(tui: &Tui, picture: &Picture, area: Rect, buf: &mut Buffer) -> u16 {
621 + if !picture.as_layout().speaks() {
622 + return 0;
623 + }
624 + // Muted, because this is standing in for something rather than being it.
625 + let used = text::draw(&picture.alt, tui.style().muted, area, buf);
626 + let Some(caption) = &picture.caption else {
627 + return used;
628 + };
629 + // A caption is ordinary content that happens to sit under a picture, so it
630 + // is not muted: it reads the same whether or not the picture arrived.
631 + used + text::draw(caption, tui.style().secondary, below(area, used), buf)
632 + }
633 +
596 634 /// A question takes its label row, its value row, and a row for whatever went
597 635 /// wrong.
598 636 fn field_height(tui: &Tui, field: &Field, width: u16) -> u16 {
@@ -85,6 +85,27 @@
85 85 out.push('"');
86 86 }
87 87
88 + /// How a picture sits in its box, where it is not the default.
89 + ///
90 + /// `tone_attr`'s shape and for its reason: `Natural` is what an `<img>` does
91 + /// with no rule at all, so saying it would be a stylesheet hook that changes
92 + /// nothing. The two that need a rule get one.
93 + fn fit_attr(fit: layout::Fit, out: &mut String) {
94 + let value = match fit {
95 + layout::Fit::Natural => return,
96 + layout::Fit::Cover => "cover",
97 + layout::Fit::Contain => "contain",
98 + // `Fit` is `#[non_exhaustive]`, so a member added upstream lands here
99 + // rather than failing the build. Drawing it as natural is the safe
100 + // read: the picture is whole and its own shape, which is wrong about
101 + // the box and never wrong about the content.
102 + _ => return,
103 + };
104 + out.push_str(" data-fit=\"");
105 + out.push_str(value);
106 + out.push('"');
107 + }
108 +
88 109 /// What goes back in the box, for a form being offered again after a refusal.
89 110 ///
90 111 /// `1c4a66a4`. The description carries the value as a string, because that is
@@ -938,6 +959,46 @@
938 959 out.push_str(&figure_html(&figure.as_layout(), opts));
939 960 }
940 961
962 + Node::Image(picture) => {
963 + // A captioned picture is a `<figure>`, which is what the element is
964 + // for and what the shipped carousel already writes by hand. An
965 + // uncaptioned one is the bare `<img>`: wrapping it would put a
966 + // grouping element around a group of one.
967 + let captioned = picture.caption.is_some();
968 + if captioned {
969 + out.push_str("<figure");
970 + class_attr(&["picture"], opts, out);
971 + out.push('>');
972 + }
973 +
974 + out.push_str("<img");
975 + class_attr(&["picture-img"], opts, out);
976 + out.push_str(" src=\"");
977 + // Escaped as an attribute and otherwise untouched, the treatment
978 + // every app-supplied string gets here. No scheme guard: quoting is
979 + // what stops an attribute breaking out, and unlike `href` an `src`
980 + // has no scheme that executes -- `javascript:` in an `<img src>` is
981 + // a broken picture, not a script.
982 + out.push_str(&escape(&picture.src));
983 + out.push_str("\" alt=\"");
984 + out.push_str(&escape(&picture.alt));
985 + out.push('"');
986 + // Lazy by default. Every measured consumer wrote it, nothing wanted
987 + // the other value, and a description that carried the choice would
988 + // be carrying a browser's loading policy in a host-neutral layer.
989 + out.push_str(" loading=\"lazy\"");
990 + fit_attr(picture.fit, out);
991 + out.push('>');
992 +
993 + if let Some(caption) = &picture.caption {
994 + out.push_str("<figcaption");
995 + class_attr(&["picture-caption"], opts, out);
996 + out.push('>');
997 + out.push_str(&escape(caption));
998 + out.push_str("</figcaption></figure>");
999 + }
1000 + }
1001 +
941 1002 Node::Notice { kind, tone, text } => {
942 1003 out.push_str("<div");
943 1004 class_attr(