max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+189 insertions,
-8 deletions
| @@ -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( |