mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 23:16:49 +01:00
Transform modes: Skew / Distort / Perspective as real modes; legacy corners as a preference (#671)
- Edit › Transform › Skew, Distort and Perspective now set a mode on the box instead of just starting Free Transform: in Distort a corner moves alone with no keys held (Skew: edges skew; Perspective: corners move in mirrored pairs). While a box is up they switch its mode, as does a right-click on the canvas (Free Transform, Scale, Rotate, Skew, Distort, Perspective), with the current mode checked. - Distort moves only corners (or the whole box from inside): dragging outside doesn't rotate, edge handles don't move, nothing snaps; the cursor shows when a drag would do nothing. - Corner drags stay proportional by default (⇧ frees them). Preferences › General › Use Legacy Free Transform (already a setting, hidden until now) swaps that: free by default, ⇧ keeps proportions. - The handle under a press is hit-tested where the press was, before snapping, which could push it off a corner and move the whole box. Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
8 files changed
+242
-22
No files matched your search
@@ -804,7 +804,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"general.alwaysCreateSmartObjectsWhenPlacing",
|
||||
"general.animatedZoom",
|
||||
"general.zoomResizesWindows",
|
||||
"general.useLegacyFreeTransform",
|
||||
"interface.uiFontSize",
|
||||
"interface.showChannelsInColor",
|
||||
"interface.dynamicColorSliders",
|
||||
|
||||
@@ -1662,8 +1662,18 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// Right-click while transforming: switch the box's mode (Free Transform, Scale, Rotate,
|
||||
// Skew, Distort, Perspective).
|
||||
let transforming = app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none());
|
||||
if response.secondary_clicked()
|
||||
&& transforming
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
crate::canvas_tool_menu::open_transform(app, [p.x, p.y]);
|
||||
}
|
||||
// Right-click with the Move tool, or ⌘/Ctrl+right-click: the layers under the pointer.
|
||||
if response.secondary_clicked()
|
||||
&& !transforming
|
||||
&& crate::layer_pick_ui::is_gesture(tool, mods)
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
@@ -1672,6 +1682,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::layer_pick_ui::open(app, [p.x, p.y], d[0], d[1]);
|
||||
}
|
||||
if response.secondary_clicked()
|
||||
&& !transforming
|
||||
&& !crate::layer_pick_ui::is_gesture(tool, mods)
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
@@ -2249,7 +2260,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
let raw = ev;
|
||||
let ev = crate::snap_ui::filter_event(app, ev, mods);
|
||||
if crate::transform_tool::pointer(app, ev, mods) {
|
||||
// Free Transform picks the handle under the press where the user clicked: the snapped press
|
||||
// can land outside the corner's grab area and turn a corner drag into a move of the whole box.
|
||||
let transform_ev = if matches!(raw, ToolEvent::Down { .. }) { raw } else { ev };
|
||||
if crate::transform_tool::pointer(app, transform_ev, mods) {
|
||||
return;
|
||||
}
|
||||
if crate::distort_ui::pointer(app, ev, mods) {
|
||||
|
||||
@@ -16,8 +16,21 @@ pub struct CanvasToolMenu {
|
||||
pub has_path: bool,
|
||||
#[serde(default)]
|
||||
pub path_name: Option<String>,
|
||||
/// Opened over a Free Transform box: its modes instead of the tool's actions.
|
||||
#[serde(default)]
|
||||
pub transform: bool,
|
||||
}
|
||||
|
||||
/// Right-click while transforming: what the box's handles do.
|
||||
pub const TRANSFORM_MENU: &[(&str, &str)] = &[
|
||||
("Free Transform", "edit.freeTransform"),
|
||||
("Scale", "edit.transform.scale"),
|
||||
("Rotate", "edit.transform.rotate"),
|
||||
("Skew", "edit.transform.skew"),
|
||||
("Distort", "edit.transform.distort"),
|
||||
("Perspective", "edit.transform.perspective"),
|
||||
];
|
||||
|
||||
/// Photoshop's Pen context menu order. `None` is a separator. Rows whose operation is not
|
||||
/// applicable to the current path or layer stay visible and disabled.
|
||||
pub const PEN_MENU: &[Option<(&str, &str)>] = &[
|
||||
@@ -73,11 +86,36 @@ pub fn entries(has_selection: bool) -> &'static [(&'static str, &'static str)] {
|
||||
}
|
||||
|
||||
pub fn menu_entries(menu: &CanvasToolMenu) -> &'static [(&'static str, &'static str)] {
|
||||
if menu.tool == Tool::Pen { &[] } else { entries(menu.has_selection) }
|
||||
if menu.transform {
|
||||
TRANSFORM_MENU
|
||||
} else if menu.tool == Tool::Pen {
|
||||
&[]
|
||||
} else {
|
||||
entries(menu.has_selection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `command` the transform box's current mode (checked in the transform menu)?
|
||||
/// Scale and Rotate are Free Transform's mode, so only Free Transform shows it checked.
|
||||
fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
|
||||
menu.transform
|
||||
&& !matches!(command, "edit.transform.scale" | "edit.transform.rotate")
|
||||
&& app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::for_command(command))
|
||||
}
|
||||
|
||||
/// Right-click over a Free Transform box: its modes.
|
||||
pub fn open_transform(app: &mut PhotocraftApp, pos: [f32; 2]) -> bool {
|
||||
if !pos.iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
app.ui.brush_picker = None;
|
||||
app.ui.layer_menu = None;
|
||||
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool: app.ui.tool, has_selection: false, has_path: false, path_name: None, transform: true });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
|
||||
if menu.tool != Tool::Pen {
|
||||
if menu.transform || menu.tool != Tool::Pen {
|
||||
return crate::menus::is_enabled(app, command);
|
||||
}
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
@@ -123,13 +161,13 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, pos: [f32; 2]) -> bool {
|
||||
let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some());
|
||||
let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
|
||||
let path_name = crate::vector_ui::active_path_name(app);
|
||||
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name });
|
||||
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
|
||||
let Some(menu) = app.ui.canvas_tool_menu.take() else { return };
|
||||
if menu.tool == Tool::Pen {
|
||||
if menu.tool == Tool::Pen && !menu.transform {
|
||||
if !PEN_MENU.iter().flatten().any(|(_, id)| *id == command) || !entry_enabled(app, &menu, command) {
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +177,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !entries(menu.has_selection).iter().any(|&(_, id)| id == command) || !crate::menus::is_enabled(app, command) {
|
||||
if !menu_entries(&menu).iter().any(|&(_, id)| id == command) || !crate::menus::is_enabled(app, command) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, command, json!({})) {
|
||||
@@ -211,7 +249,7 @@ fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, com
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
let Some(menu) = app.ui.canvas_tool_menu.clone() else { return };
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || app.ui.tool != menu.tool {
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) {
|
||||
app.ui.canvas_tool_menu = None;
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +271,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
v.widgets.hovered.corner_radius = egui::CornerRadius::same(3);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_width(200.0);
|
||||
if menu.tool == Tool::Pen {
|
||||
if menu.tool == Tool::Pen && !menu.transform {
|
||||
for row in PEN_MENU {
|
||||
if let Some((label, command)) = row {
|
||||
let item = egui::Button::selectable(false, tl!(label)).min_size(egui::vec2(200.0, 20.0));
|
||||
@@ -246,7 +284,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
}
|
||||
} else {
|
||||
for &(label, command) in menu_entries(&menu) {
|
||||
let item = egui::Button::selectable(false, tl!(&label)).min_size(egui::vec2(200.0, 22.0));
|
||||
let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(&label)).min_size(egui::vec2(200.0, 22.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(command);
|
||||
}
|
||||
|
||||
@@ -377,7 +377,17 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
| "edit.transform.rotate"
|
||||
| "edit.transform.skew"
|
||||
| "edit.transform.distort"
|
||||
| "edit.transform.perspective" => crate::transform_tool::begin(app, ctx).map(|_| json!({"transform": app.ui.transform})),
|
||||
| "edit.transform.perspective" => {
|
||||
// While a box is up, Scale / Rotate / Skew / Distort / Perspective (and Free Transform)
|
||||
// switch its mode; otherwise they start one in that mode.
|
||||
if app.ui.transform.is_none() {
|
||||
crate::transform_tool::begin(app, ctx)?;
|
||||
}
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
t.mode = crate::state::TransformMode::for_command(id);
|
||||
}
|
||||
Ok(json!({"transform": app.ui.transform}))
|
||||
}
|
||||
"edit.freeTransformCopy" => crate::transform_tool::begin_copy(app, ctx).map(|_| json!({"transform": app.ui.transform})),
|
||||
// Edit › Transform › Warp from the menu: interactive Warp mode (with params: the engine).
|
||||
"edit.transform.warp" | "layer.smartObjects.warp" if params.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
@@ -496,13 +506,16 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
|
||||
i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => {
|
||||
app.session.active().is_some()
|
||||
}
|
||||
"edit.freeTransformCopy" => app.ui.transform.is_none() && app.session.active().and_then(|s| s.active_layer).is_some(),
|
||||
"edit.freeTransform"
|
||||
| "edit.freeTransformCopy"
|
||||
| "edit.transform.scale"
|
||||
| "edit.transform.rotate"
|
||||
| "edit.transform.skew"
|
||||
| "edit.transform.distort"
|
||||
| "edit.transform.perspective" => app.ui.transform.is_none() && app.session.active().and_then(|s| s.active_layer).is_some(),
|
||||
| "edit.transform.perspective" => match &app.ui.transform {
|
||||
Some(t) => t.warp.is_none(),
|
||||
None => app.session.active().and_then(|s| s.active_layer).is_some(),
|
||||
},
|
||||
i => app.session.is_enabled(i),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2]) {
|
||||
let tool = app.ui.tool;
|
||||
let tol = tolerance(app);
|
||||
let gesture = if let Some(t) = &app.ui.transform {
|
||||
if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) {
|
||||
if t.mode == crate::state::TransformMode::Distort {
|
||||
// Distort is freehand corner placement (fitting an image onto a screen, say): guides
|
||||
// and edges pulling the corners about only get in the way.
|
||||
None
|
||||
} else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) {
|
||||
Some((Gesture::Point, vec![LayerId(t.layer)]))
|
||||
} else if in_quad(&t.quad, p) {
|
||||
Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)]))
|
||||
@@ -389,6 +393,21 @@ mod tests {
|
||||
assert_eq!(sel.x1, 116);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_mode_never_snaps() {
|
||||
let mut app = app_with_box();
|
||||
let ctx = egui::Context::default();
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap();
|
||||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||||
let m = egui::Modifiers::NONE;
|
||||
// Drag the top-right corner to 2 px short of the target's right edge (x = 350).
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[1][0], y: q[1][1], pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 348.0, y: q[1][1] }, m);
|
||||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||||
assert_eq!(q[1][0], 348.0, "the corner stays where it was dropped");
|
||||
assert!(app.prefs_rt.snap_lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_drags_and_handles_snap() {
|
||||
let mut app = app_with_box();
|
||||
|
||||
@@ -521,6 +521,34 @@ impl Default for ToolOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Transform's mode: what a handle drag does with no modifier keys held.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TransformMode {
|
||||
/// Free Transform, Scale and Rotate.
|
||||
#[default]
|
||||
Free,
|
||||
/// Edge handles skew.
|
||||
Skew,
|
||||
/// Corner handles move freely, one at a time; nothing else moves them (no rotating, no
|
||||
/// edges) and nothing snaps.
|
||||
Distort,
|
||||
/// Corner handles move in pairs, mirrored (one-point perspective).
|
||||
Perspective,
|
||||
}
|
||||
|
||||
impl TransformMode {
|
||||
/// The mode an `edit.transform.*` / `edit.freeTransform` menu id starts.
|
||||
pub fn for_command(id: &str) -> Self {
|
||||
match id {
|
||||
"edit.transform.skew" => Self::Skew,
|
||||
"edit.transform.distort" => Self::Distort,
|
||||
"edit.transform.perspective" => Self::Perspective,
|
||||
_ => Self::Free,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free Transform in progress: the source frame `rect` and where its corners currently are.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TransformSession {
|
||||
@@ -546,6 +574,9 @@ pub struct TransformSession {
|
||||
/// and OK folds it into the transform's history step (#352).
|
||||
#[serde(default)]
|
||||
pub copy: bool,
|
||||
/// Edit › Transform › Skew / Distort / Perspective (`Free` for Free Transform).
|
||||
#[serde(default)]
|
||||
pub mode: TransformMode,
|
||||
}
|
||||
|
||||
/// In-progress inline type editing (Type tool). Offsets are character indices.
|
||||
|
||||
@@ -18,7 +18,7 @@ use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform};
|
||||
use crate::state::{Tool, TransformSession};
|
||||
use crate::state::{Tool, TransformMode, TransformSession};
|
||||
|
||||
/// Which Split button is armed. The guide follows the pointer and the split is added on release.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -191,6 +191,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
selection: false,
|
||||
target: None,
|
||||
copy: false,
|
||||
mode: Default::default(),
|
||||
});
|
||||
start_steps(app);
|
||||
Ok(())
|
||||
@@ -290,6 +291,7 @@ fn begin_lone(
|
||||
selection: false,
|
||||
target: Some(target),
|
||||
copy: false,
|
||||
mode: Default::default(),
|
||||
});
|
||||
start_steps(app);
|
||||
Ok(())
|
||||
@@ -348,6 +350,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
|
||||
selection: true,
|
||||
target: None,
|
||||
copy: false,
|
||||
mode: Default::default(),
|
||||
});
|
||||
start_steps(app);
|
||||
Ok(())
|
||||
@@ -658,21 +661,52 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
s.pivot = [x, y];
|
||||
h = Hit::Pivot;
|
||||
}
|
||||
pv.gesture = Some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||||
// Distort only moves corners (and the whole box from inside): no rotating, no edges.
|
||||
pv.gesture = distort_allows(t.mode, h).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||||
}
|
||||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
|
||||
let g = pv.gesture;
|
||||
if matches!(ev, ToolEvent::Up { .. }) {
|
||||
pv.gesture = None;
|
||||
}
|
||||
let legacy = app.session.prefs().general.use_legacy_free_transform;
|
||||
if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) {
|
||||
apply_drag(s, g, [x, y], mods);
|
||||
apply_drag(s, g, [x, y], mode_mods(t.mode, g.hit, corner_mods(legacy, g.hit, mods)));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Preferences › General › Use Legacy Free Transform: corner drags stretch freely and ⇧ keeps
|
||||
/// the proportions, the reverse of the default (proportional, ⇧ frees them).
|
||||
fn corner_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||||
if legacy && matches!(hit, Hit::Corner(_)) && !mods.command {
|
||||
mods.shift = !mods.shift;
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
/// Skew / Distort / Perspective modes: a handle drag acts as if that gesture's keys were held
|
||||
/// (⌘-drag an edge skews, ⌘-drag a corner distorts, ⌘⌥⇧-drag a corner adds perspective).
|
||||
fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||||
match (mode, hit) {
|
||||
(TransformMode::Skew, Hit::Edge(_)) | (TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
|
||||
(TransformMode::Perspective, Hit::Corner(_)) => {
|
||||
mods.command = true;
|
||||
mods.alt = true;
|
||||
mods.shift = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
/// In Distort mode only corner drags (and moving the box from inside) do anything.
|
||||
fn distort_allows(mode: TransformMode, h: Hit) -> bool {
|
||||
mode != TransformMode::Distort || !matches!(h, Hit::Outside | Hit::Edge(_))
|
||||
}
|
||||
|
||||
fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) {
|
||||
let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]);
|
||||
match g.hit {
|
||||
@@ -797,7 +831,8 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
|
||||
r[1] = 2.0 * pv - r[3];
|
||||
}
|
||||
}
|
||||
// Corners scale proportionally by default (Photoshop CC); ⇧ frees them.
|
||||
// Corners scale proportionally by default; ⇧ frees them (the legacy preference swaps
|
||||
// the two, `corner_mods`).
|
||||
let corner = matches!(g.hit, Hit::Corner(_));
|
||||
if corner && !mods.shift {
|
||||
let (sx, sy) = (r[2] - r[0], r[3] - r[1]);
|
||||
@@ -1013,7 +1048,11 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], alt: bool) -> Option<CursorIcon>
|
||||
}
|
||||
return Some(if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { CursorIcon::Crosshair } else { CursorIcon::Default });
|
||||
}
|
||||
Some(match hit(t, p, tol) {
|
||||
let h = hit(t, p, tol);
|
||||
if !distort_allows(t.mode, h) {
|
||||
return Some(CursorIcon::Default);
|
||||
}
|
||||
Some(match h {
|
||||
Hit::Corner(0 | 2) => CursorIcon::ResizeNwSe,
|
||||
Hit::Corner(_) => CursorIcon::ResizeNeSw,
|
||||
Hit::Edge(0 | 2) => CursorIcon::ResizeVertical,
|
||||
@@ -1493,6 +1532,7 @@ mod tests {
|
||||
selection: false,
|
||||
target: None,
|
||||
copy: false,
|
||||
mode: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1815,6 +1855,72 @@ mod tests {
|
||||
assert!(b.x0 < 8 && b.x1 >= 44 && b.y0 == 8 && b.y1 > 50, "moved square and stroke: {b:?}");
|
||||
}
|
||||
|
||||
/// Drags with real pointer events (snapping off so the drops land exactly).
|
||||
fn press_drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], m: egui::Modifiers) {
|
||||
crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_mode_moves_one_corner_and_ignores_rotate_and_edges() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
app.ui.extras.snap = false;
|
||||
let ctx = egui::Context::default();
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap();
|
||||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort);
|
||||
// Outside (rotate) and an edge handle do nothing in Distort.
|
||||
press_drag(&mut app, [44.0, 44.0], [54.0, 10.0], egui::Modifiers::NONE);
|
||||
press_drag(&mut app, [24.0, 16.0], [34.0, 16.0], egui::Modifiers::NONE);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0);
|
||||
// A corner moves alone, with no keys held.
|
||||
press_drag(&mut app, q0[1], [q0[1][0] + 7.0, q0[1][1] - 5.0], egui::Modifiers::NONE);
|
||||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||||
assert_eq!(q[1], [q0[1][0] + 7.0, q0[1][1] - 5.0]);
|
||||
assert_eq!([q[0], q[2], q[3]], [q0[0], q0[2], q0[3]]);
|
||||
// Free Transform from the menu switches the live box back.
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_preference_swaps_corner_proportions() {
|
||||
let none = egui::Modifiers::NONE;
|
||||
let corner = Hit::Corner(1);
|
||||
// Default: proportional (⇧ frees), so the drag keeps its keys.
|
||||
assert!(!corner_mods(false, corner, none).shift);
|
||||
// Legacy: free by default, ⇧ keeps proportions; edges and ⌘ gestures are left alone.
|
||||
assert!(corner_mods(true, corner, none).shift);
|
||||
assert!(!corner_mods(true, corner, egui::Modifiers::SHIFT).shift);
|
||||
assert!(!corner_mods(true, Hit::Edge(1), none).shift);
|
||||
assert!(!corner_mods(true, corner, egui::Modifiers::COMMAND).shift);
|
||||
// Through the box: a plain corner drag on a 16×16 square.
|
||||
for (legacy, proportional) in [(false, true), (true, false)] {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
app.ui.extras.snap = false;
|
||||
app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy);
|
||||
begin(&mut app, &egui::Context::default()).unwrap();
|
||||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||||
press_drag(&mut app, q0[2], [q0[2][0] + 16.0, q0[2][1]], none);
|
||||
let q = app.ui.transform.as_ref().unwrap().quad;
|
||||
let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]);
|
||||
assert_eq!((w - h).abs() < 1e-6, proportional, "legacy {legacy}: {w} × {h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_while_transforming_switches_the_mode() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
let ctx = egui::Context::default();
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
assert!(crate::canvas_tool_menu::open_transform(&mut app, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.clone().unwrap();
|
||||
let ids: Vec<&str> = crate::canvas_tool_menu::menu_entries(&menu).iter().map(|e| e.1).collect();
|
||||
assert!(ids.contains(&"edit.transform.distort") && ids.contains(&"edit.freeTransform"));
|
||||
crate::canvas_tool_menu::choose(&mut app, &ctx, "edit.transform.distort");
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort);
|
||||
}
|
||||
|
||||
fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": size, "height": size})).unwrap();
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 6 | 13 | 11 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 60 of 135 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 59 of 135 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
@@ -154,9 +154,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 60 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 59 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in new issue
Block a user