diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 6096b3419..5dd8232a5 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -371,10 +371,18 @@ impl Renderer { } } // What an object can paint on the spread: its bounds with its (and its children's) - // strokes and effects. + // strokes and effects, which are drawn through each one's transform and grow with it. + fn outset(it: &Item, outer: Affine, o: &mut f64) { + let xf = outer * it.xf; + let scale = crate::fx::max_scale(xf).unwrap_or(1.0); + *o = o.max(crate::fx::outset_scaled(it, scale) + it.stroke.extent() * scale); + for c in it.children() { + outset(c, xf, o); + } + } let reach = |it: &Item| { let mut o: f64 = 0.0; - it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent())); + outset(it, Affine::IDENTITY, &mut o); it.bounds().inflate(o + 2.0, o + 2.0) }; let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect(); @@ -601,6 +609,39 @@ mod tests { assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels); } + /// A hardened outer glow is stroked through the object's transform, and through its group's, + /// so the ink-pass area grows by the scaled growth: the square at 30..60 glows out to -30. + #[test] + fn cmyk_area_holds_a_scaled_objects_glow() { + for nested in [false, true] { + let mut doc = document(); + let mut item = Item::new( + ItemId(doc.alloc()), + doc.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), + ); + item.fill = Fill::swatch("C=100 M=0 Y=0 K=0"); + let g = &mut item.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + if nested { + let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); + group.xf = Affine::scale(3.0); + group.content = Content::Group { items: vec![Arc::new(item)] }; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + } else { + item.xf = Affine::scale(3.0); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + let cache = Cache::new(); + let opts = RenderOptions::default(); + let f = + Frame { doc: &doc, cache: &cache, view: Affine::IDENTITY, visible: Rect::new(-1e4, -1e4, 1e4, 1e4), px: 1.0, opts: &opts, mt: false }; + let area = Renderer::new().transparent_area(&f, SpreadRef::Doc(0)).unwrap(); + assert!(area.x0 <= -30.0, "nested {nested}: the area {area:?} holds the glow out to -30"); + } + } + #[test] fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() { let mut doc = document(); diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index ff76e0ecb..aa1780014 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -11,6 +11,8 @@ use std::sync::Arc; use designcraft_doc::{Document, Item, ItemId, SpreadRef, Story, StoryId}; use designcraft_geom::Rect; +use crate::fx::max_scale; + /// Changed regions in spread coordinates, or `None` when the whole view must be repainted. pub fn damage(old: &Document, new: &Document) -> Option> { damage_with(old, new, None) @@ -158,36 +160,47 @@ fn shows_other_text(d: &Document, s: &Story) -> bool { fn item_area(it: &Item) -> Option { let mut ok = true; it.walk(&mut |i| { - // Effects spill outside; text wrap reflows other frames. - if i.effects.any() || i.has_wrap() { + // Text wrap reflows other frames. A nested item's effects are drawn through its parents' + // transforms, which can scale how far they reach. + if i.has_wrap() || !std::ptr::eq(i, it) && i.effects.any() { ok = false; } }); if !ok { return None; } - let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 }; - Some(painted_bounds(it).inflate(pad, pad)) + // The stroke and the effects' spread are drawn through the item's transform. The item's own + // drop shadow and outer glow reach `fx::outset_scaled` past what it paints; its inner effects + // and feathers stay inside the shape. + let scale = max_scale(it.xf)?; + let stroke = it.stroke.extent().max(0.0) * scale; + let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset_scaled(it, scale); + if !pad.is_finite() { + return None; + } + Some(painted_bounds(it)?.inflate(pad, pad)) } /// An item's bounds, grown to its text: text is composed in the box around the frame's path in /// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds. -fn painted_bounds(it: &Item) -> Rect { +/// Its children's bounds include their strokes. None when a child's transform is not finite. +fn painted_bounds(it: &Item) -> Option { let mut b = it.bounds(); if it.is_text_frame() { b = b.union(it.xf.transform_rect_bbox(it.inner_bounds())); } for c in it.children() { - b = b.union(it.xf.transform_rect_bbox(painted_bounds(c))); + let stroke = c.stroke.extent().max(0.0) * max_scale(c.xf)?; + b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)?.inflate(stroke, stroke))); } - b + Some(b) } /// A text frame's area: its spread and bounds with room for glyph overhang. fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> { let loc = d.find(f)?; let it = d.item_at(&loc)?; - let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)); + let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)?); // Overhang of italics, swashes and big initials, and anchored objects pinned to lines. Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0))) } @@ -384,4 +397,124 @@ mod tests { let spreads: Vec = dmg.iter().map(|(r, _)| *r).collect(); assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}"); } + + /// Moving an object with a drop shadow or an outer glow damages where its effect was and is, + /// and every pixel the move changes lies in that damage: the canvas repaints those regions, + /// not the whole view. + #[test] + fn moving_an_object_with_a_shadow_or_glow_damages_its_effect_area() { + for glow in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0))); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + if glow { + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 30.0, 20.0); + } else { + let s = &mut it.effects.drop_shadow; + (s.on, s.color, s.opacity, s.size, s.distance, s.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 25.0, 30.0); + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.path.transform(designcraft_geom::Affine::translate((60.0, 40.0))); + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| !Rect::new(98.0, 98.0, 312.0, 242.0).contains(*p)), "glow {glow}: the effect paints outside the object"); + } + // A hardened glow on a scaled object: its growth is stroked through the transform, so it + // reaches three times as far on the spread as the glow's size says. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0))); + it.xf = designcraft_geom::Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf; + // The square covers 150..270 before the move; its glow reaches 60 pt past that. + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 110.0), "the hard glow grows with the object's scale"); + // An effect on an object inside a group is drawn through the group's transform: unbounded. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let mut child = + Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 9.0, 9.0))); + child.effects.drop_shadow.on = true; + let gid = ItemId(d.alloc()); + let mut group = Item::new(gid, lid, designcraft_doc::Shape::Group, Default::default()); + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] }; + d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let old = d.clone(); + d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::scale(4.0); + assert!(damage(&old, &d).is_none()); + } + + /// A stroke is drawn through its item's transform: a frame scaled up paints its stroke that + /// much wider, and moving it damages all of it. + #[test] + fn moving_a_scaled_frame_damages_its_whole_stroke() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 125.0, 100.0))); + it.xf = designcraft_geom::Affine::scale(2.0); + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.weight = 20.0; + it.stroke.align = designcraft_doc::StrokeAlign::Outside; + it.effects.outer_glow.on = true; + it.effects.outer_glow.size = 0.0; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf; + // The stroke reaches 40 pt past the frame's 100..250 × 100..200 outline on the spread. + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 70.0), "the stroke paints its scaled width"); + // The same frame inside a group with a gradient feather: the child's stroke counts too. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let mut child = old.spreads[0].items[0].as_ref().clone(); + child.effects = Default::default(); + child.id = ItemId(d.alloc()); + let gid = ItemId(d.alloc()); + let outline = designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)); + let mut group = Item::new(gid, d.default_layer(), designcraft_doc::Shape::Group, outline); + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] }; + let gf = &mut group.effects.gradient_feather; + (gf.on, gf.start, gf.end) = (true, 1.0, 0.5); + d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let old = d.clone(); + d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::translate((60.0, 40.0)); + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 70.0), "the child's stroke paints its scaled width"); + } + + /// The pixels a page render changes from `old` to `new`, each checked to lie in the damage. + fn changed_pixels_in_damage(old: &Document, new: &Document) -> Vec { + let dmg = damage(old, new).expect("bounded damage"); + let cache = designcraft_compose::Cache::new(); + let mut r = crate::Renderer::new(); + r.threads = 0; + let opts = crate::RenderOptions::default(); + let before = r.render_page(old, &cache, 0, 1.0, false, &opts).unwrap(); + let after = r.render_page(new, &cache, 0, 1.0, false, &opts).unwrap(); + let mut changed = Vec::new(); + for y in 0..before.height { + for x in 0..before.width { + if before.pixel(x, y) == after.pixel(x, y) { + continue; + } + let p = designcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + assert!(dmg.iter().any(|(_, r)| r.inflate(1.0, 1.0).contains(p)), "changed pixel {p:?} outside {dmg:?}"); + changed.push(p); + } + } + changed + } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 625338eb0..dbaa1d1b3 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -179,7 +179,10 @@ impl Renderer { return; } let xf = parent * it.xf; - let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0); + // The stroke and the effects' growth are drawn through `xf`, which stretches them. + let scale = fx::max_scale(xf).unwrap_or(1.0); + let stroke = it.stroke.extent() * scale; + let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(stroke + 2.0, stroke + 2.0); // A frame with items pasted into it draws like any other object (draw_body: fill, the items // clipped to the frame, stroke), so its effects apply to all of it. if !it.children().is_empty() && !it.has_nested_items() { @@ -193,7 +196,7 @@ impl Renderer { _ => 0.0, }; let effects = it.effects.any() && f.opts.quality != DisplayQuality::Fast; - let fx_outset = if effects { fx::outset(it) } else { 0.0 }; + let fx_outset = if effects { fx::outset_scaled(it, scale) } else { 0.0 }; if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) { return; } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 65cb4a66e..89f193d06 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -40,17 +40,37 @@ pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 { /// How far (points) the effects of `it` reach outside its geometry. pub(crate) fn outset(it: &Item) -> f64 { + outset_scaled(it, 1.0) +} + +/// [`outset`] for an item whose transform stretches lengths by up to `scale`: the blur and the +/// shadow's distance are spread lengths, but the spread's growth is stroked through the item's +/// transform and grows with it. +pub(crate) fn outset_scaled(it: &Item, scale: f64) -> f64 { let e = &it.effects; + let extra = |size: f64, pct: f64| split(size, pct).1 * (scale - 1.0).max(0.0); let mut o: f64 = 0.0; if e.drop_shadow.on { - o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0); + let s = &e.drop_shadow; + o = o.max(bound(s.distance).abs() + bound(s.size).abs() * 1.6 + 1.0 + extra(s.size, s.spread)); } if e.outer_glow.on { - o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0); + let g = &e.outer_glow; + o = o.max(bound(g.size).abs() * 1.6 + 1.0 + extra(g.size, g.spread)); } o } +/// The largest factor `m` stretches a length by (its linear part's largest singular value), or +/// None when that is not finite. +pub(crate) fn max_scale(m: Affine) -> Option { + let [a, b, c, d, _, _] = m.as_coeffs(); + let s = a * a + b * b + c * c + d * d; + let det = a * d - b * c; + let v = ((s + (s * s - 4.0 * det * det).max(0.0).sqrt()) / 2.0).sqrt(); + v.is_finite().then_some(v) +} + /// Whether `it` has a basic feather: a width on a closed shape. fn basic_feather(it: &Item) -> bool { it.effects.feather > 0.0 && it.path.is_closed() @@ -120,7 +140,9 @@ impl Renderer { pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) { let doc = f.doc; let e = &it.effects; - let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() }; + // The stroke and the effects' growth are drawn through `xf`, which stretches them. + let scale = max_scale(xf).unwrap_or(1.0); + let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() * scale }; // Spread-space reach of what the object paints. let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); // The silhouette is the shape when the object paints its area (fill / image); text frames @@ -150,7 +172,7 @@ impl Renderer { let filter = shadow(sigma, paint); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. - let r = (reach + off).inflate(grow, grow); + let r = (reach + off).inflate(grow * scale, grow * scale); // The effect's blend sees what's behind the object: a layer around the filtered one. let mix = crate::blend_mode(mode).mix; if mix != Mix::Normal { @@ -1516,4 +1538,86 @@ mod tests { } } } + + /// A hardened outer glow is stroked through the item's transform, so a scaled item's glow + /// grows with it. The multithreaded renderer draws the glow offscreen, cropped to its reach, + /// and that reach scales too. + #[test] + fn a_scaled_items_hard_glow_is_not_cropped() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)), + ); + it.xf = Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + // max_scale's own cases: a plain scale, a rotation and a shear. + assert_eq!(max_scale(it.xf), Some(3.0)); + assert!((max_scale(Affine::rotate(0.7)).unwrap() - 1.0).abs() < 1e-12); + assert!((max_scale(Affine::skew(1.0, 0.0)).unwrap() - (1.0 + 5f64.sqrt()) / 2.0).abs() < 1e-12); + assert_eq!(max_scale(Affine::scale(f64::INFINITY)), None); + // The 20 pt growth is 60 pt on the spread: the glow covers 90..330. + assert_eq!(outset_scaled(&it, 3.0), outset(&it) + 40.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + for threads in [0, 2] { + let mut r = Renderer::new(); + r.threads = threads; + let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); + let p = page.pixel(100, 210); + let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + assert!(lum < 200, "{threads} threads: the glow 50 pt left of the scaled square is drawn, luminance {lum}"); + } + } + + /// Culling measures a scaled item's stroke and hardened glow as they are drawn, through its + /// transform, so a view (or a repaint patch) that shows only that part still draws it. + #[test] + fn a_scaled_items_hard_glow_and_stroke_are_not_culled() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let square = |glow: bool| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)), + ); + it.xf = Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + if glow { + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + } else { + (it.stroke.swatch, it.stroke.weight) = (designcraft_color::swatch::BLACK.into(), 20.0); + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d + }; + let cache = crate::Cache::new(); + let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + // The square covers 150..270 on the spread. Its glow grows 60 pt (to 90), past the + // unscaled 33 pt reach; its 20 pt stroke is 60 pt wide (to 120), past the unscaled 22 pt. + for (glow, x) in [(true, 95.0), (false, 123.0)] { + let d = square(glow); + for threads in [0, 2] { + let mut r = Renderer::new(); + r.threads = threads; + let place = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let image = r.render(&d, &cache, &place, (4, 20), Affine::translate((-x, -200.0)), &opts); + let p = image.pixel(1, 10); + let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + assert!(lum < 200, "glow {glow}, {threads} threads: the view at x {x} shows the scaled square's edge, luminance {lum}"); + } + } + } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02a63a299..3e9040050 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2420,6 +2420,34 @@ fn edits_patch_the_canvas_only_at_full_resolution() { } } +/// Moving a frame with a drop shadow repaints the regions the frame and its shadow cover, as +/// moving one without a shadow does, instead of rendering the whole canvas again on every step. +#[test] +fn moving_a_frame_with_a_drop_shadow_patches_the_canvas() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut DesignApp| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 180], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + app.run("object.dropShadow", json!({"on": true})).unwrap(); + for _ in 0..3 { + frame(&mut app); + } + for step in 1..=3 { + app.run("transform.move", json!({"dx": 4, "dy": 2})).unwrap(); + frame(&mut app); + assert_eq!(app.canvas.patches, step, "drag step {step} repaints only what changed"); + } +} + /// The Type tool's caret at the end of a new text frame holding `text`, the app's first frames /// drawn; returns the story id. fn typing_app(app: &mut DesignApp, ctx: &egui::Context, text: &str) -> Value {