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
278 lines
11 KiB
Rust
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",
|
|
);
|
|
}
|
|
}
|