Generative: a mask shown to read takes no pen; strokes fail loud
InpaintCanvas is a Tab stop with the focus ring only when on_brush serves its keys, and takes the pen, the crosshair and the ring only with on_stroke. new() asserts each stroke's radius within REACH and its points on the picture. The settled strokes' cells stay cached, so a drag paints only the stroke under the pen over a copy. The canvas keeps square corners, so the wash fits the picture. The gallery drags with moves.
This commit is contained in:
1 parent
02f0a8deaf
commit
23e65d75b9
4 files changed
+143
-67
No files matched your search
@@ -295,3 +295,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T25a | 2 | PASS | |
|
| 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 | 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 | |
|
| 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 |
|
||||||
@@ -30,6 +30,7 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::Wait(300),
|
Step::Wait(300),
|
||||||
Step::Shot("verdict"),
|
Step::Shot("verdict"),
|
||||||
Step::DownAt("gen-canvas", 300.0, 70.0),
|
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::UpAt("gen-canvas", 390.0, 220.0),
|
||||||
Step::Wait(300),
|
Step::Wait(300),
|
||||||
Step::Shot("masked"),
|
Step::Shot("masked"),
|
||||||
|
|||||||
+89
-67
@@ -3,14 +3,14 @@ use std::rc::Rc;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels, Point,
|
App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels, Point,
|
||||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point,
|
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point,
|
||||||
size, transparent_black,
|
prelude::*, size, transparent_black,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::source,
|
documents::source,
|
||||||
forms::{Drawing, pen},
|
forms::{Drawing, pen},
|
||||||
primitives::{FocusRing, Image, checked_ratio, framed, tab_stop},
|
primitives::{FocusRing, Image, checked_ratio, framed, tab_stop},
|
||||||
theme::{ActiveTheme, Radius},
|
theme::ActiveTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Cells across a mask; rows follow the picture's shape.
|
/// 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
|
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.
|
/// 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.
|
||||||
pub(crate) fn raster(strokes: &[MaskStroke], columns: usize, rows: usize, ratio: f32) -> Vec<bool> {
|
fn paint(cells: &mut [bool], stroke: &MaskStroke, columns: usize, rows: usize, ratio: f32) {
|
||||||
let mut cells = vec![false; columns * rows];
|
|
||||||
let column = |x: f32| ((x * columns as f32).floor().max(0.0) as usize).min(columns);
|
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);
|
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 across: Vec<(f32, f32)> = stroke.points.iter().map(|&(x, y)| (x, y / ratio)).collect();
|
let pairs: Vec<_> = match across.as_slice() {
|
||||||
let pairs: Vec<_> = match across.as_slice() {
|
[] => return,
|
||||||
[] => continue,
|
[only] => vec![(*only, *only)],
|
||||||
[only] => vec![(*only, *only)],
|
_ => across.windows(2).map(|pair| (pair[0], pair[1])).collect(),
|
||||||
_ => across.windows(2).map(|pair| (pair[0], pair[1])).collect(),
|
};
|
||||||
};
|
let (r, near) = (stroke.radius, stroke.radius * stroke.radius);
|
||||||
let (r, near) = (stroke.radius, stroke.radius * stroke.radius);
|
for (a, b) in pairs {
|
||||||
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 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);
|
||||||
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 j in rows_in {
|
for i in columns_in.clone() {
|
||||||
for i in columns_in.clone() {
|
let center = (
|
||||||
let center = (
|
(i as f32 + 0.5) / columns as f32,
|
||||||
(i as f32 + 0.5) / columns as f32,
|
(j as f32 + 0.5) / rows as f32 / ratio,
|
||||||
(j as f32 + 0.5) / rows as f32 / ratio,
|
);
|
||||||
);
|
if reach(center, a, b) <= near {
|
||||||
if reach(center, a, b) <= near {
|
cells[j * columns + i] = !stroke.erase;
|
||||||
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<bool> {
|
||||||
|
let mut cells = vec![false; columns * rows];
|
||||||
|
for stroke in strokes {
|
||||||
|
paint(&mut cells, stroke, columns, rows, ratio);
|
||||||
|
}
|
||||||
cells
|
cells
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,11 +134,24 @@ impl InpaintCanvas {
|
|||||||
ratio: f32,
|
ratio: f32,
|
||||||
strokes: impl IntoIterator<Item = MaskStroke>,
|
strokes: impl IntoIterator<Item = MaskStroke>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let strokes: Vec<MaskStroke> = 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 {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
picture: picture.into(),
|
picture: picture.into(),
|
||||||
ratio: checked_ratio(ratio),
|
ratio: checked_ratio(ratio),
|
||||||
strokes: strokes.into_iter().collect(),
|
strokes,
|
||||||
radius: 0.04,
|
radius: 0.04,
|
||||||
erase: false,
|
erase: false,
|
||||||
on_stroke: None,
|
on_stroke: None,
|
||||||
@@ -171,28 +189,40 @@ impl InpaintCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strokes a mask last drew, its rows, and the rectangles they made.
|
type Rects = Rc<Vec<(usize, usize, usize, usize)>>;
|
||||||
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.
|
/// The strokes a mask last drew, its rows, and the cells and rectangles they made.
|
||||||
|
type Shape = (Vec<MaskStroke>, usize, Rc<Vec<bool>>, 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(
|
fn shaped(
|
||||||
cache: &Entity<Shape>,
|
cache: &Entity<Shape>,
|
||||||
live: Vec<MaskStroke>,
|
strokes: Vec<MaskStroke>,
|
||||||
|
live: Option<MaskStroke>,
|
||||||
rows: usize,
|
rows: usize,
|
||||||
ratio: f32,
|
ratio: f32,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Rc<Vec<(usize, usize, usize, usize)>> {
|
) -> Rects {
|
||||||
let (kept, kept_rows, rects) = cache.read(cx);
|
let stale = {
|
||||||
if *kept == live && *kept_rows == rows {
|
let (kept, kept_rows, _, _) = cache.read(cx);
|
||||||
return rects.clone();
|
*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`.
|
/// `at` as shares of `bounds`.
|
||||||
@@ -216,31 +246,29 @@ impl RenderOnce for InpaintCanvas {
|
|||||||
);
|
);
|
||||||
let cache: Entity<Shape> =
|
let cache: Entity<Shape> =
|
||||||
window.use_keyed_state((self.id.clone(), "shape"), cx, |_, _| {
|
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 (bounds, under) = (drawing.read(cx).bounds, drawing.read(cx).stroke.clone());
|
||||||
let (radius, erase) = (self.radius, self.erase);
|
let (radius, erase) = (self.radius, self.erase);
|
||||||
let mut live = self.strokes;
|
let live = (!under.is_empty()).then(|| MaskStroke {
|
||||||
if !under.is_empty() {
|
points: under.iter().map(|at| share(bounds, *at)).collect(),
|
||||||
let points = under.iter().map(|at| share(bounds, *at)).collect();
|
radius,
|
||||||
live.push(MaskStroke {
|
erase,
|
||||||
points,
|
});
|
||||||
radius,
|
|
||||||
erase,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let rows = ((COLUMNS as f32 / self.ratio).round() as usize).max(1);
|
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 theme = cx.theme();
|
||||||
let colors = theme.colors.clone();
|
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
|
let ring = pointer
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.filter(|_| bounds.size.width > Pixels::ZERO)
|
.filter(|_| drawn && bounds.size.width > Pixels::ZERO)
|
||||||
.map(|at| {
|
.map(|at| {
|
||||||
let span = bounds.size.width * radius * 2.0;
|
let span = bounds.size.width * radius * 2.0;
|
||||||
div()
|
div()
|
||||||
|
.debug_selector(|| "inpaint-ring".into())
|
||||||
.absolute()
|
.absolute()
|
||||||
.left(at.x - bounds.left() - span / 2.0)
|
.left(at.x - bounds.left() - span / 2.0)
|
||||||
.top(at.y - bounds.top() - 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 (id, on_stroke) = (self.id.clone(), self.on_stroke.clone());
|
||||||
let area = framed(self.ratio, cx)
|
let area = framed(self.ratio, cx)
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.track_focus(&focus)
|
|
||||||
.rounded(corner)
|
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(transparent_black())
|
.border_color(transparent_black())
|
||||||
.focus_ring(cx)
|
.when(keyed, |area| area.track_focus(&focus).focus_ring(cx))
|
||||||
.cursor_crosshair()
|
.when(drawn, |area| area.cursor_crosshair())
|
||||||
.child(
|
.child(Image::new((self.id.clone(), "picture"), source(&self.picture)).size_full())
|
||||||
Image::new((self.id.clone(), "picture"), source(&self.picture))
|
|
||||||
.size_full()
|
|
||||||
.rounded(corner),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(
|
||||||
move |bounds, _, cx| {
|
move |bounds, _, cx| {
|
||||||
@@ -335,11 +357,11 @@ impl RenderOnce for InpaintCanvas {
|
|||||||
log::info!("inpaint canvas: brush {next:.3}, erase {swap}");
|
log::info!("inpaint canvas: brush {next:.3}, erase {swap}");
|
||||||
keys(next, swap, window, cx)
|
keys(next, swap, window, cx)
|
||||||
});
|
});
|
||||||
|
let Some(on_stroke) = on_stroke else {
|
||||||
|
return area;
|
||||||
|
};
|
||||||
let sized = drawing.clone();
|
let sized = drawing.clone();
|
||||||
pen(area, &drawing, move |stroke, window, cx| {
|
pen(area, &drawing, move |stroke, window, cx| {
|
||||||
let Some(on_stroke) = &on_stroke else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let bounds = sized.read(cx).bounds;
|
let bounds = sized.read(cx).bounds;
|
||||||
let points = stroke.iter().map(|at| share(bounds, *at)).collect();
|
let points = stroke.iter().map(|at| share(bounds, *at)).collect();
|
||||||
log::info!("inpaint canvas {id:?}: a stroke of {} points", stroke.len());
|
log::info!("inpaint canvas {id:?}: a stroke of {} points", stroke.len());
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ fn painting(cx: &mut TestAppContext) -> (Entity<Painting>, &mut VisualTestContex
|
|||||||
fn a_drag_lands_a_stroke_in_shares_of_the_picture(cx: &mut TestAppContext) {
|
fn a_drag_lands_a_stroke_in_shares_of_the_picture(cx: &mut TestAppContext) {
|
||||||
let (view, cx) = painting(cx);
|
let (view, cx) = painting(cx);
|
||||||
let none = Modifiers::none();
|
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);
|
cx.simulate_mouse_down(point(px(100.0), px(50.0)), MouseButton::Left, none);
|
||||||
for x in [150.0, 200.0, 250.0] {
|
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_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)
|
(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<Self>) -> 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]);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user