Merge pull request #443 from Aavirash/fix/shape-drag-preview

# Conflicts:
#	crates/ui-egui/src/frames.rs
#	crates/ui-egui/src/panels/viewer.rs
This commit is contained in:
Claude committed 2026-10-10 12:50:43 +00:00
commit b7b47a0e10
6 files changed
+380 -43

No files matched your search

+3 -1
View File
@@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
// directory through `current_dir()`, which is canonical, so compare canonical paths.
std::fs::canonicalize(&d).unwrap_or(d).join(name)
}
#[test]
+3
View File
@@ -36,3 +36,6 @@ display_name = "ShirokoWaWu"
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.Aavirash]
display_name = "Asquareportal"
+11 -3
View File
@@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha
Some(build::shape_group(ids, gname, items))
}
/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space).
pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry
/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's
/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit.
pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
let bez = match kind {
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
@@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<
effectcraft_path::from_kurbo(&bez).into_iter().next()
}
/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape
/// layer, else none (a new shape layer).
pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option<LayerId> {
s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked))
}
/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer),
/// else the first selected unlocked shape layer, else none (a new shape layer).
pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
None => selected_shape_target(s, comp),
};
if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) {
return Err(bad(cmd, "the layer is not a shape layer"));
+165 -38
View File
@@ -4,9 +4,10 @@
//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and
//! camera/light wireframes.
use effectcraft_engine::commands::shape_tool::PaintKind;
use effectcraft_engine::effects::puppet::PinKind;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{Comp, Layer, LayerId};
use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
use effectcraft_engine::time::Tick;
@@ -667,16 +668,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let frame = comp.frame_rate.frame_at(time);
let key = app.frame_key(cid, frame, scale);
app.request_frame_urgent(cid, frame, scale);
if let Some((key, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, k)| k)) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
// The frame on screen. While a drag or a property scrub is rendering, the exact key changes
// with every edit and its render is still in flight; the newest completed revision of the
// same frame keeps the picture following the pointer instead of freezing until it stops.
if let Some((k, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, s)| s)) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k);
// A CPU frame goes up again when the magnification calls for another factor.
let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64);
let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify);
if stale || refit {
show_frame(app, &ctx, key, img, minify);
} else if let Some((_, k)) = app.viewer_shown.as_mut() {
show_frame(app, &ctx, k, img, minify);
} else if let Some((_, s)) = app.viewer_shown.as_mut() {
// The same frame at a later revision (an edit elsewhere, an undo): it shows that one.
k.revision = key.revision;
s.revision = k.revision;
}
}
if app.ui.viewer.transparency_grid {
@@ -1173,7 +1177,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
anchor_only: mods.alt,
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
// From where the button went down: by the time the pointer has moved far enough to
// count as a drag it has already left the press point, and the shape is drawn there.
t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }),
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
(mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
@@ -1380,10 +1386,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
Gesture::Create { start, .. } => {
let a = map.to_screen(start);
let r = Rect::from_two_pos(a, pos);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
Gesture::Create { tool, start } => {
if let Some(d) = shape_drag(app, tool, start, cpt, mods) {
draw_shape_ghost(&painter, &map, app, &d, &t);
}
}
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
@@ -1522,7 +1528,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
Gesture::Create { tool, start } => {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods.shift);
create_shape(app, tool, start, end, mods);
}
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
let c = map.to_comp(pos);
@@ -1896,38 +1902,159 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg
Some(hit)
}
fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) {
let mut w = (b[0] - a[0]).abs();
let mut h = (b[1] - a[1]).abs();
if w < 2.0 && h < 2.0 {
return;
/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new
/// shape layer.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ShapeTarget {
Mask,
IntoLayer,
NewLayer,
}
impl ShapeTarget {
/// What the drag will make and where, named the way the ghost captions it.
fn label(self, layer_name: &str) -> String {
match self {
ShapeTarget::Mask => format!("Mask on “{layer_name}”"),
ShapeTarget::IntoLayer => format!("Into “{layer_name}”"),
ShapeTarget::NewLayer => "New shape layer".to_string(),
}
}
if square {
let m = w.max(h);
w = m;
h = m;
}
let cx = a[0].min(b[0]) + w / 2.0;
let cy = a[1].min(b[1]) + h / 2.0;
let kind = match tool {
}
/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it
/// will land.
struct ShapeDrag {
/// The `shape.newShape` / `layer.addMask` kind for the tool.
kind: &'static str,
/// Comp-space centre and size, what the drag will make.
centre: [f64; 2],
size: [f64; 2],
target: ShapeTarget,
/// The selected layer's name and the index its next mask would take, for the ghost's caption
/// and colour.
layer_name: String,
mask_index: usize,
}
/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it.
fn shape_kind(tool: Tool) -> &'static str {
match tool {
Tool::Rectangle => "rect",
Tool::RoundedRect => "rounded",
Tool::Ellipse => "ellipse",
Tool::Polygon => "polygon",
_ => "star",
};
}
}
/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make
/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and
/// [`create_shape`] both go through this, so the drag always shows what it will commit.
fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<ShapeDrag> {
let comp = app.session.active_comp()?;
let selected = app.session.state.selected_layers.first().copied();
let sel = selected.and_then(|id| comp.layer(id));
// A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates
// Mask: a mask on it instead.
let creates_mask = app.session.state.shape_tool.creates_mask;
let sel = app.session.state.selected_layers.first().copied();
let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
if let Some(l) = sel_layer
&& l.source.is_av()
&& (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape))
{
// Mask in layer space: invert the layer transform.
let comp = app.session.active_comp_arc();
if let Some(comp) = comp {
// Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked
// shape layer, else a new one.
let target = match sel {
Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)),
_ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) {
Some(l) => (ShapeTarget::IntoLayer, Some(l)),
None => (ShapeTarget::NewLayer, None),
},
};
let (target, layer) = target;
let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 };
let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs());
// Alt doubles the box about the press point, which is then its centre.
if mods.alt {
w *= 2.0;
h *= 2.0;
}
if w < 2.0 && h < 2.0 {
return None;
}
if mods.shift {
let m = w.max(h);
w = m;
h = m;
}
Some(ShapeDrag {
kind: shape_kind(tool),
centre: if mods.alt { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] },
size: [w, h],
target,
layer_name: layer.map_or_else(String::new, |l| l.name.clone()),
mask_index,
})
}
/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost.
fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 {
let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha)
}
/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and
/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes.
fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) {
// The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it.
let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return };
let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map);
if pts.first() == pts.last() {
pts.pop();
}
if pts.len() < 3 {
return;
}
let tool = &app.session.state.shape_tool;
let (fill, line) = match d.target {
ShapeTarget::Mask => {
let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index);
(Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b))
}
_ => {
let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0;
(
if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) },
if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent },
)
}
};
let Some(bounds) = bounds_of(&pts) else { return };
// A screen-width outline reads at every zoom, where a comp-pixel hairline does not.
painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() }));
// Caption it with where the shape lands: a mask, a group in the selected shape layer or a
// new layer.
let font = Tokens::ui(11.0);
let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line);
let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size());
painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150));
painter.galley(caption.min, galley, line);
}
/// The bounds of points on screen, or `None` for an empty set.
fn bounds_of(pts: &[Pos2]) -> Option<Rect> {
let first = *pts.first()?;
let mut r = Rect::from_min_max(first, first);
for p in pts.iter().skip(1) {
r.extend_with(*p);
}
Some(r)
}
fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) {
let Some(d) = shape_drag(app, tool, a, b, mods) else { return };
let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1]));
// A mask on the selected layer, as the ghost showed.
if d.target == ShapeTarget::Mask {
// In layer space: invert the layer transform.
let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
if let Some(l) = sel_layer
&& let Some(comp) = app.session.active_comp_arc()
{
let ectx = EvalCtx {
project: &app.session.project,
comp_id: app.session.active_comp_id().unwrap_or_default(),
@@ -1943,8 +2070,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) {
app.ui.status = e.to_string();
}
return;
}
return;
}
// Into the selected shape layer's Contents as a new group (as in After Effects), else a new
// shape layer; painted with the Tools bar's Fill and Stroke.
+3 -1
View File
@@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point
/// repeated for a closed path.
pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
let k = effectcraft_engine::render::kurbo_path(sp);
let mut pts: Vec<Pos2> = vec![];
kurbo::flatten(k.iter(), 0.5, |el| match el {
+195
View File
@@ -368,6 +368,201 @@ fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() {
assert!(!h.state().session.state.shape_tool.creates_mask);
}
// ---- the shape tools' drag ghost
/// Run frames until the viewer's background frame has landed, so the comp is painted (and not
/// still black) before the drag begins: the ghost is then the only thing that changes.
fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(4);
}
/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still
/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers
/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set.
fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage {
h.input_mut().events.push(Event::ModifiersChanged(mods));
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.render().expect("the mid-drag frame")
}
/// Release the button where the drag left it.
fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
}
/// The pixel at a comp point of a rendered frame.
fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] {
let s = screen(h, p);
let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0;
[p[0], p[1], p[2]]
}
/// How different two pixels are, summed over the channels.
fn delta(a: [u8; 3], b: [u8; 3]) -> u32 {
(0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum()
}
/// The Tools-bar Fill as bytes, the colour a filled ghost carries.
fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] {
let c = h.state().session.state.shape_tool.fill.color;
std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// The shape tools show the shape they will draw under the cursor while the drag is still going:
/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was
/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box.
#[test]
fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind.
let corner = [146.0, 106.0];
for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] {
let mut h = harness();
h.state_mut().ui.tool = tool;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let before = h.render().expect("the frame before the drag");
let mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let fill = fill_rgb(&h);
// Drawn while the button is down, and painted with the Fill rather than left to a hairline.
let was = px_at(&before, &h, centre);
let now = px_at(&mid, &h, centre);
assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})");
assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})");
// The tool's own form: only the Rectangle reaches the corner of its box.
let was_c = px_at(&before, &h, corner);
let now_c = px_at(&mid, &h, corner);
assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape");
release(&mut h, b, egui::Modifiers::default());
}
}
/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a
/// lie: Shift squares the box and Alt (Option) draws it from the press point, both live.
#[test]
fn shape_drag_ghost_normalises_the_same_way_as_the_release() {
let from = [140.0f64, 100.0];
let to = [400.0f64, 260.0];
// (mods, centre, size): Shift squares the box from its top-left, Alt centres it on the press.
for (mods, centre, size) in [
(egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]),
(egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]),
(egui::Modifiers::ALT, [140.0, 100.0], [520.0, 320.0]),
(egui::Modifiers { alt: true, shift: true, ..Default::default() }, [140.0, 100.0], [520.0, 520.0]),
] {
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32)));
let before = h.render().expect("the frame before the drag");
// Measured with the button down, so this is the ghost and not the committed shape.
let mid = mid_drag(&mut h, a, b, mods);
let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0];
assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land");
release(&mut h, b, mods);
// And the shape it commits is that box: a new shape layer carries the position, its
// group sits at its own centre's origin.
let comp = h.state().session.active_comp().unwrap().clone();
let layer = comp.layers.first().expect("a new shape layer").clone();
assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer");
let at = layer.transform().unwrap().get("position").unwrap().value.components();
assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}");
let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone();
let path = group.sub("contents").unwrap().sub("ellipse").unwrap();
let s = path.get("size").unwrap().value.components();
assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}");
}
}
/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour
/// and not painted with the Fill, where a shape drawn into the selected shape layer is.
#[test]
fn shape_drag_ghost_shows_where_the_shape_will_land() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// Nothing selected: the shape becomes a new layer, painted with the Fill.
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let shape_px = px_at(&shape_mid, &h, centre);
let fill = fill_rgb(&h);
assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour");
release(&mut h, b, egui::Modifiers::default());
// A solid selected: the same drag is a mask on it, in the mask colour.
let mut h = harness();
let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let mask_px = px_at(&mask_mid, &h, centre);
assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})");
release(&mut h, b, egui::Modifiers::default());
let comp = h.state().session.active_comp().unwrap();
assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask");
}
/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces
/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting
/// for it would freeze the viewer on the last completed frame for the whole drag.
#[test]
fn a_layer_drag_moves_the_picture_while_the_button_is_down() {
let mut h = harness();
let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap();
settle_viewer(&mut h);
let from = screen(&h, [320.0, 180.0]);
let to = screen(&h, [520.0, 180.0]);
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
// Where the layer is not (yet): the plate behind it.
let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]);
// Drag in small steps, still holding the button, and give the renders a chance to land.
let mut moved = false;
for i in 1..=40 {
let p = from + (to - from) * (i as f32 / 40.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
// 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor
// icon and where it started.
let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]);
if i >= 30 && delta(at, plate) > 30 {
moved = true;
break;
}
}
assert!(moved, "the layer's pixels did not follow the pointer while the button was down");
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the
/// next shape drawn (#227).
#[test]