diff --git a/TASKS.md b/TASKS.md index 49955e7..4c77eb1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -295,3 +295,4 @@ Per-component lines live in `tasks/`. Tags there: | T25a | 2 | PASS | | | T25b | 1 | FAIL | a done result with nothing to open still took focus on a press and drew a ring the keys could not reach | | T25b | 2 | PASS | | +| T25c | 1 | FAIL | a canvas with no handlers still took Tab, the pen and the ring; strokes from the host went unchecked | diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs index 9bf6b75..a2ded88 100644 --- a/examples/gallery/pages/generative/mod.rs +++ b/examples/gallery/pages/generative/mod.rs @@ -30,6 +30,7 @@ const SCRIPT: &[Step] = &[ Step::Wait(300), Step::Shot("verdict"), Step::DownAt("gen-canvas", 300.0, 70.0), + Step::DragTo("gen-canvas", 390.0, 220.0), Step::UpAt("gen-canvas", 390.0, 220.0), Step::Wait(300), Step::Shot("masked"), diff --git a/src/generative/mask.rs b/src/generative/mask.rs index 30949f3..236b58b 100644 --- a/src/generative/mask.rs +++ b/src/generative/mask.rs @@ -3,14 +3,14 @@ use std::rc::Rc; use gpui::{ App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, - size, transparent_black, + prelude::*, size, transparent_black, }; use crate::{ documents::source, forms::{Drawing, pen}, primitives::{FocusRing, Image, checked_ratio, framed, tab_stop}, - theme::{ActiveTheme, Radius}, + theme::ActiveTheme, }; /// Cells across a mask; rows follow the picture's shape. @@ -39,35 +39,40 @@ fn reach(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { x * x + y * y } -/// The cells of a `columns` by `rows` grid over a picture `ratio` wide over tall that the strokes cover, row by row: a later stroke wins, and an erasing one clears. -pub(crate) fn raster(strokes: &[MaskStroke], columns: usize, rows: usize, ratio: f32) -> Vec { - let mut cells = vec![false; columns * rows]; +/// Marks the cells `stroke` reaches on a `columns` by `rows` grid over a picture `ratio` wide over tall, or clears them if it erases; reach is measured in widths, so a dab is as tall as wide. +fn paint(cells: &mut [bool], stroke: &MaskStroke, columns: usize, rows: usize, ratio: f32) { let column = |x: f32| ((x * columns as f32).floor().max(0.0) as usize).min(columns); let row = |y: f32| ((y * ratio * rows as f32).floor().max(0.0) as usize).min(rows); - for stroke in strokes { - let across: Vec<(f32, f32)> = stroke.points.iter().map(|&(x, y)| (x, y / ratio)).collect(); - let pairs: Vec<_> = match across.as_slice() { - [] => continue, - [only] => vec![(*only, *only)], - _ => across.windows(2).map(|pair| (pair[0], pair[1])).collect(), - }; - let (r, near) = (stroke.radius, stroke.radius * stroke.radius); - for (a, b) in pairs { - let columns_in = column(a.0.min(b.0) - r)..(column(a.0.max(b.0) + r) + 1).min(columns); - let rows_in = row(a.1.min(b.1) - r)..(row(a.1.max(b.1) + r) + 1).min(rows); - for j in rows_in { - for i in columns_in.clone() { - let center = ( - (i as f32 + 0.5) / columns as f32, - (j as f32 + 0.5) / rows as f32 / ratio, - ); - if reach(center, a, b) <= near { - cells[j * columns + i] = !stroke.erase; - } + let across: Vec<(f32, f32)> = stroke.points.iter().map(|&(x, y)| (x, y / ratio)).collect(); + let pairs: Vec<_> = match across.as_slice() { + [] => return, + [only] => vec![(*only, *only)], + _ => across.windows(2).map(|pair| (pair[0], pair[1])).collect(), + }; + let (r, near) = (stroke.radius, stroke.radius * stroke.radius); + for (a, b) in pairs { + let columns_in = column(a.0.min(b.0) - r)..(column(a.0.max(b.0) + r) + 1).min(columns); + let rows_in = row(a.1.min(b.1) - r)..(row(a.1.max(b.1) + r) + 1).min(rows); + for j in rows_in { + for i in columns_in.clone() { + let center = ( + (i as f32 + 0.5) / columns as f32, + (j as f32 + 0.5) / rows as f32 / ratio, + ); + if reach(center, a, b) <= near { + cells[j * columns + i] = !stroke.erase; } } } } +} + +/// The cells the strokes cover, row by row: a later stroke wins, and an erasing one clears. +pub(crate) fn raster(strokes: &[MaskStroke], columns: usize, rows: usize, ratio: f32) -> Vec { + let mut cells = vec![false; columns * rows]; + for stroke in strokes { + paint(&mut cells, stroke, columns, rows, ratio); + } cells } @@ -129,11 +134,24 @@ impl InpaintCanvas { ratio: f32, strokes: impl IntoIterator, ) -> Self { + let strokes: Vec = strokes.into_iter().collect(); + for stroke in &strokes { + assert!( + (REACH.0..=REACH.1).contains(&stroke.radius), + "a mask stroke's radius of {}", + stroke.radius + ); + assert!( + (stroke.points.iter()) + .all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)), + "a mask stroke outside its picture" + ); + } Self { id: id.into(), picture: picture.into(), ratio: checked_ratio(ratio), - strokes: strokes.into_iter().collect(), + strokes, radius: 0.04, erase: false, on_stroke: None, @@ -171,28 +189,40 @@ impl InpaintCanvas { } } -/// The strokes a mask last drew, its rows, and the rectangles they made. -type Shape = ( - Vec, - usize, - Rc>, -); +type Rects = Rc>; -/// The mask's rectangles, made again only when its strokes or rows change, not as the pointer moves. +/// The strokes a mask last drew, its rows, and the cells and rectangles they made. +type Shape = (Vec, usize, Rc>, Rects); + +/// The mask's rectangles: the settled strokes' cells kept until they or the rows change, and a stroke under the pen painted over a copy. fn shaped( cache: &Entity, - live: Vec, + strokes: Vec, + live: Option, rows: usize, ratio: f32, cx: &mut App, -) -> Rc> { - let (kept, kept_rows, rects) = cache.read(cx); - if *kept == live && *kept_rows == rows { - return rects.clone(); +) -> Rects { + let stale = { + let (kept, kept_rows, _, _) = cache.read(cx); + *kept != strokes || *kept_rows != rows + }; + if stale { + let cells = raster(&strokes, COLUMNS, rows, ratio); + let rects = Rc::new(spans(&cells, COLUMNS)); + cache.update(cx, |cache, _| { + *cache = (strokes, rows, Rc::new(cells), rects) + }); + } + let (_, _, cells, rects) = cache.read(cx); + match live { + None => rects.clone(), + Some(stroke) => { + let mut cells = cells.to_vec(); + paint(&mut cells, &stroke, COLUMNS, rows, ratio); + Rc::new(spans(&cells, COLUMNS)) + } } - let rects = Rc::new(spans(&raster(&live, COLUMNS, rows, ratio), COLUMNS)); - cache.update(cx, |cache, _| *cache = (live, rows, rects.clone())); - rects } /// `at` as shares of `bounds`. @@ -216,31 +246,29 @@ impl RenderOnce for InpaintCanvas { ); let cache: Entity = window.use_keyed_state((self.id.clone(), "shape"), cx, |_, _| { - (Vec::new(), 0, Rc::new(Vec::new())) + (Vec::new(), 0, Rc::new(Vec::new()), Rc::new(Vec::new())) }); - let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let (keyed, drawn) = (self.on_brush.is_some(), self.on_stroke.is_some()); + let focus = tab_stop((self.id.clone(), "focus").into(), keyed, window, cx); let (bounds, under) = (drawing.read(cx).bounds, drawing.read(cx).stroke.clone()); let (radius, erase) = (self.radius, self.erase); - let mut live = self.strokes; - if !under.is_empty() { - let points = under.iter().map(|at| share(bounds, *at)).collect(); - live.push(MaskStroke { - points, - radius, - erase, - }); - } + let live = (!under.is_empty()).then(|| MaskStroke { + points: under.iter().map(|at| share(bounds, *at)).collect(), + radius, + erase, + }); let rows = ((COLUMNS as f32 / self.ratio).round() as usize).max(1); - let rects = shaped(&cache, live, rows, self.ratio, cx); + let rects = shaped(&cache, self.strokes, live, rows, self.ratio, cx); let theme = cx.theme(); let colors = theme.colors.clone(); - let (wash, corner) = (colors.accent.alpha(0.45), theme.radius(Radius::Lg)); + let wash = colors.accent.alpha(0.45); let ring = pointer .read(cx) - .filter(|_| bounds.size.width > Pixels::ZERO) + .filter(|_| drawn && bounds.size.width > Pixels::ZERO) .map(|at| { let span = bounds.size.width * radius * 2.0; div() + .debug_selector(|| "inpaint-ring".into()) .absolute() .left(at.x - bounds.left() - span / 2.0) .top(at.y - bounds.top() - span / 2.0) @@ -265,17 +293,11 @@ impl RenderOnce for InpaintCanvas { let (id, on_stroke) = (self.id.clone(), self.on_stroke.clone()); let area = framed(self.ratio, cx) .id(self.id.clone()) - .track_focus(&focus) - .rounded(corner) .border_1() .border_color(transparent_black()) - .focus_ring(cx) - .cursor_crosshair() - .child( - Image::new((self.id.clone(), "picture"), source(&self.picture)) - .size_full() - .rounded(corner), - ) + .when(keyed, |area| area.track_focus(&focus).focus_ring(cx)) + .when(drawn, |area| area.cursor_crosshair()) + .child(Image::new((self.id.clone(), "picture"), source(&self.picture)).size_full()) .child( canvas( move |bounds, _, cx| { @@ -335,11 +357,11 @@ impl RenderOnce for InpaintCanvas { log::info!("inpaint canvas: brush {next:.3}, erase {swap}"); keys(next, swap, window, cx) }); + let Some(on_stroke) = on_stroke else { + return area; + }; let sized = drawing.clone(); pen(area, &drawing, move |stroke, window, cx| { - let Some(on_stroke) = &on_stroke else { - return; - }; let bounds = sized.read(cx).bounds; let points = stroke.iter().map(|at| share(bounds, *at)).collect(); log::info!("inpaint canvas {id:?}: a stroke of {} points", stroke.len()); diff --git a/src/generative/tests/canvas.rs b/src/generative/tests/canvas.rs index 3842139..e86028c 100644 --- a/src/generative/tests/canvas.rs +++ b/src/generative/tests/canvas.rs @@ -70,6 +70,12 @@ fn painting(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContex fn a_drag_lands_a_stroke_in_shares_of_the_picture(cx: &mut TestAppContext) { let (view, cx) = painting(cx); let none = Modifiers::none(); + cx.simulate_mouse_move(point(px(100.0), px(50.0)), None, none); + settle(cx); + assert!( + cx.debug_bounds("inpaint-ring").is_some(), + "a ring follows the pointer" + ); cx.simulate_mouse_down(point(px(100.0), px(50.0)), MouseButton::Left, none); for x in [150.0, 200.0, 250.0] { cx.simulate_mouse_move(point(px(x), px(50.0)), MouseButton::Left, none); @@ -127,3 +133,49 @@ fn undo_waits_for_a_stroke(cx: &mut TestAppContext) { (1, 0) ); } + +/// A mask shown to read, with nothing to take its strokes or keys. +struct Shown; + +impl Render for Shown { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let stroke = MaskStroke { + points: vec![(0.5, 0.5)], + radius: 0.05, + erase: false, + }; + div() + .w(px(400.0)) + .child(InpaintCanvas::new("shown", "missing.jpg", 2.0, [stroke])) + } +} + +#[gpui::test] +fn a_mask_to_read_takes_no_focus_and_no_pen(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Shown); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab(1, cx); + assert!( + cx.update(|window, cx| window.focused(cx).is_none()), + "no Tab stop" + ); + cx.simulate_mouse_move(point(px(100.0), px(50.0)), None, Modifiers::none()); + settle(cx); + assert!( + cx.debug_bounds("inpaint-ring").is_none(), + "no ring without a pen" + ); +} + +#[test] +#[should_panic(expected = "a mask stroke's radius of 5")] +fn a_stroke_past_the_brush_fails_loud() { + let stroke = MaskStroke { + points: vec![(0.5, 0.5)], + radius: 5.0, + erase: false, + }; + let _ = InpaintCanvas::new("canvas", "missing.jpg", 1.5, [stroke]); +}