Merge PR #8623: fix(ui): add transform handles for multi-layer selection

This commit is contained in:
David Mann committed 2026-10-29 14:53:29 +01:00
commit c7e1bcd03c
19 files changed
+847 -66

No files matched your search

+120 -13
View File
@@ -57,7 +57,7 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
}
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect {
pub fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect {
let content = match &layer.content {
LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)),
// A soft shape (Density below 100 % or a Feather) renders past its path, over the whole
@@ -734,6 +734,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
if p.get("target").is_none()
&& p.get("layer").is_none()
&& p.get("layers").is_none()
&& let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s))
{
m.extend(t);
@@ -741,15 +742,60 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
p
}
/// Several layers explicitly targeted by a transform. This is used by the Move tool's shared
/// transform controls: the same homography is applied to every top-level layer in one history
/// step, relative to the shared source frame. A group already carries its selected descendants,
/// so those descendants are removed from the effective target set.
fn explicit_layers(doc: &Document, p: &Value) -> Result<Option<Vec<LayerId>>> {
let Some(raw) = p.get("layers") else { return Ok(None) };
if p.get("layer").is_some_and(|v| !v.is_null()) {
return Err(bad("use either `layer` or `layers`, not both"));
}
let a = raw.as_array().ok_or_else(|| bad("`layers` must be an array of layer ids"))?;
if a.is_empty() {
return Err(bad("`layers` must contain at least one layer id"));
}
let mut ids = Vec::with_capacity(a.len());
for v in a {
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("every `layers` entry must be a non-negative integer layer id"))?;
if doc.layer(id).is_none() {
return Err(EngineError::NoLayer(id));
}
if !ids.contains(&id) {
ids.push(id);
}
}
let ids = crate::layer_multi_cmds::top_level(doc, &ids);
if ids.is_empty() {
return Err(bad("`layers` contains no transformable layers"));
}
Ok(Some(ids))
}
fn common_bounds(doc: &Document, ids: &[LayerId]) -> Result<Rect> {
let mut b = Rect::EMPTY;
for &id in ids {
let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
b = b.union(&whole_layer_bounds(doc, layer));
}
Ok(b)
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let p = &with_panel_target(s, p);
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer);
let lone = lone_target(&st.doc, id, p)?;
let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) {
(_, true) => None,
(Some(id), false) => Some(id),
(None, false) => return Err(EngineError::Other("no active layer".into())),
let layers = explicit_layers(&st.doc, p)?;
if layers.is_some() && p.get("target").is_some() {
return Err(bad("`target` cannot be combined with `layers`"));
}
let explicit = crate::commands::explicit_layer(s, p)?;
let id = if layers.is_some() { None } else { explicit.or(st.active_layer) };
let lone = if layers.is_some() { None } else { lone_target(&st.doc, id, p)? };
let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p), layers.is_some()) {
(_, _, true) => None,
(_, true, false) => None,
(Some(id), false, false) => Some(id),
(None, false, false) => return Err(EngineError::Other("no active layer".into())),
};
let rect = match p.get("rect").and_then(Value::as_array) {
Some(r) if r.len() == 4 => {
@@ -757,10 +803,11 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
[v[0], v[1], v[2], v[3]]
}
_ => {
let b = match (lone, id) {
(Some(surf), _) => target_bounds(&st.doc, surf),
(None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?),
(None, None) => Rect::EMPTY,
let b = match (&layers, lone, id) {
(Some(ids), _, _) => common_bounds(&st.doc, ids)?,
(None, Some(surf), _) => target_bounds(&st.doc, surf),
(None, None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?),
(None, None, None) => Rect::EMPTY,
};
if b.is_empty() {
return Err(EngineError::Other("nothing to transform".into()));
@@ -790,17 +837,77 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
(m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] });
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
let lone = lone.is_some();
let layers_for_job = layers.clone();
let p = p.clone();
// A background job when started with `Session::start` (#1015): the warp of a large layer runs
// on a worker with progress, and can be cancelled.
crate::jobs::edit_job(
s,
"Free Transform",
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)),
move |doc, _, ctx| {
ctx.stage(0.0, 1.0, "Transforming", |ctl| {
if let Some(ids) = &layers_for_job {
let several = crate::layer_multi_cmds::top_level(doc, ids).len() > 1;
transform_layers_doc(doc, ids, &h, affine, interp, ctl)?;
// Photoshop drops the selection after transforming several layers (whole
// layers move, so the outline no longer frames anything); in the same edit,
// so one undo brings both back. A list that is one layer (or a group and
// its own children) keeps it.
if several && !h.is_identity() {
doc.selection = None;
}
Ok(())
} else {
transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)
}
})
},
move |()| json!({"rect": rect}),
)
}
/// Apply one homography to several top-level layers in one edit. Multi-layer transforms move the
/// layers as whole objects: a pixel selection does not crop each selected layer independently.
/// The selection is left alone here (the live preview keeps showing it); the committed
/// `edit.transform` drops it, as Photoshop does.
fn transform_layers_doc(doc: &mut Document, ids: &[LayerId], h: &Homography, affine: Option<Affine>, interp: Interp, ctl: &Interrupt) -> Result<()> {
let ids = crate::layer_multi_cmds::top_level(doc, ids);
if ids.is_empty() {
return Err(EngineError::Other("no layers to transform".into()));
}
for &id in &ids {
let group = group_locks(doc, id);
crate::extra_cmds::background_to_layer(doc, id);
let layer = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
transform_layer_with(None, group, layer, h, affine, interp, ctl)?;
}
if !h.is_identity() {
let snapshot = doc.clone();
for &id in &ids {
if let Some(layer) = doc.layer_mut(id) {
refresh_text(&snapshot, layer);
}
}
}
Ok(())
}
/// A synchronous document clone for the Move tool's live preview of several selected layers.
/// The source document is never mutated.
pub fn preview_layers(doc: &Document, ids: &[LayerId], rect: [f64; 4], quad: [[f64; 2]; 4], interpolation: &str) -> Result<Document> {
let h = Homography::rect_to_quad(rect, quad).ok_or_else(|| bad("degenerate quad"))?;
if h.inverse().is_none() {
return Err(bad("the transform collapses the layers"));
}
let m = h.0;
let affine =
(m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] });
let interp = Interp::parse(interpolation);
let mut out = doc.clone();
transform_layers_doc(&mut out, ids, &h, affine, interp, &Interrupt::NONE)?;
Ok(out)
}
/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline.
#[allow(clippy::too_many_arguments)]
fn transform_doc(
@@ -865,7 +972,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Free Transform",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
params: r##"{"layer":id? OR "layers":[id,…]? (shared multi-layer transform; top-level layers only; two or more drop the selection),"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection, or the union of multi-layer content),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (single-layer only; an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
enabled: has_layer,
journal: true,
run: transform,
@@ -23,6 +23,103 @@ fn active_bounds(s: &Session) -> Rect {
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
}
#[test]
fn shared_transform_moves_several_layers_in_one_command() {
let mut s = session();
let first = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let second = s.active().unwrap().active_layer.unwrap();
s.edit("paint second", |doc, _| {
doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 40, 70, 60), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute(
"edit.transform",
json!({
"layers": [first.0, second.0],
"rect": [10.0, 10.0, 70.0, 60.0],
"quad": [[25.0, 30.0], [85.0, 30.0], [85.0, 80.0], [25.0, 80.0]],
"interpolation": "nearest"
}),
)
.unwrap();
let st = s.active().unwrap();
let a = st.doc.layer(first).unwrap().surface().unwrap().content_bounds();
let b = st.doc.layer(second).unwrap().surface().unwrap().content_bounds();
assert_eq!(a, Rect::new(25, 30, 45, 40));
assert_eq!(b, Rect::new(65, 60, 85, 80));
}
/// Photoshop, with two layers selected and a marquee active: Free Transform moves both whole
/// layers and drops the selection. One undo restores the layers and the selection.
#[test]
fn shared_transform_moves_whole_layers_and_drops_the_selection() {
let mut s = session();
let first = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let second = s.active().unwrap().active_layer.unwrap();
s.edit("paint second", |doc, _| {
doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 40, 70, 60), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 15, "height": 100})).unwrap();
let before_a = s.active().unwrap().doc.layer(first).unwrap().surface().unwrap().content_bounds();
let sel_before = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds();
s.execute("edit.transform", json!({"layers": [first.0, second.0], "dx": 20, "dy": 0, "interpolation": "nearest"})).unwrap();
let st = s.active().unwrap();
let a = st.doc.layer(first).unwrap().surface().unwrap().content_bounds();
let b = st.doc.layer(second).unwrap().surface().unwrap().content_bounds();
assert_eq!(a, Rect::new(before_a.x0 + 20, before_a.y0, before_a.x1 + 20, before_a.y1), "the whole layer moves");
assert_eq!(b, Rect::new(70, 40, 90, 60));
assert!(st.doc.selection.is_none(), "the selection is dropped");
s.execute("edit.undo", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.layer(first).unwrap().surface().unwrap().content_bounds(), before_a);
assert_eq!(st.doc.selection.as_ref().map(|m| m.content_bounds()), Some(sel_before), "one undo brings the selection back");
}
/// A `layers` list that is only one layer is no multi-layer transform: the selection stays.
#[test]
fn a_one_layer_list_keeps_the_selection() {
let mut s = session();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 15, "height": 100})).unwrap();
s.execute("edit.transform", json!({"layers": [id.0], "dx": 20, "dy": 0})).unwrap();
assert!(s.active().unwrap().doc.selection.is_some());
}
/// A single layer keeps its behaviour: only the selected pixels move, and the selection with them.
#[test]
fn single_layer_transform_still_moves_the_selection() {
let mut s = session();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("edit.transform", json!({"layer": id.0, "dx": 20, "dy": 0})).unwrap();
let sel = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds();
assert_eq!((sel.x0, sel.x1), (30, 40));
}
#[test]
fn shared_transform_rejects_invalid_layer_lists() {
let mut s = session();
let active = s.active().unwrap().active_layer.unwrap();
for params in [
json!({"layers": "not-an-array", "matrix": [1, 0, 0, 1, 1, 1]}),
json!({"layers": [], "matrix": [1, 0, 0, 1, 1, 1]}),
json!({"layers": [u64::MAX], "matrix": [1, 0, 0, 1, 1, 1]}),
json!({"layer": active.0, "layers": [active.0], "matrix": [1, 0, 0, 1, 1, 1]}),
json!({"layers": [active.0], "target": "mask", "matrix": [1, 0, 0, 1, 1, 1]}),
] {
assert!(s.execute("edit.transform", params).is_err());
}
}
// ---- Memory budget (#3773, #7807). Tests run with a 20 MB budget (2²⁰ px of 32-bit CMYK with
// alpha), so nothing here comes near the real one. ----
+1
View File
@@ -144,6 +144,7 @@ impl PhotocraftApp {
clip_external: false,
clip_read_for_paste: false,
transform_preview: None,
multi_transform: None,
move_mods: Default::default(),
nudge_bundle: Default::default(),
style_preview: None,
+5 -8
View File
@@ -114,11 +114,10 @@ use textures::{clamp_texture_side, preview_bounds, texture_factor};
use textures::{ensure_gpu, redraw_focus, visible_doc_rect};
pub use tool_input::{ToolEvent, selection_drag_kind, tool_event};
pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool};
#[cfg(test)]
use transform_controls::transform_controls_several;
pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect};
use transform_controls::{
begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_several,
transform_controls_turn,
};
use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn};
#[cfg(test)]
use transform_controls::{transform_controls_hit, transform_controls_layers};
pub use view_xform::{ViewXform, doc_point_at, fill_view, fit_view};
@@ -1360,10 +1359,8 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C
// corner badge, as Photoshop's corner radius cursor.
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
crate::icons::cursor_badge(ctx, "scan", p);
} else if let Some((r, p, c)) = response
.hover_pos()
.filter(|_| !transform_controls_several(app))
.and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
} else if let Some((r, p, c)) =
response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
{
// The Move tool draws handles without opening a Free Transform session.
// Give them their resize/rotate cursors before the first pointer press (#2871).
+6
View File
@@ -140,6 +140,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::gradient_ui::display_doc(app, idx) {
return shown;
}
// Move › Show Transform Controls on several selected layers: one shared live transform.
// It comes before the Move preview so a floating selection can remain part of that shared
// transform instead of short-circuiting to its untransformed floating preview.
if let Some(shown) = crate::transform_tool::multi_display_doc(app, idx) {
return shown;
}
// Move tool drag: the moving layers at the pointer.
if let Some(shown) = crate::move_ui::display_doc(app, idx) {
return shown;
+5 -3
View File
@@ -215,16 +215,18 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods);
let crop_tol = crate::crop_ui::tolerance(app);
let gesture = if let Some(t) = &app.ui.transform {
// A shared box moves every layer of its session: none of them is a target.
let moving = crate::transform_tool::moving_layers(app).unwrap_or_else(|| vec![LayerId(t.layer)]);
let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) {
crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])),
crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, moving.clone())),
_ => None,
};
if t.mode == crate::state::TransformMode::Distort {
corner
} else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) {
corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)])))
corner.or(Some((Gesture::Point, moving)))
} else if in_quad(&t.quad, p) {
Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)]))
Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, moving))
} else {
None
}
@@ -617,6 +617,35 @@ fn distort_corner_does_not_snap_to_layer_edges() {
assert!(app.prefs_rt.snap_lines.is_empty());
}
/// A shared Free Transform box for several layers doesn't snap to those layers' original
/// edges: they move with it. Its left edge starts exactly on the mover's left edge, so a
/// 2 px drag snapped straight back when only the active layer was left out.
#[test]
fn a_shared_transform_box_does_not_snap_to_its_own_layers() {
let mut app = app_with_box();
let mover = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.new.layer", json!({"name": "second"})).unwrap();
app.run("select.rect", json!({"x": 120, "y": 40, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"color": "#00ff00"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.select", json!({"layer": mover.0, "mode": "add"})).unwrap();
app.ui.tool = Tool::Move;
crate::transform_tool::begin_layers(&mut app, vec![mover, second], [40.0, 40.0, 160.0, 80.0]).unwrap();
assert_eq!(crate::transform_tool::moving_layers(&app).map(|l| l.len()), Some(2));
let m = egui::Modifiers::NONE;
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 51.0, y: 50.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 52.0, y: 50.0 }, m);
let q = app.ui.transform.as_ref().unwrap().quad;
assert!((q[0][0] - 42.0).abs() < 1e-6, "the box follows the pointer: {q:?}");
// The snap still works against a layer that stays put (the target's left edge, x = 300).
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, m);
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 306.0, y: 50.0 }, m);
let q = app.ui.transform.as_ref().unwrap().quad;
assert!((q[0][0] - 300.0).abs() < 1e-6, "{q:?}");
}
#[test]
fn free_transform_drags_and_handles_snap() {
let mut app = app_with_box();
+1
View File
@@ -113,6 +113,7 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
// Move tool (⌘ with a painting tool too, `event_tool`) drags it from anywhere.
if let ToolEvent::Down { x, y, .. } = raw
&& app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some())
&& app.ui.transform.is_none()
&& !crate::move_ui::moves_selected_pixels_with(app, event_tool(app, mods))
&& selection_drag_kind(app, app.ui.tool, [x, y], mods) != Some(true)
{
+50 -16
View File
@@ -37,27 +37,60 @@ pub(super) fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_
return Vec::new();
}
let Some(st) = app.session.active() else { return Vec::new() };
st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect()
let layers: Vec<_> =
st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect();
if layers.len() > 1 && layers.iter().any(|l| photocraft_engine::transform_cmds::locked_for_transform(&st.doc, l.id)) {
return Vec::new();
}
layers
}
/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but
/// Free Transform works on one layer, so the frame has no handles to scale or rotate them.
/// Several layers are selected: Show Transform Controls frames and transforms them together.
pub(super) fn transform_controls_several(app: &PhotocraftApp) -> bool {
transform_controls_layers(app).len() > 1
}
/// The document-space frame behind Move › Show Transform Controls. Several selected layers are
/// transformed as whole layers, so a pixel selection does not crop every one to the selection.
fn transform_controls_bounds(app: &PhotocraftApp) -> Option<photocraft_geom::Rect> {
let st = app.session.active()?;
let layers = transform_controls_layers(app);
let several = layers.len() > 1;
let floating = photocraft_engine::float_cmds::floating(st).map(|f| f.layer);
let floating_piece = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map(|o| o.content_bounds());
let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0));
layers
.into_iter()
.map(|l| {
if several && floating == Some(l.id) {
let remaining = photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, l);
floating_piece.map_or(remaining, |piece| remaining.union(&piece.translate(dx, dy)))
} else if several {
photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, l)
} else {
photocraft_engine::transform_cmds::transform_frame(&st.doc, l)
}
})
.filter(|b| !b.is_empty())
.reduce(|a, b| a.union(&b))
}
/// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected
/// layers, joined into one.
pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option<Rect> {
let st = app.session.active()?;
let layers = transform_controls_layers(app);
let b = layers.iter().map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)).filter(|b| !b.is_empty()).reduce(|a, b| a.union(&b))?;
let b = transform_controls_bounds(app)?;
// Floating selected pixels are framed by their outline, as their ants are (#7538), not by the
// selection they were cut with; a frame round other selected layers too keeps them all.
let alone = matches!(layers.as_slice(), [l] if photocraft_engine::float_cmds::floating(st).is_some_and(|f| f.layer == l.id));
let b = app.session.active_index().filter(|_| alone).and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds());
let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0));
let b = b.translate(dx, dy);
let b = if alone {
let b = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds());
let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0));
b.translate(dx, dy)
} else {
b
};
if b.is_empty() {
return None;
}
@@ -112,14 +145,18 @@ pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool {
/// Enter the existing Free Transform session when a Move-tool transform control is pressed.
pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool {
if transform_controls_several(app) {
return false;
}
let Some(r) = transform_controls_rect(app, xf) else { return false };
if !transform_controls_hit(r, p) {
return false;
}
match crate::transform_tool::begin(app, ctx) {
let result = if transform_controls_several(app) {
let Some(b) = transform_controls_bounds(app) else { return false };
let ids = transform_controls_layers(app).into_iter().map(|l| l.id).collect();
crate::transform_tool::begin_layers(app, ids, [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64])
} else {
crate::transform_tool::begin(app, ctx)
};
match result {
Ok(()) => true,
Err(e) => {
app.ui.status = e;
@@ -128,8 +165,8 @@ pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::C
}
}
/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles
/// for one layer, and the outline of every selected shape layer, as in Photoshop.
/// Move tool › Show Transform Controls: the selected layers' bounding box with its eight handles,
/// and the outline of every selected shape layer, as in Photoshop.
pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
let Some(r) = transform_controls_rect(app, xf) else { return };
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
@@ -140,9 +177,6 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P
}
}
painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle);
if transform_controls_several(app) {
return;
}
for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] {
let h = Rect::from_center_size(p, vec2(7.0, 7.0));
painter.rect_filled(h, 0.0, Color32::WHITE);
@@ -108,6 +108,10 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() {
let mut app = floating_square();
app.session.execute("select.drop", json!({})).unwrap();
let a = app.session.active().unwrap().active_layer.unwrap();
// Leave some pixels on the floating layer outside the later marquee. A shared frame must
// enclose both these stationary pixels and the translated floating piece.
app.session.execute("select.rect", json!({"x": 5, "y": 10, "width": 10, "height": 10})).unwrap();
app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session.execute("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap();
app.session.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
@@ -122,18 +126,46 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() {
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
let r = transform_controls_rect(&app, &xf).unwrap();
let (lt, rb) = (xf.to_doc(r.left_top()), xf.to_doc(r.right_bottom()));
assert!(rb[0] - lt[0] > 60.0 && rb[1] - lt[1] > 60.0, "the frame isn't shrunk to the one piece: {lt:?} {rb:?}");
assert_eq!(
[lt[0], lt[1], rb[0], rb[1]],
[5.0, 10.0, 170.0, 170.0],
"the frame encloses the remaining pixels, translated floating piece, and other selected layer"
);
let ctx = egui::Context::default();
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
let shown = app.ui.transform.as_ref().unwrap().rect;
for (actual, expected) in shown.into_iter().zip([lt[0], lt[1], rb[0], rb[1]]) {
assert!((actual - expected).abs() < 1e-3, "the transform starts on the displayed shared frame: {shown:?}");
}
// The shared preview must start from the displayed floating document too. Otherwise the
// Move preview wins first, or the transformed clone is built from the cut-out document,
// and the floating pixels disappear while the shared box is moved.
let st = app.session.active().unwrap();
let floating = photocraft_engine::float_cmds::floating(st).unwrap();
let piece = floating.parts.outline().unwrap().content_bounds();
let at = [(piece.x0 + piece.x1) / 2 + floating.offset.0, (piece.y0 + piece.y1) / 2 + floating.offset.1];
let displayed = photocraft_engine::float_cmds::displayed(st, (0, 0)).unwrap();
assert!(photocraft_compose::render(&displayed, displayed.bounds()).get(at[0], at[1])[3] > 0.5, "the floating piece is visible before transform");
for p in &mut app.ui.transform.as_mut().unwrap().quad {
p[0] += 60.0;
}
let (preview, _) = display_doc(&mut app, 0);
let rendered = photocraft_compose::render(&preview, preview.bounds());
assert!(rendered.get(at[0] + 60, at[1])[3] > 0.5, "the floating piece follows the shared transform preview");
assert!(rendered.get(at[0], at[1])[3] < 0.5, "the floating piece no longer stays at its untransformed preview position");
}
#[test]
fn several_selected_layers_share_one_frame_without_handles() {
// As in Photoshop: Show Transform Controls frames every selected layer. Free Transform
// works on one layer, so the shared frame doesn't start it.
fn several_selected_layers_share_one_frame_with_handles_and_transform_session() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap();
let a = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap();
let b = app.session.active().unwrap().active_layer.unwrap();
app.sync_views();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_show_transform = true;
@@ -147,13 +179,61 @@ fn several_selected_layers_share_one_frame_without_handles() {
assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both");
let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom()));
assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}");
let ctx = egui::Context::default();
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()));
assert!(app.ui.transform.is_none());
// The Background never joins the frame.
assert_eq!(transform_controls_cursor(both, both.right_bottom()), Some(egui::CursorIcon::ResizeNwSe));
assert_eq!(transform_controls_cursor(both, both.center_top()), Some(egui::CursorIcon::ResizeVertical));
// The Background never joins the frame, even when selected too.
let bg = app.session.active().unwrap().doc.layers[0].id;
app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap();
assert_eq!(transform_controls_layers(&app).len(), 2);
// A shared handle now opens a real multi-layer Free Transform session.
let ctx = egui::Context::default();
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()));
assert!(app.ui.transform.is_some());
assert!(app.multi_transform.is_some());
assert!(app.transform_preview.is_none(), "multi-layer preview uses a transformed document clone");
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [20.0, 30.0, 150.0, 150.0]);
// Applying the shared box routes one transform to both selected layers.
app.background_jobs = false;
app.ui.transform.as_mut().unwrap().quad = [[30.0, 35.0], [160.0, 35.0], [160.0, 155.0], [30.0, 155.0]];
crate::transform_tool::commit(&mut app);
assert!(app.ui.transform.is_none() && app.multi_transform.is_none());
let st = app.session.active().unwrap();
assert_eq!(photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, st.doc.layer(a).unwrap()), photocraft_geom::Rect::new(30, 35, 70, 75));
assert_eq!(photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, st.doc.layer(b).unwrap()), photocraft_geom::Rect::new(110, 125, 160, 155));
}
#[test]
fn shared_transform_controls_hide_if_any_selected_layer_is_locked() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap();
let a = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap();
let b = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
app.session
.edit("lock", |doc, _| {
doc.layer_mut(b).unwrap().locks.position = true;
Ok(())
})
.unwrap();
app.sync_views();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_show_transform = true;
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
assert!(transform_controls_layers(&app).is_empty(), "a shared box that cannot start must not be shown");
assert!(transform_controls_rect(&app, &xf).is_none());
// Preserve the existing single-layer behaviour: a locked layer still shows its controls;
// pressing one reports the lock rather than silently removing the frame.
app.session.execute("layer.select", json!({"layer": b.0})).unwrap();
assert_eq!(transform_controls_layers(&app).len(), 1);
assert!(transform_controls_rect(&app, &xf).is_some());
}
#[test]
+2
View File
@@ -524,6 +524,8 @@ pub struct PhotocraftApp {
pub(crate) hist_uncached: Option<(DocId, u64)>,
/// Free Transform preview (document without the moving pixels + their texture).
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
/// Move-tool Show Transform Controls session spanning several selected layers.
pub(crate) multi_transform: Option<transform_tool::MultiTransform>,
/// Move-tool ⇧/⌥ drag state (move_mods).
pub(crate) move_mods: move_mods::MoveDrag,
/// Cached document for a modal text Style Options color preview.
+22 -1
View File
@@ -33,8 +33,12 @@ mod warp;
pub(crate) use begin::begin_content_aware;
pub use begin::{begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type};
pub(crate) use commit::{SAVE_FOR_WEB, committing, nudge};
pub(crate) use begin::{begin_layers, moving_layers};
#[cfg(test)]
use commit::multi_selection_changed;
pub(crate) use commit::{SAVE_FOR_WEB, committing, multi_display_doc, nudge};
pub use commit::{cancel, commit, end_if_left, follow_edits, intercept, is_enabled, on_job_event, track_steps};
use drag_modes::multi_has_text;
#[cfg(test)]
use drag_modes::{HandleIndicator, handle_indicator, keep_convex, turns};
pub(crate) use drag_modes::{apply_drag, corner_distorts, legacy_mods};
@@ -139,6 +143,23 @@ pub struct TransformPreview {
pub(crate) content_aware: Option<crate::cas_transform::Live>,
}
/// A Free Transform geometry session started from the Move tool's shared controls for several
/// selected layers. Unlike [`TransformPreview`], the preview is a transformed clone of the whole
/// document, because there is no single moving texture to peel out and draw on a quad.
pub(crate) struct MultiTransform {
document: DocId,
revision: u64,
layers: Vec<LayerId>,
gesture: Option<Gesture>,
shown_quad: Option<[[f64; 2]; 4]>,
shown_interpolation: Option<String>,
shown: Option<Arc<Document>>,
generation: u64,
tool: Tool,
/// A background `edit.transform` applying the shared transform.
committing: Option<JobId>,
}
/// What [`follow_edits`] needs to rebuild the preview document.
#[derive(Clone, Copy, Debug)]
struct Follow {
@@ -1,3 +1,4 @@
use super::MultiTransform;
use super::commit::{commit, start_steps};
use super::preview::{Above, preview_image};
use super::{
@@ -8,6 +9,9 @@ use super::{
/// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked
/// layer mask, alpha channel or Quick Mask.
pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
if app.multi_transform.is_some() {
return Err("Commit or cancel the transform first".into());
}
if let Some((params, path)) = crate::vector_ui::free_transform_path(app) {
return begin_path(app, ctx, params, path);
}
@@ -102,9 +106,82 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
Ok(())
}
/// The layers a shared transform box moves (none outside one): snapping must not target them.
pub(crate) fn moving_layers(app: &PhotocraftApp) -> Option<Vec<LayerId>> {
app.multi_transform.as_ref().map(|m| m.layers.clone())
}
/// Start one shared transform box for several selected layers from Move › Show Transform
/// Controls. Every layer is transformed by the same homography around `rect`; the engine commits
/// them together as one history step. This path deliberately does not use the single-layer lifted
/// texture preview: [`multi_display_doc`] previews a transformed document clone instead.
pub(crate) fn begin_layers(app: &mut PhotocraftApp, layers: Vec<LayerId>, rect: [f64; 4]) -> Result<(), String> {
if layers.len() < 2 {
return Err("select two or more layers".into());
}
let st = app.session.active().ok_or("no document")?;
let doc = st.doc.clone();
let revision = st.revision;
let active_layer = st.active_layer;
let mut ids = Vec::with_capacity(layers.len());
for id in layers {
let Some(layer) = doc.layer(id) else { return Err("a selected layer no longer exists".into()) };
if crate::doc_props_ui::is_background(&doc, layer) {
continue;
}
if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) {
app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default());
return Err(LOCKED.into());
}
if !ids.contains(&id) {
ids.push(id);
}
}
if ids.len() < 2 {
return Err("select two or more transformable layers".into());
}
crate::type_tool::commit(app);
let fallback = ids.first().copied().ok_or("select two or more transformable layers")?;
let active = active_layer.filter(|id| ids.contains(id)).unwrap_or(fallback);
let session = app.ui.alloc_id();
app.transform_preview = None;
app.multi_transform = Some(MultiTransform {
document: doc.id,
revision,
layers: ids,
gesture: None,
shown_quad: None,
shown_interpolation: None,
shown: None,
generation: 0,
tool: app.ui.tool,
committing: None,
});
app.ui.transform = Some(TransformSession {
session,
layer: active.0,
rect,
quad: corners(rect),
pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0],
interpolation: "bicubic".into(),
warp: None,
selection: false,
target: None,
path: None,
made: None,
mode: Default::default(),
content_aware: None,
skew_v: 0.0,
});
Ok(())
}
/// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection,
/// its selected pixels, as Layer via Copy) and transforms the copy (#352).
pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
if app.multi_transform.is_some() {
return Err("Commit or cancel the transform first".into());
}
let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some());
let params = if selection { json!({}) } else { json!({"inPlace": true}) };
app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, params)?;
@@ -402,6 +479,9 @@ pub(super) fn rect_to_quad_affine(r: [f64; 4], q: [[f64; 2]; 4]) -> Option<photo
/// Start Select › Transform Selection: the same box over the selection's bounds, previewing the
/// selection mask (the marching ants are hidden meanwhile); commits `select.transformSelection`.
pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
if app.multi_transform.is_some() {
return Err("Commit or cancel the transform first".into());
}
let st = app.session.active().ok_or("no document")?;
let doc = st.doc.clone();
let sel = doc.selection.clone().ok_or("no selection")?;
@@ -469,6 +549,9 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
/// Start (or switch an active Free Transform into) Warp mode.
pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
if app.multi_transform.is_some() {
return Err("Warp is not available for a multi-layer transform".into());
}
if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) {
return Err(CAS_ONLY_SCALES.into());
}
@@ -488,6 +571,10 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St
/// custom mesh following the current box (so a transform made first carries over). Editable type
/// gets its own Warp Text style instead ([`enter_type_warp`]).
pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.multi_transform.is_some() {
app.ui.status = "Warp is not available for a multi-layer transform".into();
return;
}
if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) {
return enter_type_warp(app, ctx);
}
@@ -519,6 +606,9 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) {
/// The open transform moves an editable type layer as a whole (not a lone mask, channel, path or
/// the selection). Its Warp is Warp Text: only the styles, and the type stays editable (#3004).
pub fn warps_type(app: &PhotocraftApp) -> bool {
if app.multi_transform.is_some() {
return false;
}
let Some(t) = &app.ui.transform else { return false };
if t.selection || t.target.is_some() || t.path.is_some() {
return false;
@@ -3,6 +3,7 @@ use super::options_bar::{rotate_about_pivot, scale_about_pivot};
use super::{
Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Step, Steps, TransformPreview, TransformSession, corners, json,
};
use super::{Document, MultiTransform, multi_has_text};
/// Undo and Redo while the commit runs in the background (Free Transform, or Warp).
const STILL_APPLYING: &str = "Free Transform is still being applied";
@@ -13,7 +14,7 @@ pub fn commit(app: &mut PhotocraftApp) {
if committing(app).is_some() {
return;
}
let document = app.transform_preview.as_ref().map(|pv| pv.doc.id);
let document = app.transform_preview.as_ref().map(|pv| pv.doc.id).or_else(|| app.multi_transform.as_ref().map(|m| m.document));
if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) {
cancel(app);
return;
@@ -21,6 +22,41 @@ pub fn commit(app: &mut PhotocraftApp) {
let type_layer = warps_type(app);
let Some(t) = app.ui.transform.take() else { return };
let mut preview = app.transform_preview.take();
let mut multi = app.multi_transform.take();
if let Some(mut m) = multi.take() {
if t.warp.is_some() {
app.ui.transform = Some(t);
app.multi_transform = Some(m);
app.ui.status = "Warp is not available for a multi-layer transform".into();
return;
}
if t.quad == corners(t.rect) {
return;
}
let r = app.run(
"edit.transform",
json!({
"layers": m.layers.iter().map(|id| id.0).collect::<Vec<_>>(),
"rect": t.rect,
"quad": t.quad,
"interpolation": t.interpolation,
}),
);
if let Ok(v) = &r
&& let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64)
{
m.committing = Some(JobId(job));
app.ui.transform = Some(t);
app.multi_transform = Some(m);
return;
}
if let Err(e) = r {
app.ui.transform = Some(t);
app.multi_transform = Some(m);
app.ui.status = e;
}
return;
}
if let Some(options) = &t.content_aware {
crate::cas_transform::commit(app, &t, options);
return;
@@ -107,7 +143,18 @@ fn finish(app: &mut PhotocraftApp, document: Option<DocId>, layer: LayerId, made
/// The job applying the transform, while it runs.
pub(crate) fn committing(app: &PhotocraftApp) -> Option<JobId> {
app.transform_preview.as_ref().and_then(|pv| pv.committing)
app.transform_preview.as_ref().and_then(|pv| pv.committing).or_else(|| app.multi_transform.as_ref().and_then(|m| m.committing))
}
/// Whether the selected transformable layers no longer match a shared transform session.
pub(super) fn multi_selection_changed(app: &PhotocraftApp, m: &MultiTransform) -> bool {
let Some(st) = app.session.active() else { return true };
if st.doc.id != m.document {
return true;
}
let selected =
st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| !crate::doc_props_ui::is_background(&st.doc, l))).collect::<Vec<_>>();
selected.len() != m.layers.len() || m.layers.iter().any(|id| !selected.contains(id))
}
/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its
@@ -116,6 +163,19 @@ pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) {
if committing(app) != Some(e.id) {
return;
}
if app.multi_transform.as_ref().is_some_and(|m| m.committing == Some(e.id)) {
let Some(t) = app.ui.transform.take() else { return };
let Some(mut m) = app.multi_transform.take() else { return };
if let JobOutcome::Failed(msg) = &e.outcome {
app.ui.status = msg.clone();
if m.tool == app.ui.tool && !multi_selection_changed(app, &m) {
m.committing = None;
app.ui.transform = Some(t);
app.multi_transform = Some(m);
}
}
return;
}
let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return };
// A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline,
// unless picking another tool or layer started it: then the box ends, as Esc would.
@@ -151,6 +211,22 @@ pub fn end_if_left(app: &mut PhotocraftApp) {
}
let Some(st) = app.session.active() else { return cancel(app) };
if let Some(m) = app.multi_transform.as_ref() {
let switched_document = st.doc.id != m.document;
let missing = m.layers.iter().any(|id| st.doc.layer(*id).is_none());
let switched_tool = m.tool != app.ui.tool;
let changed_selection = multi_selection_changed(app, m);
if switched_document || missing {
cancel(app);
} else if switched_tool || changed_selection {
commit(app);
if app.ui.transform.is_some() && committing(app).is_none() {
cancel(app);
}
}
return;
}
let layer = LayerId(t.layer);
let made = t.made;
let preview_doc_id = app.transform_preview.as_ref().map(|pv| pv.doc.id);
@@ -178,6 +254,65 @@ pub fn end_if_left(app: &mut PhotocraftApp) {
}
}
/// Document shown while a shared Move-tool transform is open. The engine applies the current
/// quad to a clone, so all selected layers preview together while the source document stays
/// untouched until [`commit`]. The clone is rebuilt only when the quad or source revision changes.
pub(crate) fn multi_display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
if app.session.active_index() != Some(idx) {
return None;
}
let t = app.ui.transform.as_ref()?.clone();
let st = app.session.documents().get(idx)?;
let (doc_id, revision) = (st.doc.id, st.revision);
let (document, ids, old_revision, shown_quad, shown_interpolation, committing) = {
let m = app.multi_transform.as_ref()?;
(m.document, m.layers.clone(), m.revision, m.shown_quad, m.shown_interpolation.clone(), m.committing)
};
if document != doc_id {
return None;
}
if t.quad == corners(t.rect) && committing.is_none() {
if let Some(m) = app.multi_transform.as_mut() {
m.shown = None;
m.shown_quad = None;
m.shown_interpolation = None;
m.revision = revision;
}
return None;
}
if committing.is_some() {
let m = app.multi_transform.as_ref()?;
return m.shown.as_ref().map(|doc| {
let key = (1_u64 << 62) ^ t.session.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ m.generation;
(doc.clone(), key)
});
}
let fresh = old_revision == revision && shown_quad == Some(t.quad) && shown_interpolation.as_deref() == Some(t.interpolation.as_str());
if !fresh {
let st = app.session.documents().get(idx)?;
let source =
photocraft_engine::float_cmds::displayed(st, crate::canvas::selection_drag_delta(app).unwrap_or((0, 0))).unwrap_or_else(|| st.doc.as_ref().clone());
let shown = match photocraft_engine::transform_cmds::preview_layers(&source, &ids, t.rect, t.quad, &t.interpolation) {
Ok(doc) => Some(Arc::new(doc)),
Err(e) => {
log::warn!("multi-layer transform preview: {e}");
None
}
};
if let Some(m) = app.multi_transform.as_mut() {
m.revision = revision;
m.shown_quad = Some(t.quad);
m.shown_interpolation = Some(t.interpolation.clone());
m.shown = shown;
m.generation = m.generation.wrapping_add(1);
}
}
let m = app.multi_transform.as_ref()?;
let shown = m.shown.clone()?;
let key = (1_u64 << 62) ^ t.session.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ m.generation;
Some((shown, key))
}
/// Another tool picked, or (outside Transform Selection) another layer selected, since the box
/// opened: the session ends ([`end_if_left`], #1400).
fn switched_away(app: &PhotocraftApp) -> bool {
@@ -216,6 +351,7 @@ pub fn cancel(app: &mut PhotocraftApp) {
crate::jobs_ui::cancel(app, job);
return;
}
app.multi_transform = None;
let transform = app.ui.transform.take();
let preview = app.transform_preview.take();
if let (Some(t), Some(pv)) = (transform, preview)
@@ -288,6 +424,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option<bool> {
/// Does the box move an editable type layer (not its selection outline, a lone mask or a path)?
pub(super) fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool {
if app.multi_transform.is_some() {
return multi_has_text(app);
}
!t.selection
&& t.target.is_none()
&& t.path.is_none()
@@ -51,11 +51,17 @@ pub(super) fn handle_indicator(app: &PhotocraftApp, t: &TransformSession, h: Hit
/// Editable type can be scaled, rotated and skewed but not distorted (#2630): the rule that greys
/// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path,
/// on the selection outline or on a lone mask or channel moves no type.
pub(super) fn multi_has_text(app: &PhotocraftApp) -> bool {
let (Some(st), Some(m)) = (app.session.active(), app.multi_transform.as_ref()) else { return false };
m.layers.iter().any(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Text(_))))
}
fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool {
t.path.is_none()
&& !t.selection
&& t.target.is_none()
&& app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))
&& (multi_has_text(app)
|| app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))))
}
/// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a
@@ -1,7 +1,8 @@
use super::begin::warps_type;
use super::committing;
use super::drag_modes::{HandleIndicator, handle_indicator};
use super::options_bar::{quad_scale, readout};
use super::pointer::{Hit, handle_at, rotate_dir, rotation_degrees};
use super::pointer::{Hit, handle_at, live_gesture, rotate_dir, rotation_degrees};
use super::warp::{on_section_edge, quick_split_kind, single_patch};
use super::{
BezierMesh, Color32, DocId, HANDLE_PX, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp,
@@ -150,9 +151,14 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
/// The box, handles, reference point and path outline over the preview that [`draw_content`]
/// drew; the warp grid and its split guides for a warp.
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return };
let Some(t) = &app.ui.transform else { return };
let pv = app.transform_preview.as_ref();
if pv.is_none() && app.multi_transform.is_none() {
return;
}
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
if let Some(w) = &t.warp {
let Some(pv) = pv else { return };
let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p));
let at = pv.split_pointer.or(over_canvas);
// The same keys as `cursor`, sticky ones included, so the guide matches the pointer.
@@ -170,11 +176,13 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
draw_warp_overlay(painter, xf, w, pv, guide);
return;
}
if pv.committing.is_some() {
if committing(app).is_some() {
return;
}
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) {
if let Some(pv) = pv
&& let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad))
{
crate::vector_ui::draw_outline(
painter,
path,
@@ -202,7 +210,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) {
let p = xf.to_doc(at);
let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01));
if let Some(k) = rotate_dir(t, Some(pv), p, tol) {
if let Some(k) = rotate_dir(t, live_gesture(app), p, tol) {
// Through the view, so the arc bends round the corner as it shows under a turned or
// flipped view.
let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at;
@@ -324,7 +332,7 @@ pub(crate) enum Readout {
/// reference point, a warp, or a distortion that has no single scale).
pub(crate) fn live_readout(app: &PhotocraftApp) -> Option<Readout> {
let t = app.ui.transform.as_ref().filter(|t| t.warp.is_none())?;
let g = app.transform_preview.as_ref()?.gesture?;
let g = app.transform_preview.as_ref().and_then(|pv| pv.gesture).or_else(|| app.multi_transform.as_ref().and_then(|m| m.gesture))?;
match g.hit {
Hit::Outside => Some(Readout::Angle(rotation_degrees(t.quad))),
Hit::Inside => Some(Readout::Offset([t.quad[0][0] - g.quad0[0][0], t.quad[0][1] - g.quad0[0][1]])),
@@ -1,10 +1,10 @@
use super::begin::warps_type;
use super::commit::{commit, committing, moves_type};
use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods};
use super::multi_has_text;
use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer};
use super::{
CursorIcon, HANDLE_PX, Homography, LayerContent, LayerId, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform,
WarpStyle, handle_tolerance,
CursorIcon, HANDLE_PX, Homography, LayerContent, LayerId, PhotocraftApp, ToolEvent, TransformMode, TransformSession, ViewXform, WarpStyle, handle_tolerance,
};
/// Which part of the box a document point hits.
@@ -72,6 +72,49 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
warp_pointer(app, ev, tol, mods);
return true;
}
if app.multi_transform.is_some() {
match ev {
ToolEvent::Down { x, y, .. } => {
let mut h = hit(&t, [x, y], tol);
// ⌥-click away from the handles moves the reference point there (and drags it).
if mods.alt
&& matches!(h, Hit::Inside | Hit::Outside)
&& let Some(s) = app.ui.transform.as_mut()
{
s.pivot = [x, y];
h = Hit::Pivot;
}
let g = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
if let Some(m) = app.multi_transform.as_mut() {
m.gesture = g;
}
}
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
let g = app.multi_transform.as_ref().and_then(|m| m.gesture);
if matches!(ev, ToolEvent::Up { .. })
&& let Some(m) = app.multi_transform.as_mut()
{
m.gesture = None;
}
if matches!(ev, ToolEvent::Up { .. }) && g.is_some_and(|g| accepts_outside_click(g, [x, y], tol)) {
commit(app);
return true;
}
let type_layer = multi_has_text(app);
let adjusted = g.map(|g| drag_mods(app, &t, g.hit, mods));
if let (Some(g), Some((mode, mods)), Some(s)) = (g, adjusted, app.ui.transform.as_mut())
&& mode_allows(t.mode, g.hit)
{
apply_mode_drag(s, g, [x, y], mode, type_layer, mods);
round_to_pixel_grid(app);
}
}
}
crate::snap_ui::retain_transform_lines(app, mods);
return true;
}
let Some(pv) = app.transform_preview.as_mut() else { return false };
match ev {
ToolEvent::Down { x, y, .. } => {
@@ -130,6 +173,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
/// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6.
/// Rotated, skewed and distorted boxes are left as dragged.
fn round_to_pixel_grid(app: &mut PhotocraftApp) {
if app.multi_transform.is_some() {
return;
}
if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid {
return;
}
@@ -230,10 +276,15 @@ pub(super) fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 {
/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the
/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box.
pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> {
rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app))
rotate_dir(app.ui.transform.as_ref()?, live_gesture(app), p, handle_tolerance(app))
}
pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> {
/// The drag on the box, of a one-layer session or of a shared box over several layers.
pub(super) fn live_gesture(app: &PhotocraftApp) -> Option<Gesture> {
app.transform_preview.as_ref().and_then(|pv| pv.gesture).or_else(|| app.multi_transform.as_ref().and_then(|m| m.gesture))
}
pub(super) fn rotate_dir(t: &TransformSession, gesture: Option<Gesture>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> {
// Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow.
if !(p[0].is_finite() && p[1].is_finite())
|| t.warp.is_some()
@@ -244,7 +295,7 @@ pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p:
}
// During a drag the arrow shows only while it rotates: not when a handle that scales, skews
// or distorts strays past a corner (#5405).
match pv.and_then(|pv| pv.gesture) {
match gesture {
Some(g) if g.hit == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(g.hit, Hit::Corner(_) | Hit::Edge(_))) => {}
Some(_) => return None,
None if !near_rotate_corner(t, p, tol) => return None,
@@ -265,7 +316,7 @@ pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p:
/// The handle a drag in progress holds, else the one under document point `p`.
pub(super) fn handle_at(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2], tol: f64) -> Hit {
app.transform_preview.as_ref().and_then(|pv| pv.gesture.map(|g| g.hit)).unwrap_or_else(|| hit(t, p, tol))
live_gesture(app).map(|g| g.hit).unwrap_or_else(|| hit(t, p, tol))
}
/// Cursor for hovering a document point while transforming. `xf` is the canvas's view;
@@ -1092,6 +1092,115 @@ fn free_transform_a_copy() {
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers);
}
/// Changing interpolation rerenders a shared preview even when its geometry is unchanged.
#[test]
fn shared_transform_preview_refreshes_when_interpolation_changes() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.session
.edit("paint second", |doc, _| {
doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap();
for p in &mut app.ui.transform.as_mut().unwrap().quad {
p[0] += 4.0;
}
let (_, first_key) = multi_display_doc(&mut app, 0).expect("bicubic preview");
app.ui.transform.as_mut().unwrap().interpolation = "nearest".into();
let (_, second_key) = multi_display_doc(&mut app, 0).expect("nearest preview");
assert_ne!(first_key, second_key, "changing interpolation invalidates the cached shared preview");
assert_eq!(app.multi_transform.as_ref().unwrap().shown_interpolation.as_deref(), Some("nearest"));
}
/// A shared session is kept only while the same transformable layers stay selected.
#[test]
fn shared_transform_detects_a_changed_layer_selection() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap();
begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap();
assert!(!multi_selection_changed(&app, app.multi_transform.as_ref().unwrap()));
app.session.execute("layer.select", json!({"layer": first.0})).unwrap();
assert!(multi_selection_changed(&app, app.multi_transform.as_ref().unwrap()));
}
/// A shared box keeps the held handle's pointer for the whole drag, as a one-layer box does
/// (#5405): dragged off its corner, the resize pointer doesn't turn into the commit one.
#[test]
fn a_shared_box_keeps_the_held_handles_cursor_during_a_drag() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
app.ui.extras.snap = false;
let first = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap();
begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap();
let xf = ViewXform::active(&app).unwrap();
let none = egui::Modifiers::NONE;
let corner = cursor(&app, &xf, [48.0, 24.0], none);
let far = cursor(&app, &xf, [48.0, 60.0], none);
assert_ne!(corner, far, "away from the box the pointer differs");
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 48.0, y: 24.0, pressure: 1.0 }, none);
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 48.0, y: 60.0, pressure: 1.0 }, none);
assert!(app.multi_transform.as_ref().is_some_and(|m| m.gesture.is_some()), "the shared box holds the corner");
assert_eq!(cursor(&app, &xf, [48.0, 60.0], none), corner, "the dragged corner keeps its pointer");
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 48.0, y: 60.0 }, none);
}
/// Transform Selection cannot replace a shared transform that is already open.
#[test]
fn transform_selection_rejects_an_open_multi_transform_without_replacing_it() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.session
.edit("paint second", |doc, _| {
doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
app.session.execute("select.rect", json!({"x": 4, "y": 4, "width": 48, "height": 24})).unwrap();
begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap();
let session = app.ui.transform.as_ref().unwrap().session;
assert_eq!(begin_selection(&mut app, &egui::Context::default()), Err("Commit or cancel the transform first".into()));
assert_eq!(app.ui.transform.as_ref().map(|t| t.session), Some(session));
assert!(app.multi_transform.is_some(), "the existing shared transform stays open");
}
/// Transform Copy rejects an overlapping shared transform before it creates a layer.
#[test]
fn free_transform_copy_rejects_an_open_multi_transform_without_making_a_layer() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.session
.edit("paint second", |doc, _| {
doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap();
let before = {
let st = app.session.active().unwrap();
(st.doc.layers.len(), st.revision, st.history.past_len())
};
assert_eq!(begin_copy(&mut app, &egui::Context::default()), Err("Commit or cancel the transform first".into()));
let st = app.session.active().unwrap();
assert_eq!((st.doc.layers.len(), st.revision, st.history.past_len()), before, "a rejected start is mutation-free");
assert!(app.multi_transform.is_some() && app.ui.transform.is_some(), "the existing transform stays open");
}
/// #4112: ⌥⌘T on a cylinder smart object, Warp mode, Enter with nothing touched: the copy
/// keeps the cylinder and stays its own step (no warp step to fold into), so one undo takes
/// back the copy and nothing before it.
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; multi-layer Show Transform Controls handles and shared Free Transform; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -99,7 +99,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 |
| Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) |
| View › Extras (⌘H) hides the Move tool's transform controls; Show Transform Controls keeps its setting | done | `canvas.rs` `transform_controls_layers`, `hiding_extras_hides_the_move_transform_controls` (#6699) |
| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | |
| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | done | The shared frame has eight transform handles and opens one multi-layer Free Transform; scale/rotate/skew are previewed and committed to all selected top-level layers in one history step (`canvas/transform_controls.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_with_handles_and_transform_session`; `tools/transform_tool.rs`; `cmd/transform_cmds.rs`) |
| ⌘-drag temporary Move shows the box and snaps | partial | #2681 |
| ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` |
| Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` |
@@ -312,6 +312,7 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit |
| 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) |
| 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes |
| 2026-10-22 | minor | Move › Show Transform Controls: multi-layer selections now show all eight handles and transform the selected top-level layers together in one Free Transform/history step |
| 2026-10-21 | minor | Crop rows corrected against the code and the scorecard: saved custom crop presets (TOOL-214-9, #2723) and Delete Cropped Pixels off are done (#6562); no percentage changed |
| 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. |
| 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) |