mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 01:12:03 +00:00
Merge upstream main 048ae7977 into curves follow-up
This commit is contained in:
commit
d9400a7cb8
55 files changed
+1193
-115
No files matched your search
@@ -272,9 +272,9 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several
|
||||
/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but
|
||||
/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or
|
||||
/// only the Background besides it), that layer; the active layer when nothing else is.
|
||||
/// layers selected, every one that no selected group already carries, as in Photoshop (#6501). The
|
||||
/// Background stays among them, so such a box refuses as locked (#11101, [`background_among`]).
|
||||
/// With one layer selected, that layer; the active layer when nothing else is.
|
||||
pub fn selection_targets(st: &crate::DocState) -> Vec<LayerId> {
|
||||
targets_of(&st.doc, &st.selected_layers(), st.active_layer)
|
||||
}
|
||||
@@ -285,14 +285,23 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option<La
|
||||
if selected.len() < 2 {
|
||||
return active.into_iter().collect();
|
||||
}
|
||||
let ids: Vec<LayerId> = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect();
|
||||
let ids = crate::layer_multi_cmds::top_level(doc, selected);
|
||||
if ids.is_empty() { active.into_iter().collect() } else { ids }
|
||||
}
|
||||
|
||||
/// The Background when it is among several layers one box would move: Photoshop refuses that Free
|
||||
/// Transform with its locked-layer alert rather than leaving the Background out (#11101).
|
||||
pub fn background_among(doc: &Document, ids: &[LayerId]) -> Option<LayerId> {
|
||||
if ids.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
ids.iter().copied().find(|id| doc.is_background(*id))
|
||||
}
|
||||
|
||||
/// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move
|
||||
/// tool takes them along. In Photoshop transforming one linked layer transforms the whole link
|
||||
/// group (#11217). A linked Background joins only when it is a target itself: `targets_of`
|
||||
/// leaves it out of a box over other layers, and a link doesn't bring it back in.
|
||||
/// group (#11217). A linked Background joins only when it is a target itself: a link doesn't
|
||||
/// bring it into a box over other layers.
|
||||
pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec<LayerId> {
|
||||
let mut ids = crate::layer_multi_cmds::move_targets(doc, targets);
|
||||
ids.retain(|id| targets.contains(id) || !doc.is_background(*id));
|
||||
@@ -874,7 +883,14 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(ids), _) => ids,
|
||||
_ if lone.is_some() => Vec::new(),
|
||||
(None, Some(id)) => vec![id],
|
||||
(None, None) => selection_targets(st),
|
||||
(None, None) => {
|
||||
let ids = selection_targets(st);
|
||||
if let Some(bg) = background_among(&st.doc, &ids) {
|
||||
let name = st.doc.layer(bg).map_or("Background", |l| l.name.as_str());
|
||||
return Err(EngineError::Other(format!("layer \"{name}\" is locked")));
|
||||
}
|
||||
ids
|
||||
}
|
||||
};
|
||||
if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) {
|
||||
return Err(EngineError::NoLayer(*missing));
|
||||
@@ -1016,7 +1032,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Free Transform",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' 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?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"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?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers, as Photoshop's Free Transform with several selected; refused as locked when the Background is among several),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' 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?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"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)}"##,
|
||||
enabled: has_layer,
|
||||
journal: true,
|
||||
run: transform,
|
||||
|
||||
@@ -91,13 +91,17 @@ fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_stays_out_of_a_selection_of_several() {
|
||||
fn the_background_among_a_selection_of_several_refuses_as_locked() {
|
||||
// Photoshop shows its locked-layer alert rather than leaving the Background out (#11101).
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap();
|
||||
let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([paint.0, text.0]));
|
||||
let st = s.active().unwrap();
|
||||
let (before, revision) = (st.doc.clone(), st.revision);
|
||||
let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("\"Background\" is locked"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.revision == revision, "nothing moved, not even {paint:?} or {text:?}");
|
||||
assert!(st.doc.is_background(bg), "still the Background");
|
||||
assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200));
|
||||
}
|
||||
|
||||
@@ -304,6 +304,16 @@ impl PhotocraftApp {
|
||||
// snapshot and advance its revision; a replaced snapshot without a newer revision is a
|
||||
// reopened document, even with the same ID.
|
||||
let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc)));
|
||||
// Crop's Fill goes back to its default entry when another document that was already open
|
||||
// becomes active (a tab switch, or closing the active one), as in the reference
|
||||
// application. A new or opened document isn't in `view_docs` yet, so it keeps the setting,
|
||||
// as do tool switches, commits and cancels, which leave the active document alone (#10894).
|
||||
if let (Some((id, ..)), Some((old_id, ..))) = (&document, &self.path_document)
|
||||
&& id != old_id
|
||||
&& self.view_docs.contains(id)
|
||||
{
|
||||
self.ui.tool_options.crop_content_aware = false;
|
||||
}
|
||||
let same_document = match (&self.path_document, &document) {
|
||||
(Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)),
|
||||
(None, None) => true,
|
||||
|
||||
@@ -118,6 +118,7 @@ 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};
|
||||
pub(crate) use transform_controls::ControlsPress;
|
||||
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_turn};
|
||||
#[cfg(test)]
|
||||
@@ -1036,12 +1037,13 @@ fn handle_canvas_tool_stroke(
|
||||
if buttons.started
|
||||
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
|
||||
{
|
||||
if tool == Tool::Move && app.ui.transform.is_none() {
|
||||
begin_transform_controls_at(app, ctx, xf, p);
|
||||
// A press the controls refused (the Background among the layers) goes nowhere else (#11101).
|
||||
let refused = tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused;
|
||||
if !refused {
|
||||
let d = xf.to_doc(p);
|
||||
let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods };
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
|
||||
}
|
||||
let d = xf.to_doc(p);
|
||||
let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods };
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
|
||||
}
|
||||
// A stroke started on the press takes its moves from the press on, not only once egui
|
||||
// recognises a drag (past its click distance): otherwise the first few points are lost
|
||||
@@ -1200,10 +1202,10 @@ fn handle_canvas_tool_clicks(
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
// A click the transform controls refused (the Background among the layers) goes
|
||||
// nowhere else (#11101).
|
||||
_ if tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused => {}
|
||||
_ => {
|
||||
if tool == Tool::Move && app.ui.transform.is_none() {
|
||||
begin_transform_controls_at(app, ctx, xf, p);
|
||||
}
|
||||
// If this tool was not handled on pointer-down, fall back to a
|
||||
// click-sized gesture while preserving pen pressure (mouse stays at 1).
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);
|
||||
|
||||
@@ -192,7 +192,7 @@ pub(crate) fn marching_ants_line_shapes(painter: &egui::Painter, lines: impl Ite
|
||||
/// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright
|
||||
/// frame, the composition `guides` (unit-square polylines, `crop_overlay::guides`), corner and
|
||||
/// edge handles.
|
||||
fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option<Color32>, guides: &[crate::crop_overlay::Polyline]) {
|
||||
fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option<Color32>, guides: &[crate::crop_overlay::Polyline], look: crate::crop_overlay::FrameLook) {
|
||||
let clip = painter.clip_rect();
|
||||
if let Some(dim) = shield {
|
||||
for band in [
|
||||
@@ -204,9 +204,8 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option<Color32>, guide
|
||||
painter.rect_filled(band, 0.0, dim);
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
|
||||
let h = crop_frame_outline(painter, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], look);
|
||||
draw_crop_guides(painter, guides, |a, b| r.min + r.size() * vec2(a, b));
|
||||
let h = Stroke::new(3.0, Color32::WHITE);
|
||||
let l = 14.0f32.min(r.width() / 3.0).min(r.height() / 3.0);
|
||||
for (c, dx, dy) in [(r.left_top(), 1.0, 1.0), (r.right_top(), -1.0, 1.0), (r.left_bottom(), 1.0, -1.0), (r.right_bottom(), -1.0, -1.0)] {
|
||||
painter.line_segment([c, c + vec2(l * dx, 0.0)], h);
|
||||
@@ -222,7 +221,13 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option<Color32>, guide
|
||||
|
||||
/// [`crop_overlay`] for a turned frame (or a rotated view): `q` is the frame's top-left, top-right,
|
||||
/// bottom-right and bottom-left corner on screen, a parallelogram.
|
||||
fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option<Color32>, guides: &[crate::crop_overlay::Polyline]) {
|
||||
fn crop_overlay_turned(
|
||||
painter: &egui::Painter,
|
||||
q: [Pos2; 4],
|
||||
shield: Option<Color32>,
|
||||
guides: &[crate::crop_overlay::Polyline],
|
||||
look: crate::crop_overlay::FrameLook,
|
||||
) {
|
||||
let (u, v) = (q[1] - q[0], q[3] - q[0]);
|
||||
let (lu, lv) = (u.length(), v.length());
|
||||
if !(lu.is_finite() && lv.is_finite()) || lu < 1e-3 || lv < 1e-3 {
|
||||
@@ -246,9 +251,8 @@ fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option<Col
|
||||
}
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::WHITE)));
|
||||
let h = crop_frame_outline(painter, q, look);
|
||||
draw_crop_guides(painter, guides, at);
|
||||
let h = Stroke::new(3.0, Color32::WHITE);
|
||||
let l = 14.0f32.min(lu / 3.0).min(lv / 3.0);
|
||||
let (du, dv) = (u / lu, v / lv);
|
||||
for (c, su, sv) in [(q[0], 1.0, 1.0), (q[1], -1.0, 1.0), (q[3], 1.0, -1.0), (q[2], -1.0, -1.0)] {
|
||||
@@ -262,6 +266,78 @@ fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option<Col
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the crop frame's outline along its corners `q` (top-left, top-right, bottom-right,
|
||||
/// bottom-left) as `look` asks, and return the stroke its handles take (#10930). The outline is
|
||||
/// drawn over the image, so it uses the overlay's own black and light tones, like a selection's;
|
||||
/// the handles' grey comes from the theme.
|
||||
fn crop_frame_outline(painter: &egui::Painter, q: [Pos2; 4], look: crate::crop_overlay::FrameLook) -> Stroke {
|
||||
use crate::crop_overlay::FrameLook;
|
||||
let grey = Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).text_faint);
|
||||
match look {
|
||||
FrameLook::Classic => {
|
||||
painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::WHITE)));
|
||||
Stroke::new(3.0, Color32::WHITE)
|
||||
}
|
||||
FrameLook::Untouched => {
|
||||
// Dashed like a selection outline: 4 px black, then 4 px light grey. Static rather
|
||||
// than marching, so an untouched frame doesn't keep the canvas repainting.
|
||||
// Each edge is cut to the visible area first: zoomed far in, a frame's edges run
|
||||
// thousands of points off screen, and dashing them all would make a shape per dash.
|
||||
let view = painter.clip_rect().expand(4.0);
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
if let Some([from, to]) = clip_segment(a, b, view) {
|
||||
painter.line_segment([from, to], Stroke::new(1.0, Color32::from_gray(210)));
|
||||
let offset = dash_offset(a, from, DASH);
|
||||
painter.extend(egui::Shape::dashed_line_with_offset(&[from, to], Stroke::new(1.0, Color32::BLACK), &[DASH], &[DASH], offset));
|
||||
}
|
||||
}
|
||||
grey
|
||||
}
|
||||
FrameLook::Started => {
|
||||
painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::from_white_alpha(220))));
|
||||
grey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the untouched frame's dashes and of the gaps between them.
|
||||
const DASH: f32 = 4.0;
|
||||
|
||||
/// Where the dashes of an edge cut to the view begin, so they keep the places they have counted
|
||||
/// from the edge's `corner`: started afresh at the cut, the pattern slid along the edge as the view
|
||||
/// panned (noted in review). The dashes start every `2 * dash` from the corner; `dashes_from_line`
|
||||
/// can only begin on a dash, so a dash the cut divides starts a period later -- that piece is in
|
||||
/// the margin the view is grown by, out of sight.
|
||||
fn dash_offset(corner: Pos2, start: Pos2, dash: f32) -> f32 {
|
||||
let period = 2.0 * dash;
|
||||
(period - (start - corner).length() % period) % period
|
||||
}
|
||||
|
||||
/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or None when none of it is.
|
||||
fn clip_segment(a: Pos2, b: Pos2, r: Rect) -> Option<[Pos2; 2]> {
|
||||
let d = b - a;
|
||||
let (mut t0, mut t1) = (0.0f32, 1.0f32);
|
||||
for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] {
|
||||
if p == 0.0 {
|
||||
if q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t = q / p;
|
||||
if p < 0.0 {
|
||||
t0 = t0.max(t);
|
||||
} else {
|
||||
t1 = t1.min(t);
|
||||
}
|
||||
if t0 > t1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(t0.is_finite() && t1.is_finite()).then(|| [a + d * t0, a + d * t1])
|
||||
}
|
||||
|
||||
/// The crop overlay's guides, each unit-square point placed on screen by `at`.
|
||||
fn draw_crop_guides(painter: &egui::Painter, guides: &[crate::crop_overlay::Polyline], at: impl Fn(f32, f32) -> Pos2) {
|
||||
let thin = Stroke::new(1.0, Color32::from_white_alpha(90));
|
||||
@@ -480,10 +556,17 @@ pub(super) fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf:
|
||||
r,
|
||||
shield,
|
||||
&crate::crop_overlay::current_guides_for_frame(app, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], painter.clip_rect()),
|
||||
crate::crop_overlay::frame_look(app),
|
||||
);
|
||||
} else {
|
||||
let q = crate::crop_ui::corners(c, deg).map(|p| xf.to_screen(p[0] as f32, p[1] as f32));
|
||||
crop_overlay_turned(painter, q, shield, &crate::crop_overlay::current_guides_for_frame(app, q, painter.clip_rect()));
|
||||
crop_overlay_turned(
|
||||
painter,
|
||||
q,
|
||||
shield,
|
||||
&crate::crop_overlay::current_guides_for_frame(app, q, painter.clip_rect()),
|
||||
crate::crop_overlay::frame_look(app),
|
||||
);
|
||||
}
|
||||
// The frame's angle beside the pointer while it turns (#1792).
|
||||
if let (Some(a), Some(h)) = (crate::crop_ui::turning(app), hover) {
|
||||
@@ -630,3 +713,76 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_segment_tests {
|
||||
use super::{DASH, Pos2, Rect, clip_segment, dash_offset, pos2};
|
||||
|
||||
/// Where each dash of the edge from `corner`, cut to `view`, begins, as distances from the corner.
|
||||
fn dash_starts(corner: Pos2, end: Pos2, view: Rect) -> Vec<f32> {
|
||||
let Some([from, to]) = clip_segment(corner, end, view) else { return Vec::new() };
|
||||
let shapes =
|
||||
egui::Shape::dashed_line_with_offset(&[from, to], egui::Stroke::new(1.0, egui::Color32::BLACK), &[DASH], &[DASH], dash_offset(corner, from, DASH));
|
||||
shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
egui::Shape::LineSegment { points, .. } => Some((points[0] - corner).length()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The dashes of an edge cut to the view stay where they are counted from the frame's corner,
|
||||
/// however the view sits on the edge, so panning does not slide them along it (noted in review).
|
||||
#[test]
|
||||
fn the_dashes_keep_their_places_as_the_view_pans() {
|
||||
let (corner, end) = (pos2(0.0, 50.0), pos2(10_000.0, 50.0));
|
||||
for left in [0.0f32, 1.0, 3.3, 4.0, 5.9, 123.45, 4_001.7] {
|
||||
let view = Rect::from_min_max(pos2(left, 0.0), pos2(left + 300.0, 100.0));
|
||||
let starts = dash_starts(corner, end, view);
|
||||
assert!(!starts.is_empty(), "the view at {left} shows dashes");
|
||||
for d in starts {
|
||||
let off = d % (2.0 * DASH);
|
||||
assert!(off < 0.01 || off > 2.0 * DASH - 0.01, "view at {left}: a dash starts {d} from the corner, off the pattern by {off}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view() -> Rect {
|
||||
Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0))
|
||||
}
|
||||
|
||||
/// Within a hundredth of a point, far below a device pixel: f32 on edges tens of thousands of
|
||||
/// points long can't do better, and the screen doesn't need it to.
|
||||
fn near(a: Pos2, b: Pos2) -> bool {
|
||||
(a - b).length() < 1e-2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_inside_is_kept_whole() {
|
||||
let [a, b] = clip_segment(pos2(10.0, 10.0), pos2(90.0, 40.0), view()).unwrap();
|
||||
assert!(near(a, pos2(10.0, 10.0)) && near(b, pos2(90.0, 40.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_running_far_off_screen_is_cut_to_the_view() {
|
||||
// A frame edge at 3200 % runs tens of thousands of points past the view.
|
||||
let [a, b] = clip_segment(pos2(-40_000.0, 20.0), pos2(40_000.0, 20.0), view()).unwrap();
|
||||
assert!(near(a, pos2(0.0, 20.0)) && near(b, pos2(100.0, 20.0)), "{a:?} {b:?}");
|
||||
let [a, b] = clip_segment(pos2(50.0, -1000.0), pos2(50.0, 1000.0), view()).unwrap();
|
||||
assert!(near(a, pos2(50.0, 0.0)) && near(b, pos2(50.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slanted_segment_through_a_corner_is_cut_on_both_sides() {
|
||||
let [a, b] = clip_segment(pos2(-10.0, -10.0), pos2(60.0, 60.0), view()).unwrap();
|
||||
assert!(near(a, pos2(0.0, 0.0)) && near(b, pos2(50.0, 50.0)), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_outside_the_view_draws_nothing() {
|
||||
assert!(clip_segment(pos2(-50.0, 10.0), pos2(-5.0, 40.0), view()).is_none(), "left of it");
|
||||
assert!(clip_segment(pos2(10.0, 60.0), pos2(90.0, 90.0), view()).is_none(), "below it");
|
||||
assert!(clip_segment(pos2(-10.0, 70.0), pos2(70.0, 200.0), view()).is_none(), "past a corner");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ pub enum SelCursor {
|
||||
Cut,
|
||||
/// Arrow with a second arrow: ⌥ lifts a copy of them instead.
|
||||
Copy,
|
||||
/// Plain arrow: a floating piece, moved again by a plain drag until it is dropped.
|
||||
/// Plain arrow: a floating piece, moved again by the Move tool (⌘ with a marquee) until it is
|
||||
/// dropped.
|
||||
Piece,
|
||||
/// Hollow arrowhead: the selected pixels are being dragged.
|
||||
Dragging,
|
||||
|
||||
@@ -109,6 +109,11 @@ fn preflight_tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modi
|
||||
Some((armed, mods))
|
||||
}
|
||||
|
||||
/// The handlers a pointer event meets before the tool: dropping a floating piece the press won't
|
||||
/// drag, snapping, and the overlays and modifier gestures (Free Transform, sticky keys, brush
|
||||
/// resize, the ⌥ eyedropper, path and shape widgets…) that take the event for themselves. `None`:
|
||||
/// one of them handled it; otherwise the event as it came and as filtered (snapped, the Move
|
||||
/// tool's axis lock), and the modifiers with the sticky keys applied.
|
||||
fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers, armed: bool) -> Option<(ToolEvent, ToolEvent, egui::Modifiers)> {
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
let raw = ev;
|
||||
@@ -116,8 +121,9 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
|
||||
if crate::move_box::pointer(app, raw) {
|
||||
return None;
|
||||
}
|
||||
// A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first; the
|
||||
// Move tool (⌘ with a painting tool too, `event_tool`) drags it from anywhere.
|
||||
// A press that doesn't drag the floating piece drops it first: off it, with ⇧ / ⌥ to draw, or a
|
||||
// plain selection-tool press on it, which moves the outline only (#11368). The 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())
|
||||
&& !crate::move_ui::moves_selected_pixels_with(app, event_tool(app, mods))
|
||||
@@ -673,12 +679,13 @@ fn covers(sel: &photocraft_raster::Surface, p: [f64; 2]) -> bool {
|
||||
}
|
||||
|
||||
/// Does a press with `tool` at `p` drag the selection rather than draw a new one? `Some(true)`
|
||||
/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; a floating piece moves with a plain
|
||||
/// drag; the Polygonal Lasso and Magic Wand need ⌘, as a plain press there places or samples),
|
||||
/// `Some(false)` just the outline (no ⇧ / ⌥ and the options bar on New Selection, so a combining
|
||||
/// drag still draws). The Single Row / Single Column Marquee drag the outline too (#11392); a click
|
||||
/// without moving, inside the selection or on a floating piece (dropped first), still selects the
|
||||
/// line under the pointer (`finish_gesture`).
|
||||
/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; the Polygonal Lasso and Magic Wand need
|
||||
/// ⌘, as a plain press there places or samples), `Some(false)` just the outline (no ⇧ / ⌥ and the
|
||||
/// options bar on New Selection, so a combining drag still draws). A plain drag on a floating piece
|
||||
/// moves the outline too, as in Photoshop: the press drops the piece first (#11368). The Single Row
|
||||
/// / Single Column Marquee drag the outline too (#11392); a click without moving, inside the
|
||||
/// selection or on a floating piece (dropped first), still selects the line under the pointer
|
||||
/// (`finish_gesture`).
|
||||
pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<bool> {
|
||||
// Click-driven selection tools (a press places a point or samples): only ⌘ drags the
|
||||
// selection, and not while a polygon is being drawn.
|
||||
@@ -690,11 +697,18 @@ pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: e
|
||||
// ⌘ cuts, ⌘⌥ copies (`select.float`'s `copy`).
|
||||
let cut = mods.command && !mods.shift;
|
||||
if let Some(f) = app.session.active().and_then(photocraft_engine::float_cmds::floating) {
|
||||
// A press on the piece's outline (where its ants are) drags it; one in the margin the cut
|
||||
// left of the selection starts a new selection (#7538).
|
||||
// A ⌘ press on the piece's outline (where its ants are) drags it; a plain one drags the
|
||||
// outline, dropping the piece where it is first (`filter_event_before_tool`, #11368); one
|
||||
// in the margin the cut left of the selection starts a new selection (#7538).
|
||||
let at = [p[0] - f64::from(f.offset.0), p[1] - f64::from(f.offset.1)];
|
||||
let on = f.parts.outline().map_or_else(|| inside_selection(app, at), |o| covers(o, at));
|
||||
return (on && !mods.shift && (!mods.alt || mods.command) && (cut || !clicky)).then_some(true);
|
||||
if !on {
|
||||
return None;
|
||||
}
|
||||
if cut {
|
||||
return Some(true);
|
||||
}
|
||||
return (!clicky && !mods.command && selection_mode(app, mods) == "replace").then_some(false);
|
||||
}
|
||||
if !inside_selection(app, p) {
|
||||
return None;
|
||||
|
||||
@@ -22,7 +22,9 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option<photoc
|
||||
Some(b.translate(dx, dy))
|
||||
}
|
||||
|
||||
/// The layers Move › Show Transform Controls frames: every selected layer but the Background.
|
||||
/// The layers Move › Show Transform Controls frames: every selected layer, the Background too when
|
||||
/// it is among several (a handle drag then refuses, [`begin_transform_controls_at`]), but not the
|
||||
/// Background alone (#11101).
|
||||
/// A corner radius drag hides the box, as in Photoshop. So does View › Extras (⌘H, #6699): the
|
||||
/// controls are an Extra, hidden and inert until Extras comes back, while Show Transform Controls
|
||||
/// keeps its setting.
|
||||
@@ -37,21 +39,21 @@ 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 selected = st.selected_layers();
|
||||
let several = selected.len() > 1;
|
||||
selected.into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| several || !crate::doc_props_ui::is_background(&st.doc, l)).collect()
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Control-channel presses use document coordinates, but the canvas owns the screen-sized
|
||||
/// handle and rotation hit areas. Start the same session before forwarding the press.
|
||||
pub(crate) fn begin_move_transform_controls(&mut self, ctx: &egui::Context, x: f64, y: f64) {
|
||||
pub(crate) fn begin_move_transform_controls(&mut self, ctx: &egui::Context, x: f64, y: f64) -> ControlsPress {
|
||||
if !crate::menus::modal_allows(self, "edit.freeTransform") {
|
||||
return;
|
||||
return ControlsPress::Missed;
|
||||
}
|
||||
let Some(xf) = ViewXform::active(self) else { return };
|
||||
let Some(xf) = ViewXform::active(self) else { return ControlsPress::Missed };
|
||||
let p = xf.to_screen(x as f32, y as f32);
|
||||
if p.is_finite() && xf.rect.contains(p) {
|
||||
begin_transform_controls_at(self, ctx, &xf, p);
|
||||
}
|
||||
if p.is_finite() && xf.rect.contains(p) { begin_transform_controls_at(self, ctx, &xf, p) } else { ControlsPress::Missed }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,18 +121,40 @@ pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool {
|
||||
transform_controls_cursor(r, p).is_some()
|
||||
}
|
||||
|
||||
/// The reference application's alert when a Move-tool transform control is dragged on a frame
|
||||
/// that holds the Background among other layers (measured, #11101).
|
||||
pub(crate) const CONTROLS_LOCKED: &str = "Could not transform because the layer is locked.";
|
||||
|
||||
/// What a press on the Move tool's transform controls did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ControlsPress {
|
||||
/// Not on a control, or no session could start: the press is the Move tool's.
|
||||
Missed,
|
||||
/// A Free Transform session started; the press goes on to drive it.
|
||||
Started,
|
||||
/// Refused with the locked-layer alert: the press goes nowhere else (#11101).
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// Enter the existing Free Transform session when a Move-tool transform control is pressed: on
|
||||
/// every selected layer, as the frame shows them (#6501).
|
||||
pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool {
|
||||
let Some(r) = transform_controls_rect(app, xf) else { return false };
|
||||
/// every selected layer, as the frame shows them (#6501). With the Background among them the press
|
||||
/// shows the locked-layer alert and changes nothing (#11101).
|
||||
pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> ControlsPress {
|
||||
let Some(r) = transform_controls_rect(app, xf) else { return ControlsPress::Missed };
|
||||
if !transform_controls_hit(r, p) {
|
||||
return false;
|
||||
return ControlsPress::Missed;
|
||||
}
|
||||
let background = app.session.active().is_some_and(|st| photocraft_engine::transform_cmds::background_among(&st.doc, &st.selected_layers()).is_some());
|
||||
if background {
|
||||
let message = serde_json::json!({ "message": tl!(CONTROLS_LOCKED) });
|
||||
app.ui.open_dialog(crate::state::DialogKind::Error, message.as_object().cloned().unwrap_or_default());
|
||||
return ControlsPress::Refused;
|
||||
}
|
||||
match crate::transform_tool::begin(app, ctx) {
|
||||
Ok(()) => true,
|
||||
Ok(()) => ControlsPress::Started,
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
false
|
||||
ControlsPress::Missed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,14 +89,16 @@ fn floating_square() -> PhotocraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// #7538: a selection tool's press on a floating piece's ants drags it; one in the margin its
|
||||
/// cut left of the loose marquee draws a new selection, as the ants say.
|
||||
/// #7538: a selection tool's press on a floating piece's ants drags its outline (#11368), a ⌘
|
||||
/// press the piece; one in the margin its cut left of the loose marquee draws a new selection, as
|
||||
/// the ants say.
|
||||
#[test]
|
||||
fn a_press_in_the_margin_of_a_floating_piece_starts_a_new_selection() {
|
||||
let app = floating_square();
|
||||
let none = egui::Modifiers::NONE;
|
||||
for tool in [Tool::RectMarquee, Tool::Lasso] {
|
||||
assert_eq!(selection_drag_kind(&app, tool, [70.0, 60.0], none), Some(true), "{tool:?}: on the square drags it");
|
||||
assert_eq!(selection_drag_kind(&app, tool, [70.0, 60.0], none), Some(false), "{tool:?}: on the square drags its outline");
|
||||
assert_eq!(selection_drag_kind(&app, tool, [70.0, 60.0], egui::Modifiers::COMMAND), Some(true), "{tool:?}: ⌘ drags the square");
|
||||
assert_eq!(selection_drag_kind(&app, tool, [33.0, 28.0], none), None, "{tool:?}: in the old marquee's margin");
|
||||
}
|
||||
}
|
||||
@@ -146,13 +148,16 @@ fn several_selected_layers_share_one_frame_that_transforms_them_all() {
|
||||
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:?}");
|
||||
// The Background never joins the frame.
|
||||
// The Background among them joins the frame, as in Photoshop; its handles refuse (#11101).
|
||||
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(), 3);
|
||||
assert_eq!(transform_controls_rect(&app, &xf), Some(Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0))));
|
||||
app.session.execute("layer.select", json!({"layer": bg.0, "mode": "toggle"})).unwrap();
|
||||
assert_eq!(transform_controls_layers(&app).len(), 2);
|
||||
assert_eq!(transform_controls_rect(&app, &xf), Some(both));
|
||||
let ctx = egui::Context::default();
|
||||
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()), ControlsPress::Started);
|
||||
let t = app.ui.transform.as_ref().expect("the shared frame's handle starts Free Transform");
|
||||
assert_eq!(t.rect, [20.0, 30.0, 150.0, 150.0]);
|
||||
assert_eq!(t.layers, [a.0, b.0]);
|
||||
@@ -173,7 +178,7 @@ fn move_transform_controls_start_free_transform_for_vector_shapes() {
|
||||
assert!(transform_controls_hit(r, r.right_bottom()));
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Started);
|
||||
let t = app.ui.transform.as_ref().expect("control press starts Free Transform");
|
||||
assert_eq!(t.rect, [20.0, 30.0, 100.0, 70.0]);
|
||||
}
|
||||
@@ -210,7 +215,7 @@ fn hiding_extras_hides_the_move_transform_controls() {
|
||||
assert!(transform_controls_rect(&app, &xf).is_none(), "no box with Extras hidden");
|
||||
assert_eq!(painted(&mut app), 0, "nothing is drawn");
|
||||
// Hidden handles don't act: a press on a corner doesn't start Free Transform.
|
||||
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, shown.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, shown.right_bottom()), ControlsPress::Missed);
|
||||
assert!(app.ui.transform.is_none());
|
||||
assert!(app.ui.tool_options.move_show_transform, "Show Transform Controls is unchanged");
|
||||
assert_eq!(app.session.active().unwrap().doc.layers, layer, "the layer is unchanged");
|
||||
@@ -235,7 +240,7 @@ fn move_transform_controls_follow_floating_selected_pixels() {
|
||||
let r = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert_eq!(r, Rect::from_min_max(pos2(200.0, 150.0), pos2(300.0, 210.0)));
|
||||
let ctx = egui::Context::default();
|
||||
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Started);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [200.0, 150.0, 300.0, 210.0]);
|
||||
}
|
||||
|
||||
@@ -256,7 +261,7 @@ fn move_transform_controls_frame_the_selection() {
|
||||
let r = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert_eq!(r, Rect::from_min_max(pos2(60.0, 80.0), pos2(160.0, 200.0)));
|
||||
let ctx = egui::Context::default();
|
||||
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Started);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().rect, [60.0, 80.0, 160.0, 200.0]);
|
||||
}
|
||||
|
||||
@@ -296,7 +301,7 @@ fn free_transform_on_a_locked_layer_is_refused_up_front() {
|
||||
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 };
|
||||
let r = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Missed);
|
||||
assert!(app.ui.transform.is_none());
|
||||
assert_eq!(errors(&app), [crate::transform_tool::LOCKED]);
|
||||
}
|
||||
@@ -357,3 +362,42 @@ fn transform_control_hit_keeps_move_interior_free_and_has_rotation_band() {
|
||||
assert!(!transform_controls_hit(r, r.center()));
|
||||
assert!(!transform_controls_hit(r, pos2(60.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_among_several_joins_the_frame_and_its_handles_refuse() {
|
||||
// Measured in the reference application (#11101): the box frames them all, the Background
|
||||
// included, and a handle drag shows its locked alert and changes nothing.
|
||||
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();
|
||||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).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 };
|
||||
let r = transform_controls_rect(&app, &xf).expect("the box frames them all");
|
||||
assert_eq!(r, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), "the Background included");
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Refused, "the press is taken, not a move");
|
||||
assert!(app.ui.transform.is_none(), "no box opens");
|
||||
let alerts: Vec<_> =
|
||||
app.ui.dialogs.iter().filter(|d| d.kind == crate::state::DialogKind::Error).filter_map(|d| d.fields.get("message")?.as_str()).collect();
|
||||
assert_eq!(alerts, [super::transform_controls::CONTROLS_LOCKED]);
|
||||
assert!(app.session.active().unwrap().doc == before, "nothing changed");
|
||||
// The Background alone: no box at all.
|
||||
app.session.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
assert_eq!(transform_controls_rect(&app, &xf), None);
|
||||
// Without the Background the handles start Free Transform, as before.
|
||||
app.ui.dialogs.clear();
|
||||
app.session.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
let r = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()), ControlsPress::Started);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().layers, [a.0, b.0]);
|
||||
}
|
||||
@@ -1272,7 +1272,10 @@ fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
continue;
|
||||
}
|
||||
if app.ui.tool == Tool::Move && !gesture.secondary && !gesture.middle && !gesture.space && matches!(sample.ev, ToolEvent::Down { .. }) {
|
||||
app.begin_move_transform_controls(ctx, sample.x, sample.y);
|
||||
// A press the controls refused (the Background among the layers) goes nowhere else (#11101).
|
||||
if app.begin_move_transform_controls(ctx, sample.x, sample.y) == crate::canvas::ControlsPress::Refused {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
forward_pointer_event(app, &gesture, sample);
|
||||
}
|
||||
|
||||
@@ -1787,6 +1787,7 @@
|
||||
Move Tool أداة التحريك
|
||||
Could not use the move tool because the layer is locked. تعذر استخدام أداة التحريك لأن الطبقة مقفلة.
|
||||
Could not complete the Free Transform command because the layer is locked. تعذر إكمال أمر التحويل الحر لأن الطبقة مقفلة.
|
||||
Could not transform because the layer is locked. تعذر التحويل لأن الطبقة مقفلة.
|
||||
Multiply ضرب
|
||||
My Brushes الفُرش الخاصة بي
|
||||
Neutrals الدرجات المحايدة
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1711,6 +1711,7 @@
|
||||
Move Tool Alat za pomjeranje
|
||||
Could not use the move tool because the layer is locked. Nije moguće koristiti alat za pomjeranje jer je sloj zaključan.
|
||||
Could not complete the Free Transform command because the layer is locked. Naredbu Slobodna transformacija nije moguće dovršiti jer je sloj zaključan.
|
||||
Could not transform because the layer is locked. Transformacija nije moguća jer je sloj zaključan.
|
||||
Multiply Množenje
|
||||
My Brushes Moje četkice
|
||||
Neutrals Neutralni tonovi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1901,6 +1901,7 @@
|
||||
Move Tool Nástroj přesun
|
||||
Could not use the move tool because the layer is locked. Nástroj Přesun nelze použít, protože vrstva je zamčená.
|
||||
Could not complete the Free Transform command because the layer is locked. Příkaz Volná transformace nelze dokončit, protože vrstva je zamčená.
|
||||
Could not transform because the layer is locked. Transformaci nelze provést, protože vrstva je zamčená.
|
||||
Multiply Násobit
|
||||
My Brushes Moje štětce
|
||||
Neutrals Neutrální
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1638,6 +1638,7 @@
|
||||
Move Tool Flytteværktøj
|
||||
Could not use the move tool because the layer is locked. Kunne ikke bruge flytteværktøjet, fordi laget er låst.
|
||||
Could not complete the Free Transform command because the layer is locked. Kunne ikke fuldføre kommandoen Fri transformering, fordi laget er låst.
|
||||
Could not transform because the layer is locked. Kunne ikke transformere, fordi laget er låst.
|
||||
Rectangular Marquee Tool Rektangulært markeringsværktøj
|
||||
Elliptical Marquee Tool Elliptisk markeringsværktøj
|
||||
Single Row Marquee Tool Markeringsværktøj til enkelt række
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1862,6 +1862,7 @@
|
||||
Move Tool Verschieben-Werkzeug
|
||||
Could not use the move tool because the layer is locked. Das Verschieben-Werkzeug konnte nicht verwendet werden, weil die Ebene gesperrt ist.
|
||||
Could not complete the Free Transform command because the layer is locked. Der Befehl „Frei transformieren“ konnte nicht ausgeführt werden, weil die Ebene gesperrt ist.
|
||||
Could not transform because the layer is locked. Transformieren nicht möglich, weil die Ebene gesperrt ist.
|
||||
Multiply Multiplizieren
|
||||
My Brushes Meine Pinsel
|
||||
Neutrals Neutrale
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3481,6 +3481,7 @@ cameraRaw Yellow Κίτρινο
|
||||
Move Tool Μετακίνηση (Move Tool)
|
||||
Could not use the move tool because the layer is locked. Δεν ήταν δυνατή η χρήση του εργαλείου Μετακίνηση, επειδή το επίπεδο είναι κλειδωμένο.
|
||||
Could not complete the Free Transform command because the layer is locked. Δεν ήταν δυνατή η ολοκλήρωση της εντολής Ελεύθερος μετασχηματισμός, επειδή το επίπεδο είναι κλειδωμένο.
|
||||
Could not transform because the layer is locked. Δεν ήταν δυνατός ο μετασχηματισμός, επειδή το επίπεδο είναι κλειδωμένο.
|
||||
Note Tool Σημείωση (Note Tool)
|
||||
Object Selection Tool Επιλογή αντικειμένου (Object Selection Tool)
|
||||
Paint Bucket Tool Κουβάς γεμίσματος (Paint Bucket Tool)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -1832,6 +1832,7 @@
|
||||
Move Tool Herramienta Mover
|
||||
Could not use the move tool because the layer is locked. No se pudo usar la herramienta Mover porque la capa está bloqueada.
|
||||
Could not complete the Free Transform command because the layer is locked. No se pudo completar el comando Transformación libre porque la capa está bloqueada.
|
||||
Could not transform because the layer is locked. No se pudo transformar porque la capa está bloqueada.
|
||||
Multiply Multiplicar
|
||||
My Brushes Mis pinceles
|
||||
Neutrals Neutros
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -1639,6 +1639,7 @@
|
||||
Move Tool Siirtotyökalu
|
||||
Could not use the move tool because the layer is locked. Siirtotyökalua ei voitu käyttää, koska taso on lukittu.
|
||||
Could not complete the Free Transform command because the layer is locked. Vapaa muunnos -komentoa ei voitu suorittaa, koska taso on lukittu.
|
||||
Could not transform because the layer is locked. Muuntaminen ei onnistunut, koska taso on lukittu.
|
||||
Rectangular Marquee Tool Suorakulmainen valintakehys
|
||||
Elliptical Marquee Tool Elliptinen valintakehys
|
||||
Single Row Marquee Tool Yhden rivin valintakehys
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1840,6 +1840,7 @@
|
||||
Move Tool Outil Déplacement
|
||||
Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé.
|
||||
Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation libre, car le calque est verrouillé.
|
||||
Could not transform because the layer is locked. Impossible de transformer, car le calque est verrouillé.
|
||||
Multiply Produit
|
||||
My Brushes Mes pinceaux
|
||||
Neutrals Neutres
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1073,6 +1073,7 @@
|
||||
Control: Vezérlés:
|
||||
Corners Sarkok
|
||||
Could not complete the Free Transform command because the layer is locked. A szabad alakítás nem hajtható végre, mert a réteg zárolva van.
|
||||
Could not transform because the layer is locked. Az alakítás nem hajtható végre, mert a réteg zárolva van.
|
||||
Could not transform: the layer is empty Az alakítás nem hajtható végre: a réteg üres
|
||||
Could not use Puppet Warp: no opaque pixels A bábtorzítás nem használható: nincsenek átlátszatlan pixelek
|
||||
Could not use Puppet Warp: the layer is empty A bábtorzítás nem használható: a réteg üres
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1851,6 +1851,7 @@
|
||||
Move Tool Alat Pindah
|
||||
Could not use the move tool because the layer is locked. Tidak dapat menggunakan alat Pindahkan karena layer terkunci.
|
||||
Could not complete the Free Transform command because the layer is locked. Tidak dapat menyelesaikan perintah Transformasi Bebas karena layer terkunci.
|
||||
Could not transform because the layer is locked. Tidak dapat mentransformasi karena layer terkunci.
|
||||
Multiply Multiply
|
||||
My Brushes Kuas Saya
|
||||
Neutrals Netral
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -1877,6 +1877,7 @@
|
||||
Move Tool Strumento Sposta
|
||||
Could not use the move tool because the layer is locked. Impossibile usare lo strumento Sposta perché il livello è bloccato.
|
||||
Could not complete the Free Transform command because the layer is locked. Impossibile completare il comando Trasformazione libera perché il livello è bloccato.
|
||||
Could not transform because the layer is locked. Impossibile trasformare perché il livello è bloccato.
|
||||
Multiply Moltiplica
|
||||
My Brushes I miei pennelli
|
||||
Neutrals Neutri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -1888,6 +1888,7 @@
|
||||
Move Tool 移動ツール
|
||||
Could not use the move tool because the layer is locked. レイヤーがロックされているため、移動ツールを使用できません。
|
||||
Could not complete the Free Transform command because the layer is locked. レイヤーがロックされているため、自由変形コマンドを完了できません。
|
||||
Could not transform because the layer is locked. レイヤーがロックされているため、変形できません。
|
||||
Multiply 乗算
|
||||
My Brushes マイブラシ
|
||||
Neutrals 中間色系
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1863,6 +1863,7 @@
|
||||
Move Tool 이동 도구
|
||||
Could not use the move tool because the layer is locked. 레이어가 잠겨 있어 이동 도구를 사용할 수 없습니다.
|
||||
Could not complete the Free Transform command because the layer is locked. 레이어가 잠겨 있어 자유 변형 명령을 완료할 수 없습니다.
|
||||
Could not transform because the layer is locked. 레이어가 잠겨 있어 변형할 수 없습니다.
|
||||
Multiply 곱하기
|
||||
My Brushes 내 브러시
|
||||
Neutrals 중립색
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -1638,6 +1638,7 @@
|
||||
Move Tool Flytteverktøy
|
||||
Could not use the move tool because the layer is locked. Kunne ikke bruke flytteverktøyet fordi laget er låst.
|
||||
Could not complete the Free Transform command because the layer is locked. Kunne ikke fullføre Fri transformering fordi laget er låst.
|
||||
Could not transform because the layer is locked. Kunne ikke transformere fordi laget er låst.
|
||||
Rectangular Marquee Tool Rektangulær markeringsramme
|
||||
Elliptical Marquee Tool Elliptisk markeringsramme
|
||||
Single Row Marquee Tool Markeringsramme for enkeltrad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1809,6 +1809,7 @@
|
||||
Move Tool Verplaatsgereedschap
|
||||
Could not use the move tool because the layer is locked. Kan het verplaatsgereedschap niet gebruiken omdat de laag is vergrendeld.
|
||||
Could not complete the Free Transform command because the layer is locked. Kan de opdracht Vrije transformatie niet voltooien omdat de laag is vergrendeld.
|
||||
Could not transform because the layer is locked. Kan niet transformeren omdat de laag is vergrendeld.
|
||||
Multiply Vermenigvuldigen
|
||||
My Brushes Mijn penselen
|
||||
Neutrals Neutrale tinten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1874,6 +1874,7 @@
|
||||
Move Tool Narzędzie Przesunięcie
|
||||
Could not use the move tool because the layer is locked. Nie można użyć narzędzia Przesunięcie, ponieważ warstwa jest zablokowana.
|
||||
Could not complete the Free Transform command because the layer is locked. Nie można ukończyć polecenia Dowolne przekształcanie, ponieważ warstwa jest zablokowana.
|
||||
Could not transform because the layer is locked. Nie można przekształcić, ponieważ warstwa jest zablokowana.
|
||||
Multiply Mnożenie
|
||||
My Brushes Moje pędzle
|
||||
Neutrals Neutralne
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1834,6 +1834,7 @@
|
||||
Move Tool Ferramenta Mover
|
||||
Could not use the move tool because the layer is locked. Não foi possível usar a ferramenta Mover porque a camada está bloqueada.
|
||||
Could not complete the Free Transform command because the layer is locked. Não foi possível concluir o comando Transformação livre porque a camada está bloqueada.
|
||||
Could not transform because the layer is locked. Não foi possível transformar porque a camada está bloqueada.
|
||||
Multiply Multiplicação
|
||||
My Brushes Meus pincéis
|
||||
Neutrals Neutros
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1749,6 +1749,7 @@
|
||||
Move Tool Перемещение
|
||||
Could not use the move tool because the layer is locked. Не удалось использовать инструмент «Перемещение», потому что слой заблокирован.
|
||||
Could not complete the Free Transform command because the layer is locked. Не удалось выполнить команду «Свободное трансформирование», потому что слой заблокирован.
|
||||
Could not transform because the layer is locked. Не удалось выполнить трансформирование, потому что слой заблокирован.
|
||||
Rectangular Marquee Tool Прямоугольная область
|
||||
Elliptical Marquee Tool Овальная область
|
||||
Single Row Marquee Tool Область (горизонтальная строка)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1847,6 +1847,7 @@
|
||||
Move Tool Alatka za pomeranje
|
||||
Could not use the move tool because the layer is locked. Alatka za pomeranje ne može da se koristi jer je sloj zaključan.
|
||||
Could not complete the Free Transform command because the layer is locked. Komanda Slobodna transformacija ne može da se izvrši jer je sloj zaključan.
|
||||
Could not transform because the layer is locked. Transformacija nije moguća jer je sloj zaključan.
|
||||
Multiply Množenje
|
||||
My Brushes Moje četkice
|
||||
Neutrals Neutralne
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1636,6 +1636,7 @@
|
||||
Move Tool Flyttverktyg
|
||||
Could not use the move tool because the layer is locked. Det gick inte att använda flyttverktyget eftersom lagret är låst.
|
||||
Could not complete the Free Transform command because the layer is locked. Det gick inte att slutföra kommandot Ombilda fritt eftersom lagret är låst.
|
||||
Could not transform because the layer is locked. Det gick inte att omforma eftersom lagret är låst.
|
||||
Rectangular Marquee Tool Verktyget Rektangulär markeringsram
|
||||
Elliptical Marquee Tool Verktyget Elliptisk markeringsram
|
||||
Single Row Marquee Tool Verktyget Enradsmarkeringsram
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1762,6 +1762,7 @@
|
||||
Move Tool Taşıma Aracı
|
||||
Could not use the move tool because the layer is locked. Katman kilitli olduğundan taşıma aracı kullanılamadı.
|
||||
Could not complete the Free Transform command because the layer is locked. Katman kilitli olduğundan Serbest Dönüştürme komutu tamamlanamadı.
|
||||
Could not transform because the layer is locked. Katman kilitli olduğundan dönüştürme yapılamadı.
|
||||
Multiply Çoğalt
|
||||
My Brushes Fırçalarım
|
||||
Neutrals Nötrler
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1719,6 +1719,7 @@
|
||||
Move Tool Інструмент «Переміщення»
|
||||
Could not use the move tool because the layer is locked. Не вдалося використати інструмент «Переміщення», бо шар заблоковано.
|
||||
Could not complete the Free Transform command because the layer is locked. Не вдалося виконати команду «Вільна трансформація», бо шар заблоковано.
|
||||
Could not transform because the layer is locked. Не вдалося трансформувати, бо шар заблоковано.
|
||||
Rectangular Marquee Tool Інструмент «Прямокутне виділення»
|
||||
Elliptical Marquee Tool Інструмент «Еліптичне виділення»
|
||||
Single Row Marquee Tool Інструмент «Виділення одного рядка»
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -2718,6 +2718,7 @@ shape Appearance Diện mạo
|
||||
Measurement Scale Marker Dấu tỉ lệ đo
|
||||
Could not use the move tool because the layer is locked. Không thể dùng công cụ di chuyển vì lớp đã bị khoá.
|
||||
Could not complete the Free Transform command because the layer is locked. Không thể hoàn tất lệnh Biến đổi tự do vì lớp đã bị khoá.
|
||||
Could not transform because the layer is locked. Không thể biến đổi vì lớp đã bị khoá.
|
||||
My Brushes Cọ của tôi
|
||||
Neutrals Vùng trung tính
|
||||
New Workspace Không gian làm việc mới
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1912,6 +1912,7 @@
|
||||
Move Tool 移动工具
|
||||
Could not use the move tool because the layer is locked. 无法使用移动工具,因为图层已锁定。
|
||||
Could not complete the Free Transform command because the layer is locked. 无法完成“自由变换”命令,因为图层已锁定。
|
||||
Could not transform because the layer is locked. 无法变换,因为图层已锁定。
|
||||
Multiply 正片叠底
|
||||
My Brushes 我的画笔
|
||||
Neutrals 中性色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -1882,6 +1882,7 @@
|
||||
Move Tool 移動工具
|
||||
Could not use the move tool because the layer is locked. 無法使用移動工具,因為圖層已鎖定。
|
||||
Could not complete the Free Transform command because the layer is locked. 無法完成「任意變形」指令,因為圖層已鎖定。
|
||||
Could not transform because the layer is locked. 無法變形,因為圖層已鎖定。
|
||||
Multiply 色彩增值
|
||||
My Brushes 我的筆刷
|
||||
Neutrals 中間色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -848,6 +848,9 @@ mod transform_reference_point_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_several_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod transform_selection_rules_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod psd_open_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -803,7 +803,7 @@ fn shift_constrains_a_floating_selection_drag() {
|
||||
}
|
||||
|
||||
/// ⌘-drag cuts the selected pixels into a floating piece (`select.float`): shown at the pointer
|
||||
/// while dragging, moved again by plain drags, put back by Undo, dropped by Deselect.
|
||||
/// while dragging, moved again by ⌘-drags, put back by Undo, dropped by Deselect.
|
||||
#[test]
|
||||
fn cmd_drag_floats_the_selected_pixels() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
@@ -819,8 +819,8 @@ fn cmd_drag_floats_the_selected_pixels() {
|
||||
tool_event(&mut app, ToolEvent::Up { x: 35.0, y: 20.0 }, Modifiers::COMMAND);
|
||||
assert_eq!(offset(&app), Some((15, 0)));
|
||||
assert_eq!(alpha(&doc(&app), 12, 20), 1.0, "the document waits for the drop");
|
||||
// A plain drag on the piece moves it again.
|
||||
drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::NONE);
|
||||
// A ⌘-drag on the piece moves it again.
|
||||
drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::COMMAND);
|
||||
assert_eq!(offset(&app), Some((15, 10)));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
// Undo puts it back.
|
||||
@@ -836,6 +836,29 @@ fn cmd_drag_floats_the_selected_pixels() {
|
||||
assert_eq!(labels.first().map(String::as_str), Some("Move"), "{labels:?}");
|
||||
}
|
||||
|
||||
/// #11368: after the Move tool moves the selected pixels, a plain marquee drag inside the
|
||||
/// selection moves only the outline, as in Photoshop: the pixels are dropped where they are (one
|
||||
/// Move step), then the outline moves on its own (one Move Selection step).
|
||||
#[test]
|
||||
fn a_marquee_drag_after_moving_pixels_moves_only_the_outline() {
|
||||
let (mut app, layer) = painted();
|
||||
let alpha = |app: &PhotocraftApp, x, y| app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().rgba(x, y)[3];
|
||||
let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds());
|
||||
let floating = |app: &PhotocraftApp| photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
app.ui.tool = Tool::Move;
|
||||
drag(&mut app, [20.0, 20.0], [35.0, 20.0], Modifiers::NONE);
|
||||
assert!(floating(&app), "the Move tool leaves the moved pixels floating");
|
||||
app.ui.tool = Tool::RectMarquee;
|
||||
drag(&mut app, [30.0, 20.0], [30.0, 30.0], Modifiers::NONE);
|
||||
assert!(!floating(&app), "the press dropped the piece");
|
||||
assert_eq!(sel(&app), Some(Rect::new(25, 20, 45, 40)), "the outline moved down");
|
||||
assert!(alpha(&app, 40, 15) == 1.0 && alpha(&app, 40, 35) == 0.0, "the pixels stay where the Move tool left them");
|
||||
assert_eq!(alpha(&app, 12, 20), 0.0, "the cut stays cut");
|
||||
let labels: Vec<String> = app.session.active().unwrap().history.entries().into_iter().skip(steps + 1).map(|e| e.to_string()).collect();
|
||||
assert_eq!(labels, ["Move", "Move Selection"]);
|
||||
}
|
||||
|
||||
/// Through the real canvas (mouse events): a drag inside the ants moves the selection.
|
||||
#[test]
|
||||
fn mouse_drag_inside_the_selection_moves_it() {
|
||||
@@ -1070,7 +1093,8 @@ fn selected_pixels_move_off_the_background_but_not_off_a_fully_locked_layer() {
|
||||
|
||||
/// Photoshop's cursor over a selection: the move cursor where a marquee press moves the outline,
|
||||
/// scissors where ⌘ (or the Move tool) cuts the selected pixels, a double arrow where ⌥ copies
|
||||
/// them, a hollow arrowhead while they are dragged, and a plain arrow over the floating piece.
|
||||
/// them, a hollow arrowhead while they are dragged, and a plain arrow over the floating piece where
|
||||
/// a press would drag it (the Move tool, ⌘).
|
||||
#[test]
|
||||
fn selection_cursor_follows_the_modifiers_and_the_floating_piece() {
|
||||
use crate::canvas::{SelCursor, selection_cursor};
|
||||
@@ -1094,7 +1118,8 @@ fn selection_cursor_follows_the_modifiers_and_the_floating_piece() {
|
||||
tool_event(&mut app, ToolEvent::Move { x: 35.0, y: 20.0, pressure: 1.0 }, Modifiers::COMMAND);
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], Modifiers::COMMAND), Some(SelCursor::Dragging));
|
||||
tool_event(&mut app, ToolEvent::Up { x: 35.0, y: 20.0 }, Modifiers::COMMAND);
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], none), Some(SelCursor::Piece));
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], none), Some(SelCursor::Outline), "a plain drag moves the outline (#11368)");
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], Modifiers::COMMAND), Some(SelCursor::Piece));
|
||||
assert_eq!(selection_cursor(&app, Tool::Move, [35.0, 20.0], none), Some(SelCursor::Piece));
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [35.0, 20.0], cmd_alt), Some(SelCursor::Copy), "a copy of the piece");
|
||||
assert_eq!(selection_cursor(&app, Tool::RectMarquee, inside, none), None, "where it was cut from");
|
||||
|
||||
@@ -127,8 +127,9 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool {
|
||||
if transform && (target.is_object() || target == "quickMask") {
|
||||
return false;
|
||||
}
|
||||
// Several layers selected: one box moves them all, the Background left out (#6501). It doesn't
|
||||
// warp, and doesn't distort while editable type is among them.
|
||||
// Several layers selected: one box moves them all (#6501); with the Background among them it
|
||||
// refuses as locked when it opens (#11101). It doesn't warp, and doesn't distort while
|
||||
// editable type is among them.
|
||||
if transform {
|
||||
let targets = photocraft_engine::transform_cmds::selection_targets(st);
|
||||
// Judged on the layers the box moves, linked ones included (#11217): one linked layer
|
||||
|
||||
@@ -50,6 +50,12 @@ fn steps(app: &PhotocraftApp) -> usize {
|
||||
app.session.active().unwrap().history.entries().len()
|
||||
}
|
||||
|
||||
/// The active layer's pixel at `(x, y)`.
|
||||
fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
/// Rows 150 and 250 (New, then ⇧ adds).
|
||||
fn two_rows(app: &mut PhotocraftApp) {
|
||||
click(app, Tool::SingleRowMarquee, [300.0, 250.5], Modifiers::NONE);
|
||||
@@ -148,7 +154,8 @@ fn click_inside_with_snapping_on_selects_the_line() {
|
||||
|
||||
/// A drag from inside that snapping pulls back to where it started (the selection's edge on a
|
||||
/// guide, 2 px away) is not a click: the selection stays, with no step. It took the click's path:
|
||||
/// one line replaced the rows, a floating piece was dropped, and the Rectangular Marquee deselected.
|
||||
/// one line replaced the rows, and the Rectangular Marquee deselected. A floating piece is dropped
|
||||
/// where it is by the press, which drags the outline (#11368).
|
||||
#[test]
|
||||
fn a_drag_snapped_back_to_its_start_leaves_the_selection() {
|
||||
let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
|
||||
@@ -170,14 +177,15 @@ fn a_drag_snapped_back_to_its_start_leaves_the_selection() {
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap();
|
||||
drag(&mut app, Tool::SingleRowMarquee, [200.0, 200.5], [200.0, 202.5], Modifiers::NONE);
|
||||
assert!(floats(&app), "the piece still floats");
|
||||
assert!(!floats(&app), "the press dropped the piece where it was (#11368)");
|
||||
assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250));
|
||||
assert_eq!(pixel(&app, 200, 200), [1.0, 0.0, 0.0, 1.0], "the dropped pixels are where they were");
|
||||
}
|
||||
|
||||
/// A drag from inside that goes away and comes back to the press is not a click either: the
|
||||
/// selection stays where it was, with no step. Only the release was checked, so it took the
|
||||
/// click's path (one line replaced the rows, the piece was dropped, the Rectangular Marquee
|
||||
/// deselected).
|
||||
/// click's path (one line replaced the rows, the Rectangular Marquee deselected). A floating piece
|
||||
/// is dropped where it is by the press, which drags the outline (#11368).
|
||||
#[test]
|
||||
fn a_drag_that_returns_to_its_start_leaves_the_selection() {
|
||||
let floats = |app: &PhotocraftApp| app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
|
||||
@@ -208,8 +216,9 @@ fn a_drag_that_returns_to_its_start_leaves_the_selection() {
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.float", json!({"dx": 0, "dy": 0})).unwrap();
|
||||
out_and_back(&mut app, Tool::SingleRowMarquee, [200.0, 200.5]);
|
||||
assert!(floats(&app), "the piece still floats");
|
||||
assert!(!floats(&app), "the press dropped the piece where it was (#11368)");
|
||||
assert_eq!(bounds(&app), Rect::new(100, 150, 300, 250));
|
||||
assert_eq!(pixel(&app, 200, 200), [1.0, 0.0, 0.0, 1.0], "the dropped pixels are where they were");
|
||||
}
|
||||
|
||||
/// A click whose release drifts under a pixel from the press (a zoomed-in hand jitter) is still a
|
||||
|
||||
@@ -192,7 +192,8 @@ pub struct ToolOptions {
|
||||
#[serde(default = "yes")]
|
||||
pub perspective_crop_grid: bool,
|
||||
/// Crop overlay (#1919): the guide in the crop box, when it shows and its orientation
|
||||
/// (Photoshop's defaults: Rule of Thirds, Auto Show Overlay). See `crop_overlay`.
|
||||
/// (the reference application's defaults: Rule of Thirds, Always Show Overlay, #10894). See
|
||||
/// `crop_overlay`.
|
||||
#[serde(default)]
|
||||
pub crop_overlay: crate::crop_overlay::CropOverlay,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -73,8 +73,8 @@ pub struct TransformSession {
|
||||
pub skew_v: f64,
|
||||
/// Free Transform of several selected layers (#6501): every layer the box moves, in
|
||||
/// `edit.transform`'s `layers` order. Empty when it moves `layer` alone. `layer` stays the
|
||||
/// active layer, which need not be one of them (the Background is left out, a selected group
|
||||
/// carries its selected children).
|
||||
/// active layer, which need not be one of them (a selected group carries its selected
|
||||
/// children).
|
||||
#[serde(default)]
|
||||
pub layers: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -94,8 +94,10 @@ impl CropOverlay {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OverlayShow {
|
||||
/// Only while the crop box is being dragged (drawn, moved, resized or turned).
|
||||
#[default]
|
||||
Auto,
|
||||
/// The whole time a crop is pending, as the reference application starts out (its Reset Tool
|
||||
/// brings back Always Show Overlay, #10894).
|
||||
#[default]
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
@@ -263,7 +265,11 @@ pub fn current_guides_for_frame(app: &PhotocraftApp, q: [egui::Pos2; 4], clip: R
|
||||
|
||||
fn current_guides_visible(app: &PhotocraftApp, w: f32, h: f32, visible: Rect) -> Vec<Polyline> {
|
||||
let o = &app.ui.tool_options;
|
||||
if app.ui.tool != Tool::Crop || !o.crop_overlay_show.shows(app.crop.drag.is_some()) {
|
||||
// A turn or a straighten line in progress shows its guides from the start, whether or not the
|
||||
// frame has changed yet: they are how the gesture is aimed. Otherwise the overlay waits for
|
||||
// the crop's first change (#10930).
|
||||
let gesture = crate::crop_ui::turning(app).is_some() || app.crop.straighten.line.is_some();
|
||||
if app.ui.tool != Tool::Crop || !(crop_started(app) || gesture) || !o.crop_overlay_show.shows(app.crop.drag.is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Classic Mode and Straighten were not measured: only the default mode's turn
|
||||
@@ -274,6 +280,55 @@ fn current_guides_visible(app: &PhotocraftApp, w: f32, h: f32, visible: Rect) ->
|
||||
guides(o.crop_overlay, o.crop_overlay_orientation, w, h)
|
||||
}
|
||||
|
||||
/// Whether the crop has started: its frame was changed since it was made (it is no longer the
|
||||
/// untouched default frame), or a drag is changing it now. Until then the reference application shows
|
||||
/// only the frame, with no shield and no overlay in any show mode; both start with the first change
|
||||
/// and stay until the crop is committed or cancelled (#10930). Classic Mode wasn't measured and
|
||||
/// keeps the behaviour it had.
|
||||
///
|
||||
/// Within a drag, the first change is what counts: a drag that changes the frame and brings it back
|
||||
/// to where it started keeps the started look until it ends, rather than flickering back to the
|
||||
/// untouched one (noted in review). Once it ends, `crop_ui::release` decides, as before.
|
||||
pub fn crop_started(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool_options.crop_shield.classic_mode || !app.crop.default_frame || (app.crop.drag.is_some() && app.crop.drag_moved) || drag_changed(app)
|
||||
}
|
||||
|
||||
/// Whether the drag in progress has changed the frame since its press. A press that hasn't moved
|
||||
/// anything yet is not the first change, so it leaves the untouched look (#10930). The tests mirror
|
||||
/// the ones `crop_ui` uses when a drag ends.
|
||||
pub(crate) fn drag_changed(app: &PhotocraftApp) -> bool {
|
||||
use crate::crop_ui::CropDrag;
|
||||
match &app.crop.drag {
|
||||
None => false,
|
||||
Some(CropDrag::Draw { anchor, cur, .. }) => anchor != cur,
|
||||
Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) => app.ui.crop_rect != Some(*rect),
|
||||
Some(CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. }) => crate::crop_ui::angle(app) != *from,
|
||||
}
|
||||
}
|
||||
|
||||
/// How the crop frame's outline and handles are drawn (#10930).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FrameLook {
|
||||
/// Before the first change: a dashed outline, black on light grey like a selection's, and grey
|
||||
/// handles.
|
||||
Untouched,
|
||||
/// Once the crop has started: a solid, thin, light outline and the same grey handles.
|
||||
Started,
|
||||
/// Classic Mode, which wasn't measured: the solid white outline and handles it always had.
|
||||
Classic,
|
||||
}
|
||||
|
||||
/// The look of the pending crop frame.
|
||||
pub fn frame_look(app: &PhotocraftApp) -> FrameLook {
|
||||
if app.ui.tool_options.crop_shield.classic_mode {
|
||||
FrameLook::Classic
|
||||
} else if crop_started(app) {
|
||||
FrameLook::Started
|
||||
} else {
|
||||
FrameLook::Untouched
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas's visible extent in the crop frame's own screen-point axes. The
|
||||
/// inverse basis also handles a flipped/rotated view and pixel aspect correction.
|
||||
fn visible_frame(q: [egui::Pos2; 4], clip: Rect, w: f32, h: f32) -> Option<Rect> {
|
||||
@@ -540,7 +595,7 @@ mod tests {
|
||||
#[test]
|
||||
fn cycling_overlays_and_orientations() {
|
||||
let mut o = ToolOptions::default();
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0));
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0));
|
||||
let mut seen = vec![o.crop_overlay];
|
||||
for _ in 0..6 {
|
||||
cycle(&mut o);
|
||||
@@ -641,6 +696,8 @@ mod tests {
|
||||
#[test]
|
||||
fn the_overlay_shows_by_mode_and_gesture() {
|
||||
let mut app = crop_app();
|
||||
// Auto is no longer the default (#10894): set it for the Auto half of this test.
|
||||
app.ui.tool_options.crop_overlay_show = OverlayShow::Auto;
|
||||
assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Auto: no guides without a drag");
|
||||
app.crop.drag = Some(crate::crop_ui::CropDrag::Move { start: [0.0, 0.0], rect: [0.0, 0.0, 10.0, 10.0] });
|
||||
assert_eq!(current_guides(&app, 300.0, 200.0).len(), 4, "Auto: the thirds while dragging");
|
||||
@@ -649,6 +706,9 @@ mod tests {
|
||||
app.crop.drag = None;
|
||||
app.ui.tool_options.crop_overlay_show = OverlayShow::Always;
|
||||
app.ui.tool_options.crop_overlay = CropOverlay::Triangle;
|
||||
assert!(current_guides(&app, 300.0, 200.0).is_empty(), "Always: still nothing on the untouched frame (#10930)");
|
||||
// The frame has been changed, so the crop is pending: Always keeps showing after release.
|
||||
app.crop.default_frame = false;
|
||||
assert_eq!(current_guides(&app, 300.0, 200.0).len(), 3);
|
||||
app.ui.tool = Tool::Brush;
|
||||
assert!(current_guides(&app, 300.0, 200.0).is_empty(), "a pending frame under another tool shows no guide");
|
||||
@@ -657,7 +717,7 @@ mod tests {
|
||||
#[test]
|
||||
fn old_state_files_get_photoshops_defaults() {
|
||||
let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap();
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Auto, 0));
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::Thirds, OverlayShow::Always, 0));
|
||||
let o: ToolOptions = serde_json::from_str(r#"{"crop_overlay": "goldenSpiral", "crop_overlay_show": "always", "crop_overlay_orientation": 3}"#).unwrap();
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show, o.crop_overlay_orientation), (CropOverlay::GoldenSpiral, OverlayShow::Always, 3));
|
||||
}
|
||||
|
||||
@@ -112,7 +112,13 @@ pub fn canvas_color(app: &PhotocraftApp, ctx: &egui::Context) -> Color32 {
|
||||
|
||||
/// The shield over the pending crop box now ([`fill`]).
|
||||
pub fn current(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Color32> {
|
||||
fill(&app.ui.tool_options.crop_shield, canvas_color(app, ctx), app.crop.drag.is_some())
|
||||
let s = &app.ui.tool_options.crop_shield;
|
||||
// No shield before the crop has started: an untouched frame shows only itself (#10930).
|
||||
// Show Cropped Area off still hides everything outside the frame, as it does once started.
|
||||
if s.shows_cropped_area() && !crate::crop_overlay::crop_started(app) {
|
||||
return None;
|
||||
}
|
||||
fill(s, canvas_color(app, ctx), app.crop.drag.is_some())
|
||||
}
|
||||
|
||||
/// Show Cropped Area is off with the Crop tool showing a box: nothing outside it shows.
|
||||
@@ -313,6 +319,8 @@ mod tests {
|
||||
#[test]
|
||||
fn match_canvas_is_the_black_full_screen_pasteboard() {
|
||||
let mut app = crop_app();
|
||||
// A shield shows only once the crop has started (#10930); this test is about its colour.
|
||||
app.crop.default_frame = false;
|
||||
let ctx = egui::Context::default();
|
||||
let theme_canvas = crate::theme::Tokens::get(&ctx).canvas;
|
||||
assert_ne!(theme_canvas, Color32::BLACK);
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct CropState {
|
||||
reset_to: Option<[f64; 4]>,
|
||||
/// The frame is being edited (pressed since it was made): the canvas shows what lies past it.
|
||||
pub editing: bool,
|
||||
/// The drag in progress has changed the frame at some point, even if it has since come back to
|
||||
/// where it started: the crop has started, and its look stays for the rest of the drag
|
||||
/// (`crop_overlay::crop_started`, #10930).
|
||||
pub drag_moved: bool,
|
||||
/// The document the pending frame (or gesture) belongs to ([`cancel_stale`], #1918).
|
||||
doc: Option<photocraft_doc::DocId>,
|
||||
/// The options-bar Straighten button and ⌘-drag (`crop_straighten`).
|
||||
@@ -1013,10 +1017,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
/// Press, drag and release of the frame. Returns true when the frame needs syncing to the view.
|
||||
fn track(app: &mut PhotocraftApp, ev: ToolEvent, p: [f64; 2], mods: Modifiers) -> bool {
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => begin_drag(app, p),
|
||||
ToolEvent::Move { .. } => update(app, p, mods),
|
||||
ToolEvent::Down { .. } => {
|
||||
app.crop.drag_moved = false;
|
||||
begin_drag(app, p);
|
||||
}
|
||||
ToolEvent::Move { .. } => {
|
||||
update(app, p, mods);
|
||||
app.crop.drag_moved |= crate::crop_overlay::drag_changed(app);
|
||||
}
|
||||
ToolEvent::Up { .. } => {
|
||||
update(app, p, mods);
|
||||
// The release's own position counts toward whether the gesture changed the frame.
|
||||
app.crop.drag_moved |= crate::crop_overlay::drag_changed(app);
|
||||
let Some(drag) = app.crop.drag.take() else { return false };
|
||||
release(app, drag);
|
||||
}
|
||||
@@ -1101,11 +1113,16 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C
|
||||
/// Ends `drag`: keeps a valid frame or restores the previous one, and records an undo step when it changed.
|
||||
fn release(app: &mut PhotocraftApp, drag: CropDrag) {
|
||||
let ok = app.ui.crop_rect.is_some_and(size_ok);
|
||||
// A click outside the untouched default frame (no turn) leaves it the default.
|
||||
let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app))
|
||||
&& !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect))
|
||||
// A press that never moved draws nothing: the frame shown is still the previous one.
|
||||
&& !matches!(drag, CropDrag::Draw { anchor, cur, .. } if anchor == cur);
|
||||
// A click outside the untouched default frame (no turn) leaves it the default. A drawn, moved
|
||||
// or resized frame has changed when the gesture changed it at some point (`drag_moved`), not
|
||||
// when it ends unlike its start: a click inside the frame or on a handle draws or resizes
|
||||
// nothing, and an Alt-drag from the centre can draw the very frame it started from (noted in
|
||||
// review).
|
||||
let changed = match drag {
|
||||
CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } => from != angle(app),
|
||||
CropDrag::MoveImage { rect, .. } => app.ui.crop_rect != Some(rect),
|
||||
CropDrag::Draw { .. } | CropDrag::Move { .. } | CropDrag::Resize { .. } => app.crop.drag_moved,
|
||||
};
|
||||
if ok && changed {
|
||||
app.crop.default_frame = false;
|
||||
}
|
||||
|
||||
@@ -228,6 +228,9 @@ struct Follow {
|
||||
revision: u64,
|
||||
/// The moving layers hidden in `doc` (nothing was lifted out of them).
|
||||
hidden: Vec<LayerId>,
|
||||
/// The Layers panel's selection when the box opened, sorted; `None` when the box doesn't
|
||||
/// follow it. Any change to it applies the box (`end_if_left`, #11101).
|
||||
selected: Option<Vec<LayerId>>,
|
||||
}
|
||||
|
||||
/// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls)
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
opacity: layer.opacity * layer.fill_opacity,
|
||||
above: (!frame_only).then_some(above),
|
||||
styled: (styled && !frame_only).then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))),
|
||||
follow: Some(Follow { revision, hidden: if lifted.is_none() && !frame_only { vec![id] } else { Vec::new() } }),
|
||||
follow: Some(Follow { revision, hidden: if lifted.is_none() && !frame_only { vec![id] } else { Vec::new() }, selected: selected_now(app) }),
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
@@ -136,7 +136,8 @@ pub(super) fn interpolation_preference(interpolation: &str) -> Option<photocraft
|
||||
/// layer move when there is a selection; type, shapes, smart objects and groups move whole.
|
||||
fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc<Document>, revision: u64, active: LayerId, ids: Vec<LayerId>) -> Result<(), String> {
|
||||
use photocraft_engine::transform_cmds as tc;
|
||||
if ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) {
|
||||
// The Background among them refuses the same way, rather than being left out (#11101).
|
||||
if tc::background_among(&doc, &ids).is_some() || ids.iter().any(|id| tc::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());
|
||||
}
|
||||
@@ -192,7 +193,7 @@ fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc<Document
|
||||
opacity: 1.0,
|
||||
above: Some(above),
|
||||
styled: None,
|
||||
follow: Some(Follow { revision, hidden }),
|
||||
follow: Some(Follow { revision, hidden, selected: selected_now(app) }),
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
@@ -240,6 +241,13 @@ pub(crate) fn moving_texel(app: &PhotocraftApp, p: [i32; 2]) -> (Option<Color32>
|
||||
(texel, texture_quad(t, pv))
|
||||
}
|
||||
|
||||
/// The Layers panel's selection, sorted, as a layer's box records it when it opens (#11101).
|
||||
pub(super) fn selected_now(app: &PhotocraftApp) -> Option<Vec<LayerId>> {
|
||||
let mut ids = app.session.active()?.selected_layers();
|
||||
ids.sort_unstable();
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// The layers the box moves: every layer of a several-layer session (#6501), else its one layer.
|
||||
pub(crate) fn moving_layers(t: &TransformSession) -> Vec<LayerId> {
|
||||
if t.layers.is_empty() { vec![LayerId(t.layer)] } else { t.layers.iter().copied().map(LayerId).collect() }
|
||||
@@ -343,7 +351,7 @@ pub(crate) fn begin_content_aware(
|
||||
texture,
|
||||
opacity: layer.opacity * layer.fill_opacity,
|
||||
above: Some(above),
|
||||
follow: Some(Follow { revision, hidden: Vec::new() }),
|
||||
follow: Some(Follow { revision, hidden: Vec::new(), selected: None }),
|
||||
committing: None,
|
||||
gesture: None,
|
||||
warp_drag: None,
|
||||
|
||||
@@ -205,11 +205,30 @@ pub fn end_if_left(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Another tool picked, or (outside Transform Selection) another layer selected, since the box
|
||||
/// opened: the session ends ([`end_if_left`], #1400).
|
||||
/// Another tool picked, or (outside Transform Selection) another layer selected or the selected
|
||||
/// layers changed, since the box opened: the session ends ([`end_if_left`], #1400, #11101).
|
||||
fn switched_away(app: &PhotocraftApp) -> bool {
|
||||
let (Some(t), Some(st)) = (&app.ui.transform, app.session.active()) else { return false };
|
||||
app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) || (!t.selection && st.active_layer.is_some_and(|active| active.0 != t.layer))
|
||||
let opened = app.transform_preview.as_ref().and_then(|pv| pv.follow.as_ref()).and_then(|f| f.selected.as_deref());
|
||||
app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool)
|
||||
|| (!t.selection && st.active_layer.is_some_and(|active| active.0 != t.layer))
|
||||
|| selected_layers_changed(st, t, opened)
|
||||
}
|
||||
|
||||
/// A layer's box no longer matches the Layers panel's selection it opened with (`opened`): a layer
|
||||
/// was added to the selection or taken out of it, a group's child included. Photoshop applies the
|
||||
/// box first, to the layers it moves, and doesn't open it again (#11101). The selection itself is
|
||||
/// compared, not the layers the box moves: those also hold the layers linked to it (#11217) and
|
||||
/// leave out the children of a selected group. Boxes over a selection, a mask or channel, a path,
|
||||
/// a placed file or Content-Aware Scale don't follow the selection.
|
||||
fn selected_layers_changed(st: &photocraft_engine::DocState, t: &TransformSession, opened: Option<&[LayerId]>) -> bool {
|
||||
if t.selection || t.target.is_some() || t.path.is_some() || t.made == Some(MadeLayer::Place) || t.content_aware.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(opened) = opened else { return false };
|
||||
let mut now = st.selected_layers();
|
||||
now.sort_unstable();
|
||||
now != opened
|
||||
}
|
||||
|
||||
/// Edits made while the box is open show in the preview at once, not only after the commit
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Free Transform's rules for the Layers panel's selection (#11101): the Background among several
|
||||
//! selected layers refuses with the locked-layer alert, and any change to the selected layers
|
||||
//! while the box is open applies the box first, as in Photoshop.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::transform_tool::LOCKED;
|
||||
|
||||
/// A 200 × 200 document on a white Background: "A" with a red block at x 10..50, y 10..30, and
|
||||
/// "B" with a blue one at x 100..140, y 100..120. Only B is selected.
|
||||
fn app() -> (PhotocraftApp, LayerId, LayerId, LayerId) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
|
||||
app.sync_views();
|
||||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||||
let mut block = |name: &str, rect: [i32; 4], color: &str| {
|
||||
let id = LayerId(app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.run("select.rect", json!({"x": rect[0], "y": rect[1], "width": rect[2], "height": rect[3]})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
id
|
||||
};
|
||||
let a = block("A", [10, 10, 40, 20], "#ff0000");
|
||||
let b = block("B", [100, 100, 40, 20], "#0000ff");
|
||||
(app, bg, a, b)
|
||||
}
|
||||
|
||||
fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect {
|
||||
let st = app.session.active().unwrap();
|
||||
photocraft_engine::transform_cmds::transform_bounds(&st.doc, st.doc.layer(id).unwrap())
|
||||
}
|
||||
|
||||
fn select(app: &mut PhotocraftApp, id: LayerId, mode: &str) {
|
||||
app.run("layer.select", json!({"layer": id.0, "mode": mode})).unwrap();
|
||||
}
|
||||
|
||||
fn invoke(app: &mut PhotocraftApp, id: &str) -> Result<(), String> {
|
||||
crate::menus::invoke(app, &egui::Context::default(), id, json!({})).map(|_| ())
|
||||
}
|
||||
|
||||
fn alerts(app: &PhotocraftApp) -> Vec<String> {
|
||||
app.ui.dialogs.iter().filter(|d| d.kind == crate::state::DialogKind::Error).filter_map(|d| d.fields.get("message")?.as_str().map(String::from)).collect()
|
||||
}
|
||||
|
||||
/// Drag the open box `dx` px right, with nearest neighbour so the blocks keep their edges.
|
||||
fn nudge(app: &mut PhotocraftApp, dx: f64) {
|
||||
let t = app.ui.transform.as_mut().expect("a box is open");
|
||||
t.interpolation = "nearest".into();
|
||||
t.quad = t.quad.map(|[x, y]| [x + dx, y]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_among_several_layers_shows_the_locked_alert_and_opens_no_box() {
|
||||
for id in ["edit.freeTransform", "edit.transform.scale", "edit.transform.rotate"] {
|
||||
let (mut app, bg, a, _) = app();
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, bg, "add");
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
assert_eq!(invoke(&mut app, id), Err(LOCKED.into()), "{id}");
|
||||
assert!(app.ui.transform.is_none(), "{id}: no box opens");
|
||||
assert_eq!(alerts(&app), [LOCKED], "{id}: the same alert as a position-locked layer");
|
||||
assert!(app.session.active().unwrap().doc == before, "{id}: nothing moved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_is_refused_whichever_of_them_is_active() {
|
||||
let (mut app, bg, a, _) = app();
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, bg, "add");
|
||||
select(&mut app, a, "add");
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(a));
|
||||
assert_eq!(invoke(&mut app, "edit.freeTransform"), Err(LOCKED.into()));
|
||||
assert!(app.ui.transform.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engines_default_targets_refuse_the_background_but_an_explicit_list_is_kept() {
|
||||
let (mut app, bg, a, b) = app();
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, bg, "add");
|
||||
let err = app.run("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
// A recorded or scripted step naming its layers moves those, as before.
|
||||
app.run("edit.transform", json!({"layers": [a.0, b.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!(bounds(&app, a), Rect::new(15, 10, 55, 30));
|
||||
assert_eq!(bounds(&app, b), Rect::new(105, 100, 145, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_without_the_background_still_open_one_box() {
|
||||
let (mut app, _, a, b) = app();
|
||||
select(&mut app, a, "add");
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!(t.layers, [a.0, b.0]);
|
||||
assert!(alerts(&app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_a_layer_out_of_the_selection_applies_the_box_to_both_first() {
|
||||
// The reporter's case: the box over A and B (B active), then ⌘-click A in the Layers panel.
|
||||
let (mut app, _, a, b) = app();
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, b, "add");
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(b));
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
nudge(&mut app, 10.0);
|
||||
select(&mut app, a, "toggle");
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(b), "the active layer didn't change");
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_none(), "the box is applied and not opened again");
|
||||
assert_eq!(bounds(&app, a), Rect::new(20, 10, 60, 30), "A moved with the box");
|
||||
assert_eq!(bounds(&app, b), Rect::new(110, 100, 150, 120));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), steps + 1, "one Free Transform step");
|
||||
assert_eq!(st.selected_layers(), [b], "then A leaves the selection");
|
||||
// Nudging B now moves B alone: the old box is gone.
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
assert!(app.ui.transform.as_ref().unwrap().layers.is_empty(), "a box over B alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_layer_to_the_selection_applies_the_box_first() {
|
||||
let (mut app, _, a, b) = app();
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
nudge(&mut app, 10.0);
|
||||
// ⌘-click A, then make B active again: the set changed though the active layer is B.
|
||||
select(&mut app, a, "add");
|
||||
select(&mut app, b, "add");
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(b));
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_none());
|
||||
assert_eq!(bounds(&app, b), Rect::new(110, 100, 150, 120), "B moved");
|
||||
assert_eq!(bounds(&app, a), Rect::new(10, 10, 50, 30), "A wasn't in the box");
|
||||
assert_eq!(app.session.active().unwrap().selected_layers(), [a, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_selection_keeps_the_box_open() {
|
||||
let (mut app, _, a, b) = app();
|
||||
select(&mut app, a, "add");
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
nudge(&mut app, 10.0);
|
||||
// Selecting what is selected already, or editing a moving layer, isn't a change.
|
||||
select(&mut app, a, "add");
|
||||
app.run("layer.setProps", json!({"layer": b.0, "opacity": 0.5})).unwrap();
|
||||
crate::transform_tool::follow_edits(&mut app);
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_some(), "the box stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_alone_keeps_todays_box() {
|
||||
let (mut app, bg, _, _) = app();
|
||||
select(&mut app, bg, "replace");
|
||||
let r = invoke(&mut app, "edit.freeTransform");
|
||||
assert_ne!(r, Err(LOCKED.into()), "not the several-layers alert");
|
||||
assert!(alerts(&app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_over_a_linked_layer_stays_open_while_the_selection_stays() {
|
||||
// The box over A also moves B, linked to it (#11217); the selection is still A alone, so
|
||||
// nothing changed and the box stays open.
|
||||
let (mut app, _, a, b) = app();
|
||||
select(&mut app, a, "add");
|
||||
app.run("layer.linkLayers", json!({})).unwrap();
|
||||
select(&mut app, a, "replace");
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().layers, [a.0, b.0], "the box moves the link group");
|
||||
nudge(&mut app, 10.0);
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_some(), "the box stays open");
|
||||
// Adding B to the selection is a change, though the box already moves B.
|
||||
select(&mut app, b, "add");
|
||||
select(&mut app, a, "add");
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_none(), "the box is applied");
|
||||
assert_eq!(bounds(&app, a), Rect::new(20, 10, 60, 30));
|
||||
assert_eq!(bounds(&app, b), Rect::new(110, 100, 150, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_a_child_of_a_selected_group_out_of_the_selection_applies_the_box() {
|
||||
// The box over group G moves its child A whether A is selected or not; ⌘-clicking A out of
|
||||
// the selection still changes the selection, so the box is applied first.
|
||||
let (mut app, _, a, b) = app();
|
||||
select(&mut app, a, "replace");
|
||||
let g = LayerId(app.run("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap());
|
||||
select(&mut app, g, "replace");
|
||||
select(&mut app, a, "add");
|
||||
select(&mut app, b, "add");
|
||||
select(&mut app, g, "add");
|
||||
invoke(&mut app, "edit.freeTransform").unwrap();
|
||||
nudge(&mut app, 10.0);
|
||||
select(&mut app, a, "toggle");
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(g));
|
||||
crate::transform_tool::end_if_left(&mut app);
|
||||
assert!(app.ui.transform.is_none(), "the box is applied and not opened again");
|
||||
assert_eq!(bounds(&app, a), Rect::new(20, 10, 60, 30), "A moved with its group");
|
||||
assert_eq!(bounds(&app, b), Rect::new(110, 100, 150, 120));
|
||||
}
|
||||
|
||||
/// An unselected layer "C" under the frame's bottom-right handle (x, y 180..200), so a press there
|
||||
/// that reached the Move tool's Auto-Select would select it.
|
||||
fn corner_layer(app: &mut PhotocraftApp) -> LayerId {
|
||||
let c = LayerId(app.run("layer.new.layer", json!({"name": "C"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.run("select.rect", json!({"x": 180, "y": 180, "width": 20, "height": 20})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_handle_press_on_the_canvas_reaches_no_move_tool() {
|
||||
// The Background among the selected layers: a press on the Move tool's transform handle shows
|
||||
// the locked-layer alert and goes nowhere else. Auto-Select (on) used to take it and select
|
||||
// C under the handle, a click and a drag alike.
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
for drag in [false, true] {
|
||||
let (mut app, bg, a, _) = app();
|
||||
corner_layer(&mut app);
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, bg, "add");
|
||||
app.ui.tool = crate::state::Tool::Move;
|
||||
app.ui.tool_options.move_show_transform = true;
|
||||
app.ui.tool_options.move_auto_select = true;
|
||||
app.sync_views();
|
||||
let mut view = app.ui.views[0].clone();
|
||||
view.zoom = 1.0;
|
||||
view.center = [100.0, 100.0];
|
||||
view.fit_pending = false;
|
||||
view.fill_pending = false;
|
||||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(300.0, 300.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut frame = |app: &mut PhotocraftApp, events: Vec<Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
view = crate::canvas::canvas_view(app, ui, 0, rect, view.clone(), true);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let press = |pos: Pos2, pressed: bool| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
// The frame holds the Background, the whole canvas: its bottom-right handle is at doc
|
||||
// (200, 200), screen (250, 250); the press is inside its grab area, over C.
|
||||
let at = Pos2::new(246.0, 246.0);
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let selected = app.session.active().unwrap().selected_layers();
|
||||
frame(&mut app, vec![Event::PointerMoved(at)]);
|
||||
frame(&mut app, vec![press(at, true)]);
|
||||
let mut end = at;
|
||||
if drag {
|
||||
for i in 1..=6 {
|
||||
end = at + vec2(-8.0 * i as f32, -8.0 * i as f32);
|
||||
frame(&mut app, vec![Event::PointerMoved(end)]);
|
||||
}
|
||||
}
|
||||
frame(&mut app, vec![press(end, false)]);
|
||||
frame(&mut app, vec![]);
|
||||
assert!(app.ui.transform.is_none(), "drag {drag}: no box opens");
|
||||
assert!(alerts(&app).iter().any(|m| m == "Could not transform because the layer is locked."), "drag {drag}: {:?}", alerts(&app));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.selected_layers(), selected, "drag {drag}: the selection is unchanged");
|
||||
assert!(st.doc == before, "drag {drag}: the document is unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_handle_press_over_the_control_channel_reaches_no_move_tool() {
|
||||
// `ui.pointer` takes the same path: the alert, and the press is not forwarded to the Move tool.
|
||||
use crate::control::{ControlRequest, Outcome};
|
||||
let (mut app, bg, a, _) = app();
|
||||
corner_layer(&mut app);
|
||||
select(&mut app, a, "replace");
|
||||
select(&mut app, bg, "add");
|
||||
app.sync_views();
|
||||
app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0));
|
||||
app.ui.tool_options.move_auto_select = true;
|
||||
let ctx = egui::Context::default();
|
||||
let call = |app: &mut PhotocraftApp, method: &str, params: serde_json::Value| {
|
||||
let (request, _receiver) = ControlRequest::new(method, params);
|
||||
let Outcome::Done(reply) = crate::control::handle(app, &ctx, &request) else { panic!("expected an immediate {method} reply") };
|
||||
assert_eq!(reply["ok"], true, "{method}: {reply}");
|
||||
};
|
||||
call(&mut app, "ui.set", json!({"tool": "Move", "moveShowTransform": true, "zoom": 1.0, "center": [100, 100]}));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let selected = app.session.active().unwrap().selected_layers();
|
||||
call(
|
||||
&mut app,
|
||||
"ui.pointer",
|
||||
json!({"events": [{"kind": "down", "x": 196, "y": 196}, {"kind": "move", "x": 180, "y": 180}, {"kind": "up", "x": 180, "y": 180}]}),
|
||||
);
|
||||
assert!(app.ui.transform.is_none(), "no box opens");
|
||||
assert!(alerts(&app).iter().any(|m| m == "Could not transform because the layer is locked."), "{:?}", alerts(&app));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.selected_layers(), selected, "the selection is unchanged");
|
||||
assert!(st.doc == before, "the document is unchanged");
|
||||
}
|
||||
@@ -153,26 +153,16 @@ fn a_locked_layer_among_them_refuses_before_the_box_opens() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_background_active_the_box_moves_the_other_selected_layers() {
|
||||
let (mut app, paint, text) = app();
|
||||
fn with_the_background_active_among_them_the_box_refuses_as_locked() {
|
||||
// Photoshop's locked-layer alert, not a box that leaves the Background out (#11101).
|
||||
let (mut app, _, _) = app();
|
||||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.run("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().active_layer, Some(bg));
|
||||
assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "not greyed for the Background");
|
||||
free_transform(&mut app);
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!((t.layer, t.layers.clone()), (bg.0, vec![paint.0, text.0]));
|
||||
let p0 = bounds(&app, paint);
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
// Nearest neighbour: no resampling fringe at the block's edge.
|
||||
t.interpolation = "nearest".into();
|
||||
t.quad = t.quad.map(|[x, y]| [x + 5.0, y]);
|
||||
}
|
||||
crate::transform_tool::commit(&mut app);
|
||||
assert_eq!(app.ui.status, "");
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.doc.is_background(bg), "the Background is left alone");
|
||||
assert_eq!(bounds(&app, paint), p0.translate(5, 0));
|
||||
assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "not greyed: choosing it shows the alert");
|
||||
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({}));
|
||||
assert_eq!(r, Err(crate::transform_tool::LOCKED.into()));
|
||||
assert!(app.ui.transform.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! #10894: two Crop defaults as in the reference application. The overlay shows Always by
|
||||
//! default, and Reset Tool brings back Always and Rule of Thirds. Fill goes back to its default
|
||||
//! entry when another document that was already open becomes active, and keeps Content-Aware
|
||||
//! Fill otherwise.
|
||||
|
||||
use photocraft_ui_egui::control::{ControlRequest, handle};
|
||||
use photocraft_ui_egui::crop_overlay::{CropOverlay, OverlayShow};
|
||||
use photocraft_ui_egui::{PhotocraftApp, Tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.sync_views();
|
||||
app
|
||||
}
|
||||
|
||||
/// A control request as the control channel sends it, then the frame's sync.
|
||||
fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value {
|
||||
let ctx = egui::Context::default();
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
handle(app, &ctx, &req);
|
||||
app.sync_views();
|
||||
rx.try_recv().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Pick a document by its Window menu row, as the tab bar does.
|
||||
fn activate(app: &mut PhotocraftApp, index: usize) {
|
||||
control(app, "ui.menu.invoke", json!({"id": format!("window.document.{index}")}));
|
||||
assert_eq!(app.session.active_index(), Some(index), "document {index} is active");
|
||||
}
|
||||
|
||||
fn content_aware(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool_options.crop_content_aware
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overlay_shows_always_by_default_and_reset_tool_brings_it_back() {
|
||||
let mut app = app();
|
||||
let o = &app.ui.tool_options;
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show), (CropOverlay::Thirds, OverlayShow::Always), "a fresh Crop tool");
|
||||
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.tool_options.crop_overlay = CropOverlay::Diagonal;
|
||||
app.ui.tool_options.crop_overlay_show = OverlayShow::Auto;
|
||||
control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"}));
|
||||
let o = &app.ui.tool_options;
|
||||
assert_eq!((o.crop_overlay, o.crop_overlay_show), (CropOverlay::Thirds, OverlayShow::Always), "Reset Tool");
|
||||
|
||||
app.ui.tool_options.crop_overlay_show = OverlayShow::Never;
|
||||
control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"}));
|
||||
assert_eq!(app.ui.tool_options.crop_overlay_show, OverlayShow::Always, "Reset Tool from Never too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_show_mode_already_saved_keeps_its_value() {
|
||||
for (saved, want) in [("auto", OverlayShow::Auto), ("never", OverlayShow::Never), ("always", OverlayShow::Always)] {
|
||||
let o: photocraft_ui_egui::state::ToolOptions = serde_json::from_value(json!({"crop_overlay_show": saved})).unwrap();
|
||||
assert_eq!(o.crop_overlay_show, want, "a saved `{saved}` loads as it was saved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_is_kept_by_tool_switches_and_new_documents() {
|
||||
let mut app = app();
|
||||
app.ui.tool_options.crop_content_aware = true;
|
||||
|
||||
app.ui.tool = Tool::Brush;
|
||||
app.sync_views();
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.sync_views();
|
||||
assert!(content_aware(&app), "switch to another tool and back: kept");
|
||||
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.sync_views();
|
||||
assert_eq!(app.session.active_index(), Some(1), "the new document is active");
|
||||
assert!(content_aware(&app), "create a new document: kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_is_kept_when_a_crop_is_committed_or_cancelled() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.tool_options.crop_content_aware = true;
|
||||
|
||||
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
|
||||
app.ui.crop_rect = Some([8.0, 8.0, 40.0, 32.0]);
|
||||
photocraft_ui_egui::canvas::commit_crop(&mut app);
|
||||
app.sync_views();
|
||||
let size = app.session.active().unwrap().doc.size;
|
||||
assert_eq!((size.width, size.height), (32, 24), "the crop was committed");
|
||||
assert!(content_aware(&app), "commit a crop: kept");
|
||||
|
||||
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
|
||||
app.ui.crop_rect = Some([4.0, 4.0, 20.0, 16.0]);
|
||||
photocraft_ui_egui::crop_ui::cancel(&mut app);
|
||||
app.sync_views();
|
||||
assert_eq!(app.session.active().unwrap().doc.size.width, 32, "the cancelled crop changed nothing");
|
||||
assert!(content_aware(&app), "cancel a crop: kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_goes_back_to_the_default_entry_when_another_open_document_becomes_active() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.sync_views();
|
||||
|
||||
// A tab switch to a document that was already open.
|
||||
app.ui.tool_options.crop_content_aware = true;
|
||||
activate(&mut app, 0);
|
||||
assert!(!content_aware(&app), "make another already-open document active: back to the default");
|
||||
|
||||
// Switching back to the first one doesn't bring it back.
|
||||
activate(&mut app, 1);
|
||||
assert!(!content_aware(&app), "switch back to the first document: stays at the default");
|
||||
|
||||
// Closing the active document makes another open one active.
|
||||
app.ui.tool_options.crop_content_aware = true;
|
||||
control(&mut app, "ui.menu.invoke", json!({"id": "file.close"}));
|
||||
assert_eq!(app.session.documents().len(), 1, "one document closed");
|
||||
assert!(!content_aware(&app), "close the active document so another becomes active: back to the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_is_kept_while_the_same_document_stays_active() {
|
||||
let mut app = app();
|
||||
app.ui.tool_options.crop_content_aware = true;
|
||||
// Repeated syncs of the same document, as every frame does, leave it alone.
|
||||
for _ in 0..3 {
|
||||
app.sync_views();
|
||||
}
|
||||
assert!(content_aware(&app));
|
||||
// An edit replaces the snapshot without changing the document.
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.sync_views();
|
||||
assert!(content_aware(&app), "an edit in the active document keeps it");
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! #10930: before the first change to a crop frame the reference application shows only a dashed
|
||||
//! outline with grey handles, with no shield and no overlay in any show mode. The shield and the
|
||||
//! overlay start with the first change and stay until the crop is committed or cancelled. These
|
||||
//! test the decisions the canvas draws from (`current_guides`, `crop_shield::current`,
|
||||
//! `frame_look`), driven by real Crop gestures through the control channel.
|
||||
|
||||
use photocraft_ui_egui::control::{ControlRequest, handle};
|
||||
use photocraft_ui_egui::crop_overlay::{FrameLook, OverlayShow, current_guides, frame_look};
|
||||
use photocraft_ui_egui::{PhotocraftApp, Tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn control(app: &mut PhotocraftApp, method: &str, params: Value) {
|
||||
let ctx = egui::Context::default();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
handle(app, &ctx, &req);
|
||||
app.sync_views();
|
||||
}
|
||||
|
||||
/// A document with the Crop tool showing its untouched default frame, overlay mode `show`.
|
||||
fn crop_app(show: OverlayShow) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 120})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.tool_options.crop_overlay_show = show;
|
||||
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some(), "the Crop tool shows a frame");
|
||||
app
|
||||
}
|
||||
|
||||
/// What the canvas would draw now: (guides shown, shield shown, outline look).
|
||||
fn drawn(app: &PhotocraftApp) -> (bool, bool, FrameLook) {
|
||||
let ctx = egui::Context::default();
|
||||
let guides = !current_guides(app, 300.0, 200.0).is_empty();
|
||||
let shield = photocraft_ui_egui::crop_shield::current(app, &ctx).is_some();
|
||||
(guides, shield, frame_look(app))
|
||||
}
|
||||
|
||||
/// Press inside the untouched frame and drag: that draws a new frame, the first change.
|
||||
fn press_and_drag(app: &mut PhotocraftApp) {
|
||||
control(app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}, {"kind": "move", "x": 150, "y": 90}]}));
|
||||
}
|
||||
|
||||
fn release(app: &mut PhotocraftApp) {
|
||||
control(app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "up", "x": 150, "y": 90}]}));
|
||||
}
|
||||
|
||||
const MODES: [OverlayShow; 3] = [OverlayShow::Always, OverlayShow::Auto, OverlayShow::Never];
|
||||
|
||||
#[test]
|
||||
fn an_untouched_frame_shows_only_a_dashed_outline_in_every_show_mode() {
|
||||
for show in MODES {
|
||||
let app = crop_app(show);
|
||||
assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: no overlay, no shield, dashed outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shield_and_the_overlay_start_with_the_first_change() {
|
||||
// (mode, overlay while dragging, overlay after release with the crop still pending)
|
||||
for (show, dragging, released) in [(OverlayShow::Always, true, true), (OverlayShow::Auto, true, false), (OverlayShow::Never, false, false)] {
|
||||
let mut app = crop_app(show);
|
||||
press_and_drag(&mut app);
|
||||
assert_eq!(drawn(&app), (dragging, true, FrameLook::Started), "{show:?} mid-drag: shield on, solid outline");
|
||||
release(&mut app);
|
||||
assert!(app.ui.crop_rect.is_some(), "{show:?}: the crop is still pending after release");
|
||||
assert_eq!(drawn(&app), (released, true, FrameLook::Started), "{show:?} after release: the shield stays until commit or cancel");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_or_cancelling_brings_back_the_untouched_look() {
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
press_and_drag(&mut app);
|
||||
release(&mut app);
|
||||
photocraft_ui_egui::canvas::commit_crop(&mut app);
|
||||
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
|
||||
// A commit hides the frame until the next press, so there is nothing to draw.
|
||||
assert!(app.ui.crop_rect.is_none(), "{show:?}: the commit hides the frame");
|
||||
|
||||
press_and_drag(&mut app);
|
||||
release(&mut app);
|
||||
assert!(drawn(&app).1, "{show:?}: a second crop starts again");
|
||||
photocraft_ui_egui::crop_ui::cancel(&mut app);
|
||||
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
|
||||
assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?} after cancel: a new untouched frame");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mode_keeps_the_look_it_had() {
|
||||
// Classic Mode wasn't measured, so it keeps its solid white outline, shield and overlay rule.
|
||||
let mut app = crop_app(OverlayShow::Always);
|
||||
app.ui.tool_options.crop_shield.classic_mode = true;
|
||||
let (guides, shield, look) = drawn(&app);
|
||||
assert_eq!(look, FrameLook::Classic);
|
||||
assert!(guides && shield, "Classic Mode shows its overlay and shield as before, even untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_that_has_not_moved_yet_is_not_a_change() {
|
||||
// The first change is a drag; a press held still leaves the untouched look in every mode.
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}]}));
|
||||
assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: pressed, not moved");
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "move", "x": 150, "y": 90}]}));
|
||||
assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: the move is the first change");
|
||||
}
|
||||
}
|
||||
|
||||
/// A click inside the untouched frame that doesn't move is no change either: released, the frame
|
||||
/// keeps its untouched look, with no shield and no overlay (noted in review).
|
||||
#[test]
|
||||
fn a_click_inside_the_untouched_frame_is_not_a_change() {
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
let before = app.ui.crop_rect;
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}, {"kind": "up", "x": 50, "y": 40}]}));
|
||||
assert_eq!(app.ui.crop_rect, before, "{show:?}: the frame is where it was");
|
||||
assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: clicked, not changed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a handle of the untouched frame that doesn't move resizes nothing, so the frame
|
||||
/// keeps its untouched look after the release, as a click inside it does (noted in review).
|
||||
#[test]
|
||||
fn a_click_on_a_handle_of_the_untouched_frame_is_not_a_change() {
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
let before = app.ui.crop_rect.unwrap();
|
||||
let (x, y) = (before[2], before[3]);
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]}));
|
||||
assert_eq!(app.ui.crop_rect, Some(before), "{show:?}: the frame is where it was");
|
||||
assert_eq!(drawn(&app), (false, false, FrameLook::Untouched), "{show:?}: a handle clicked, not dragged");
|
||||
}
|
||||
}
|
||||
|
||||
/// An Alt-drag from the centre can draw the very frame the drag started from. The drag changed
|
||||
/// the frame on its way there, so after the release the crop is started, not untouched again
|
||||
/// (noted in review): what counts is that the gesture changed the frame, not how it ended.
|
||||
#[test]
|
||||
fn a_drag_that_draws_the_same_frame_again_has_started_the_crop() {
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
let before = app.ui.crop_rect.unwrap();
|
||||
let c = [(before[0] + before[2]) / 2.0, (before[1] + before[3]) / 2.0];
|
||||
control(
|
||||
&mut app,
|
||||
"ui.pointer",
|
||||
json!({"tool": "Crop", "alt": true, "events": [{"kind": "down", "x": c[0], "y": c[1]}, {"kind": "move", "x": c[0] + 20.0, "y": c[1] + 10.0}, {"kind": "move", "x": before[2], "y": before[3]}, {"kind": "up", "x": before[2], "y": before[3]}]}),
|
||||
);
|
||||
assert_eq!(app.ui.crop_rect, Some(before), "{show:?}: drawn from the centre back to the same frame");
|
||||
assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: the drag changed it, so the crop has started");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_cropped_area_off_still_hides_what_is_outside_an_untouched_frame() {
|
||||
let mut app = crop_app(OverlayShow::Always);
|
||||
app.ui.tool_options.crop_shield.show_cropped_area = false;
|
||||
let ctx = egui::Context::default();
|
||||
assert!(photocraft_ui_egui::crop_shield::current(&app, &ctx).is_some(), "the opaque outside fill stays");
|
||||
assert!(current_guides(&app, 300.0, 200.0).is_empty(), "but no overlay before the first change");
|
||||
}
|
||||
|
||||
/// Within a drag the first change counts: a drag that changes the frame and comes back to where it
|
||||
/// started keeps the started look, rather than flickering back to the untouched one mid-drag
|
||||
/// (noted in review). Released, the crop is pending as after any drawn frame.
|
||||
#[test]
|
||||
fn a_drag_brought_back_to_its_start_keeps_the_started_look_until_it_ends() {
|
||||
for show in MODES {
|
||||
let mut app = crop_app(show);
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "down", "x": 50, "y": 40}, {"kind": "move", "x": 150, "y": 90}]}));
|
||||
assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: the first change");
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "move", "x": 50, "y": 40}]}));
|
||||
assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: back where it started, still the started look");
|
||||
assert!(drawn(&app).1, "{show:?}: and the shield");
|
||||
control(&mut app, "ui.pointer", json!({"tool": "Crop", "events": [{"kind": "up", "x": 50, "y": 40}]}));
|
||||
assert_eq!(drawn(&app).2, FrameLook::Started, "{show:?}: released, the crop is pending");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ mod color_managed_canvas;
|
||||
mod crop_beyond_canvas;
|
||||
mod crop_ctrl_straighten;
|
||||
mod crop_default_mode;
|
||||
mod crop_defaults;
|
||||
mod crop_frame_look;
|
||||
mod crop_overlay_icons;
|
||||
mod crop_popup_keys;
|
||||
mod crop_shift_corner;
|
||||
|
||||
@@ -60,7 +60,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
|
||||
- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error.
|
||||
- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks, as four floats 0..1 (not 0..255).
|
||||
- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box.
|
||||
- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`always`: the whole time a crop is pending, the default, as in the reference application; `auto`: only while the crop box is dragged; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box.
|
||||
- `ui.set` also accepts the Pen tools' options bar (#1397): `penMode` (`path` or `shape`) and `penPathOp`, the Path Operations menu (`newLayer`, `combine`, `subtract`, `intersect`, `exclude`): how the next Pen, Freeform Pen or Curvature Pen path joins the selected shape layer (Shape mode; `newLayer` makes a new one) or the targeted vector mask, selected path or Work Path (Path mode, where `newLayer` combines). `ui.inspect` reports them under `toolOptions` (`vector_mode`, `pen_path_op`). Merge Shape Components is the engine command `path.mergeShapeComponents` (work, saved or `layer` path) or `layer.combineShapes.mergeShapeComponents` (shape layers).
|
||||
- `ui.set` also accepts `cropFill` (`background`, the Fill dropdown's first entry, which reads Background (default), or Transparent (default) with Delete Cropped Pixels off, or `contentAware`), the Crop tool's Fill dropdown (#3823, #10872): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`.
|
||||
Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them.
|
||||
|
||||
@@ -220,6 +220,15 @@ Validation on Linux, full sweep, serial tests and CI's pinned fonts:
|
||||
|
||||
Evidence is retained in `plan/merge-main-20261104/` (gitignored).
|
||||
|
||||
## Main integration (2026-11-07)
|
||||
|
||||
Integrated upstream main `8df2b7a2e`, then `048ae7977` after three more upstream PRs landed. The Smart Filter conflicts preserve captured tone targets and precision while adopting main's replacement of adjustment parameters. Consumed aliases and smoothing counts disappear when the filter saves. Both sides' regressions are retained, with additional fractional Lab Levels and Curves records in the round-trip test. The Action dialog setup moved into a helper to keep playback within Clippy's complexity limit and preserve the authorization and receiver checks.
|
||||
|
||||
- Full Linux engine/UI suite at `8df2b7a2e` with pinned CI fonts and the full serial sweep: **9,486 passed, 0 failed, 46 normally ignored**, including doctest builds.
|
||||
- At `048ae7977`, **1,222 focused tests passed, 0 failed, 6 normally ignored**. They cover tone targets, Smart Filters, Actions, transforms, crop, marquee, lasso, and localization.
|
||||
- Strict all-target Clippy, formatting, layering, wasm, and the ignored full-registry adversarial command test passed at `048ae7977`. Changed-corpus checking skipped because the PR diff contains no corpus-critical paths.
|
||||
- Offscreen captures verified both addressed and unavailable mask targets for Action dialogs. Lab Smart Filters reopened with Lightness selected and their recorded settings while an alpha channel was selected. The three Smart Filter captures were byte-identical on the final snapshot.
|
||||
|
||||
## 24 MP release comparison
|
||||
|
||||
Dense synthetic 6000 × 4000 RGB8 raster layer, raster mask, saved alpha channel and Quick Mask, each filled with 25% gray. The central selection is a 3000 × 2000 rectangle at 0.5 coverage. Proxy factor 4 (1500 × 1000). AMD Ryzen 7 9700X, 16 logical CPUs, Ubuntu 26.04.1, Rust 1.99.0, default optimized release profile (thin LTO).
|
||||
|
||||
Reference in new issue
Block a user