T25c Generative: canvas
InpaintCanvas lays a mask over a picture: brush strokes in shares of the picture, rastered into a 320-column grid where a later stroke wins and an erasing one clears, painted as merged rectangles snapped to device pixels. A ring follows the pointer at the brush's size; [ and ] size the brush and X swaps it when focused. MaskBrush sets paint or erase and the size, with Undo and Clear waiting for a stroke. Pointer strokes move to forms::pen, which SignaturePad and the canvas share.
This commit is contained in:
11 files changed
+853
-57
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<MaskStroke> {
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+73
-55
@@ -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<Point<Pixels>>;
|
||||
|
||||
type OnStrokes = Rc<dyn Fn(&[Stroke], &mut Window, &mut App)>;
|
||||
type Lift = Rc<dyn Fn(Point<Pixels>, &mut Window, &mut App)>;
|
||||
type Lift = Rc<dyn Fn(Stroke, &mut Window, &mut App)>;
|
||||
|
||||
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<Pixels>,
|
||||
stroke: Stroke,
|
||||
pub(crate) struct Drawing {
|
||||
pub bounds: Bounds<Pixels>,
|
||||
pub stroke: Stroke,
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
@@ -67,6 +68,63 @@ fn ink(points: &[Point<Pixels>], origin: Point<Pixels>, 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<Div>,
|
||||
drawing: &Entity<Drawing>,
|
||||
lift: impl Fn(Stroke, &mut Window, &mut App) + 'static,
|
||||
) -> Stateful<Div> {
|
||||
let owner = drawing.entity_id();
|
||||
let lift: Lift = Rc::new(lift);
|
||||
let finish = {
|
||||
let drawing = drawing.clone();
|
||||
move |at: Point<Pixels>, 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<Pen>, _, 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<Pixels>,
|
||||
@@ -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<Pen>, _, 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()
|
||||
|
||||
@@ -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<OnBrush>,
|
||||
on_undo: Option<Run>,
|
||||
on_clear: Option<Run>,
|
||||
}
|
||||
|
||||
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<ElementId>, 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>| {
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<dyn Fn(MaskStroke, &mut Window, &mut App)>;
|
||||
pub(crate) type OnBrush = Rc<dyn Fn(f32, bool, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<MaskStroke>,
|
||||
radius: f32,
|
||||
erase: bool,
|
||||
on_stroke: Option<OnStroke>,
|
||||
on_brush: Option<OnBrush>,
|
||||
}
|
||||
|
||||
impl InpaintCanvas {
|
||||
/// `ratio` is the picture's width over height; `strokes` are the mask so far, oldest first.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
picture: impl Into<SharedString>,
|
||||
ratio: f32,
|
||||
strokes: impl IntoIterator<Item = MaskStroke>,
|
||||
) -> 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<MaskStroke>,
|
||||
usize,
|
||||
Rc<Vec<(usize, usize, usize, usize)>>,
|
||||
);
|
||||
|
||||
/// The mask's rectangles, made again only when its strokes or rows change, not as the pointer moves.
|
||||
fn shaped(
|
||||
cache: &Entity<Shape>,
|
||||
live: Vec<MaskStroke>,
|
||||
rows: usize,
|
||||
ratio: f32,
|
||||
cx: &mut App,
|
||||
) -> Rc<Vec<(usize, usize, usize, usize)>> {
|
||||
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<Pixels>, at: Point<Pixels>) -> (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::<Point<Pixels>>,
|
||||
);
|
||||
let cache: Entity<Shape> =
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MaskStroke>,
|
||||
brush: (f32, bool),
|
||||
undone: usize,
|
||||
}
|
||||
|
||||
impl Render for Painting {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Painting>, &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)
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext}
|
||||
|
||||
use crate::{forms, primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod canvas;
|
||||
mod results;
|
||||
mod settings;
|
||||
|
||||
|
||||
+2
-2
@@ -227,8 +227,8 @@
|
||||
|
||||
## T25c Generative · Canvas
|
||||
|
||||
- [ ] InpaintCanvas
|
||||
- [ ] MaskBrush
|
||||
- [x] InpaintCanvas
|
||||
- [x] MaskBrush
|
||||
|
||||
## T25d Generative · Sound and motion
|
||||
|
||||
|
||||
Reference in new issue
Block a user