From 2dba90bd0fb64b3c3e0d758ac85f6ec0b87bd3c4 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sun, 20 Sep 2026 04:50:31 -0400 Subject: [PATCH] Grow images in the generated trees `Image` is the only widget in the repository whose size hint is a length in pixels -- everything else hints a share, or nothing -- so it is the only one that exercises a rule beside a hint, a box a widget knows before it is drawn, and the answer the commit before this one changed. The generated trees had none, which is why nothing there could reach that case. `Kind::Image` is a fifth leaf, drawn one time in five, and it steps to a plain rect when the shrinker reduces it: a picture measures nothing either, but its length is its own, so the leaf that takes whatever it is given is the simpler one. The picture is a 64x64 checkerboard of purple and black in 8 px cells, committed at `src/assets/checkerboard.png` beside the generator that draws it -- the way `examples/tabs` keeps its own -- and included rather than opened, so that growing a tree does not depend on a working directory and one seed is one tree whatever anything else does. One upload per tree, however many images it grows: a `TextureHandle` is a counted reference, so the first image in a tree uploads the checkerboard and every one after it clones the handle. Measured: seed 1 at depth 4 grows 13 images and holds 1 texture, seed 6 grows none and holds none, and `a_tree_of_images_uploads_one_texture` asserts it. `Image::new` is what a caller holding a handle needs, since `image` uploads what it is given. A seed names a tree only while the generator draws the same things in the same order, so every seed now grows a different tree. The seed list in `generated.rs` says so: 20 and 86 no longer grow the trees whose defects they once caught, and both of those live on as shrunk fixtures in `unsettled.rs`, which are trees rather than numbers. The seeds those fixtures name are similarly historical, and their file says that too. Format, clippy with and without layout-diagnostics, and the suite (135 + 19 + 13 + 4) are clean. The cold dump is a new baseline of 34,571 boxes over the 400 depth-5 trees, since the trees themselves changed; all three seed scans pass over the new ones -- 400 at depth 5 in 62.79s, 1,000 at depth 6 in 160.20s, 2,000 at depth 4 in 299.58s -- which is what actually checks that images lay out warm the way they do cold. Co-Authored-By: Claude Opus 5 --- src/assets/checkerboard.png | Bin 0 -> 191 bytes src/random.rs | 32 +++++++++++++++++++++++++++++--- src/widget/image.rs | 9 +++++++++ tests/cases/plan.rs | 23 ++++++++++++++++++++--- tests/cases/unsettled.rs | 6 ++++++ tests/generated.rs | 12 ++++++++---- 6 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 src/assets/checkerboard.png diff --git a/src/assets/checkerboard.png b/src/assets/checkerboard.png new file mode 100644 index 0000000000000000000000000000000000000000..af7a5fe0416ec85c8e5373a2357073125bf86acb GIT binary patch literal 191 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=ZJsWUAr-gY-q^@_z<`J4K=_FV z_hN1ySeJZv`?0f%ia(-Wo>SAF`nUS|`Tz5I_< out.push(Kind::OneLine), - Kind::OneLine => out.push(Kind::Rect { + Kind::OneLine | Kind::Image => out.push(Kind::Rect { color: 0, alpha: 255, }), @@ -627,9 +632,10 @@ struct Sow<'a> { impl Sow<'_> { fn leaf(&mut self) -> Plan { - Plan::bare(match self.rng.below(4) { + Plan::bare(match self.rng.below(5) { 0 => Kind::Wrapped, 1 => Kind::OneLine, + 2 => Kind::Image, _ => { let color = self.rng.below(COLORS.len()); let alpha = (self.rng.below(5) * 63) as u8; @@ -793,6 +799,7 @@ pub fn build(rsc: &mut Rsc, plan: &Plan) -> (StrongWidget, let mut build = Build { rsc, tree: Tree::default(), + checkerboard: None, }; let root = build.node(plan); (root, build.tree) @@ -801,6 +808,10 @@ pub fn build(rsc: &mut Rsc, plan: &Plan) -> (StrongWidget, struct Build<'a, Rsc> { rsc: &'a mut Rsc, tree: Tree, + /// The checkerboard, uploaded when the first image in this tree is built. + /// A handle is a reference to the texture, so every image after that one + /// clones this rather than uploading the same picture again. + checkerboard: Option, } impl Build<'_, Rsc> { @@ -826,6 +837,20 @@ impl Build<'_, Rsc> { built } + /// The one picture the generated trees draw: a 64x64 checkerboard of purple + /// and black in 8 px cells. Committed rather than drawn here, so that one + /// seed is one tree whatever anything else does, and included rather than + /// opened, so that growing a tree does not depend on a working directory. + fn checkerboard(&mut self) -> TextureHandle { + if self.checkerboard.is_none() { + let image = include_bytes!("assets/checkerboard.png") + .get_image() + .expect("the checkerboard is committed beside this file"); + self.checkerboard = Some(self.rsc.ui_mut().textures.add(image)); + } + self.checkerboard.clone().unwrap() + } + fn kind(&mut self, kind: &Kind) -> StrongWidget { let id: StrongWidget = match kind { Kind::Wrapped => wtext(WORDS).size(16).wrap(true).add_strong(self.rsc), @@ -834,6 +859,7 @@ impl Build<'_, Rsc> { .wrap(false) .add_strong(self.rsc), Kind::Rect { color, alpha } => rect(COLORS[*color].alpha(*alpha)).add_strong(self.rsc), + Kind::Image => Image::new(self.checkerboard()).add_strong(self.rsc), Kind::Scroll { axis, inner } => { let inner = self.node(inner); let id = Scroll::new(inner, *axis).add(self.rsc); diff --git a/src/widget/image.rs b/src/widget/image.rs index 50bd1ff..6145f33 100644 --- a/src/widget/image.rs +++ b/src/widget/image.rs @@ -16,6 +16,15 @@ impl Widget for Image { } } +impl Image { + /// One texture already uploaded, for a caller holding its handle: [`image`] + /// uploads what it is given, and several widgets showing one picture want + /// one upload and one slot between them. + pub fn new(handle: TextureHandle) -> Self { + Self { handle } + } +} + pub fn image(image: impl LoadableImage) -> impl WidgetFn { let image = image.get_image().expect("Failed to load image"); move |state| Image { diff --git a/tests/cases/plan.rs b/tests/cases/plan.rs index 3428786..a9a398c 100644 --- a/tests/cases/plan.rs +++ b/tests/cases/plan.rs @@ -5,7 +5,9 @@ //! and the oracle another. And reducing a plan has to end, or a shrinker //! searching for the smallest counterexample never returns. -use iris::random::{Edits, Kind, Plan, Rng, SpanEdit, plan}; +use iris::harness::Harness; +use iris::prelude::*; +use iris::random::{Edits, Kind, Plan, Rng, SpanEdit, grow, plan}; use std::collections::HashMap; fn some_edits(seed: u64, of: &Plan) -> Edits { @@ -67,8 +69,6 @@ fn some_edits(seed: u64, of: &Plan) -> Edits { } } -use iris::prelude::*; - /// The two routes to an edited tree are one tree. `plan` resolves edits out /// of the random stream as it draws; `edited` puts them on a tree that /// already exists, which is the only route a shrunk plan has, since no seed @@ -137,3 +137,20 @@ fn reducing_a_plan_all_the_way_ends() { ); } } + +/// Every image in a tree is the same picture, and a handle is a reference to +/// the texture rather than a copy of it, so one upload and one slot serve all +/// of them however many a tree grows -- and the trees are grown in hundreds. +#[test] +fn a_tree_of_images_uploads_one_texture() { + let mut images = 0; + let mut tree = plan(1, 4, &Edits::default()); + tree.walk_mut(&mut |p| images += (p.kind == Kind::Image) as usize); + assert!(images > 1, "a tree of {images} images tests nothing"); + + let mut h = Harness::new((900, 1200)); + let (root, _) = grow(&mut h.rsc, 1, 4, &Edits::default()); + h.state.root = Some(root); + h.frame(); + assert_eq!(h.rsc.ui().textures.count(), 1); +} diff --git a/tests/cases/unsettled.rs b/tests/cases/unsettled.rs index 80043ad..ed0d9cb 100644 --- a/tests/cases/unsettled.rs +++ b/tests/cases/unsettled.rs @@ -9,6 +9,12 @@ //! reached through a region node's own entry rather than through the offer //! that node was given. The last is a wrapping text handed back the width //! it measured, rounded to a step below the line it measured there. +//! +//! Each says which seed it was shrunk from, of the generator as it stood when +//! it was found. Those numbers no longer grow those trees -- a seed names one +//! only while the generator draws the same things in the same order, and the +//! leaves have grown an image since -- so what is written out below is the +//! record of the case, and the seed is where it came from. use std::collections::HashSet; diff --git a/tests/generated.rs b/tests/generated.rs index 6dfdd87..45ce661 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -25,10 +25,14 @@ fn depth() -> usize { env("IRIS_GENERATED_DEPTH", 4) } -/// The seeds the ordinary tests take. Eight that have never failed; 86, -/// which a `Scroll` fixed point once settled differently on; and 20, which -/// caught a locally redrawn widget being placed twice in the box its parent -/// had already placed it in. +/// The seeds the ordinary tests take: a corpus rather than a set of +/// regression cases, since a seed names a tree only for as long as the +/// generator draws the same things in the same order. Adding images to the +/// leaves moved every one of them, so 20 and 86 -- which once caught a widget +/// placed twice in a box its parent had already placed it in, and a `Scroll` +/// fixed point settling differently -- no longer grow those trees. Both +/// defects are pinned by the shrunk fixtures in `cases/unsettled.rs`, which +/// are trees rather than numbers. const SEEDS: [u64; 10] = [1, 2, 3, 5, 8, 10, 13, 20, 86, 98]; fn check(seed: u64, depth: usize, case: Case) {