makepad/examples/text_center/tests/ui.rs
Admin 45e65e69db draw+widgets: a Splash keeps its host's walk across a body rebuild, a dock redraws its panels, boxed labels center on their ink, hsv2rgb compiles, and slider/tip/dropdown catch up
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: video goes NV12 end to end, and the GPU does the unpacking
- widgets: boxed labels center on their ink, not on the font's line box
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- fab: a 3D creation shell and the viewer built on it
- widgets: a dock redraws its panels, not just its own frame
- draw: hsv2rgb takes vector bounds, so it compiles
- texcomp: the block codec and the container every texture will travel in
- widgets: a Splash keeps the walk its host declared across a body rebuild, and logs a body that fails to evaluate instead of drawing nothing
- vj: DJ-tab scroll knobs, title-click reset, crossfade fix, stem headroom — plus the pre-Ampere CUDA fix (#1193)

Co-authored-by: Rogier de Leeuw <vjroger@gmail.com>
2026-08-26 08:49:45 +02:00

351 lines
13 KiB
Rust

//! The vertical text-centering contract for boxed labels, measured in pixels.
//!
//! A widget that paints a box and puts one line of text in it (button, tab,
//! dropdown, a label centered in a view) must center the *ink* of that line —
//! the cap-height band, from the flat top of a capital `H` down to the
//! baseline — on the box's center line. Centering the font's line box instead
//! (ascender over baseline over descender) leaves the ink sitting high,
//! because a text font's ascender reaches further above the cap line than its
//! descender reaches below the baseline.
//!
//! Every specimen on the page is labelled `H` for that reason: `H` has a flat
//! cap top and sits flat on the baseline, so its ink band *is* the cap band,
//! and its stem gives a column of pixels whose coverage integrates to a
//! sub-pixel edge position.
use makepad_test::{makepad_test, run_with_config, Selector, TestApp, TestConfig, WidgetSnapshot};
use std::path::Path;
/// How far the ink center may sit from the box center, in logical pixels.
const TOLERANCE_LPX: f64 = 0.5;
// ---------------------------------------------------------------- image ----
struct Frame {
width: usize,
height: usize,
/// Luminance-ish: these specimens are gray, so the red channel is enough.
lum: Vec<f64>,
}
impl Frame {
fn at(&self, x: usize, y: usize) -> f64 {
self.lum[y * self.width + x]
}
fn decode(path: &Path) -> Frame {
use makepad_zune_core::bytestream::ZCursor;
use makepad_zune_png::PngDecoder;
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
let mut dec = PngDecoder::new(ZCursor::new(&bytes));
dec.decode_headers().expect("png headers");
let (width, height) = dec.dimensions().expect("png dimensions");
let components = dec.colorspace().expect("png colorspace").num_components();
let decoded = dec.decode().expect("png decode");
let src = decoded.u8().expect("png 8-bit");
let lum = (0..width * height)
.map(|i| src[i * components] as f64)
.collect();
Frame {
width,
height,
lum,
}
}
}
/// The sub-pixel top and bottom of a band along one column.
///
/// `inside`/`outside` are the two flat values the band sits between, so
/// `coverage = (value - outside) / (inside - outside)`. For a band with flat
/// horizontal edges, the coverage of the partially covered rows *is* the
/// fraction of those rows the band covers, so summing coverage outward from a
/// row that is definitely inside the band gives the edges directly. Whatever
/// spread the antialiasing adds is the same at both edges, so it cancels in
/// the center — which is the only number this suite asserts on.
///
/// The row to start from is the one with the most coverage, so that a band
/// sitting anywhere in the window is measured correctly — including a badly
/// off-center one, which is the whole point.
fn band_edges(
frame: &Frame,
x: usize,
y0: usize,
y1: usize,
inside: f64,
outside: f64,
) -> (f64, f64) {
let span = inside - outside;
assert!(span.abs() > 8.0, "no contrast between band and surround");
let cov = |y: usize| ((frame.at(x, y) - outside) / span).clamp(0.0, 1.0);
let mid = (y0..=y1)
.max_by(|a, b| cov(*a).partial_cmp(&cov(*b)).unwrap())
.expect("non-empty window");
assert!(cov(mid) > 0.5, "no band on column {x} between {y0} and {y1}");
let mut top = mid as f64;
for y in y0..mid {
top -= cov(y);
}
let mut bottom = mid as f64;
for y in mid..=y1 {
bottom += cov(y);
}
assert!(
top > y0 as f64 + 0.5 && bottom < y1 as f64 - 0.5,
"band on column {x} runs into the edge of its window {y0}..{y1} \
(measured {top:.2}..{bottom:.2}) — the wrong thing is being measured"
);
(top, bottom)
}
/// The most common pixel value strictly inside a rect: the widget's own fill.
fn fill_value(frame: &Frame, r: &DevRect) -> f64 {
let mut hist = [0usize; 256];
for y in (r.y0 + 3)..(r.y1 - 3) {
for x in (r.x0 + 3)..(r.x1 - 3) {
hist[frame.at(x, y) as usize] += 1;
}
}
let (value, _) = hist
.iter()
.enumerate()
.max_by_key(|(_, n)| **n)
.expect("non-empty rect");
value as f64
}
#[derive(Clone, Copy, Debug)]
struct DevRect {
x0: usize,
y0: usize,
x1: usize,
y1: usize,
}
// ------------------------------------------------------------ measuring ----
struct Measurement {
/// Center of the painted box, in device pixels.
box_center: f64,
/// Center of the ink band, in device pixels.
ink_center: f64,
ink_height: f64,
box_source: &'static str,
}
impl Measurement {
fn delta_lpx(&self, dpi: f64) -> f64 {
(self.ink_center - self.box_center) / dpi
}
}
/// Measures where the label's ink sits inside the widget's box.
///
/// The ink comes out of the pixels either way. The box comes out of the
/// pixels too whenever the widget paints a flat face with a clean column
/// (every specimen but the dock tab, whose face is a gradient that bleeds
/// past its own rect to hide the seam with the dock body); otherwise it falls
/// back to the widget rect the app reports, which is rounded to whole logical
/// pixels.
fn measure(frame: &Frame, snap: &WidgetSnapshot, dpi: f64) -> Measurement {
let rect = DevRect {
x0: (snap.x as f64 * dpi).round() as usize,
y0: (snap.y as f64 * dpi).round() as usize,
x1: ((snap.x + snap.width) as f64 * dpi).round() as usize,
y1: ((snap.y + snap.height) as f64 * dpi).round() as usize,
};
assert!(
rect.x1 <= frame.width && rect.y1 <= frame.height && rect.x1 > rect.x0 + 12,
"widget {} rect {rect:?} is not on the frame",
snap.id
);
let fill = fill_value(frame, &rect);
// The ink: the column that departs furthest from the fill, which for an
// `H` is one of its stems, running from the cap line to the baseline. Ink
// is lighter than its box under a dark theme and darker under a light one,
// so everything here works on the distance from the fill, not on
// brightness. The search stays clear of the box's own antialiased rim and
// rounded corners, which depart from the fill too.
let rim = (2.0 * dpi).ceil() as usize;
let corner = (3.0 * dpi).ceil() as usize;
let columns = (rect.x0 + corner)..(rect.x1 - corner);
let span = columns.len();
let lit = |y: usize| {
columns
.clone()
.filter(|x| (frame.at(*x, y) - fill).abs() > 40.0)
.count()
};
// Rows a glyph touches are lit across a few columns; rows a seam, border
// or gradient step touches are lit clear across the widget. Only the
// former are text, so the ink window is the run of rows between the first
// and the last of them (a dock tab, whose face bleeds past its own rect to
// hide the seam with the dock body, needs this).
let text_rows: Vec<usize> = ((rect.y0 + rim)..(rect.y1 - rim))
.filter(|y| {
let n = lit(*y);
n > 0 && n * 2 < span
})
.collect();
assert!(
!text_rows.is_empty(),
"widget {} has no legible ink over fill {fill}",
snap.id
);
let window = (text_rows[0].saturating_sub(2))..=(text_rows[text_rows.len() - 1] + 2);
let mass = |x: usize| {
window
.clone()
.map(|y| (frame.at(x, y) - fill).abs())
.sum::<f64>()
};
let stem = columns
.clone()
.max_by(|a, b| mass(*a).partial_cmp(&mass(*b)).unwrap())
.expect("non-empty rect");
let peak = window.clone().map(|y| frame.at(stem, y)).fold(fill, |acc, v| {
if (v - fill).abs() > (acc - fill).abs() {
v
} else {
acc
}
});
assert!(
(peak - fill).abs() > 40.0,
"widget {} has no legible ink (peak {peak}, fill {fill})",
snap.id
);
let (ink_top, ink_bottom) =
band_edges(frame, stem, *window.start(), *window.end(), peak, fill);
let interior = (rect.y0 + rim)..(rect.y1 - rim);
// The box: a column inside the widget that the label does not cross, read
// from a few pixels above the rect to a few below it.
let pad = 4;
let clean = ((rect.x0 + corner)..(rect.x1 - corner)).find(|x| {
interior
.clone()
.all(|y| (frame.at(*x, y) - fill).abs() <= 6.0)
});
let outside = clean.map(|x| frame.at(x, rect.y0.saturating_sub(pad)));
let sampled = match (clean, outside) {
(Some(x), Some(out))
if (out - fill).abs() > 20.0
&& rect.y0 > pad
&& rect.y1 + pad < frame.height
// The surround has to be flat on both sides, or the "edge"
// being integrated is somebody else's gradient.
&& (frame.at(x, rect.y1 + pad) - out).abs() <= 6.0 =>
{
Some(band_edges(frame, x, rect.y0 - pad, rect.y1 + pad, fill, out))
}
_ => None,
};
let (box_center, box_source) = match sampled {
Some((top, bottom)) => ((top + bottom) / 2.0, "pixels"),
None => (
(snap.y as f64 + snap.height as f64 / 2.0) * dpi,
"widget rect",
),
};
Measurement {
box_center,
ink_center: (ink_top + ink_bottom) / 2.0,
ink_height: ink_bottom - ink_top,
box_source,
}
}
fn dpi_of(frame: &Frame, widgets: &[WidgetSnapshot]) -> f64 {
let widest = widgets.iter().map(|w| w.width).max().unwrap_or(0);
assert!(widest > 0, "no widgets in snapshot");
let dpi = frame.width as f64 / widest as f64;
assert!(
(dpi - dpi.round()).abs() < 0.02,
"frame {}px over widest widget {widest}px is not a whole dpi factor",
frame.width
);
dpi.round()
}
fn snapshot_of<'a>(widgets: &'a [WidgetSnapshot], id: &str) -> &'a WidgetSnapshot {
widgets
.iter()
.find(|w| w.id == id && w.visible)
.unwrap_or_else(|| panic!("no visible widget with id `{id}`"))
}
fn tab_snapshot<'a>(widgets: &'a [WidgetSnapshot], name: &str) -> &'a WidgetSnapshot {
widgets
.iter()
.find(|w| w.widget_type == "DockTab" && w.text.as_deref() == Some(name) && w.visible)
.unwrap_or_else(|| panic!("no visible DockTab named `{name}`"))
}
/// Every specimen on the page, measured and asserted in one pass.
fn assert_page_is_centered(app: TestApp, label: &str) {
app.locator(Selector::id("btn_10")).wait_visible();
let widgets = app.widget_snapshot();
let shot = app.screenshot();
let frame = Frame::decode(&shot);
let dpi = dpi_of(&frame, &widgets);
println!("[{label}] frame {shot:?} {}x{} dpi {dpi}", frame.width, frame.height);
let by_id = [
"btn_08", "btn_10", "btn_14", "btn_24", "btn_tall", "btn_bold", "btn_icon", "drop_10",
"drop_14", "label_box",
];
let mut worst: Option<(String, f64)> = None;
let mut check = |name: String, snap: &WidgetSnapshot| {
let m = measure(&frame, snap, dpi);
let delta = m.delta_lpx(dpi);
println!(
"[{label}] {name:12} box_c {:8.3} ({}) ink_c {:8.3} ink_h {:6.3} -> {delta:+.3} lpx",
m.box_center, m.box_source, m.ink_center, m.ink_height
);
let is_worse = match &worst {
None => true,
Some((_, w)) => delta.abs() > w.abs(),
};
if is_worse {
worst = Some((name, delta));
}
};
for id in by_id {
check(id.to_string(), snapshot_of(&widgets, id));
}
check("tab(H)".to_string(), tab_snapshot(&widgets, "H"));
let (name, delta) = worst.expect("measured nothing");
assert!(
delta.abs() <= TOLERANCE_LPX,
"[{label}] `{name}`: label ink sits {delta:+.3} logical px off the box center \
(tolerance {TOLERANCE_LPX}); see {shot:?}"
);
}
#[makepad_test]
fn boxed_labels_are_ink_centered(app: TestApp) {
assert_page_is_centered(app, "dark");
}
/// The same page under the light theme. The two desktop themes share every
/// number that decides vertical placement, so this is a guard against a
/// future theme growing its own padding or font size and quietly reopening
/// the bug on one side only.
#[test]
fn boxed_labels_are_ink_centered_light_theme() {
let mut config = TestConfig::current_package(
env!("CARGO_MANIFEST_DIR"),
env!("CARGO_PKG_NAME"),
"boxed_labels_are_ink_centered_light_theme",
)
.expect("test config");
config
.env
.insert("MAKEPAD_TEXT_CENTER_THEME".to_string(), "light".to_string());
run_with_config(config, |app| assert_page_is_centered(app, "light")).expect("light theme run");
}