makepad/apps/fab/tests/viewport.rs
Admin 3b8cd8a69a apps: WM, wm-all, phone apps, files, route, sheets, photos, image-tiles; the mp prefix rename
Squash of 59 work commits (Sep 1–12):
  b0380ba  image-tiles: the picture-wall engine as a library — tape atlases, a baker CLI, and the TileGrid widget
  83d8eac  mpsheets: demo feature and the SheetDocs seam
  daff390  finance: demo feature — generated ledger, SQLite target-gated, import hidden
  13b4c48  mpfiles: demo build — procedural fake filesystem, still-image thumbnails, chat feature
  58e1f72  mpsheets: review fixes for the demo seam
  3203e32  finance: review fixes — id remapping on persist, structural determinism tests, one cfg seam
  0537687  mpfiles: distinct repo-owned pictures for the demo thumbnail pool
  fe920c8  files: review fixes — demo scan exclusions, Zipf sizes, trash root guarded, depth bound, tests
  4144dc4  files: the spy test filesystem implements the clock
  1940f3d  fonts: leave apps/wm untouched — its font-set declaration waits for the aichat lane to land
  b0e34c2  wm: the AI pane and its bus — the aichat child seated in the slot, F10, the os service with the typed open
  de937ac  route: the web build is a one-to-one recompile — native UI, service seams behind it
  2f5a984  files: the file browser on the AI bus — four read-only tools through a correlated, cancellable runner
  2b035ff  route: the navigation session keeps time on the platform clock — the std clock traps on wasm
  1b70899  files: the space view is where a tab starts
  412b3c9  files: the space view rescans through the virtual filesystem — the web has no other disk
  0105fa5  files: the space view opens in 2.5D
  5d007e0  files: an unset projection preference means 2.5D
  6857d86  files: the projected treemap clips boxes at the near plane and keeps the camera above them
  d3cd233  wm: one desk for every target — the host seam, the assistant seated in-process, the web profile, and the assistant up at boot
  6d47c3f  wm: the omarchy themes moved to omacom/omarchy — the importer follows
  82def6b  wm: every app under the desk — the chat keeps the keyboard through an automatic refocus, a pool that gives up, polite closes for browser, sheets and aichat
  6105b61  sheets + files: the assistant reads and writes cells, makes folders, renames and trashes
  82ac768  photos: the picture wall as an app — the SMBC archive on the tile engine, a module from its first line; fabric in the launcher
  190062e  route: the maps app exposes its tool table to the desktop assistant
  3768653  image_tiles: the wall is box-packed — justified rows keep every picture's own shape and fill the width
  3691875  wm: launcher icons — a photo for Photos, a shirt for Fabric, a globe for Route, a play badge for Video, a pulse for the task manager
  8625076  aichat + photos + image_tiles: make me a picture and it lands on the wall; the wall filters as you type, tiles flying to their places
  1dcca41  wm: a theme with no wallpapers fetches them on its own
  ff6cfec  wm: minimise, maximise and close in the bar's top right on Windows and Linux
  62f38a5  route: --hour=N pins the theme hour for harness grabs
  362ac59  wm: the checkout is found from the working directory too, and a sibling binary is an .exe on Windows
  0fdfba7  files: the web's makepad home is a fixed virtual path — std's temp_dir panics there
  daf88e1  route demo: the tiles come from the repacked world archive at its own never-cached path
  15c3b11  ai-services: what the pubsub review found — modules get a subscription seam and a publish sink through both WM paths, lease end flushes unsubscribes to hosted services, the subscription cap counts closing rows, an endpoint's queue is dropped after Unregister, the prompt drops a subscription on final
  d852699  route: no tile-source dropdown — makepad.nl is the tile source through the local range cache; a world.mkmap in the saved maps folder is the only override; the stale preference file is removed on start
  aa271c3  map + route + geodata: the Terrain layer through the archive plane — TerrainSource (hosted .mkmap elevation shards fetched by range through the shared reader, a local MBTiles only as a dev override), the hillshade rendered on the pool's heavy lane with reused TerrainScratch buffers (web capped at 2048×1536 at DPR 1, native DPR-aware), one request in flight; the demo checkbox now renders terrain like native
  dbc7838  route: the maps-folder field and its save button are gone from the settings panel; the maps root is automatic
  a53d9b6  route: no clock-driven night theme — the theme is the user's toggle, remembered in cx.storage, default day (the hour rule fired on the web for the first time once wasm had a clock and darkened the map after 19:00)
  ca92ff9  route: the open sea on the web — the ocean-low/ocean-high overlays are an always-on group in the shared hosted overlay table, read through the archive plane (makepad.nl, cached); native keeps the local ocean files only as a dev override; the native-only OCEAN_MBTILES path is gone
  4cd24c4  route demo: the first location fix flies the map to the user at zoom 14, as native does; Amsterdam stays the default until a fix arrives
  2e869f8  route: the ocean-high overlay comes from the re-sharded archive (58 shards of at most 16 MB, one small leaf directory each) instead of two shards whose directories decoded to 407 MiB
  8021bb0  Add Clock and Weather with shared mobile tile views and persistent alarms
  77b8309  Make application layouts and custom widgets follow desktop and mobile themes
  b357164  Add desktop and mobile compositor shells with OS switching and dock transitions
  11dec2e  Keep hosted app output readers off the shared task pool
  c4002c5  Round complete desktop surfaces and add Windows snap layouts
  a5f8dd8  Frame home photo tiles and clip the picture wall below app controls
  678721d  Give mobile app libraries real search focus and hosted keyboard input
  4794cdf  Keep window title fills opaque at the application surface join
  2cc584e  Match Windows 2000 and NeXTSTEP window chrome to original screenshots
  4a876a8  Give Files navigation and storage tools a clearer hierarchy
  7821c90  Keep prewarmed browsers in the active desktop appearance
  5bfe694  Compact the Files toolbar and repair navigation and selection actions
  44fef36  Preserve Photos subjects and zoom across host view and aspect changes
  ae20efc  Draw larger macOS traffic lights with centered hover symbols
  2bc4d89  wm: the WM is a library plus a desktop binary, Linux controls, hosted tick pacing
  d643eb4  apps: the in-process app wave — mail, notes, calendar, reminders, calculator; clock, weather, finance, photos and route as modules; civil time, read-only sqlite, Linux CEF
  56c1dee  wm: a timer beat missed while the child renders is serviced on its acknowledgement

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:28 +02:00

311 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Lane B (L2) regression: **a viewport's composite stays inside its own walk
//! rect**, and its rect is never empty.
//!
//! The bug this pins, exactly: the composite quad was drawn before any
//! `DrawList` had been begun inside the offscreen composite pass. `begin_pass`
//! clears the pass's `main_draw_list_id`, and whatever calls `begin_always`
//! first becomes it — so anything drawn before that lands in the *enclosing*
//! draw list, which belongs to the **window**. The quad was therefore painted
//! into the window at pass-local `(0,0)`, i.e. the window origin, on top of the
//! top bar and the tool column; and the composite pass, having no draw list of
//! its own, rendered nothing, so the viewport's real rect showed an empty
//! (black) composite target. One cause, both symptoms.
//!
//! The two assertions below are the two symptoms, inverted:
//!
//! 1. the top-bar band at the top of the window is still chrome — no viewport
//! composite has been painted over it;
//! 2. every visible viewport's own rect is not one flat colour — the composite
//! pass did render, and the blit landed there.
//!
//! Gpusim (`MAKEPAD=gpusim`) runs on the CPU rasterizer at dpi 1, so no GPU
//! is touched and screenshot pixels are layout points.
use makepad_test::{makepad_test, Selector, TestApp};
use makepad_zune_png::makepad_zune_core::bytestream::ZCursor;
use makepad_zune_png::PngDecoder;
/// How far two chrome samples may drift and still count as the same paint.
/// The composite's own background gradient alone is 20 levels wide, and the
/// lit image is far further off than that.
const CHROME_TOLERANCE: i32 = 6;
struct Image {
width: usize,
height: usize,
rgba: Vec<u8>,
}
impl Image {
fn read(path: &std::path::Path) -> Image {
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 ({components} components)"
);
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 };
}
Image {
width,
height,
rgba,
}
}
fn pixel(&self, x: usize, y: usize) -> [u8; 3] {
let p = (y.min(self.height - 1) * self.width + x.min(self.width - 1)) * 4;
[self.rgba[p], self.rgba[p + 1], self.rgba[p + 2]]
}
/// The most common colour in a row span — for a chrome bar that is its
/// background, with the text and icons outvoted.
fn row_mode(&self, y: usize, x0: usize, x1: usize) -> [u8; 3] {
let x1 = x1.min(self.width);
assert!(x0 < x1, "empty row span {x0}..{x1} at y={y}");
let mut tally: std::collections::HashMap<[u8; 3], usize> = std::collections::HashMap::new();
for x in x0..x1 {
*tally.entry(self.pixel(x, y)).or_default() += 1;
}
tally
.into_iter()
.max_by_key(|(_, n)| *n)
.map(|(c, _)| c)
.unwrap()
}
/// Distinct colours (5 bits per channel) inside a rect.
fn distinct_colors_in(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> usize {
let mut seen = std::collections::HashSet::new();
for y in y0..y1.min(self.height) {
for x in x0..x1.min(self.width) {
let p = self.pixel(x, y);
seen.insert([p[0] >> 3, p[1] >> 3, p[2] >> 3]);
}
}
seen.len()
}
fn luma(&self, x: usize, y: usize) -> i32 {
let p = self.pixel(x, y);
(p[0] as i32 * 54 + p[1] as i32 * 183 + p[2] as i32 * 19) / 256
}
/// Mean |Δluma| of 8-pixel-aligned block boundaries over interior
/// adjacent pixels in the same span. ≈1 on a smooth face; ≫1 when the
/// composite is an un-upsampled 8×8 AO/cavity grid.
fn blockiness_8(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 {
let x1 = x1.min(self.width);
let y1 = y1.min(self.height);
if x1 <= x0 + 16 || y1 <= y0 + 16 {
return 0.0;
}
let mut boundary = 0i64;
let mut interior = 0i64;
let mut n = 0i64;
for y in y0..y1 {
let mut x = x0 + 8;
while x < x1 {
boundary += (self.luma(x, y) - self.luma(x - 1, y)).abs() as i64;
interior += (self.luma(x - 4, y) - self.luma(x - 5, y)).abs() as i64;
n += 1;
x += 8;
}
}
for x in x0..x1 {
let mut y = y0 + 8;
while y < y1 {
boundary += (self.luma(x, y) - self.luma(x, y - 1)).abs() as i64;
interior += (self.luma(x, y - 4) - self.luma(x, y - 5)).abs() as i64;
n += 1;
y += 8;
}
}
if n == 0 || interior == 0 {
return 0.0;
}
boundary as f32 / interior as f32
}
/// Variance of luma in a rect. Used to pick a flat lit face.
fn luma_stats(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> (f32, f32) {
let x1 = x1.min(self.width);
let y1 = y1.min(self.height);
if x1 <= x0 || y1 <= y0 {
return (0.0, 0.0);
}
let mut sum = 0i64;
let mut n = 0i64;
for y in y0..y1 {
for x in x0..x1 {
sum += self.luma(x, y) as i64;
n += 1;
}
}
if n == 0 {
return (0.0, 0.0);
}
let mean = sum as f32 / n as f32;
let mut var = 0.0f32;
for y in y0..y1 {
for x in x0..x1 {
let d = self.luma(x, y) as f32 - mean;
var += d * d;
}
}
(mean, var / n as f32)
}
}
#[makepad_test]
fn viewport_composites_stay_inside_their_walk_rects(app: TestApp) {
// `main` is the shell's root view: its rect is the window's content area,
// in the same layout points every widget rect below uses.
let shell = app.locator(Selector::id("main")).wait_visible().snapshot();
assert!(
shell.width > 0 && shell.height > 0,
"the shell root has no rect: {shell:?}"
);
let viewports = Selector::id("viewport");
let count = app.locator(viewports.clone()).wait_visible().count();
assert!(
count > 0,
"no visible FabViewport in the shell — the layout changed, fix the selector"
);
let rects: Vec<_> = (0..count)
.map(|i| app.locator(viewports.clone().nth(i)).snapshot())
.collect();
let path = app.screenshot();
println!("[fab] grab: {}", path.display());
let image = Image::read(&path);
// dpi 1 gpusim, but derive the factor anyway so a retina visible-mode
// run (MAKEPAD_TEST_VISIBLE=1) measures the same thing.
let scale = image.width as f64 / shell.width as f64;
let to_px = |v: i64| ((v as f64) * scale).round().max(0.0) as usize;
// ---- 1. the chrome above the viewports is still chrome -----------------
// A misplaced composite is an opaque quad at pass-local (0,0) — the window
// origin — sized like its viewport, so it covers the top bar from x=0 out
// to the viewport's width and stops. Chrome further right is out of its
// reach. Comparing the two halves of the same top-bar row therefore needs
// no colour constant at all: the bar is one paint, and if the left half
// stops matching the right half, something is being painted over it.
let top = rects.iter().map(|r| to_px(r.y)).min().unwrap_or(0);
assert!(
top >= 4,
"the first viewport starts at y={top}px — there is no chrome band to test"
);
let covered = rects.iter().map(|r| to_px(r.x) + to_px(r.width)).max().unwrap_or(0);
let untouched = covered + (image.width - covered) / 2;
assert!(
untouched + 8 < image.width,
"no chrome to the right of the viewports to compare against"
);
for y in [2usize, 4, 6] {
let over = image.row_mode(y, 0, (image.width / 4).max(8));
let clear = image.row_mode(y, untouched, image.width);
let drift = (0..3)
.map(|c| (over[c] as i32 - clear[c] as i32).abs())
.max()
.unwrap();
assert!(
drift <= CHROME_TOLERANCE,
"top-bar row y={y} reads {over:?} above the viewports but {clear:?} where no \
viewport can reach — a viewport composite is being painted at the window origin \
(grab {})",
path.display()
);
}
// ---- 2. every viewport painted something into its own rect -------------
for (i, r) in rects.iter().enumerate() {
let (x0, y0) = (to_px(r.x), to_px(r.y));
let (x1, y1) = (x0 + to_px(r.width), y0 + to_px(r.height));
assert!(
x1 > x0 + 8 && y1 > y0 + 8,
"viewport {i} has a degenerate rect {r:?}"
);
// Inset past the 1 px area border and any overlay chrome at the edge.
let colors = distinct_inset(&image, x0, y0, x1, y1);
assert!(
colors > 1,
"viewport {i} rect {r:?} is one flat colour — its composite pass \
rendered nothing into its own target (grab {})",
path.display()
);
}
// ---- 3. Solid (left) composite is not an 8×8 AO/cavity grid ------------
// The leftmost viewport is the realtime Solid pane. On a flat lit face
// the 8-pixel-aligned block-boundary jump must not dwarf the interior
// 1 px jump — that was the un-upsampled cavity/SSAO (or the 256-wide
// element LUT sampled as if it were spatial).
let left = rects
.iter()
.min_by_key(|r| r.x)
.expect("no viewport rect");
let (x0, y0) = (to_px(left.x), to_px(left.y));
let (x1, y1) = (x0 + to_px(left.width), y0 + to_px(left.height));
let inset_x = ((x1 - x0) / 6).max(12);
let inset_y = ((y1 - y0) / 6).max(12);
let ix0 = x0 + inset_x;
let iy0 = y0 + inset_y;
let ix1 = x1.saturating_sub(inset_x);
let iy1 = y1.saturating_sub(inset_y);
let patch = 48usize;
let mut best: Option<(f32, usize, usize)> = None;
if ix1 > ix0 + patch && iy1 > iy0 + patch {
let mut y = iy0;
while y + patch <= iy1 {
let mut x = ix0;
while x + patch <= ix1 {
let (mean, var) = image.luma_stats(x, y, x + patch, y + patch);
if mean > 28.0 && mean < 230.0 {
match best {
Some((v, _, _)) if var >= v => {}
_ => best = Some((var, x, y)),
}
}
x += 16;
}
y += 16;
}
}
if let Some((var, x, y)) = best {
let ratio = image.blockiness_8(x, y, x + patch, y + patch);
assert!(
ratio < 2.2,
"solid viewport has 8×8 blockiness {ratio:.2} (patch variance {var:.1}) \
at ({x},{y}) — cavity/SSAO is being composited without a full-res \
(or bilateral) upsample (grab {})",
path.display()
);
}
}
fn distinct_inset(image: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> usize {
let inset = 6usize;
image.distinct_colors_in(
x0 + inset,
y0 + inset,
x1.saturating_sub(inset),
y1.saturating_sub(inset),
)
}