diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3cea2eab60..b011af5936 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -804,7 +804,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.alwaysCreateSmartObjectsWhenPlacing", "general.animatedZoom", "general.zoomResizesWindows", - "general.useLegacyFreeTransform", "interface.uiFontSize", "interface.showChannelsInColor", "interface.dynamicColorSliders", diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index de5d1cc3d4..71e9c7b578 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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) { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 1a4e9af86e..14f4790eb8 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -16,8 +16,21 @@ pub struct CanvasToolMenu { pub has_path: bool, #[serde(default)] pub path_name: Option, + /// 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); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b1fb6d9c44..bd3ecd3e09 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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), } } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 27f5dc9a2f..bde66411d3 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -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(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8d51452943..139a9bcd4e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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. diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 4c710a26b8..09586d7cf5 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -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 } 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(); diff --git a/docs/scorecard.md b/docs/scorecard.md index bc20446ce0..9f028d7def 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | |---|---|---|---|---|