From 2e0c42669299e428884d9dde229283d1299bd821 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 13:12:53 +0200 Subject: [PATCH] Tools: Artboard tool (#2858) (#2862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard): dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a board selects it, dragging it moves it with its contents, and the selected board's edge and corner handles resize it (`layer.artboard.set`, moveContents: false). Each gesture is one undo step. Options bar: Size preset, W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph (assets/icons/artboard.svg, ATTRIBUTION.md row). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 3 +- README.md | 4 +- assets/icons/artboard.svg | 16 ++ crates/ui-egui/src/artboard_tool.rs | 338 ++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 7 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 7 +- crates/ui-egui/src/state.rs | 13 +- crates/ui-egui/src/tool_reset.rs | 3 +- docs/gaps.md | 4 +- docs/roadmap.md | 2 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 6 +- scorecard/file_compat.toml | 2 +- 33 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 assets/icons/artboard.svg create mode 100644 crates/ui-egui/src/artboard_tool.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2c2e3dee0b..26edba3ad0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,9 +20,10 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index 1433d065b7..8c9ee0743b 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 62 tools

- Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 63 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/assets/icons/artboard.svg b/assets/icons/artboard.svg new file mode 100644 index 0000000000..c18d366ec8 --- /dev/null +++ b/assets/icons/artboard.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs new file mode 100644 index 0000000000..d02ccbf17a --- /dev/null +++ b/crates/ui-egui/src/artboard_tool.rs @@ -0,0 +1,338 @@ +//! Artboard tool (Move group, V / ⇧V cycles with the Move tool): drag on an empty spot to draw a +//! new artboard, click a board to select it, drag it to move it with its contents, and drag the +//! selected board's edge and corner handles to resize it. The options bar sets the selected board's +//! size (preset, W/H, portrait/landscape) and adds a board of the same size beside the last one. +//! Every change is one engine command (`layer.new.artboard`, `layer.artboard.set`), so each gesture +//! is one undo step; this module only turns pointer gestures into them. + +use egui::{CursorIcon, Pos2, Rect, Stroke, vec2}; +use photocraft_doc::{Document, Layer, LayerId}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; +use crate::crop_ui::{Hit, hit}; +use crate::state::Tool; +use crate::theme::Tokens; + +/// How far from an edge (screen points) a press still grabs its handle. +const HANDLE_PX: f64 = 6.0; +/// A press that moves less than this (screen points) is a click, not a drawn board. +const CLICK_PX: f64 = 3.0; + +/// Artboard tool state (serde: readable through the control channel). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArtboardToolUi { + #[serde(skip)] + pub drag: Option, +} + +/// A gesture in progress. Board rects are `[x0, y0, x1, y1]` in document pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ArtboardDrag { + /// Drawing a new board from `start`. + New { start: [f64; 2], cur: [f64; 2] }, + /// Moving board `id`, whose rect was `rect` at the press. + Move { id: LayerId, rect: [i64; 4], start: [f64; 2], cur: [f64; 2] }, + /// Dragging handle (`hx`, `hy`) of board `id`: −1 the left/top edge, 1 the right/bottom, 0 neither. + Resize { id: LayerId, rect: [i64; 4], hx: i8, hy: i8, start: [f64; 2], cur: [f64; 2] }, +} + +fn edges(r: photocraft_geom::Rect) -> [i64; 4] { + [i64::from(r.x0), i64::from(r.y0), i64::from(r.x1), i64::from(r.y1)] +} + +fn as_f64(r: [i64; 4]) -> [f64; 4] { + r.map(|v| v as f64) +} + +/// `[x, y, width, height]` for the engine's `rect` param. +fn xywh(r: [i64; 4]) -> [i64; 4] { + [r[0], r[1], r[2].saturating_sub(r[0]), r[3].saturating_sub(r[1])] +} + +/// The selected board: the active layer's artboard, with its edges. +fn active_board(app: &PhotocraftApp) -> Option<(LayerId, [i64; 4])> { + let st = app.session.active()?; + let id = st.doc.artboard_of(st.active_layer?)?; + Some((id, edges(st.doc.layer(id).and_then(Layer::artboard)?.rect))) +} + +/// The topmost board under a document point. +fn board_at(doc: &Document, p: [f64; 2]) -> Option<(LayerId, [i64; 4])> { + let inside = |r: [f64; 4]| p[0] >= r[0] && p[0] < r[2] && p[1] >= r[1] && p[1] < r[3]; + doc.artboards().iter().rev().map(|b| (b.0, edges(b.2.rect))).find(|b| inside(as_f64(b.1))) +} + +fn tolerance(app: &PhotocraftApp) -> f64 { + HANDLE_PX / f64::from(app.point_zoom()).max(0.01) +} + +/// The selected board's handle under a document point. +fn handle_at(app: &PhotocraftApp, p: [f64; 2]) -> Option<(LayerId, [i64; 4], i8, i8)> { + let (id, r) = active_board(app)?; + match hit(as_f64(r), p, tolerance(app)) { + Hit::Handle(hx, hy) => Some((id, r, hx, hy)), + _ => None, + } +} + +/// The board drawn from `a` to `b`, edges on whole pixels. +fn drawn(a: [f64; 2], b: [f64; 2]) -> [i64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) +} + +/// `d` rounded to whole pixels. +fn offset(start: [f64; 2], cur: [f64; 2]) -> [i64; 2] { + [(cur[0] - start[0]).round() as i64, (cur[1] - start[1]).round() as i64] +} + +/// `r` with handle (`hx`, `hy`) dragged by `d`: an edge dragged past the opposite one flips the +/// board, which never gets narrower than one pixel. +fn resized(r: [i64; 4], hx: i8, hy: i8, d: [i64; 2]) -> [i64; 4] { + let mut e = r; + for (h, lo, hi, dv) in [(hx, 0, 2, d[0]), (hy, 1, 3, d[1])] { + match h { + -1 => e[lo] = e[lo].saturating_add(dv), + 1 => e[hi] = e[hi].saturating_add(dv), + _ => {} + } + let (a, b) = (e[lo].min(e[hi]), e[lo].max(e[hi])); + (e[lo], e[hi]) = (a, b.max(a.saturating_add(1))); + } + e +} + +/// The board a gesture would leave, for the preview. +fn preview(d: &ArtboardDrag) -> [i64; 4] { + match *d { + ArtboardDrag::New { start, cur } => drawn(start, cur), + ArtboardDrag::Move { rect, start, cur, .. } => { + let o = offset(start, cur); + [rect[0].saturating_add(o[0]), rect[1].saturating_add(o[1]), rect[2].saturating_add(o[0]), rect[3].saturating_add(o[1])] + } + ArtboardDrag::Resize { rect, hx, hy, start, cur, .. } => resized(rect, hx, hy, offset(start, cur)), + } +} + +fn report(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Pointer input for the Artboard tool. Returns true when the event was its own. +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -> bool { + if app.ui.tool != Tool::Artboard { + return false; + } + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; + match ev { + ToolEvent::Down { x, y, .. } => { + let p = [x, y]; + app.ui.artboard_tool.drag = Some(if let Some((id, rect, hx, hy)) = handle_at(app, p) { + ArtboardDrag::Resize { id, rect, hx, hy, start: p, cur: p } + } else if let Some((id, rect)) = board_at(&doc, p) { + if app.session.active().and_then(|st| st.active_layer) != Some(id) { + let r = app.run("layer.select", json!({"layer": id.0})); + report(app, r); + } + ArtboardDrag::Move { id, rect, start: p, cur: p } + } else { + ArtboardDrag::New { start: p, cur: p } + }); + } + ToolEvent::Move { x, y, .. } => { + if let Some(ArtboardDrag::New { cur, .. } | ArtboardDrag::Move { cur, .. } | ArtboardDrag::Resize { cur, .. }) = &mut app.ui.artboard_tool.drag { + *cur = [x, y]; + } + } + ToolEvent::Up { x, y } => { + let Some(mut d) = app.ui.artboard_tool.drag.take() else { return true }; + let (ArtboardDrag::New { cur, .. } | ArtboardDrag::Move { cur, .. } | ArtboardDrag::Resize { cur, .. }) = &mut d; + *cur = [x, y]; + let r = preview(&d); + let result = match d { + ArtboardDrag::New { start, cur } => { + let screen = (cur[0] - start[0]).hypot(cur[1] - start[1]) * f64::from(app.point_zoom()); + if screen < CLICK_PX || r[2] <= r[0] || r[3] <= r[1] { + return true; + } + app.run("layer.new.artboard", json!({"rect": xywh(r)})) + } + ArtboardDrag::Move { id, rect, .. } if r != rect => app.run("layer.artboard.set", json!({"layer": id.0, "x": r[0], "y": r[1]})), + // Resizing leaves the contents where they are, as in Photoshop. + ArtboardDrag::Resize { id, rect, .. } if r != rect => { + app.run("layer.artboard.set", json!({"layer": id.0, "rect": xywh(r), "moveContents": false})) + } + _ => return true, + }; + report(app, result); + } + } + true +} + +/// The resize pointer for handle (`hx`, `hy`), or none away from the selected board's handles. +pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { + let (hx, hy) = match app.ui.artboard_tool.drag { + Some(ArtboardDrag::Resize { hx, hy, .. }) => (hx, hy), + Some(_) => return None, + None => handle_at(app, p).map(|h| (h.2, h.3))?, + }; + Some(match (hx, hy) { + (_, 0) => CursorIcon::ResizeHorizontal, + (0, _) => CursorIcon::ResizeVertical, + _ if hx == hy => CursorIcon::ResizeNwSe, + _ => CursorIcon::ResizeNeSw, + }) +} + +/// The selected board's handles and the board a gesture in progress would leave. +pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + if app.ui.tool != Tool::Artboard { + return; + } + let t = Tokens::get(painter.ctx()); + let pt = |x: i64, y: i64| xf.to_screen(x as f32, y as f32); + let outline = |r: [i64; 4]| -> Vec { vec![pt(r[0], r[1]), pt(r[2], r[1]), pt(r[2], r[3]), pt(r[0], r[3]), pt(r[0], r[1])] }; + let drag = app.ui.artboard_tool.drag; + if let Some(d) = drag.as_ref() { + painter.add(egui::Shape::line(outline(preview(d)), Stroke::new(1.5, t.accent))); + } + let Some((_, r)) = active_board(app).filter(|_| !matches!(drag, Some(ArtboardDrag::New { .. }))) else { return }; + let r = drag.as_ref().map_or(r, preview); + let (mx, my) = (r[0].saturating_add(r[2]) / 2, r[1].saturating_add(r[3]) / 2); + for (x, y) in [(r[0], r[1]), (mx, r[1]), (r[2], r[1]), (r[2], my), (r[2], r[3]), (mx, r[3]), (r[0], r[3]), (r[0], my)] { + let h = Rect::from_center_size(pt(x, y), vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, egui::Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } +} + +/// The Artboard tool's options bar: the selected board's size preset, W/H, Make Portrait / Make +/// Landscape, and Add New Artboard. +pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { + if tool != Tool::Artboard { + return false; + } + let t = Tokens::get(ui.ctx()); + let board = active_board(app); + let preset = board.and_then(|(id, _)| app.session.active()?.doc.layer(id)?.artboard().map(|a| a.preset.clone())); + let mut edit: Option = None; + ui.add_enabled_ui(board.is_some(), |ui| { + let (id, r) = board.unwrap_or((LayerId(0), [0, 0, 0, 0])); + ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim)); + let mut preset = preset.unwrap_or_default(); + let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; + opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); + if crate::widgets::dropdown(ui, "artboard-tool-preset", &mut preset, &opts, 150.0) && !preset.is_empty() { + edit = Some(json!({"layer": id.0, "preset": preset})); + } + let (mut w, mut h) = ((r[2] - r[0]) as f32, (r[3] - r[1]) as f32); + ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); + let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).changed(); + ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); + let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).changed(); + if cw || ch { + edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); + } + if crate::widgets::secondary_button(ui, tl!("Make Portrait"), 0.0).clicked() && w > h { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + if crate::widgets::secondary_button(ui, tl!("Make Landscape"), 0.0).clicked() && h > w { + edit = Some(json!({"layer": id.0, "width": h.round(), "height": w.round()})); + } + }); + if let Some(p) = edit { + let r = app.run("layer.artboard.set", p); + report(app, r); + } + crate::widgets::vline(ui, 22.0); + let has_doc = app.session.active().is_some(); + if ui.add_enabled_ui(has_doc, |ui| crate::widgets::secondary_button(ui, tl!("Add New Artboard"), 0.0)).inner.clicked() { + // A board the size of the selected one, right of the rightmost board (the canvas size + // without a selected board). + let p = board.map_or(json!({}), |(_, r)| json!({"width": r[2] - r[0], "height": r[3] - r[1]})); + let r = app.run("layer.new.artboard", p); + report(app, r); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::tool_event; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool = Tool::Artboard; + app + } + + fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let m = egui::Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: (from[0] + to[0]) / 2.0, y: (from[1] + to[1]) / 2.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); + } + + fn boards(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.artboards().iter().map(|b| b.2.rect).collect() + } + + /// Drawing on empty canvas makes a board; its right-edge handle resizes it in one undo step; + /// dragging it from inside moves it. + #[test] + fn artboard_tool_draws_resizes_and_moves_boards() { + let mut app = app(); + drag(&mut app, [20.0, 30.0], [120.0, 110.0]); + assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(20, 30, 120, 110)]); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer, Some(st.doc.artboards()[0].0)); + // The right edge's handle. + drag(&mut app, [120.0, 70.0], [160.0, 75.0]); + assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(20, 30, 160, 110)]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(20, 30, 120, 110)]); + // From inside: the board moves. + drag(&mut app, [60.0, 60.0], [70.0, 50.0]); + assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(30, 20, 130, 100)]); + // A click on empty canvas draws nothing. + drag(&mut app, [300.0, 250.0], [300.0, 250.0]); + assert_eq!(boards(&app).len(), 1); + } + + /// V picks the Move group, ⇧V cycles Move ↔ Artboard as in Photoshop. + #[test] + fn shift_v_cycles_move_and_artboard() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, modifiers| { + let key = egui::Key::V; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + app.ui.tool = Tool::Brush; + press(&mut app, egui::Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Move); + press(&mut app, egui::Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Artboard); + press(&mut app, egui::Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Move); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4436177bad..43194afc5d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3146,6 +3146,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::analysis_ui::draw_overlay(app, &painter, &xf); crate::gradient_ui::draw_overlay(app, &painter, &xf); crate::slice_ui::draw_overlay(app, &painter, &xf); + crate::artboard_tool::draw_overlay(app, &painter, &xf); // Tool cursors (Photoshop-style). let guide_hover = response.hover_pos().and_then(|p| { let d = xf.to_doc(p); @@ -3169,6 +3170,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_transform::cursor(app, &ctx, &xf, p, mods) }) { ui.ctx().set_cursor_icon(c); + } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Artboard).and_then(|p| crate::artboard_tool::cursor(app, xf.to_doc(p))) { + ui.ctx().set_cursor_icon(c); } else if let Some((p, c)) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| Some((p, crate::crop_ui::cursor(app, xf.to_doc(p))?))) { ui.ctx().set_cursor_icon(c); let d = xf.to_doc(p); @@ -4184,6 +4187,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::slice_ui::pointer(app, ev, mods) { return; } + // Artboard tool: draw, select, move and resize boards. + if crate::artboard_tool::pointer(app, ev, mods) { + return; + } // Crop tool: draw, move and resize the frame. if crate::crop_ui::pointer(app, ev, mods) { return; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 25c7fcae98..c24028af23 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2490,6 +2490,12 @@ cameraData Lens Objektiv Previous Document Předchozí dokument W x H x Resolution Š x V x Rozlišení Front Image Přední obraz + Artboard Tool Nástroj kreslicí plocha + Make Portrait Na výšku + Make Landscape Na šířku + Add New Artboard Přidat novou kreslicí plochu + W Š + H V Freeform Pen Tool Nástroj pero od ruky Curvature Pen Tool Nástroj pero křivosti Curve Fit: Přizpůsobení křivky: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0af9a027e7..2c96f176d3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2523,6 +2523,10 @@ cameraData Lens Objektiv Previous Document Vorheriges Dokument W x H x Resolution B x H x Auflösung Front Image Vorderes Bild + Artboard Tool Zeichenbereich-Werkzeug + Make Portrait Hochformat + Make Landscape Querformat + Add New Artboard Neuen Zeichenbereich hinzufügen Freeform Pen Tool Freiform-Stift-Werkzeug Curvature Pen Tool Rundungsstift-Werkzeug Curve Fit: Kurvenanpassung: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bc874f8e4a..4beef106a2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3587,6 +3587,12 @@ cameraData Lens Φακός Previous Document Προηγούμενο έγγραφο W x H x Resolution Π x Υ x Ανάλυση Front Image Μπροστινή εικόνα + Artboard Tool Πίνακας σχεδίασης (Artboard Tool) + Make Portrait Κατακόρυφος προσανατολισμός + Make Landscape Οριζόντιος προσανατολισμός + Add New Artboard Προσθήκη νέου πίνακα σχεδίασης + W Π + H Υ Freeform Pen Tool Πένα ελεύθερης σχεδίασης Curvature Pen Tool Πένα καμπυλότητας Curve Fit: Προσαρμογή καμπύλης: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 675568e516..9fa4754bb7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2564,6 +2564,10 @@ cameraData Lens Objetivo Previous Document Documento anterior W x H x Resolution An. x Al. x Resolución Front Image Imagen frontal + Artboard Tool Herramienta Lienzo + Make Portrait Hacer vertical + Make Landscape Hacer horizontal + Add New Artboard Añadir nuevo lienzo Freeform Pen Tool Herramienta Pluma de forma libre Curvature Pen Tool Herramienta Pluma de curvatura Curve Fit: Ajuste de curva: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0e64bd2dd2..adfdd2042a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2901,6 +2901,10 @@ cameraData Lens Objectif Previous Document Document précédent W x H x Resolution L x H x Résolution Front Image Image de premier plan + Artboard Tool Outil Plan de travail + Make Portrait Format portrait + Make Landscape Format paysage + Add New Artboard Ajouter un plan de travail Freeform Pen Tool Outil Plume libre Curvature Pen Tool Outil Plume de courbure Curve Fit: Ajustement de courbe : diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9957d63ffa..753d7b3884 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2888,6 +2888,10 @@ cameraData Lens Lensa Previous Document Dokumen Sebelumnya W x H x Resolution L x T x Resolusi Front Image Gambar Depan + Artboard Tool Alat Artboard + Make Portrait Jadikan Potret + Make Landscape Jadikan Lanskap + Add New Artboard Tambah Artboard Baru Freeform Pen Tool Alat Pen Bebas Curvature Pen Tool Alat Pen Kelengkungan Curve Fit: Penyesuaian Kurva: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f04fba1c3a..a1a5e5d0cb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2554,6 +2554,10 @@ cameraData Lens Obiettivo Previous Document Documento precedente W x H x Resolution L x A x Risoluzione Front Image Immagine in primo piano + Artboard Tool Strumento Tavola da disegno + Make Portrait Rendi verticale + Make Landscape Rendi orizzontale + Add New Artboard Aggiungi nuova tavola da disegno Freeform Pen Tool Strumento Penna a mano libera Curvature Pen Tool Strumento Penna curvatura Curve Fit: Adattamento curva: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 243f3482b1..d058fdb17d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3502,6 +3502,10 @@ cameraData Lens レンズ Previous Document 前のドキュメント W x H x Resolution 幅 x 高さ x 解像度 Front Image 前面の画像 + Artboard Tool アートボードツール + Make Portrait 縦向きにする + Make Landscape 横向きにする + Add New Artboard 新規アートボードを追加 Freeform Pen Tool フリーフォームペンツール Curvature Pen Tool 曲線ペンツール Curve Fit: カーブフィット: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f621588737..c939db47ab 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3068,6 +3068,12 @@ cameraData Lens 렌즈 Previous Document 이전 문서 W x H x Resolution 폭 x 높이 x 해상도 Front Image 전면 이미지 + Artboard Tool 아트보드 도구 + Make Portrait 세로 방향으로 + Make Landscape 가로 방향으로 + Add New Artboard 새 아트보드 추가 + W 폭 + H 높이 Freeform Pen Tool 자유 형태 펜 도구 Curvature Pen Tool 곡률 펜 도구 Curve Fit: 곡선 맞춤: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dc429077e4..e097d4cdc8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3655,6 +3655,10 @@ cameraData Lens Lens Previous Document Vorig document W x H x Resolution B x H x Resolutie Front Image Voorste afbeelding + Artboard Tool Tekengebiedgereedschap + Make Portrait Staand maken + Make Landscape Liggend maken + Add New Artboard Nieuw tekengebied toevoegen Freeform Pen Tool Pengereedschap Vrije vorm Curvature Pen Tool Pengereedschap Kromming Curve Fit: Curve aanpassen: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d512f013c9..85438ca729 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3503,6 +3503,10 @@ cameraData Lens Obiektyw Previous Document Poprzedni dokument W x H x Resolution Szer. x Wys. x Rozdzielczość Front Image Obraz na wierzchu + Artboard Tool Narzędzie Obszar roboczy + Make Portrait Ustaw pionowo + Make Landscape Ustaw poziomo + Add New Artboard Dodaj nowy obszar roboczy Freeform Pen Tool Narzędzie Pióro dowolne Curvature Pen Tool Narzędzie Pióro krzywizny Curve Fit: Dopasowanie krzywej: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c58ef14fa1..4e7b2a93af 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2909,6 +2909,10 @@ cameraData Lens Lente Previous Document Documento anterior W x H x Resolution L x A x Resolução Front Image Imagem frontal + Artboard Tool Ferramenta Prancheta + Make Portrait Tornar retrato + Make Landscape Tornar paisagem + Add New Artboard Adicionar nova prancheta Freeform Pen Tool Ferramenta Caneta de forma livre Curvature Pen Tool Ferramenta Caneta de curvatura Curve Fit: Ajuste de curva: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5549183d5a..3558fc5852 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2830,6 +2830,10 @@ cameraData Lens Объектив Previous Document Предыдущий документ W x H x Resolution Ш x В x Разрешение Front Image Изображение переднего плана + Artboard Tool Монтажная область (инструмент) + Make Portrait Сделать книжной + Make Landscape Сделать альбомной + Add New Artboard Добавить монтажную область Freeform Pen Tool Свободное перо Curvature Pen Tool Перо кривизны Curve Fit: Точность кривой: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f47d6c1be8..f5aeab1362 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3588,6 +3588,10 @@ cameraData Lens Об'єктив Previous Document Попередній документ W x H x Resolution Ш x В x Роздільна здатність Front Image Зображення переднього плану + Artboard Tool Інструмент «Монтажна область» + Make Portrait Зробити портретною + Make Landscape Зробити альбомною + Add New Artboard Додати монтажну область Freeform Pen Tool Інструмент «Довільне перо» Curvature Pen Tool Інструмент «Перо кривизни» Curve Fit: Точність кривої: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2e884494fa..7c2caf4a38 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2497,6 +2497,12 @@ cameraData Lens 镜头 Previous Document 上一个文档 W x H x Resolution 宽 x 高 x 分辨率 Front Image 前面的图像 + Artboard Tool 画板工具 + Make Portrait 设为纵向 + Make Landscape 设为横向 + Add New Artboard 添加新画板 + W 宽 + H 高 Freeform Pen Tool 自由钢笔工具 Curvature Pen Tool 弯度钢笔工具 Curve Fit: 曲线拟合: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 376014c02c..df9287e619 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2467,6 +2467,12 @@ cameraData Lens 鏡頭 Previous Document 上一個文件 W x H x Resolution 寬 x 高 x 解析度 Front Image 前面的影像 + Artboard Tool 工作區域工具 + Make Portrait 設為直向 + Make Landscape 設為橫向 + Add New Artboard 新增工作區域 + W 寬 + H 高 Freeform Pen Tool 自由筆型工具 Curvature Pen Tool 曲線筆工具 Curve Fit: 曲線擬合: diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 59ee332764..c4b4cef0a9 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -7,6 +7,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), + ("artboard", include_bytes!("../../../assets/icons/artboard.svg")), ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), ("ban", include_bytes!("../../../assets/icons/ban.svg")), ("bandage", include_bytes!("../../../assets/icons/bandage.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 312ed95835..79e1d1e67c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,7 @@ fn badge(ctx: &egui::Context, name: &str, p: egui::Pos2, doubled: bool) { pub fn tool_icon(t: Tool) -> &'static str { match t { Tool::Move => "move", + Tool::Artboard => "artboard", Tool::RectMarquee => "square-dashed", Tool::EllipseMarquee => "circle-dashed", Tool::SingleRowMarquee => "marquee-row", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9955bb3e69..9306046bb1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod adjust_ui; mod alt_grab; pub mod analysis_ui; pub mod anchor_tools; +pub mod artboard_tool; pub mod artboard_ui; pub(crate) mod blend_preview; mod brand; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d979bb3f1d..5ed4657757 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -15,7 +15,7 @@ use crate::{PhotocraftApp, icons, widgets}; /// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button /// shows the group's last-used tool. const TOOL_SECTIONS: &[&[&[Tool]]] = &[ - &[&[Tool::Move]], + &[&[Tool::Move, Tool::Artboard]], &[ &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee], &[Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso], @@ -888,6 +888,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { || crate::vector_ui::options_bar(app, ui, tool) || crate::analysis_ui::options_bar(app, ui, tool) || crate::slice_ui::options_bar(app, ui, tool) + || crate::artboard_tool::options_bar(app, ui, tool) { return; } @@ -4291,14 +4292,14 @@ mod toolbar_hidden_tests { #[test] fn a_slot_keeps_only_its_visible_tools_and_its_index() { - let sections = visible_sections(&["Sponge".to_string(), "Move".to_string()]); + let sections = visible_sections(&["Sponge".to_string(), "Move".to_string(), "Artboard".to_string()]); assert_eq!(sections.len(), TOOL_SECTIONS.len() - 1, "the Move section has no tools left"); let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Sponge)).unwrap(); let slot = sections.iter().flatten().find(|(i, _)| *i == index).map(|(_, tools)| tools.clone()); assert_eq!(slot, Some(vec![Tool::Dodge, Tool::Burn])); assert!(sections.iter().flatten().all(|(_, tools)| !tools.contains(&Tool::Move) && !tools.contains(&Tool::Sponge))); assert_eq!(visible_sections(&[]).iter().map(Vec::len).sum::(), TOOL_SECTIONS.iter().map(|section| section.len()).sum::()); - let by_command = visible_sections(&["sponge".to_string(), "move".to_string()]); + let by_command = visible_sections(&["sponge".to_string(), "move".to_string(), "artboard".to_string()]); assert_eq!(by_command, sections, "edit.toolbar names are read like `ui.set {{tool}}` names"); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index eb78ee0a91..2d9b9c5655 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -63,6 +63,7 @@ impl Default for CameraRawPreviewState { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Tool { Move, + Artboard, RectMarquee, EllipseMarquee, /// Selects one pixel row across the whole canvas at the clicked y (Photoshop's flyout only: @@ -131,8 +132,9 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 62] = [ + pub const ALL: [Tool; 63] = [ Tool::Move, + Tool::Artboard, Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, @@ -199,6 +201,7 @@ impl Tool { pub fn label(self) -> &'static str { match self { Tool::Move => "Move Tool", + Tool::Artboard => "Artboard Tool", Tool::RectMarquee => "Rectangular Marquee Tool", Tool::EllipseMarquee => "Elliptical Marquee Tool", Tool::SingleRowMarquee => "Single Row Marquee Tool", @@ -301,7 +304,7 @@ impl Tool { /// Photoshop default single-key shortcut (`'\0'` = none, e.g. the Blur group). pub fn key(self) -> char { match self { - Tool::Move => 'V', + Tool::Move | Tool::Artboard => 'V', Tool::RectMarquee | Tool::EllipseMarquee => 'M', Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::ColorReplacement => 'B', Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E', @@ -1041,6 +1044,9 @@ pub struct UiState { /// Slice and Slice Select tools (see `slice_ui`). #[serde(default)] pub slices: crate::slice_ui::SliceUi, + /// Artboard tool (see `artboard_tool`). + #[serde(default)] + pub artboard_tool: crate::artboard_tool::ArtboardToolUi, /// Modifier Keys panel, custom pixel aspect ratios, workspace dialogs (see `workspace_ui`). #[serde(default)] pub shell: crate::workspace_ui::ShellUi, @@ -1168,6 +1174,7 @@ impl Default for UiState { analysis: Default::default(), timeline: Default::default(), slices: Default::default(), + artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), pen: None, @@ -1275,7 +1282,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 62); + assert_eq!(Tool::ALL.len(), 63); assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask)); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 49fe5610ee..98435db163 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -179,7 +179,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::Slice | Tool::SliceSelect | Tool::AddAnchorPoint - | Tool::DeleteAnchorPoint => {} + | Tool::DeleteAnchorPoint + | Tool::Artboard => {} } } diff --git a/docs/gaps.md b/docs/gaps.md index d1bddd3266..6a46f7813e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,7 +24,7 @@ what an agent can't finish alone. | G3 | User-reported bug backlog | stability, UI | blocks beta | 250–400 h | [target-app-parity](target-app-parity.md) | | G4 | Brush feel | UI / painting | high | 100–180 h | [brush-parity](brush-parity.md) | | G5 | Transform, handles and modifiers | UI | high | 60–110 h | [ui-parity](ui-parity.md) | -| G6 | Missing tools (15) | features | high | 60–100 h | [ui-parity](ui-parity.md#tools) | +| G6 | Missing tools (12) | features | high | 60–100 h | [ui-parity](ui-parity.md#tools) | | G7 | Workspaces shown as parameter dialogs | features | high | 80–140 h | [target-app-parity](target-app-parity.md#feature-areas) | | G8 | Type depth | features | high | 120–200 h | [target-app-parity](target-app-parity.md#feature-areas) | | G9 | Panels, preferences and workspace | UI | medium-high | 150–250 h | [ui-parity](ui-parity.md#panels-and-workspace) | @@ -54,7 +54,7 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (9 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color +- **Missing (8 of 68):** Perspective Crop, Frame, Color Sampler (#1046), Color Replacement (engine command exists), Art History Brush, Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥). Freeform Pen and Curvature Pen landed (#1054); Horizontal and Vertical Type Mask landed (#1055, `type.createSelection`). diff --git a/docs/roadmap.md b/docs/roadmap.md index 8c650af3fd..e14a05113e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -77,7 +77,7 @@ Ranked; each item links to its gap entry. Pick from the top unless an issue is a 4. **Brush and transform feel** ([G4](gaps.md#g4-brush-feel), [G5](gaps.md#g5-transform-handles-and-modifiers)): [`brush-parity.md`](brush-parity.md) and [`ui-parity.md`](ui-parity.md) rows marked partial or missing. 150–250 h. -5. **Missing tools** ([G6](gaps.md#g6-missing-tools)): 15 of Photoshop's 68. 60–100 h. +5. **Missing tools** ([G6](gaps.md#g6-missing-tools)): 14 of Photoshop's 68. 60–100 h. ## What's next, with estimates diff --git a/docs/scorecard.md b/docs/scorecard.md index 625f68e8e8..1a3bd2cb1e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -117,7 +117,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | -| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_ui.rs:1 names/properties only; no tool, + buttons or duplicate | +| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles; Duplicate Artboard places the copy beside (#1584); no + buttons, raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | missing | [#216](https://github.com/storytold/photocraft/issues/216) | No corpus step in ci.yml; in progress in #190 | | FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | partial | [#216](https://github.com/storytold/photocraft/issues/216) | 4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 382f9b67c8..55d8d3a6d1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -61,7 +61,7 @@ architecture and the PSD writer are the serial parts. | Component | Weight | Ours / Photoshop | % | Kind | |---|---:|---|---:|---| | Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | -| Toolbox tools | 15% | 55 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 81% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | +| Toolbox tools | 15% | 56 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 82% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | | Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | @@ -75,7 +75,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| -| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 15 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | +| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | | UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 5 / 7 / 11; 46 of 149 preferences do nothing; no floating panel groups, thin Color panel, no transform context menu, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1d8eb94b1c..4d958f0e22 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -12,8 +12,8 @@ Status: **done** (behaves as in Photoshop, with a test), **partial** (exists, di **missing**. Evidence is a file under `crates/ui-egui/src/` unless another path is given, a scorecard id, or an issue. Modifier names are macOS first (⌘ ⌥ ⇧); Windows/Linux use Ctrl and Alt. -**Summary (estimated, weights = how often the gesture is used):** ~40% ready. Tools 53/68 -present (78%, measured). Free Transform modifiers and Crop are close to Photoshop; the gaps are +**Summary (estimated, weights = how often the gesture is used):** ~40% ready. Tools 56/68 +present (82%, measured). Free Transform modifiers and Crop are close to Photoshop; the gaps are in the long tail of modifiers, in-transform UI, spring-loaded tools, panels and preferences. Remaining: 60–110 h for transforms and modifiers (gap G5), 60–100 h for tools (G6), 150–250 h for panels and workspace (G9). @@ -26,7 +26,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Group | Present | Missing | |---|---|---| -| Move, artboard | Move | Artboard (commands `layer.new.artboard*` exist) | +| Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | | Selection | Object Selection, Quick Selection, Magic Wand | | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 1c3d798e87..aeb76f8427 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -143,7 +143,7 @@ id = "FILE-215-20" target = "Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages" status = "partial" issue = 215 -note = "artboard_ui.rs:1 names/properties only; no tool, + buttons or duplicate" +note = "artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles; Duplicate Artboard places the copy beside (#1584); no + buttons, raster PDF pages only" [[item]] id = "FILE-215-21"