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:
ZacharyZhang-NY committed 2026-09-26 12:54:18 -04:00
1 parent c349a025e5
commit 02f0a8deaf
11 files changed
+853 -57

No files matched your search

+153
View File
@@ -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)),
),
)
}
}
+417
View File
@@ -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)]);
}
}
+4
View File
@@ -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;
+129
View File
@@ -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)
);
}
+1
View File
@@ -2,6 +2,7 @@ use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext}
use crate::{forms, primitives::FocusNext, theme::Theme};
mod canvas;
mod results;
mod settings;