Tools: Artboard tool (#2858) (#2862)

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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 13:12:53 +02:00 committed by GitHub
parent 1f64daeba4
commit 2e0c426692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 468 additions and 20 deletions

View File

@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 |

View File

@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 62 tools</h4>
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
<h4>🧰 63 tools</h4>
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
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>

16
assets/icons/artboard.svg Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Artboard tool glyph (not from an icon set): a board with a tick at each end of its edges. -->
<rect x="7" y="7" width="10" height="10" rx="1" />
<path d="M7 2v2.5M17 2v2.5M7 19.5V22M17 19.5V22" />
<path d="M2 7h2.5M2 17h2.5M19.5 7H22M19.5 17H22" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@ -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<ArtboardDrag>,
}
/// 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<Value, String>) {
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<CursorIcon> {
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<Pos2> { 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<Value> = 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<photocraft_geom::Rect> {
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);
}
}

View File

@ -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;

View File

@ -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:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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: Προσαρμογή καμπύλης:

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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 :

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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:

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -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: カーブフィット:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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: 곡선 맞춤:

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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: Точность кривой:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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: Точність кривої:

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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: 曲线拟合:

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -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: 曲線擬合:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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")),

View File

@ -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",

View File

@ -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;

View File

@ -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::<usize>(), TOOL_SECTIONS.iter().map(|section| section.len()).sum::<usize>());
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");
}
}

View File

@ -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));

View File

@ -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 => {}
}
}

View File

@ -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`).

View File

@ -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

View File

@ -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 |

View File

@ -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) |

View File

@ -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 | |

View File

@ -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"