makepad/examples/modal_footprint/tests/ui.rs
Admin 0257d6c6dc engine libs: render and sim for a strategy round, the per-unit decision hook, the mp4 sample index, stitch and xatlas quieted, example refresh
Squashed from work:
- docs: button shader annotations — widgets/button.rs complete + splash demo buttons
- tweaker: the Shader tab shows the pinned widget's ANIMATOR STATES as little posed swatches under the well — one per tr
- widgets: the glass stops reading draw_pass.time, so glass apps idle again
- render, sim, platform: what a strategy round needs underneath
- draw, platform: overlays now composite above content that uses draw_depth
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- platform: native file and save dialogs, in-house on all three desktops
- sim: a per-unit decision hook — one commandable unit can think for itself, before the kit steers
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
2026-09-01 16:46:35 +02:00

278 lines
11 KiB
Rust

//! Regression suite for the layout footprint of a `Modal`.
//!
//! A modal draws on its own overlay draw list, on a root turtle sized by the
//! pass. It is never laid out by the parent that holds it, so it must claim no
//! space in that parent — open or closed.
//!
//! Layout under test (`src/main.rs`): two identical `flow: Down` columns side
//! by side, each `header(40) / body(Fill) / footer(40)`. The right-hand column
//! additionally parks three `Modal`s between its body and its footer. The left
//! column is the control.
//!
//! Before the fix a `Modal` reported `Fill`/`Fill` upward — the size of the
//! overlay it paints inside its own pass, which is not a request a parent can
//! honour. A `Fill` child of a `flow: Down` parent is a *deferred fill*, and
//! the parent hands every deferred fill an equal share of the column's spare
//! height at resolve time whether or not the child then draws anything. Three
//! closed modals beside one real `Fill` body split that spare height four ways,
//! so `modal_body` measured a quarter of `plain_body` and the footer beneath it
//! floated in the middle of the column with a dead band underneath.
//!
//! (Measured on the VJ DJ page, which is where this was found: `page_body`
//! resolved to 214pt of an 874pt column, and its own `lists_column` — the
//! content explorer and the queue — was then laid out at -122pt and never drew.)
//!
//! The last test in the file pins the *other* half of "a modal covers the
//! page" — depth, not layout. The page carries a `deep_band` at
//! `draw_depth: 12`, and an overlay only outranks it because
//! `DrawList2d::begin_overlay_inner` gives every overlay draw list a depth
//! floor. Without it the band paints over the card and over the backdrop.
use makepad_test::{makepad_test, Selector, TestApp, WidgetSnapshot};
use makepad_zune_png::makepad_zune_core::bytestream::ZCursor;
use makepad_zune_png::PngDecoder;
/// The one widget with this id that is actually drawn.
fn drawn(app: &TestApp, id: &str) -> WidgetSnapshot {
app.widget_snapshot()
.into_iter()
.find(|w| w.id == id && w.width > 0 && w.height > 0)
.unwrap_or_else(|| panic!("{id} is not drawn"))
}
/// The body of a column must take every point the header and footer leave, and
/// the footer must end where the column ends.
fn assert_column_is_packed(app: &TestApp, column: &str, header: &str, body: &str, footer: &str) {
let column = drawn(app, column);
let header = drawn(app, header);
let body = drawn(app, body);
let footer = drawn(app, footer);
assert_eq!(
body.y,
header.y + header.height,
"{} starts at {} but its header ends at {}",
body.id,
body.y,
header.y + header.height,
);
assert_eq!(
footer.y,
body.y + body.height,
"{} ends at {} but its footer starts at {} — the gap is height the \
body was not given",
body.id,
body.y + body.height,
footer.y,
);
assert_eq!(
footer.y + footer.height,
column.y + column.height,
"{} ends at {} but its column ends at {}",
footer.id,
footer.y + footer.height,
column.y + column.height,
);
}
/// Closed modals parked in a column take nothing from the `Fill` beside them.
#[makepad_test]
fn closed_modals_take_no_height_from_a_fill_sibling(app: TestApp) {
app.locator(Selector::id("open_button")).wait_visible();
assert_column_is_packed(&app, "plain_column", "plain_header", "plain_body", "plain_footer");
assert_column_is_packed(&app, "modal_column", "modal_header", "modal_body", "modal_footer");
// The two columns are declared identically apart from the modals, so the
// bodies must measure the same. With the modals counted as deferred fills
// this was `plain / 4`.
let plain = drawn(&app, "plain_body");
let modal = drawn(&app, "modal_body");
assert_eq!(
plain.height, modal.height,
"the column carrying three modals gave its body {}pt where the same \
column without them gave {}pt",
modal.height, plain.height,
);
}
/// An open modal still takes nothing: it paints over the page rather than
/// inside the slot its parent would hand it.
#[makepad_test]
fn an_open_modal_takes_no_height_either(app: TestApp) {
app.locator(Selector::id("open_button")).wait_visible();
let closed = drawn(&app, "modal_body");
app.locator(Selector::id("open_button")).click();
app.locator(Selector::all().text_exact("DIALOG A")).wait_visible();
assert_column_is_packed(&app, "modal_column", "modal_header", "modal_body", "modal_footer");
let open = drawn(&app, "modal_body");
assert_eq!(
closed.height, open.height,
"opening a modal moved the page under it: the body went from {}pt to {}pt",
closed.height, open.height,
);
// And the page comes back unchanged when it closes.
app.locator(Selector::id("close_a")).click();
app.locator(Selector::all().text_exact("DIALOG A")).wait_count(0);
let reclosed = drawn(&app, "modal_body");
assert_eq!(closed.height, reclosed.height);
}
/// The dim backdrop covers the whole window, not the slot a parent thought it
/// was handing over. The modal is parked inside a half-width column below a
/// header, so a backdrop sized by that slot would leave most of the page lit.
#[makepad_test]
fn the_backdrop_covers_the_whole_window(app: TestApp) {
app.locator(Selector::id("open_button")).wait_visible().click();
app.locator(Selector::all().text_exact("DIALOG A")).wait_visible();
let backdrop = drawn(&app, "bg_view");
let column = drawn(&app, "modal_column");
assert!(
backdrop.x <= 0 && backdrop.y <= 0,
"backdrop starts at ({}, {}) instead of the window origin",
backdrop.x,
backdrop.y,
);
assert!(
backdrop.width > column.width && backdrop.height > column.height,
"backdrop is {}x{}, no bigger than the {}x{} column that holds the \
modal — it was sized by the parent's slot",
backdrop.width,
backdrop.height,
column.width,
column.height,
);
}
// ---------------------------------------------------------------------------
// The other half of "a modal covers the page": depth.
// ---------------------------------------------------------------------------
/// The page's `deep_band` colour, `#xcc22cc` (see `src/main.rs`).
const BAND: [u8; 3] = [0xcc, 0x22, 0xcc];
struct Grab {
width: usize,
height: usize,
rgba: Vec<u8>,
/// Device pixels per layout point, so the widget rects the harness reports
/// can be turned into pixel coordinates on any dpi.
scale: f64,
}
impl Grab {
fn take(app: &TestApp) -> Grab {
let window = drawn(app, "main_window");
let path = app.screenshot();
let bytes = std::fs::read(&path)
.unwrap_or_else(|err| panic!("cannot read grab {}: {err}", path.display()));
let mut decoder = PngDecoder::new(ZCursor::new(&bytes));
let pixels = decoder
.decode_raw()
.unwrap_or_else(|err| panic!("cannot decode grab {}: {err:?}", path.display()));
let (width, height) = decoder.dimensions().expect("grab has no dimensions");
let components = decoder
.colorspace()
.expect("grab has no colorspace")
.num_components();
assert!(components >= 3, "grab is not a colour image");
let mut rgba = vec![0u8; width * height * 4];
for i in 0..width * height {
let src = i * components;
rgba[i * 4] = pixels[src];
rgba[i * 4 + 1] = pixels[src + 1];
rgba[i * 4 + 2] = pixels[src + 2];
rgba[i * 4 + 3] = if components == 4 { pixels[src + 3] } else { 255 };
}
Grab {
width,
height,
rgba,
scale: width as f64 / window.width.max(1) as f64,
}
}
/// The pixel under a point given in layout points.
fn at(&self, x: i64, y: i64) -> [u8; 3] {
let px = ((x as f64 * self.scale) as usize).min(self.width - 1);
let py = ((y as f64 * self.scale) as usize).min(self.height - 1);
let p = (py * self.width + px) * 4;
[self.rgba[p], self.rgba[p + 1], self.rgba[p + 2]]
}
}
fn near(a: [u8; 3], b: [u8; 3], tolerance: i32) -> bool {
(0..3).all(|c| (a[c] as i32 - b[c] as i32).abs() <= tolerance)
}
/// The three points this test reads, in layout points: the middle of the band
/// (which the dialog card covers) and both ends of it (which it cannot reach).
fn band_probes(app: &TestApp) -> (i64, i64, i64, i64) {
let band = drawn(app, "deep_band");
let y = band.y + band.height / 2;
let middle = band.x + band.width / 2;
let left = band.x + 12;
let right = band.x + band.width - 12;
(left, middle, right, y)
}
/// An open modal covers a page that spends `draw_depth`.
///
/// A 2D pass has ONE depth buffer, shared by every draw list in it, and a
/// vertex lands at `world.z = draw_depth + draw_call.zbias`. Drawing the modal
/// later than the page buys it a single `zbias_step` — 0.001 — of z, so the
/// `deep_band` at `draw_depth: 12` outranks it and the depth test throws the
/// dialog away: measured, before the fix, the band painted straight over the
/// card AND over the dim backdrop, exactly as an engraved score did.
///
/// `DrawList2d::begin_overlay_inner` gives every overlay draw list a depth
/// floor clear of that band. Nothing in this app asks for it.
#[makepad_test]
fn an_open_modal_covers_a_page_that_uses_draw_depth(app: TestApp) {
app.locator(Selector::id("open_button")).wait_visible();
let (left, middle, right, y) = band_probes(&app);
// Closed: the band is the page, and it is the band's own colour.
let closed = Grab::take(&app);
for x in [left, middle, right] {
assert!(
near(closed.at(x, y), BAND, 8),
"the deep band is not drawn at ({x}, {y}): {:?}",
closed.at(x, y),
);
}
app.locator(Selector::id("open_button")).click();
app.locator(Selector::all().text_exact("DIALOG A")).wait_visible();
let open = Grab::take(&app);
// The card covers the middle of the band outright.
let covered = open.at(middle, y);
assert!(
!near(covered, BAND, 40),
"the page punched through the dialog: ({middle}, {y}) is still the \
band's colour {covered:?}. The overlay draw list lost the depth test \
against draw_depth 12 — see DrawList2d::begin_overlay_inner",
);
// And the dim backdrop — also overlay content, also at depth 0 — reaches
// the parts of the band the card does not cover. Checking only the card's
// own region is how this bug stayed hidden the first time.
for x in [left, right] {
let dimmed = open.at(x, y);
assert!(
!near(dimmed, BAND, 40),
"the backdrop did not dim the deep band at ({x}, {y}): {dimmed:?}",
);
let before = closed.at(x, y);
assert!(
(0..3).all(|c| dimmed[c] <= before[c]),
"the deep band at ({x}, {y}) went from {before:?} to {dimmed:?} — \
the backdrop should only darken it",
);
}
}