Skip to main content

max / quasi

This renderer draws the row a region said its members share `Slot::across` and `Slot::beside` landed with the room-and-fallback ruling and only quasi-webview ever read them. `region` walked `body`; nothing walked `run`, so a description that said a toolbar's controls share one row contributed controls that were never painted, could never be pressed, and reached the accessibility tree as nothing. Silent, and the same shape as `Row::menu` being dropped until 2026-08-17. Wrap and Stack are both `horizontal_wrapped`: egui breaks the row from each member's own galley, which is the derived minimum the ruling asks for, and it has no knob for "fills its line", so the two answer alike rather than this renderer authoring a width. Shed and Menu drop by Priority through `Run::kept_at`, at the same cutoff the region's body already measures. That is the half a webview cannot do at all -- there is no `@container (inline-size < min-content)` -- and this renderer can, because it is holding the width. Menu then puts what it shed behind one control, labelled by count rather than by a name nobody described. Five tests; three fail with the call removed. quasi-tui has the same silent drop and a bigger fix (height, draw and the focus walk all have to understand a row), filed as quasicoherent 74a2f44b rather than guessed at here.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 12:39 UTC
Signed with PGP, not checked
Commit: 9804cc1c86056194713f671892e746f47ba10a1e
Parent: 7a06fa4
3 files changed, +203 insertions, -8 deletions
M Cargo.lock +8 -8
@@ -6197,6 +6197,14 @@
6197 6197 "winnow 1.0.4",
6198 6198 ]
6199 6199
6200 + [[patch.unused]]
6201 + name = "synckit-client"
6202 + version = "0.8.1"
6203 +
6204 + [[patch.unused]]
6205 + name = "quasi-type"
6206 + version = "0.1.0"
6207 +
6200 6208 [[patch.unused]]
6201 6209 name = "kberg"
6202 6210 version = "0.1.0"
@@ -6212,11 +6220,3 @@
6212 6220 [[patch.unused]]
6213 6221 name = "tagtree"
6214 6222 version = "0.4.1"
6215 -
6216 - [[patch.unused]]
6217 - name = "synckit-client"
6218 - version = "0.8.1"
6219 -
6220 - [[patch.unused]]
6221 - name = "quasi-type"
6222 - version = "0.1.0"
@@ -1725,6 +1725,7 @@
1725 1725 }
1726 1726
1727 1727 let cutoff = cutoff(ui.available_width());
1728 + run(pass, ui, slot, cutoff);
1728 1729 for placed in slot.body.iter().filter(|placed| placed.kept_at(cutoff)) {
1729 1730 draw(pass, ui, &placed.node);
1730 1731 }
@@ -1741,6 +1742,64 @@
1741 1742 }
1742 1743 }
1743 1744
1745 + /// The region's leading row: the members the description said share it.
1746 + ///
1747 + /// Ruling: wiki `layout-room-and-fallback`. **This renderer drew none of them
1748 + /// until now**, which is `Row::menu`'s bug of 2026-08-17 in a second place: a
1749 + /// description that said `across` and then `part` contributed nodes that were
1750 + /// never drawn at all, silently, because `region` walked `body` and nothing
1751 + /// walked `run`. A member nobody can see is worse than one drawn in the wrong
1752 + /// direction, so the drop is what made this a defect rather than a shortfall.
1753 + ///
1754 + /// # What each fallback gets, and what it costs
1755 + ///
1756 + /// The room is measured here, per frame, from the width egui is offering, the
1757 + /// same reading `cutoff` already takes for a region's body. Nothing is
1758 + /// authored and nothing is remembered between frames.
1759 + ///
1760 + /// [`Fallback::Wrap`](layout::Fallback::Wrap) and
1761 + /// [`Fallback::Stack`](layout::Fallback::Stack) are both `horizontal_wrapped`.
1762 + /// egui breaks the row when the next member does not fit and measures each
1763 + /// member from its own galley, which is the derived minimum the ruling asks
1764 + /// for. The two differ in the webview by whether a wrapped member fills its
1765 + /// line; egui has no equivalent knob on a wrapped layout, so this renderer
1766 + /// answers both the same way and says so rather than authoring a width.
1767 + ///
1768 + /// [`Shed`](layout::Fallback::Shed) and [`Menu`](layout::Fallback::Menu) drop
1769 + /// by [`layout::Priority`] through [`Run::kept_at`], which the webview cannot
1770 + /// do at all -- there is no `@container (inline-size < min-content)` -- and
1771 + /// this renderer can, because it is holding the width. `Menu` then puts what
1772 + /// it shed behind one control, so every member stays reachable; `Shed` does
1773 + /// not, which is what the description asked for when it chose the word.
1774 + fn run(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot, cutoff: layout::Priority) {
1775 + let Some(run) = slot.run.as_ref() else {
1776 + return;
1777 + };
1778 + let kept = run.kept_at(cutoff);
1779 + ui.horizontal_wrapped(|ui| {
1780 + for placed in &kept {
1781 + draw(pass, ui, &placed.node);
1782 + }
1783 + if matches!(run.fallback, layout::Fallback::Menu) {
1784 + let shed: Vec<&quasi_router::Ranked> = run
1785 + .members
1786 + .iter()
1787 + .filter(|member| !member.kept_at(cutoff))
1788 + .collect();
1789 + if !shed.is_empty() {
1790 + // The label is the count rather than a name, because the
1791 + // description did not give the row one and inventing "More
1792 + // actions" here would be this renderer writing copy.
1793 + ui.menu_button(format!("{} more", shed.len()), |ui| {
1794 + for placed in shed {
1795 + draw(pass, ui, &placed.node);
1796 + }
1797 + });
1798 + }
1799 + }
1800 + });
1801 + }
1802 +
1744 1803 /// The cutoff a region narrows to at this width, in points.
1745 1804 ///
1746 1805 /// egui measures in the same unit a browser's media query does, so the
@@ -1393,6 +1393,142 @@
1393 1393 }
1394 1394 }
1395 1395
1396 + /// A band whose members were said to share one row.
1397 + fn run_of(fallback: layout::Fallback, members: [(&str, layout::Priority); 3]) -> Screen {
1398 + let mut band = Slot::new("bar", RegionKind::Band).across(fallback);
1399 + for (label, priority) in members {
1400 + band = band.beside(
1401 + Node::Act(Act::new(label, Action::post(format!("/{label}")))),
1402 + priority,
1403 + );
1404 + }
1405 + Screen::sidebar_content("Test").with(band)
1406 + }
1407 +
1408 + #[test]
1409 + fn a_member_of_a_run_is_drawn_at_all() {
1410 + // The defect: `region` walked `body` and nothing walked `run`, so a
1411 + // description that said `across` and then `part` contributed controls that
1412 + // were never painted and could never be pressed. `Row::menu`'s bug of
1413 + // 2026-08-17 in a second place, and silent in the same way.
1414 + let screen = run_of(
1415 + layout::Fallback::Wrap,
1416 + [
1417 + ("Import", layout::Priority::Essential),
1418 + ("Export", layout::Priority::Essential),
1419 + ("Settings", layout::Priority::Essential),
1420 + ],
1421 + );
1422 + let mut host = Host::new();
1423 + host.settle(&screen);
1424 +
1425 + assert_eq!(
1426 + host.click(&screen, "Export").map(|fired| fired.action),
1427 + Some(Action::post("/Export")),
1428 + "a described control in a row must send what it said it sends"
1429 + );
1430 + }
1431 +
1432 + #[test]
1433 + fn a_run_puts_its_members_across_rather_than_down() {
1434 + // What the row is for. Three controls in a band drew one per line before
1435 + // this, which is the shape a toolbar is not.
1436 + let screen = run_of(
1437 + layout::Fallback::Wrap,
1438 + [
1439 + ("Import", layout::Priority::Essential),
1440 + ("Export", layout::Priority::Essential),
1441 + ("Settings", layout::Priority::Essential),
1442 + ],
1443 + );
1444 + let mut host = Host::new();
1445 + host.settle(&screen);
1446 +
1447 + let first = host.find("Import");
1448 + let second = host.find("Export");
1449 + let third = host.find("Settings");
1450 + assert!(
1451 + (first.y - second.y).abs() < 1.0 && (second.y - third.y).abs() < 1.0,
1452 + "the members left the row: {first:?} {second:?} {third:?}"
1453 + );
1454 + assert!(
1455 + first.x < second.x && second.x < third.x,
1456 + "the members are out of the order they were said in: {first:?} {second:?} {third:?}"
1457 + );
1458 + }
1459 +
1460 + #[test]
1461 + fn a_run_that_sheds_keeps_what_the_description_called_essential() {
1462 + // The half a webview cannot do at all: there is no
1463 + // `@container (inline-size < min-content)`, so `Shed` wraps there. This
1464 + // renderer is holding the width, so it can honour the word.
1465 + let screen = run_of(
1466 + layout::Fallback::Shed,
1467 + [
1468 + ("Import", layout::Priority::Essential),
1469 + ("Export", layout::Priority::Secondary),
1470 + ("Settings", layout::Priority::Optional),
1471 + ],
1472 + );
1473 + let ctx = egui::Context::default();
1474 + let immediate = renderer();
1475 +
1476 + let narrow = painted(&ctx, &immediate, &screen, &mut View::new(), 500.0);
1477 + assert!(narrow.contains("Import"), "the essential member was shed");
1478 + assert!(
1479 + !narrow.contains("Export") && !narrow.contains("Settings"),
1480 + "a shed row kept what it said it would drop: {narrow}"
1481 + );
1482 +
1483 + // And nothing is dropped when there is room for all three, or the cutoff
1484 + // would be a permanent narrowing rather than a measurement.
1485 + let wide = painted(&ctx, &immediate, &screen, &mut View::new(), 1000.0);
1486 + assert!(
1487 + wide.contains("Export") && wide.contains("Settings"),
1488 + "{wide}"
1489 + );
1490 + }
1491 +
1492 + #[test]
1493 + fn a_run_that_menus_keeps_every_member_reachable() {
1494 + // `Shed` and `Menu` drop the same members. What separates them is where the
1495 + // dropped ones go, and a `Menu` that dropped them on the floor would be
1496 + // this renderer answering with `Shed`.
1497 + let screen = run_of(
1498 + layout::Fallback::Menu,
1499 + [
1500 + ("Import", layout::Priority::Essential),
1501 + ("Export", layout::Priority::Secondary),
1502 + ("Settings", layout::Priority::Optional),
1503 + ],
1504 + );
1505 + let ctx = egui::Context::default();
1506 + let immediate = renderer();
1507 +
1508 + let narrow = painted(&ctx, &immediate, &screen, &mut View::new(), 500.0);
1509 + assert!(narrow.contains("Import"));
1510 + assert!(
1511 + narrow.contains("2 more"),
1512 + "the two shed members went nowhere a reader could follow: {narrow}"
1513 + );
1514 + }
1515 +
1516 + #[test]
1517 + fn a_region_with_no_run_draws_exactly_what_it_did_before() {
1518 + // The change is additive at the call site, so a description written before
1519 + // runs existed must paint the same picture.
1520 + let screen = screen_of([Node::Act(Act::new("Save", Action::post("/save")))]);
1521 + let ctx = egui::Context::default();
1522 + let immediate = renderer();
1523 +
1524 + let before = painted(&ctx, &immediate, &screen, &mut View::new(), 900.0);
1525 + assert!(before.contains("Save"));
1526 + assert!(
1527 + !before.contains("more"),
1528 + "a region with no run grew a control out of nothing: {before}"
1529 + );
1530 + }
1531 +
1396 1532 /// A list of two rows, the first offering both an opening route and a menu.
1397 1533 fn menu_list() -> Screen {
1398 1534 use quasi_router::Row;