diff --git a/AGENTS.md b/AGENTS.md index 155d913..439be6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. +- A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus (`tables::header`). Never poll layout with `request_animation_frame`. - gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. diff --git a/examples/gallery/pages/generative/canvas.rs b/examples/gallery/pages/generative/canvas.rs new file mode 100644 index 0000000..706795b --- /dev/null +++ b/examples/gallery/pages/generative/canvas.rs @@ -0,0 +1,66 @@ +use ely_gpui_component::generative::{InpaintCanvas, MaskBrush, MaskStroke}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +/// A mask over the olive tree, painted before the demo opens. +fn tree() -> Vec { + vec![MaskStroke { + points: vec![ + (0.07, 0.18), + (0.12, 0.34), + (0.09, 0.52), + (0.13, 0.7), + (0.1, 0.86), + ], + radius: 0.07, + erase: false, + }] +} + +pub fn canvas(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let strokes = keep("gen-mask", tree, window, cx); + let brush = keep("gen-brush", || (0.05_f32, false), window, cx); + let (now, (radius, erase)) = (strokes.read(cx).clone(), *brush.read(cx)); + let (painted, undone, cleared) = (strokes.clone(), strokes.clone(), strokes); + let (keyed, set_brush) = (brush.clone(), brush); + let count = now.len(); + section( + "InpaintCanvas / MaskBrush", + "A picture to repaint in part: paint a mask where it should change, erase where it went too far; the mask washes over the picture. On the canvas, [ and ] size the brush and X swaps painting for erasing. Undo takes the last stroke, Clear takes them all.", + cx, + ) + .child( + div() + .w(px(460.)) + .flex() + .flex_col() + .gap_3() + .child(probe( + "gen-canvas", + div().w(px(460.)).child( + InpaintCanvas::new("gen-canvas", asset!("atrium-olive.jpg"), 1.5, now) + .brush(radius, erase) + .on_stroke(move |stroke, _, cx| { + let mut next = painted.read(cx).clone(); + next.push(stroke); + set(&painted, next, cx) + }) + .on_brush(move |radius, erase, _, cx| set(&keyed, (radius, erase), cx)), + ), + )) + .child( + MaskBrush::new("gen-mask-brush", radius, erase, count) + .on_brush(move |radius, erase, _, cx| set(&set_brush, (radius, erase), cx)) + .on_undo(move |_, cx| { + let mut next = undone.read(cx).clone(); + next.pop(); + set(&undone, next, cx) + }) + .on_clear(move |_, cx| set(&cleared, Vec::new(), cx)), + ), + ) +} diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs index 45bde9b..9bf6b75 100644 --- a/examples/gallery/pages/generative/mod.rs +++ b/examples/gallery/pages/generative/mod.rs @@ -1,3 +1,4 @@ +mod canvas; mod panel; mod results; @@ -28,6 +29,10 @@ const SCRIPT: &[Step] = &[ Step::UpAt("gen-ab", 181.0, 147.0), Step::Wait(300), Step::Shot("verdict"), + Step::DownAt("gen-canvas", 300.0, 70.0), + Step::UpAt("gen-canvas", 390.0, 220.0), + Step::Wait(300), + Step::Shot("masked"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -36,5 +41,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(results::queue(window, cx)) .child(results::results(window, cx)) .child(results::variations(window, cx)) + .child(canvas::canvas(window, cx)) .into_any_element() } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 5852b0d..7b40b74 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -95,6 +95,7 @@ pub use rating::Rating; pub use search::SearchInput; pub use select::Select; pub(crate) use select::{Listing, field_button, field_text, listing}; +pub(crate) use signature::{Drawing, pen}; pub use signature::{SignaturePad, Stroke}; pub use slider::{RangeSlider, Slider}; pub use stepper::Stepper; diff --git a/src/forms/signature.rs b/src/forms/signature.rs index 9ad27e4..c4c0637 100644 --- a/src/forms/signature.rs +++ b/src/forms/signature.rs @@ -1,9 +1,10 @@ use std::rc::Rc; use gpui::{ - App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, Hsla, InteractiveElement, - IntoElement, MouseButton, ParentElement, Path, PathBuilder, Pixels, Point, RenderOnce, - StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, prelude::*, size, + App, Bounds, Corners, Div, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, Path, PathBuilder, Pixels, Point, + RenderOnce, Stateful, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, + prelude::*, size, }; use crate::{ @@ -16,17 +17,17 @@ use crate::{ pub type Stroke = Vec>; type OnStrokes = Rc; -type Lift = Rc, &mut Window, &mut App)>; +type Lift = Rc; struct Pen { owner: EntityId, } -/// Where the pad sits, and the stroke under the pen. +/// Where a pad sits, and the stroke under the pen. #[derive(Default)] -struct Drawing { - bounds: Bounds, - stroke: Stroke, +pub(crate) struct Drawing { + pub bounds: Bounds, + pub stroke: Stroke, } impl Drawing { @@ -67,6 +68,63 @@ fn ink(points: &[Point], origin: Point, width: Pixels) -> Option Some(path.build().expect("a stroke is a simple open path")) } +/// Turns a press and drag on `pad` into a stroke of points from its corner, kept inside it; `lift` gets the stroke as the pen leaves. The pad's owner keeps `drawing`'s bounds. +pub(crate) fn pen( + pad: Stateful
, + drawing: &Entity, + lift: impl Fn(Stroke, &mut Window, &mut App) + 'static, +) -> Stateful
{ + let owner = drawing.entity_id(); + let lift: Lift = Rc::new(lift); + let finish = { + let drawing = drawing.clone(); + move |at: Point, window: &mut Window, cx: &mut App| { + let stroke = drawing.update(cx, |drawing, cx| { + if !drawing.stroke.is_empty() { + drawing.reach(at); + } + cx.notify(); + std::mem::take(&mut drawing.stroke) + }); + if !stroke.is_empty() { + lift(stroke, window, cx); + } + } + }; + let (press, start, moving) = (drawing.clone(), drawing.clone(), drawing.clone()); + let up = Rc::new(finish); + let leave = up.clone(); + pad.on_mouse_down(MouseButton::Left, move |event, _, cx| { + press.update(cx, |drawing, cx| { + drawing.stroke = vec![local(drawing.bounds, event.position)]; + cx.notify(); + }) + }) + .on_drag(Pen { owner }, move |_, _, window, cx| { + let at = window.mouse_position(); + start.update(cx, |drawing, cx| { + drawing.reach(at); + cx.notify(); + }); + cx.new(|_| EmptyView) + }) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + if event.drag(cx).owner != owner { + return; + } + moving.update(cx, |drawing, cx| { + drawing.reach(event.event.position); + cx.notify(); + }) + }) + .on_mouse_up(MouseButton::Left, move |event, window, cx| { + up(event.position, window, cx) + }) + .on_mouse_up_out(MouseButton::Left, move |event, window, cx| { + leave(event.position, window, cx) + }) +} + fn paint( strokes: &[Stroke], bounds: Bounds, @@ -120,7 +178,6 @@ impl RenderOnce for SignaturePad { let strokes = use_seeded((self.id.clone(), "strokes"), self.strokes, window, cx); let drawing = window.use_keyed_state((self.id.clone(), "drawing"), cx, |_, _| Drawing::default()); - let owner = drawing.entity_id(); let set: OnStrokes = { let (id, strokes, on_change) = (self.id.clone(), strokes.clone(), self.on_change); Rc::new(move |next, window, cx| { @@ -134,22 +191,13 @@ impl RenderOnce for SignaturePad { } }) }; - let lift: Lift = { - let (strokes, drawing, set) = (strokes.clone(), drawing.clone(), set.clone()); - Rc::new(move |at, window, cx| { - let stroke = drawing.update(cx, |drawing, _| { - if !drawing.stroke.is_empty() { - drawing.reach(at); - } - std::mem::take(&mut drawing.stroke) - }); - if stroke.is_empty() { - return; - } + let lift = { + let (strokes, set) = (strokes.clone(), set.clone()); + move |stroke: Stroke, window: &mut Window, cx: &mut App| { let mut next = strokes.read(cx).value.clone(); next.push(stroke); set(&next, window, cx); - }) + } }; let theme = cx.theme(); let colors = &theme.colors; @@ -158,9 +206,7 @@ impl RenderOnce for SignaturePad { let empty = strokes.read(cx).value.is_empty(); let mut shown = strokes.read(cx).value.clone(); shown.push(drawing.read(cx).stroke.clone()); - let (measure, press, start, pen) = - (drawing.clone(), drawing.clone(), drawing.clone(), drawing); - let (up, out) = (lift.clone(), lift); + let measure = drawing.clone(); let color = colors.fg; let pad = div() .id(self.id.clone()) @@ -203,36 +249,8 @@ impl RenderOnce for SignaturePad { .top_0() .left_0() .size_full(), - ) - .on_mouse_down(MouseButton::Left, move |event, _, cx| { - press.update(cx, |drawing, cx| { - drawing.stroke = vec![local(drawing.bounds, event.position)]; - cx.notify(); - }) - }) - .on_drag(Pen { owner }, move |_, _, window, cx| { - let at = window.mouse_position(); - start.update(cx, |drawing, cx| { - drawing.reach(at); - cx.notify(); - }); - cx.new(|_| EmptyView) - }) - .on_drag_move(move |event: &DragMoveEvent, _, cx| { - if event.drag(cx).owner != owner { - return; - } - pen.update(cx, |drawing, cx| { - drawing.reach(event.event.position); - cx.notify(); - }) - }) - .on_mouse_up(MouseButton::Left, move |event, window, cx| { - up(event.position, window, cx) - }) - .on_mouse_up_out(MouseButton::Left, move |event, window, cx| { - out(event.position, window, cx) - }); + ); + let pad = pen(pad, &drawing, lift); div() .flex() .flex_col() diff --git a/src/generative/brush.rs b/src/generative/brush.rs new file mode 100644 index 0000000..3e03a11 --- /dev/null +++ b/src/generative/brush.rs @@ -0,0 +1,153 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; + +use super::mask::{OnBrush, REACH}; +use crate::{ + buttons::{IconButton, SegmentedControl}, + forms::{Run, Slider}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::{format::percent, tabular}, +}; + +/// The mask brush: paint or erase, its size, and a way back: Undo takes the last stroke, Clear takes them all. +#[derive(IntoElement)] +pub struct MaskBrush { + id: ElementId, + radius: f32, + erase: bool, + strokes: usize, + on_brush: Option, + on_undo: Option, + on_clear: Option, +} + +impl MaskBrush { + /// `radius` is a share of the picture's width; `strokes` counts the mask's strokes, which Undo and Clear wait for. + pub fn new(id: impl Into, radius: f32, erase: bool, strokes: usize) -> Self { + assert!( + (REACH.0..=REACH.1).contains(&radius), + "a brush radius of {radius}" + ); + Self { + id: id.into(), + radius, + erase, + strokes, + on_brush: None, + on_undo: None, + on_clear: None, + } + } + + /// Gets the brush's radius and whether it erases. + pub fn on_brush( + mut self, + handler: impl Fn(f32, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_brush = Some(Rc::new(handler)); + self + } + + pub fn on_undo(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_undo = Some(Rc::new(handler)); + self + } + + pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_clear = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MaskBrush { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (radius, erase, empty) = (self.radius, self.erase, self.strokes == 0); + let (mode, size) = (self.on_brush.clone(), self.on_brush); + let back = |key: &'static str, icon: IconName, tip: &'static str, run: Option| { + run.map(|run| { + IconButton::new((self.id.clone(), key), icon) + .size(ControlSize::Sm) + .tooltip(tip) + .disabled(empty) + .on_click(move |_, window, cx| { + log::info!("mask brush: {key}"); + run(window, cx) + }) + }) + }; + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + SegmentedControl::new( + (self.id.clone(), "mode"), + if erase { "erase" } else { "paint" }, + ) + .segment("paint", "Paint", Some(IconName::Brush)) + .segment("erase", "Erase", Some(IconName::Eraser)) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + if let Some(mode) = &mode { + mode(radius, value == "erase", window, cx); + } + }), + ) + .child( + div() + .flex() + .gap_1() + .children(back( + "undo", + IconName::Undo2, + "Undo the last stroke", + self.on_undo, + )) + .children(back( + "clear", + IconName::Trash2, + "Clear the mask", + self.on_clear, + )), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child(div().flex_none().text_color(colors.fg_muted).child("Size")) + .child( + div().flex_1().min_w_0().child( + Slider::new((self.id.clone(), "size"), f64::from(radius)) + .range(f64::from(REACH.0), f64::from(REACH.1)) + .step(0.005) + .on_change(move |value, window, cx| { + if let Some(size) = &size { + size(value as f32, erase, window, cx); + } + }), + ), + ) + .child( + tabular(div()) + .flex_none() + .text_color(colors.fg_muted) + .child(percent(f64::from(radius), 1, false)), + ), + ) + } +} diff --git a/src/generative/mask.rs b/src/generative/mask.rs new file mode 100644 index 0000000..30949f3 --- /dev/null +++ b/src/generative/mask.rs @@ -0,0 +1,417 @@ +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, +}; + +use crate::{ + documents::source, + forms::{Drawing, pen}, + primitives::{FocusRing, Image, checked_ratio, framed, tab_stop}, + theme::{ActiveTheme, Radius}, +}; + +/// Cells across a mask; rows follow the picture's shape. +const COLUMNS: usize = 320; + +/// The brush's smallest and largest radius, as shares of the picture's width. +pub(crate) const REACH: (f32, f32) = (0.01, 0.2); + +/// A stroke of the mask brush: points as shares of the picture across and down, a radius as a share of its width, and whether it erases. +#[derive(Clone, Debug, PartialEq)] +pub struct MaskStroke { + pub points: Vec<(f32, f32)>, + pub radius: f32, + pub erase: bool, +} + +/// The squared distance from `p` to the segment from `a` to `b`. +fn reach(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let length = dx * dx + dy * dy; + let t = match length { + 0.0 => 0.0, + _ => (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / length).clamp(0.0, 1.0), + }; + let (x, y) = (a.0 + t * dx - p.0, a.1 + t * dy - p.1); + 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]; + 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; + } + } + } + } + } + cells +} + +/// Covered cells as rectangles: runs in a row, merged down while the rows below run the same. Each is first row, row past the last, first column, column past the last. +pub(crate) fn spans(cells: &[bool], columns: usize) -> Vec<(usize, usize, usize, usize)> { + let mut open: Vec<(usize, usize, usize, usize)> = Vec::new(); + let mut done = Vec::new(); + for (j, line) in cells.chunks(columns).enumerate() { + let mut runs = Vec::new(); + let mut start = None; + for (i, &on) in line.iter().chain(std::iter::once(&false)).enumerate() { + match (on, start) { + (true, None) => start = Some(i), + (false, Some(from)) => { + runs.push((from, i)); + start = None; + } + _ => {} + } + } + let mut next = Vec::new(); + for rect in open.drain(..) { + match runs.iter().position(|&run| run == (rect.2, rect.3)) { + Some(at) => { + runs.remove(at); + next.push((rect.0, j + 1, rect.2, rect.3)); + } + None => done.push(rect), + } + } + next.extend(runs.into_iter().map(|(from, to)| (j, j + 1, from, to))); + open = next; + } + done.extend(open); + done +} + +type OnStroke = Rc; +pub(crate) type OnBrush = Rc; + +/// A picture to repaint in part: the brush masks where it passes and an erasing brush clears, the mask a wash over the picture. A ring follows the pointer at the brush's size. Focused, [ and ] size the brush and X swaps painting for erasing. +#[derive(IntoElement)] +pub struct InpaintCanvas { + id: ElementId, + picture: SharedString, + ratio: f32, + strokes: Vec, + radius: f32, + erase: bool, + on_stroke: Option, + on_brush: Option, +} + +impl InpaintCanvas { + /// `ratio` is the picture's width over height; `strokes` are the mask so far, oldest first. + pub fn new( + id: impl Into, + picture: impl Into, + ratio: f32, + strokes: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + picture: picture.into(), + ratio: checked_ratio(ratio), + strokes: strokes.into_iter().collect(), + radius: 0.04, + erase: false, + on_stroke: None, + on_brush: None, + } + } + + /// The brush's radius as a share of the picture's width, and whether it erases. + pub fn brush(mut self, radius: f32, erase: bool) -> Self { + assert!( + (REACH.0..=REACH.1).contains(&radius), + "a brush radius of {radius}" + ); + self.radius = radius; + self.erase = erase; + self + } + + /// Gets each stroke as the pointer lifts. + pub fn on_stroke( + mut self, + handler: impl Fn(MaskStroke, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_stroke = Some(Rc::new(handler)); + self + } + + /// Gets the brush the keys set: its radius and whether it erases. + pub fn on_brush( + mut self, + handler: impl Fn(f32, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_brush = Some(Rc::new(handler)); + self + } +} + +/// The strokes a mask last drew, its rows, and the rectangles they made. +type Shape = ( + Vec, + usize, + Rc>, +); + +/// The mask's rectangles, made again only when its strokes or rows change, not as the pointer moves. +fn shaped( + cache: &Entity, + live: Vec, + 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(); + } + 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`. +fn share(bounds: Bounds, at: Point) -> (f32, f32) { + let (width, height) = ( + f32::from(bounds.size.width).max(1.0), + f32::from(bounds.size.height).max(1.0), + ); + (f32::from(at.x) / width, f32::from(at.y) / height) +} + +impl RenderOnce for InpaintCanvas { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let drawing = + window.use_keyed_state((self.id.clone(), "drawing"), cx, |_, _| Drawing::default()); + let pointer = + window.use_keyed_state( + (self.id.clone(), "pointer"), + cx, + |_, _| None::>, + ); + let cache: Entity = + window.use_keyed_state((self.id.clone(), "shape"), cx, |_, _| { + (Vec::new(), 0, Rc::new(Vec::new())) + }); + let focus = tab_stop((self.id.clone(), "focus").into(), true, 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 rows = ((COLUMNS as f32 / self.ratio).round() as usize).max(1); + let rects = shaped(&cache, 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 ring = pointer + .read(cx) + .filter(|_| bounds.size.width > Pixels::ZERO) + .map(|at| { + let span = bounds.size.width * radius * 2.0; + div() + .absolute() + .left(at.x - bounds.left() - span / 2.0) + .top(at.y - bounds.top() - span / 2.0) + .size(span) + .rounded_full() + .border_1() + .border_color(colors.on_media) + .child( + div() + .size_full() + .rounded_full() + .border_1() + .border_color(colors.backdrop), + ) + }); + let (measure, hover, leave, keys) = ( + drawing.clone(), + pointer.clone(), + pointer, + self.on_brush.clone(), + ); + 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), + ) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).bounds != bounds { + measure.update(cx, |drawing, _| drawing.bounds = bounds); + } + }, + move |bounds, _, window, _| { + let scale = window.scale_factor(); + let snap = |at: f32| Pixels::from((at * scale).round() / scale); + let (width, height) = + (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let (left, top) = (f32::from(bounds.left()), f32::from(bounds.top())); + for (first, past, from, to) in rects.iter() { + let x0 = snap(left + width * *from as f32 / COLUMNS as f32); + let x1 = snap(left + width * *to as f32 / COLUMNS as f32); + let y0 = snap(top + height * *first as f32 / rows as f32); + let y1 = snap(top + height * *past as f32 / rows as f32); + window.paint_quad(fill( + Bounds::new(point(x0, y0), size(x1 - x0, y1 - y0)), + wash, + )); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .children(ring) + .on_mouse_move(move |event, _, cx| { + hover.update(cx, |pointer, cx| { + *pointer = Some(event.position); + cx.notify(); + }) + }) + .on_hover(move |hovered, _, cx| { + if !hovered { + leave.update(cx, |pointer, cx| { + *pointer = None; + cx.notify(); + }) + } + }) + .on_key_down(move |event, window, cx| { + let Some(keys) = &keys else { + return; + }; + let (next, swap) = match event.keystroke.key.as_str() { + "[" => ((radius * 0.8).max(REACH.0), erase), + "]" => ((radius * 1.25).min(REACH.1), erase), + "x" => (radius, !erase), + _ => return, + }; + cx.stop_propagation(); + log::info!("inpaint canvas: brush {next:.3}, erase {swap}"); + keys(next, swap, window, cx) + }); + 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()); + on_stroke( + MaskStroke { + points, + radius, + erase, + }, + window, + cx, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{MaskStroke, raster, spans}; + + fn stroke(points: &[(f32, f32)], radius: f32, erase: bool) -> MaskStroke { + MaskStroke { + points: points.to_vec(), + radius, + erase, + } + } + + fn count(cells: &[bool]) -> usize { + cells.iter().filter(|on| **on).count() + } + + #[test] + fn a_dab_covers_a_disc_as_wide_as_tall() { + let cells = raster(&[stroke(&[(0.5, 0.5)], 0.1, false)], 20, 10, 2.0); + assert_eq!(count(&cells), 12, "two cells of reach on square cells"); + assert!( + cells[3 * 20 + 9] && cells[4 * 20 + 8], + "as far down as across" + ); + assert!(!cells[3 * 20 + 8], "the disc's corner stays out"); + } + + #[test] + fn a_later_stroke_wins_and_an_erasing_one_clears() { + let paint = stroke(&[(0.5, 0.5)], 0.1, false); + let erase = stroke(&[(0.5, 0.5)], 0.05, true); + assert_eq!( + count(&raster(&[paint.clone(), erase.clone()], 20, 10, 2.0)), + 8 + ); + assert_eq!(count(&raster(&[erase, paint], 20, 10, 2.0)), 12); + } + + #[test] + fn a_line_covers_a_band_with_round_ends() { + let cells = raster( + &[stroke(&[(0.2, 0.5), (0.8, 0.5)], 0.05, false)], + 20, + 10, + 2.0, + ); + assert_eq!(count(&cells), 28); + } + + #[test] + fn runs_that_match_below_merge_into_one_rectangle() { + let cells = [ + false, true, true, false, // + false, true, true, false, // + true, true, false, false, + ]; + assert_eq!(spans(&cells, 4), [(0, 2, 1, 3), (2, 3, 0, 2)]); + } +} diff --git a/src/generative/mod.rs b/src/generative/mod.rs index 5ec37a6..5d53bbf 100644 --- a/src/generative/mod.rs +++ b/src/generative/mod.rs @@ -1,5 +1,7 @@ +mod brush; mod compare; mod grid; +mod mask; mod prompt; mod queue; mod ratio; @@ -9,8 +11,10 @@ mod style; mod tests; mod variation; +pub use brush::MaskBrush; pub use compare::{ABCompareView, Verdict}; pub use grid::{GenerationGrid, Outcome}; +pub use mask::{InpaintCanvas, MaskStroke}; pub use prompt::PromptEnhancer; pub use queue::{GenerationQueue, Job, JobState}; pub use ratio::AspectRatioPicker; diff --git a/src/generative/tests/canvas.rs b/src/generative/tests/canvas.rs new file mode 100644 index 0000000..3842139 --- /dev/null +++ b/src/generative/tests/canvas.rs @@ -0,0 +1,129 @@ +use gpui::{ + Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, point, px, +}; + +use super::{press, settle, setup, tab}; +use crate::generative::{InpaintCanvas, MaskBrush, MaskStroke}; + +/// A canvas 400 wide over a picture twice as wide as tall, its brush and brush panel, and the strokes and brushes heard. +struct Painting { + strokes: Vec, + brush: (f32, bool), + undone: usize, +} + +impl Render for Painting { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (stroked, keyed, set, undo) = (cx.entity(), cx.entity(), cx.entity(), cx.entity()); + let (radius, erase) = self.brush; + div() + .w(px(400.0)) + .child( + InpaintCanvas::new("canvas", "missing.jpg", 2.0, self.strokes.clone()) + .brush(radius, erase) + .on_stroke(move |stroke, _, cx| { + stroked.update(cx, |view, cx| { + view.strokes.push(stroke); + cx.notify(); + }) + }) + .on_brush(move |radius, erase, _, cx| { + keyed.update(cx, |view, cx| { + view.brush = (radius, erase); + cx.notify(); + }) + }), + ) + .child( + MaskBrush::new("brush", radius, erase, self.strokes.len()) + .on_brush(move |radius, erase, _, cx| { + set.update(cx, |view, cx| { + view.brush = (radius, erase); + cx.notify(); + }) + }) + .on_undo(move |_, cx| { + undo.update(cx, |view, cx| { + view.strokes.pop(); + view.undone += 1; + cx.notify(); + }) + }), + ) + } +} + +fn painting(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Painting { + strokes: Vec::new(), + brush: (0.04, false), + undone: 0, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, cx) +} + +#[gpui::test] +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_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); + } + cx.simulate_mouse_up(point(px(300.0), px(50.0)), MouseButton::Left, none); + settle(cx); + let strokes = view.read_with(cx, |view, _| view.strokes.clone()); + assert_eq!(strokes.len(), 1, "{strokes:?}"); + let (first, last) = ( + strokes[0].points[0], + *strokes[0].points.last().expect("points"), + ); + let near = |a: (f32, f32), b: (f32, f32)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01; + assert!( + near(first, (0.25, 0.25)) && near(last, (0.75, 0.25)), + "{first:?} to {last:?}" + ); + assert_eq!((strokes[0].radius, strokes[0].erase), (0.04, false)); +} + +#[gpui::test] +fn keys_on_the_canvas_size_the_brush_and_swap_to_erasing(cx: &mut TestAppContext) { + let (view, cx) = painting(cx); + tab(1, cx); + press("]", cx); + press("x", cx); + let brush = view.read_with(cx, |view, _| view.brush); + assert!((brush.0 - 0.05).abs() < 1e-6 && brush.1, "{brush:?}"); +} + +#[gpui::test] +fn undo_waits_for_a_stroke(cx: &mut TestAppContext) { + let (view, cx) = painting(cx); + tab(4, cx); + press("enter", cx); + assert_eq!( + view.read_with(cx, |view, _| view.undone), + 0, + "nothing to undo yet" + ); + view.update(cx, |view, cx| { + view.strokes.push(MaskStroke { + points: vec![(0.5, 0.5)], + radius: 0.04, + erase: false, + }); + cx.notify(); + }); + settle(cx); + cx.update(|window, _| window.blur()); + tab(4, cx); + press("enter", cx); + assert_eq!( + view.read_with(cx, |view, _| (view.undone, view.strokes.len())), + (1, 0) + ); +} diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index 23c35cb..c891dde 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -2,6 +2,7 @@ use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext} use crate::{forms, primitives::FocusNext, theme::Theme}; +mod canvas; mod results; mod settings; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 04b89cd..76a161e 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -227,8 +227,8 @@ ## T25c Generative · Canvas -- [ ] InpaintCanvas -- [ ] MaskBrush +- [x] InpaintCanvas +- [x] MaskBrush ## T25d Generative · Sound and motion