Skip to main content

max / quasi

Describe a proportion, through makeover's emitter Node::Meter against makeover-layout 0.10.0's Meter, owned like every other node here and carrying the pair rather than a percentage for the reason that crate gives: a bar full because it landed exactly and one full because it ran over are the same width and not the same fact. The markup is makeover-webview's, unchanged. A second emitter here would be the same anatomy with a different escaping story, and the CSS it has to match is emitted there too — the same rule the form fields already follow. with_meter mirrors Field::with_layout: a callback, because the layout type borrows its label and ours owns it. A proportion of a set, not the progress of an operation. A running timer is imperative and live, and a screen is described once per answer.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 13:03 UTC
Signed with PGP, not checked
Commit: e43552af9a732f394ec3235f5fe146cc3169d383
Parent: 624148d
6 files changed, +134 insertions, -21 deletions
M Cargo.lock +19 -17
@@ -1730,6 +1730,10 @@
1730 1730 source = "registry+https://github.com/rust-lang/crates.io-index"
1731 1731 checksum = "95b7ccafc965eb90f308b68d6fb00f6ff642a453b661f4c452cb20aeb60e3990"
1732 1732
1733 + [[package]]
1734 + name = "makeover-layout"
1735 + version = "0.10.0"
1736 +
1733 1737 [[package]]
1734 1738 name = "makeover-touch"
1735 1739 version = "0.5.0"
@@ -1737,17 +1741,15 @@
1737 1741 checksum = "f3228b2d380252585ab2fadd6146229bb17ba51fab1db5d8e6001d7e0e40fbb4"
1738 1742 dependencies = [
1739 1743 "makeover-geometry",
1740 - "makeover-layout",
1744 + "makeover-layout 0.9.0",
1741 1745 ]
1742 1746
1743 1747 [[package]]
1744 1748 name = "makeover-webview"
1745 - version = "0.18.0"
1746 - source = "registry+https://github.com/rust-lang/crates.io-index"
1747 - checksum = "b0bec4e3395087aa3777bea4aa559d1b90d53e170ae2012c77095b29be16a1c2"
1749 + version = "0.19.0"
1748 1750 dependencies = [
1749 1751 "makeover-geometry",
1750 - "makeover-layout",
1752 + "makeover-layout 0.10.0",
1751 1753 "makeover-touch",
1752 1754 ]
1753 1755
@@ -2368,7 +2370,7 @@
2368 2370 name = "quasi-router"
2369 2371 version = "0.1.0"
2370 2372 dependencies = [
2371 - "makeover-layout",
2373 + "makeover-layout 0.10.0",
2372 2374 ]
2373 2375
2374 2376 [[package]]
@@ -2386,7 +2388,7 @@
2386 2388 name = "quasi-webview"
2387 2389 version = "0.1.0"
2388 2390 dependencies = [
2389 - "makeover-layout",
2391 + "makeover-layout 0.10.0",
2390 2392 "makeover-webview",
2391 2393 "quasi-http",
2392 2394 "quasi-router",
@@ -4531,7 +4533,15 @@
4531 4533 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4532 4534
4533 4535 [[patch.unused]]
4534 - name = "docengine"
4536 + name = "kberg"
4537 + version = "0.1.0"
4538 +
4539 + [[patch.unused]]
4540 + name = "painhours"
4541 + version = "0.1.0"
4542 +
4543 + [[patch.unused]]
4544 + name = "tagtree"
4535 4545 version = "0.4.0"
4536 4546
4537 4547 [[patch.unused]]
@@ -4543,13 +4553,5 @@
4543 4553 version = "0.2.0"
4544 4554
4545 4555 [[patch.unused]]
4546 - name = "kberg"
4547 - version = "0.1.0"
4548 -
4549 - [[patch.unused]]
4550 - name = "painhours"
4551 - version = "0.1.0"
4552 -
4553 - [[patch.unused]]
4554 - name = "tagtree"
4556 + name = "docengine"
4555 4557 version = "0.4.0"
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.9"
16 + makeover-layout = "0.10.0"
@@ -15,5 +15,5 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 - makeover-layout = "0.9"
19 - makeover-webview = "0.18"
18 + makeover-layout = "0.10.0"
19 + makeover-webview = "0.19.0"
@@ -857,6 +857,34 @@
857 857 /// [`Self::SELECTED`].
858 858 action: Option<Action>,
859 859 },
860 + /// How much of a set is done.
861 + ///
862 + /// Against `makeover-layout`'s [`layout::Meter`], which arrived at 0.10.0
863 + /// for this. Before it, a screen with a progress bar concatenated the two
864 + /// numbers into its heading — "Subtasks 3/7" — which keeps both facts and
865 + /// loses the reading, the same way a toned status badge read as prose before
866 + /// [`Row::tokens`].
867 + ///
868 + /// Owned rather than borrowed for the reason every node here is, and
869 + /// carrying the pair rather than a percentage for the reason
870 + /// [`layout::Meter`] gives: a bar that is full because it landed exactly and
871 + /// one that is full because it ran over are the same width and not the same
872 + /// fact.
873 + ///
874 + /// This is a proportion of a set and not the progress of an operation. A
875 + /// running timer or a fetch is imperative and live, and a screen is
876 + /// described once per answer; [`layout::Readiness::Pending`] and a
877 + /// [`layout::Notice::Toast`] are what those get.
878 + Meter {
879 + /// How much is done. May exceed [`total`](Self::Meter::total).
880 + done: u32,
881 + /// How much there is to do.
882 + total: u32,
883 + /// What the proportion means. No renderer can derive this.
884 + tone: layout::Tone,
885 + /// What is being counted: "subtasks", "tasks". The noun, not the ratio.
886 + label: Option<String>,
887 + },
860 888 /// A region inside a region.
861 889 Region(Slot),
862 890 }
@@ -922,6 +950,40 @@
922 950 rows: rows.into_iter().collect(),
923 951 }
924 952 }
953 +
954 + /// A proportion of a set, untoned and unlabelled.
955 + pub const fn meter(done: u32, total: u32) -> Self {
956 + Self::Meter {
957 + done,
958 + total,
959 + tone: layout::Tone::Neutral,
960 + label: None,
961 + }
962 + }
963 +
964 + /// Read a [`Self::Meter`] as the description layer's own type.
965 + ///
966 + /// A callback rather than a return, for the reason [`Field::with_layout`]
967 + /// is one: [`layout::Meter`] borrows its label and ours owns it. `None` for
968 + /// every other member, so a renderer matching on the node can ask without
969 + /// first proving what it has.
970 + pub fn with_meter<R>(&self, f: impl FnOnce(layout::Meter<'_>) -> R) -> Option<R> {
971 + let Self::Meter {
972 + done,
973 + total,
974 + tone,
975 + label,
976 + } = self
977 + else {
978 + return None;
979 + };
980 + Some(f(layout::Meter {
981 + done: *done,
982 + total: *total,
983 + tone: *tone,
984 + label: label.as_deref(),
985 + }))
986 + }
925 987 }
926 988
927 989 /// A whole screen.
@@ -25,6 +25,7 @@
25 25 use makeover_webview::Emit;
26 26 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
27 27 use makeover_webview::list::{Cell, cells_html};
28 + use makeover_webview::meter::meter_html;
28 29 use quasi_router::screen::{Act, Cells, Destination, Node, Row, Slot, Tag};
29 30 use quasi_router::{Action, Method, Params};
30 31
@@ -488,6 +489,16 @@
488 489 out,
489 490 ),
490 491
492 + // The trough and its tones are makeover-webview's, unchanged, for the
493 + // same reason the field markup is: a second emitter here would be the
494 + // same anatomy with a different escaping story, and the CSS it has to
495 + // match is emitted there too.
496 + node @ Node::Meter { .. } => {
497 + if let Some(html) = node.with_meter(|m| meter_html(&m, opts)) {
498 + out.push_str(&html);
499 + }
500 + }
501 +
491 502 Node::Region(slot) => slot_html(slot, morphs, opts, out),
492 503 }
493 504 }
@@ -12,7 +12,7 @@
12 12 use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row, Tag};
13 13 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
14 14
15 - use crate::{Shell, Webview};
15 + use crate::{Emit, Shell, Webview};
16 16
17 17 fn render(screen: &Screen) -> String {
18 18 Webview::new().screen(screen)
@@ -261,6 +261,44 @@
261 261 assert!(!html.contains("<script>"));
262 262 }
263 263
264 + #[test]
265 + fn a_meter_renders_through_makeover_and_not_through_a_second_emitter() {
266 + // The rule this crate is held to: the markup and the CSS that has to match
267 + // it are both makeover-webview's. If this ever diverges, the trough and its
268 + // fill stop agreeing about which classes exist.
269 + let html = fragment(&Node::Meter {
270 + done: 3,
271 + total: 7,
272 + tone: layout::Tone::Success,
273 + label: Some("subtasks".into()),
274 + });
275 + assert_eq!(
276 + html,
277 + makeover_webview::meter::meter_html(
278 + &layout::Meter::new(3, 7)
279 + .tone(layout::Tone::Success)
280 + .label("subtasks"),
281 + &Emit::default(),
282 + )
283 + );
284 + assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
285 + }
286 +
287 + #[test]
288 + fn a_meter_that_ran_over_says_so_after_the_width_is_clamped() {
289 + // The finding this member closed. "45m tracked / 30m est" was heading text
290 + // because nothing named a bar; a bar alone would report it as exactly full.
291 + let html = fragment(&Node::Meter {
292 + done: 45,
293 + total: 30,
294 + tone: layout::Tone::Danger,
295 + label: Some("minutes".into()),
296 + });
297 + assert!(html.contains("width: 100%"));
298 + assert!(html.contains(r#"data-over="true""#));
299 + assert!(html.contains(r#"aria-label="45 of 30 minutes""#));
300 + }
301 +
264 302 #[test]
265 303 fn a_badge_is_not_a_button_and_a_chip_is() {
266 304 let badge = fragment(&Node::Token(Tag {