Integration: track t06

# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/tr.tsv
#	docs/gaps.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-22 15:38:19 +00:00
56 changed files with 2103 additions and 212 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -135,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
+33 -5
View File
@@ -141,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// object's outline. A region covering most of the canvas boundary is treated as background.
/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
///
/// When the whole-canvas pass finds nothing in a large document (an object only a few working
/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of
/// the same working size around the point, down to full resolution.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
@@ -154,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
if !canvas.contains(px, py) {
return None;
}
let step = super::scale_for(canvas, work_px.max(1024));
let work_px = work_px.max(1024);
let mut step = super::scale_for(canvas, work_px);
if let Some(r) = object_in(sampler, canvas, px, py, step) {
return Some(r);
}
// Side (working pixels) of the square windows of the finer passes.
let side = (work_px as f64).sqrt() as usize;
while step > 1 {
step = (step / 4).max(1);
let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32;
let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas);
if window.width() < 4 || window.height() < 4 {
continue;
}
if let Some(r) = object_in(sampler, window, px, py, step) {
return Some(r);
}
}
None
}
/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`;
/// `(px, py)` lies inside `canvas`.
fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option<Region> {
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
@@ -166,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count();
// shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788).
if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
+54
View File
@@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() {
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() {
let (w, h) = (400, 300);
for margin in [30, 20, 8] {
let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin;
let img = RgbImage::from_fn(w, h, |x, y| {
let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0;
if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] }
});
let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let sampler = ImageSampler { img: &img, origin: (-40, 25) };
let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25);
let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX)
.unwrap_or_else(|| panic!("missing large foreground with margin {margin}"));
let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask };
assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}");
assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}");
}
}
#[test]
fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() {
let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap();
assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9);
assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1);
let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]);
assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none());
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
@@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is).
#[test]
fn object_at_finds_small_object_in_large_canvas() {
let (w, h) = (4000usize, 3000usize);
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] {
let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh;
let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let truth: Vec<bool> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let s = ImageSampler { img: &img, origin: (0, 0) };
let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32);
let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX);
let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found"));
let v = iou(&reg, &truth, w, h);
assert!(v > 0.8, "{ow}×{oh}: IoU {v}");
}
// The flat background around the small object is still nothing.
let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let s = ImageSampler { img: &img, origin: (0, 0) };
assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none());
}
// ---------- Select Subject on the cases reported in #2788 ----------
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
+24
View File
@@ -1,4 +1,28 @@
use super::*;
#[test]
#[ignore = "release Object Finder timing on a 24 MP document"]
#[allow(clippy::assertions_on_constants)]
fn object_finder_large_foreground_release_timing() {
assert!(!cfg!(debug_assertions), "run with --release");
let (w, h) = (6000, 4000);
let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] {
let start = std::time::Instant::now();
let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX);
println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0);
if name == "foreground" {
let region = region.unwrap();
assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9);
assert!(region.at(10, 10) < 0.1);
} else {
assert!(region.is_none());
}
}
}
#[test]
#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"]
#[allow(clippy::assertions_on_constants)]
+13
View File
@@ -303,10 +303,14 @@ pub enum LiveShape {
rect: [f64; 4],
},
/// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon).
/// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the
/// Triangle tool draws a 3-sided one.
Polygon {
rect: [f64; 4],
sides: u32,
star_ratio: f64,
#[serde(default)]
corner_radius: f64,
},
/// Line from `from` to `to` drawn as a filled bar `weight` px thick.
Line {
@@ -372,6 +376,15 @@ mod tests {
assert_eq!(t.control_bounds(), Some((2.0, 3.0, 6.0, 10.0)));
}
#[test]
fn a_polygon_saved_before_corner_radii_loads_sharp() {
let old = r#"{"kind":"polygon","rect":[0.0,0.0,10.0,10.0],"sides":3,"star_ratio":1.0}"#;
let back: LiveShape = serde_json::from_str(old).unwrap();
assert_eq!(back, LiveShape::Polygon { rect: [0.0, 0.0, 10.0, 10.0], sides: 3, star_ratio: 1.0, corner_radius: 0.0 });
let round = LiveShape::Polygon { rect: [0.0; 4], sides: 6, star_ratio: 1.0, corner_radius: 4.5 };
assert_eq!(serde_json::from_str::<LiveShape>(&serde_json::to_string(&round).unwrap()).unwrap(), round);
}
#[test]
fn serde_roundtrip() {
let p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (3.0, 4.0)]).with_op(PathOp::Exclude)]);
+56 -7
View File
@@ -6,6 +6,7 @@
use photocraft_algo::matting::{self, RefineParams};
use photocraft_algo::segment::subject;
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
@@ -35,21 +36,32 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
check(&d.doc, crate::active_layer_of(s)?)
}
/// Where the subject is looked for: the layer's own pixels within the canvas. A photo that
/// doesn't fill the canvas (placed, or the canvas grown) would otherwise stand out from the
/// empty canvas around it as the subject, and the mask would reveal its whole rectangle, which a
/// Stroke then outlines (#8235). The whole canvas when sampling all layers.
fn subject_area(l: &Layer, canvas: Rect, sample_all: bool) -> Rect {
let Some(s) = l.surface().filter(|_| !sample_all) else { return canvas };
let empty_around = s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0);
let b = s.content_bounds().intersect(&canvas);
if empty_around && !b.is_empty() { b } else { canvas }
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?;
let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
check(doc, layer).map_err(EngineError::Other)?;
let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false);
let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true);
let area = subject_area(layer, doc.bounds(), sample_all);
crate::jobs::edit_job(
s,
"Remove Background",
move |doc, _, ctx| {
// Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them.
let found = ctx
.stage(0.1, 0.6, "Finding subject", |ctl| {
with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl))
})
.stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl)))
.map_err(|_| EngineError::Cancelled)?;
let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?;
if ctx.cancelled() {
@@ -57,8 +69,8 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
if refine {
ctx.progress(0.6, "Refining edge");
let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, d| {
matting::refine_mask(smp, &matting::region_reader(&region), region.bbox, d.bounds(), &REFINE)
let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| {
matting::refine_mask(smp, &matting::region_reader(&region), region.bbox, area, &REFINE)
});
region = refined.unwrap_or(region);
if ctx.cancelled() {
@@ -94,7 +106,6 @@ mod tests {
use super::*;
use photocraft_algo::segment::Rng;
use photocraft_doc::LayerId;
use photocraft_geom::Rect;
fn session_with(w: u32, h: u32, depth: u32, px: impl Fn(u32, u32) -> [f32; 3]) -> Session {
let mut s = Session::new();
@@ -287,4 +298,42 @@ mod tests {
let masks = s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| l.mask.is_some()).count();
assert_eq!(masks, 1);
}
#[test]
fn remove_background_on_a_layer_smaller_than_the_canvas_masks_the_subject_not_the_layer() {
// The photo doesn't fill the canvas (a placed photo, or the canvas grown): the empty
// canvas around it is not the background, and a Stroke must not outline the photo's
// rectangle (#8235).
for depth in [8, 16, 32] {
let (mut s, inside) = disc(depth);
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
s.execute("image.canvasSize", json!({"width": 260, "height": 220, "anchor": "center"})).unwrap();
let id = active_id(&s);
s.execute(CMD, json!({})).unwrap();
// The photo now sits at (30, 30).
let v = mask_iou(&s, 260, 220, |x, y| inside(x - 30, y - 30));
assert!(v >= 0.75, "depth {depth}: IoU {v}");
for (x, y) in [(31, 31), (228, 31), (31, 188), (228, 188), (40, 110)] {
assert!(mask_cov(&s, x, y) < 0.2, "depth {depth}: photo background at ({x}, {y}) revealed");
}
s.edit("stroke", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects.items = vec![photocraft_doc::Effect::Stroke(photocraft_doc::StrokeFx {
common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
size: 3.0,
position: photocraft_doc::StrokePosition::Outside,
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)),
overprint: false,
})];
Ok(())
})
.unwrap();
let img = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 260, 220));
// Just outside the photo's left edge, and just inside it: no stroke.
for x in [27, 28, 29, 30, 31] {
let a = img.px[110 * 260 + x][3];
assert!(a < 0.05, "depth {depth}: stroke along the layer's edge at x = {x} (alpha {a})");
}
}
}
}
+1 -20
View File
@@ -497,26 +497,7 @@ fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
*c = translate_surface(c, dx, dy);
}
}
LayerContent::Shape(sh) => {
// The rendered shape is cut at the canvas: one that reaches past it renders again.
let canvas = doc.bounds();
let was_inside = sh.cache.as_ref().is_some_and(|c| {
let b = photocraft_compose::bounds::content_bounds(c);
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
});
crate::vector_cmds::transform_shape(sh, &a);
let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil()));
let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| {
(x0 - stroke_pad) > canvas.x0 as f64
&& (y0 - stroke_pad) > canvas.y0 as f64
&& (x1 + stroke_pad) < canvas.x1 as f64
&& (y1 + stroke_pad) < canvas.y1 as f64
});
match &mut sh.cache {
Some(c) if was_inside && stays_inside => *c = translate_surface(c, dx, dy),
_ => crate::vector_cmds::refresh_shape(doc, sh),
}
}
LayerContent::Shape(sh) => crate::vector_cmds::shift_shape(doc, sh, f64::from(dx), f64::from(dy)),
LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy),
LayerContent::Group(g) => {
if let Some(ab) = &mut g.artboard {
+36 -4
View File
@@ -441,15 +441,47 @@ fn active_gradient_fill(s: &Session) -> Option<photocraft_doc::LayerId> {
matches!(d.doc.layer(id)?.content, LayerContent::Fill(Fill::Gradient { .. })).then_some(id)
}
fn active_shape(s: &Session) -> Option<photocraft_doc::LayerId> {
let d = s.active()?;
let id = d.active_layer?;
matches!(d.doc.layer(id)?.content, LayerContent::Shape(_)).then_some(id)
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let g = preset_param(s, p, CMD_SELECT)?;
let apply = p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true);
let shape = active_shape(s).filter(|_| apply);
// A locked shape refuses before anything changes, the Gradient tool's gradient included.
if let (Some(id), Some(d)) = (shape, s.active()) {
let locks = d.doc.effective_locks(id);
if locks.all || locks.pixels {
return Err(bad(CMD_SELECT, "the selected shape layer is locked"));
}
}
s.presets.gradient = g.clone();
s.presets_changed();
let mut out = json!({"current": g.to_json()});
// Clicking a preset with a shape layer selected fills the shape with it (Photoshop, #5568);
// a gradient fill keeps its angle, style, scale and reverse.
if let Some(id) = shape {
let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background);
crate::vector_cmds::with_shape(s, id, "Edit Shape", |sh, _| {
let (angle, scale, style, reverse) = match &sh.fill {
Some(Fill::Gradient { angle, scale, style, reverse, .. }) => (*angle, *scale, *style, *reverse),
_ => (90.0, 1.0, photocraft_doc::GradientStyle::Linear, false),
};
let mut fill = Fill::gradient(stops_new, angle, scale, style, reverse);
if let Fill::Gradient { opacity_stops, .. } = &mut fill {
*opacity_stops = opacity_new;
}
sh.fill = Some(fill);
Ok(())
})?;
out["layer"] = json!(id.0);
return Ok(out);
}
// Clicking a preset with a Gradient Fill layer selected changes that layer (Photoshop).
if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true)
&& let Some(id) = active_gradient_fill(s)
{
if apply && let Some(id) = active_gradient_fill(s) {
let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background);
s.edit("Change Gradient Fill", |doc, _| {
let mode = doc.mode;
@@ -674,7 +706,7 @@ pub fn specs() -> Vec<CommandSpec> {
menu: &[],
shortcut: None,
params: leak(format!(
r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."##
r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer, or fills a selected shape layer with it)}} → {{current,layer?}}. The Gradient tool paints with it."##
)),
enabled: always,
run: select,
+42
View File
@@ -766,6 +766,48 @@ fn clone_source_errors_and_overlay() {
assert_eq!(v["rotation"], json!(-90.0));
}
fn shape_fill(s: &Session, id: LayerId) -> Option<Fill> {
match &s.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh.fill.clone(),
_ => panic!("not a shape layer"),
}
}
/// #5568: clicking a gradient with a shape layer selected fills the shape with it in one undo
/// step; a gradient fill keeps its angle, style, scale and reverse.
#[test]
fn gradient_preset_click_fills_the_selected_shape_layer() {
let mut s = session(8);
let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap());
let before = shape_fill(&s, id);
let r = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap();
assert_eq!(r["layer"], json!(id.0));
let Some(Fill::Gradient { stops, angle, style, .. }) = shape_fill(&s, id) else { panic!("not a gradient") };
// Red 01 starts at #7f0000; a new gradient fill is linear at 90°.
assert_eq!(stops[0].1.to_rgba8(), [0x7f, 0, 0, 0xff]);
assert_eq!((angle, style), (90.0, photocraft_doc::GradientStyle::Linear));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(shape_fill(&s, id), before);
s.execute("shape.edit", json!({"layer": id.0, "fill": {"gradient": {"angle": 30, "style": "radial", "scale": 80, "reverse": true}}})).unwrap();
s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap();
let Some(Fill::Gradient { angle, style, scale, reverse, .. }) = shape_fill(&s, id) else { panic!("not a gradient") };
assert_eq!((angle, style, scale, reverse), (30.0, photocraft_doc::GradientStyle::Radial, 0.8, true));
}
/// A locked shape refuses the click before anything changes, the Gradient tool's gradient included.
#[test]
fn gradient_preset_click_on_a_locked_shape_changes_nothing() {
let mut s = session(8);
let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap());
let (before, gradient) = (shape_fill(&s, id), s.presets.gradient.name.clone());
s.execute("layer.lockLayers", json!({"all": true})).unwrap();
let err = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap_err().to_string();
assert!(err.contains("locked"), "{err}");
assert_eq!(shape_fill(&s, id), before);
assert_eq!(s.presets.gradient.name, gradient);
}
// ------------------------------------------------------------------ persistence
/// #5602: one damaged preset set in the preferences file must not discard the saved New
+29 -5
View File
@@ -221,12 +221,20 @@ impl ObjectFinderCache {
/// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer
/// above the photo first).
fn check_sampled_pixels(doc: &Document, layer: Option<LayerId>, all_layers: bool, area: Rect) -> Result<()> {
let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) };
if surf.content_bounds().intersect(&area).is_empty() {
let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" };
return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels")));
match empty_sampled_area(doc, layer, all_layers, area) {
Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))),
None => Ok(()),
}
Ok(())
}
/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there.
/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint.
pub fn empty_sampled_area(doc: &Document, layer: Option<LayerId>, all_layers: bool, area: Rect) -> Option<&'static str> {
let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?;
if !surf.content_bounds().intersect(&area).is_empty() {
return None;
}
Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" })
}
/// `select.object` with `"point"`: the Object Finder's object under the point (cached).
@@ -696,6 +704,22 @@ mod tests {
assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err());
}
#[test]
fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() {
for depth in [8, 16, 32] {
let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y);
let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap();
assert_eq!(r["changed"], true, "depth {depth}");
assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap();
assert_eq!(r["changed"], false, "background at depth {depth}");
assert!(s.active().unwrap().doc.selection.is_none());
}
}
#[test]
fn object_selection_point_honours_the_modes() {
let (mut s, _) = disc(8);
+41 -12
View File
@@ -412,13 +412,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>)
}
"ellipse" => LiveShape::Ellipse { rect: rect()? },
"polygon" | "star" => {
let (ps, pr) = match prev {
Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio),
_ => (5, if kind == "star" { 0.5 } else { 1.0 }),
let (ps, pr, pc) = match prev {
Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius),
_ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0),
};
let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc });
let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32);
let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0));
LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } }
LiveShape::Polygon {
rect: rect()?,
sides,
star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio },
corner_radius,
}
}
"line" => {
let (pf, pto, pw) = match prev {
@@ -481,10 +487,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) {
vm.path = vm.path.transform(&a);
}
match &mut l.content {
LayerContent::Shape(sh) => {
transform_shape(sh, &a);
refresh_shape(doc, sh);
}
LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy),
LayerContent::Group(g) => {
for c in &mut g.children {
translate_vectors(doc, c, dx, dy);
@@ -494,6 +497,30 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) {
}
}
/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a
/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at
/// the canvas, so a shape reaching past it renders again, as does a fractional move.
pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) {
transform_shape(sh, &Affine::translate(dx, dy));
let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32);
let canvas = doc.bounds();
let inside = sh.cache.as_ref().is_some_and(|c| {
let b = photocraft_compose::bounds::content_bounds(c);
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
});
let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil()));
let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| {
(x0 - stroke_pad) > canvas.x0 as f64
&& (y0 - stroke_pad) > canvas.y0 as f64
&& (x1 + stroke_pad) < canvas.x1 as f64
&& (y1 + stroke_pad) < canvas.y1 as f64
});
match (&mut sh.cache, whole(dx), whole(dy)) {
(Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy),
_ => refresh_shape(doc, sh),
}
}
/// Applies an affine transform to a shape's path, keeping the live shape when the transform
/// is a translation plus axis-aligned scale (otherwise the shape stops being parametric).
pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) {
@@ -510,7 +537,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) {
LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) }
}
LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) },
LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio },
LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => {
LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) }
}
LiveShape::Line { from, to, weight } => {
// Live-shape edits regenerate the line's filled bar from its parameters.
// Keep the weight in sync with a positive scale of the path, or an
@@ -704,7 +733,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
sh.live = None;
sh.psd_raw = None;
}
let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"];
let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"];
if geom_keys.iter().any(|k| p.get(*k).is_some()) {
let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string()));
let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?;
@@ -1732,7 +1761,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Shape Layer",
["Layer", "New"],
leak(format!(
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
)),
has_doc,
shape_create
@@ -1742,7 +1771,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Shape",
[],
leak(format!(
r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"##
r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"##
)),
has_layer,
shape_edit
+100
View File
@@ -941,6 +941,74 @@ fn shape_info_bounds_of_curved_shapes_are_exact() {
}
}
/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not
/// what PhotoCraft would draw, a block of another colour, so a re-render shows).
fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId {
s.edit("shape", |doc, active| {
let fmt = doc.pixel_format();
let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height()));
let mut cache = photocraft_raster::Surface::new(fmt);
cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0]));
let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() };
let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh)));
*active = Some(id);
Ok(id)
})
.unwrap()
}
fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer {
match &doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh.clone(),
other => panic!("not a shape layer: {}", other.kind_name()),
}
}
fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool {
let c = sh.cache.as_ref().unwrap();
let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y));
(px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01
}
#[test]
fn moving_and_exporting_imported_shapes_keeps_their_rendering() {
for depth in [8, 16, 32] {
let mut s = session(100, 80, depth);
let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40));
// The Move tool: the same pixels, moved; the path moves with them.
s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap();
let sh = shape_layer(doc(&s), id);
assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}");
assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered");
assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too");
// Artboards to Files moves the board to the origin the same way.
s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap();
let board = doc(&s).artboards()[0].0;
s.edit("into board", |doc, _| {
let l = doc.remove(id).unwrap();
let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") };
g.children.push(l);
Ok(())
})
.unwrap();
let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap();
let sh = shape_layer(&one, id);
assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}");
assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered");
}
}
#[test]
fn a_shape_reaching_past_the_canvas_renders_again_when_moved() {
let mut s = session(100, 80, 8);
// Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in.
let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40));
s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap();
let sh = shape_layer(doc(&s), id);
assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)");
assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows");
}
#[test]
fn shape_create_negative_size_rect_covers_the_ellipse_box() {
// Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and
@@ -1070,3 +1138,35 @@ fn combine_shapes_fails_gracefully() {
assert!(s.execute("layer.combineShapes.unite", json!({})).is_err());
assert_eq!(*doc(&s), before);
}
#[test]
fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() {
let mut s = session(200, 200, 8);
let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap();
let id = r["layer"].as_u64().unwrap();
let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content {
LayerContent::Shape(sh) => sh.live.clone(),
_ => None,
};
let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content {
LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(),
_ => 0,
};
assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0));
assert_eq!(path_len(&s), 6, "every vertex rounded");
// Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again.
s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap();
assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0));
assert_eq!(path_len(&s), 12);
s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap();
assert_eq!(path_len(&s), 6);
// Hostile values never panic: negative and non-finite radii are clamped or ignored.
for bad in [json!(-5), json!(1e300), json!("x")] {
let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad}));
}
assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0));
// Scaling the layer by half halves the radius, as for a rectangle's radii.
s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap();
s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap();
assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9));
}
+2 -2
View File
@@ -671,7 +671,7 @@ mod tests {
let b = vogk_bytes(&s, 72.0).unwrap();
assert_eq!(live_from_vogk(&b), Some(s));
}
assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none());
assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none());
}
#[test]
@@ -699,7 +699,7 @@ mod tests {
// Edit: path changes → vmsk regenerated, live dropped when polygon.
let mut edited = sh.clone();
edited.path.subpaths.pop();
edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 });
edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 });
shape_blocks(&edited, &mut raw, 64, 64, 72.0);
assert!(!raw.iter().any(|(k, _)| k == b"vogk"));
let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap();
+51 -11
View File
@@ -230,28 +230,37 @@ impl Drag {
/// style (fixed ratio / fixed size), ⇧ for a square or circle, ⌥ to draw from the centre.
pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -> ([f64; 2], [f64; 2]) {
let (shift, alt) = d.marquee_mods();
if o.marquee_style == "fixedSize" {
// A fixed box is positioned, not sized, by the drag. Using the press plus the drag's
// sign pinned it there and flipped it by its full width/height when the pointer crossed
// the press. The preview and release both follow the current pointer instead.
let (w, h) = (f64::from(o.marquee_width).max(1.0), f64::from(o.marquee_height).max(1.0));
let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end };
return (a, [a[0] + w, a[1] + h]);
}
let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end);
if !alt {
return (d.start, e);
}
let (dx, dy) = (e[0] - d.start[0], e[1] - d.start[1]);
// A fixed size is centred on the press point; otherwise the drag is the half extent.
let (hx, hy) = if o.marquee_style == "fixedSize" { (dx / 2.0, dy / 2.0) } else { (dx, dy) };
([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy])
// For a sized marquee the drag is the half extent when drawing from the centre.
([d.start[0] - dx, d.start[1] - dy], [d.start[0] + dx, d.start[1] + dy])
}
/// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants
/// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None`
/// while the gesture is still a click (it never left the press point by more than a few screen
/// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then
/// deselects. A one-pixel row or column is a selection (#2391).
/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a
/// click places the selection, as in Photoshop.
pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> {
if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) {
let fixed = o.marquee_style == "fixedSize";
if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) {
return None;
}
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
let (a, b) = marquee_corners(o, d, last);
let mut r = marquee_px(a, b);
let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) };
if marquee_is_square(o, d) {
r = square_px(r, d.start, last);
}
@@ -263,6 +272,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
[a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
}
/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h`
/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever
/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes
/// to the closest pixel edge and the far one is the size away.
pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] {
let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0));
let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round());
[x0, y0, x0 + w, y0 + h]
}
/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with
/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`.
fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool {
@@ -3783,9 +3802,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) {
ctx.request_repaint();
}
// Capture intent at the press, before egui's drag threshold: Type transforms keep
// Command, and marquee add/subtract must not become a live Shift/Alt constraint.
let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee);
// Capture intent at the press, before egui's drag threshold or click replay on release:
// Type transforms keep Command; selections keep Add/Subtract if Shift/Alt is released.
let capture_press = tool.is_type()
|| matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection);
let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx));
if capture_press
&& let Some((p, press_mods)) = ui.input(|i| {
@@ -4026,6 +4046,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
{
let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };
let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);
let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {
ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)
} else {
click_mods
};
let d = xf.to_doc(p);
match tool {
Tool::Zoom if !transform_owns_pointer => {
@@ -5562,8 +5587,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
let ev = crate::move_mods::filter_event(app, ev, mods);
// A live rectangle's corner radius widgets (Path Selection, Rectangle tool).
if crate::corner_radius::pointer(app, ev, mods) {
// A live shape's corner radius widgets (Path Selection, the shape tools, Move with its
// transform controls). Unsnapped, as Free Transform's handles: snapping would pull the press
// off a widget onto a nearby vertex or guide, and the shape tool would draw a new shape.
if crate::corner_radius::pointer(app, raw, mods) {
return;
}
// Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790).
@@ -6517,6 +6544,19 @@ mod tests {
assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]);
}
/// #6015: a Fixed Size marquee pressed between pixels selected one pixel more on each side.
#[test]
fn fixed_size_px_is_exactly_the_size() {
use super::fixed_size_px;
// Down-right from (10.4, 20.6): 10 × 10 from the closest pixel edges.
assert_eq!(fixed_size_px([10.4, 20.6], [20.4, 30.6], 10.0, 10.0), [10.0, 21.0, 20.0, 31.0]);
// Up-left: the far corner is the low one; still exactly the size.
assert_eq!(fixed_size_px([10.4, 20.6], [-53.6, -11.4], 64.0, 32.0), [-54.0, -11.0, 10.0, 21.0]);
// 1 × 1 is one pixel, and a fractional size rounds to whole pixels.
assert_eq!(fixed_size_px([3.5, 3.5], [4.5, 4.5], 1.0, 1.0), [4.0, 4.0, 5.0, 5.0]);
assert_eq!(fixed_size_px([0.0, 0.0], [2.6, 2.4], 2.6, 2.4), [0.0, 0.0, 3.0, 2.0]);
}
#[test]
fn display_image_matches_egui_unmultiplied_conversion() {
// Several parallel pieces and a ragged one; translucent, out-of-range and NaN values.
+1
View File
@@ -800,6 +800,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
let r = app.run("tools.setColors", params)?;
if target == "foreground" {
crate::type_tool::foreground_changed(app);
crate::shape_fill_ui::foreground_changed(app, None);
}
Ok(r)
}
+181 -79
View File
@@ -1,14 +1,17 @@
//! On-canvas corner radius widgets for live rectangles (TOOL-214-12), as in Photoshop: with Path
//! Selection, the Rectangle tool, or the Move tool showing its transform controls, every selected
//! shape layer that is a live rectangle shows a round widget inside each corner, at the centre of
//! its arc. Dragging one rounds every corner of every selected rectangle to the same radius;
//! ⌥/Alt changes that corner only, on each of them. The drag previews the new outlines and
//! commits one `shape.edit` with `radii` per changed layer on release, as one history step; a
//! click without movement changes nothing.
//! On-canvas corner radius widgets for live shapes (TOOL-214-12), as in Photoshop: with Path
//! Selection, a shape tool (Rectangle, Triangle, Polygon), or the Move tool showing its transform
//! controls, every selected shape layer that is a live rectangle, triangle, polygon or star shows
//! a round widget inside each corner, at the centre of its arc. Dragging one rounds every corner
//! of every selected shape to the same radius; ⌥/Alt changes that corner only on rectangles
//! (a triangle or polygon has one radius for all its corners, as in Photoshop). The drag previews
//! the new outlines and commits one `shape.edit` per changed layer on release (`radii` for a
//! rectangle, `cornerRadius` for a polygon), as one history step; a click without movement
//! changes nothing.
use egui::{Color32, Modifiers, Pos2, Stroke};
use photocraft_doc::LayerContent;
use photocraft_doc::vector::LiveShape;
use photocraft_vector::shapes;
use crate::PhotocraftApp;
use crate::canvas::ToolEvent;
@@ -18,24 +21,124 @@ use crate::state::Tool;
/// can still be grabbed.
const MIN_INSET: f64 = 10.0;
/// A selected live rectangle: its layer, `[x, y, w, h]` with a positive size, and its radii.
#[derive(Clone, Copy, Debug, PartialEq)]
/// The live shape a target's corners belong to.
#[derive(Clone, Debug, PartialEq)]
enum Geom {
/// `[x, y, w, h]` with a positive size.
Rect([f64; 4]),
/// The polygon's parameters and its sharp vertices, in path order.
Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, vertices: Vec<[f64; 2]> },
}
/// A selected live shape with corners: its layer, its geometry, and its radii (a rectangle's four
/// `[tl, tr, br, bl]`; a polygon's one radius, repeated).
#[derive(Clone, Debug, PartialEq)]
pub struct Target {
layer: u64,
rect: [f64; 4],
geom: Geom,
radii: [f64; 4],
}
/// One corner: its vertex, and how far its arc's centre moves per unit of radius (the centre is
/// `vertex + step * r`: `(±1, ±1)` for a rectangle, `bisector / sin(half the angle)` for a
/// polygon vertex).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Corner {
vertex: [f64; 2],
step: [f64; 2],
}
impl Target {
fn corners(&self) -> usize {
match &self.geom {
Geom::Rect(_) => 4,
Geom::Polygon { vertices, .. } => vertices.len(),
}
}
fn is_rect(&self) -> bool {
matches!(self.geom, Geom::Rect(_))
}
/// Corner `i`, `None` when it is degenerate.
fn corner(&self, i: usize) -> Option<Corner> {
match &self.geom {
Geom::Rect([x, y, w, h]) => {
let (vertex, d) = match i {
0 => ([*x, *y], [1.0, 1.0]),
1 => ([x + w, *y], [-1.0, 1.0]),
2 => ([x + w, y + h], [-1.0, -1.0]),
_ => ([*x, y + h], [1.0, -1.0]),
};
Some(Corner { vertex, step: d })
}
Geom::Polygon { vertices, .. } => {
let f = self.fillet(i, 1.0)?;
let sin = f.half_angle.sin();
(sin > 1e-9).then(|| Corner { vertex: vertices.get(i).copied().unwrap_or(f.centre), step: [f.bisector[0] / sin, f.bisector[1] / sin] })
}
}
}
/// A polygon vertex's rounding with radius `r`.
fn fillet(&self, i: usize, r: f64) -> Option<shapes::Fillet> {
let Geom::Polygon { vertices, .. } = &self.geom else { return None };
let n = vertices.len();
let at = |k: usize| vertices.get(k % n).copied();
shapes::fillet(at(i + n - 1)?, at(i)?, at(i + 1)?, r)
}
/// The largest radius every corner of the shape can take.
fn max_radius(&self) -> f64 {
match &self.geom {
Geom::Rect(r) => r[2].min(r[3]) / 2.0,
Geom::Polygon { vertices, .. } => (0..vertices.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min),
}
}
/// The radius corner `i` is drawn with for `radii`: capped at what the shape allows there.
fn radius_at(&self, radii: [f64; 4], i: usize) -> f64 {
match &self.geom {
Geom::Rect(_) => radii.get(i).copied().unwrap_or(0.0).clamp(0.0, self.max_radius()),
Geom::Polygon { .. } => self.fillet(i, radii[0]).map_or(0.0, |f| f.radius),
}
}
/// Where corner `i`'s widget is drawn for `radii`: the centre of its arc, or a few screen
/// points inside a sharper corner.
fn widget(&self, radii: [f64; 4], i: usize, zoom: f64) -> Option<[f64; 2]> {
let c = self.corner(i)?;
let most = match &self.geom {
Geom::Rect(_) => self.max_radius(),
Geom::Polygon { .. } => self.fillet(i, 1.0e12).map_or(0.0, |f| f.radius),
};
// The inset is measured like a rectangle's: 10 points along each axis of a right angle.
let len = c.step[0].hypot(c.step[1]).max(1e-9);
let inset = MIN_INSET / zoom * std::f64::consts::SQRT_2 / len;
let r = self.radius_at(radii, i).max(inset).min(most);
Some([c.vertex[0] + c.step[0] * r, c.vertex[1] + c.step[1] * r])
}
/// The outline for `radii`.
fn path(&self, radii: [f64; 4]) -> photocraft_doc::Path {
match &self.geom {
Geom::Rect([x, y, w, h]) => shapes::rounded_rect(*x, *y, *w, *h, radii),
Geom::Polygon { rect, sides, star_ratio, .. } => {
shapes::live_path(&LiveShape::Polygon { rect: *rect, sides: *sides, star_ratio: *star_ratio, corner_radius: radii[0] })
}
}
}
}
/// A corner radius drag in progress.
#[derive(Clone, Debug, PartialEq)]
pub struct Drag {
/// Every selected live rectangle, as at the press.
/// Every selected live shape with corners, as at the press.
targets: Vec<Target>,
/// The rectangle whose widget was pressed (an index into `targets`) and its corner,
/// `[tl, tr, br, bl]` order.
/// The shape whose widget was pressed (an index into `targets`) and its corner.
grabbed: usize,
corner: usize,
/// Every corner (no ⌥) or the dragged one only.
/// Every corner (no ⌥) or the dragged one only (rectangles).
all: bool,
/// Where the press was.
start: [f64; 2],
@@ -47,13 +150,39 @@ pub struct Drag {
fn shows(app: &PhotocraftApp) -> bool {
app.ui.transform.is_none()
&& match app.ui.tool {
Tool::PathSelection | Tool::Rectangle => true,
Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true,
Tool::Move => app.ui.tool_options.move_show_transform,
_ => false,
}
}
/// The selected layers that are live rectangles, front first.
/// A shape layer's live shape as a target, if it has corners.
fn target(layer: u64, live: &LiveShape) -> Option<Target> {
let finite = |v: &[f64]| v.iter().all(|v| v.is_finite());
let positive = |[x, y, w, h]: [f64; 4]| {
let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) };
let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) };
(w > 0.0 && h > 0.0 && finite(&[x, y, w, h])).then_some([x, y, w, h])
};
match *live {
LiveShape::Rect { rect, radii } => {
let rect = positive(rect)?;
finite(&radii).then_some(Target { layer, geom: Geom::Rect(rect), radii })
}
LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => {
positive(rect)?;
if !finite(&[star_ratio, corner_radius]) {
return None;
}
let sharp = shapes::polygon(rect[0], rect[1], rect[2], rect[3], sides, star_ratio);
let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] })
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => None,
}
}
/// The selected layers that are live shapes with corners, front first.
fn targets(app: &PhotocraftApp) -> Vec<Target> {
if !shows(app) {
return Vec::new();
@@ -65,10 +194,7 @@ fn targets(app: &PhotocraftApp) -> Vec<Target> {
.filter_map(|id| {
let l = st.doc.layer(id)?;
let LayerContent::Shape(sh) = &l.content else { return None };
let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None };
let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) };
let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) };
(w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii })
target(l.id.0, sh.live.as_ref()?)
})
.collect();
// `selected_layers` lists bottom to top; the topmost widget wins a press.
@@ -76,58 +202,19 @@ fn targets(app: &PhotocraftApp) -> Vec<Target> {
out
}
/// The largest radius a corner of `rect` takes.
fn max_radius(rect: [f64; 4]) -> f64 {
rect[2].min(rect[3]) / 2.0
}
/// Corner `i` of `rect` and the unit-step direction pointing inside from it.
fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) {
let [x, y, w, h] = rect;
match i {
0 => ([x, y], [1.0, 1.0]),
1 => ([x + w, y], [-1.0, 1.0]),
2 => ([x + w, y + h], [-1.0, -1.0]),
_ => ([x, y + h], [1.0, -1.0]),
}
}
/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a
/// sharper corner.
fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] {
let (c, d) = corner(rect, i);
let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect));
[c[0] + d[0] * r, c[1] + d[1] * r]
}
/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its
/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more),
/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each
/// rectangle then caps it at half its own shorter side.
fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 {
let (_, d) = corner(rect, i);
let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0;
if r.is_finite() { r.max(0.0) } else { from }
}
fn zoom(app: &PhotocraftApp) -> f64 {
f64::from(app.point_zoom().max(0.01))
}
/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an
/// index into `targets`) and its corner, the nearest when several are close.
/// The widget of `targets` within grabbing distance of document point `p`: the shape (an index
/// into `targets`) and its corner, the nearest when several are close.
fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> {
let z = zoom(app);
let tol = 7.0 / z;
targets
.iter()
.enumerate()
.flat_map(|(t, g)| {
(0..4).map(move |i| {
let w = widget(g.rect, g.radii, i, z);
(t, i, (w[0] - p[0]).hypot(w[1] - p[1]))
})
})
.flat_map(|(t, g)| (0..g.corners()).filter_map(move |i| g.widget(g.radii, i, z).map(|w| (t, i, (w[0] - p[0]).hypot(w[1] - p[1])))))
.filter(|(.., d)| *d <= tol)
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|(t, i, _)| (t, i))
@@ -159,12 +246,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
ToolEvent::Move { x, y, .. } => {
let Some(d) = app.ui.corner_radius.as_mut() else { return false };
let Some(g) = d.targets.get(d.grabbed) else { return true };
let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect));
let r = radius_after(g.rect, d.corner, from, d.start, [x, y]);
// Every selected rectangle takes the same radius, within its own size.
let Some(c) = g.corner(d.corner) else { return true };
// The radius at the press plus the move projected onto the corner's step (the way its
// arc's centre moves as the radius grows). Relative, so a click on a sharp corner's
// inset widget changes nothing.
let from = g.radius_at(g.radii, d.corner);
let len2 = c.step[0] * c.step[0] + c.step[1] * c.step[1];
let r = from + ((x - d.start[0]) * c.step[0] + (y - d.start[1]) * c.step[1]) / len2.max(1e-12);
let r = if r.is_finite() { r.max(0.0) } else { from };
// Every selected shape takes the same radius, within its own size.
for (t, now) in d.targets.iter().zip(d.now.iter_mut()) {
let r = r.min(max_radius(t.rect));
if d.all {
let r = r.min(t.max_radius());
if d.all || !t.is_rect() {
*now = [r; 4];
} else {
*now = t.radii;
@@ -184,9 +277,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
// One history step for every layer changed.
let key = format!("corner-radius-{}", app.ui.alloc_id());
for (t, now) in d.targets.iter().zip(&d.now) {
if *now != t.radii
&& let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key}))
{
if *now == t.radii {
continue;
}
let params = if t.is_rect() {
serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})
} else {
serde_json::json!({"layer": t.layer, "cornerRadius": now[0], "coalesce": key})
};
if let Err(e) = app.run("shape.edit", params) {
app.ui.status = e;
app.ui.status_error = true;
break;
@@ -199,11 +298,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
/// The radius the dragged corner shows in the readout.
fn dragged_radius(d: &Drag) -> Option<f64> {
d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied()
let g = d.targets.get(d.grabbed)?;
let now = d.now.get(d.grabbed)?;
if g.is_rect() { now.get(d.corner).copied() } else { Some(g.radius_at(*now, d.corner)) }
}
/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the
/// radius beside the pointer (`R: 25.0 px`), as in Photoshop.
/// The widgets of every selected live shape with corners; while one is dragged, the new outlines
/// and the radius beside the pointer (`R: 25.0 px`), as in Photoshop.
pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) {
let drag = app.ui.corner_radius.as_ref();
if let Some(r) = drag.and_then(dragged_radius)
@@ -216,13 +317,14 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64;
let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i)));
let radii = now.copied().unwrap_or(t.radii);
if now.is_some() {
let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii);
crate::vector_ui::draw_outline(painter, &path, to_scr, accent);
crate::vector_ui::draw_outline(painter, &t.path(radii), to_scr, accent);
}
for i in 0..4 {
let p = to_scr(widget(t.rect, radii, i, z));
painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent));
painter.circle_filled(p, 2.5, accent);
for i in 0..t.corners() {
if let Some(w) = t.widget(radii, i, z) {
let p = to_scr(w);
painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent));
painter.circle_filled(p, 2.5, accent);
}
}
}
}
+83
View File
@@ -184,3 +184,86 @@ fn the_cursor_and_readout_follow_the_widgets() {
tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE);
assert!(!super::hovers(&app, [100.0, 80.0]));
}
/// The live polygon of layer `id`: its corner radius.
fn corner_radius_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> f64 {
match &app.session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => match sh.live {
Some(LiveShape::Polygon { corner_radius, .. }) => corner_radius,
_ => panic!("live polygon"),
},
_ => panic!("shape"),
}
}
/// A live triangle (a 3-sided polygon) in (40, 40) 120 × 120, as the Triangle tool draws it.
fn triangle(tool: Tool, radius: f64) -> (PhotocraftApp, photocraft_doc::LayerId) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.sync_views();
app.run("shape.create", json!({"kind": "polygon", "rect": [40, 40, 120, 120], "sides": 3, "cornerRadius": radius})).unwrap();
app.ui.tool = tool;
let id = app.session.active().unwrap().active_layer.unwrap();
(app, id)
}
/// Where the widget of corner `i` of the first target is, and the way its arc's centre moves.
fn widget_of(app: &PhotocraftApp, i: usize) -> ([f64; 2], [f64; 2]) {
let t = &super::targets(app)[0];
let z = f64::from(app.point_zoom());
(t.widget(t.radii, i, z).unwrap(), t.corner(i).unwrap().step)
}
#[test]
fn a_triangle_rounds_all_its_corners_from_any_widget_in_one_step() {
for tool in [Tool::Triangle, Tool::Polygon, Tool::PathSelection] {
let (mut app, id) = triangle(tool, 6.0);
assert_eq!(super::targets(&app)[0].corners(), 3, "{tool:?}");
let n = steps(&app);
// Drag the bottom-right widget inwards by 4 radius units along its step: 6 + 4 = 10.
let (w, step) = widget_of(&app, 1);
let to = [w[0] + step[0] * 4.0, w[1] + step[1] * 4.0];
drag(&mut app, w, to, Modifiers::NONE);
assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9, "{tool:?}: {}", corner_radius_of(&app, id));
assert_eq!(steps(&app), n + 1, "one history step");
// ⌥ makes no difference: a triangle has one radius for all its corners.
let (w, step) = widget_of(&app, 0);
drag(&mut app, w, [w[0] + step[0] * 2.0, w[1] + step[1] * 2.0], Modifiers::ALT);
assert!((corner_radius_of(&app, id) - 12.0).abs() < 1e-9);
assert!(app.session.undo());
assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9);
}
}
#[test]
fn a_triangle_radius_is_capped_and_a_click_on_a_sharp_corner_changes_nothing() {
let (mut app, id) = triangle(Tool::PathSelection, 0.0);
let n = steps(&app);
let (w, step) = widget_of(&app, 2);
drag(&mut app, w, w, Modifiers::NONE);
assert_eq!((corner_radius_of(&app, id), steps(&app)), (0.0, n), "a click commits nothing");
// Dragged far in: the radius stops where the arcs meet halfway along the edges.
drag(&mut app, w, [w[0] + step[0] * 1000.0, w[1] + step[1] * 1000.0], Modifiers::NONE);
let max = super::targets(&app)[0].max_radius();
assert!(max > 0.0 && (corner_radius_of(&app, id) - max).abs() < 1e-9, "{} vs {max}", corner_radius_of(&app, id));
}
#[test]
fn rectangles_and_triangles_selected_together_take_the_same_radius() {
let (mut app, tri) = triangle(Tool::PathSelection, 0.0);
app.run("shape.create", json!({"kind": "rect", "rect": [10, 170, 100, 20], "radii": 0})).unwrap();
let rect = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.select", json!({"layer": tri.0, "mode": "add"})).unwrap();
// The rectangle's top-left widget (10 px inside, at (20, 180)), 4 px in each direction: 4.
drag(&mut app, [20.0, 180.0], [24.0, 184.0], Modifiers::NONE);
assert_eq!(radii(&app), None, "the active layer is the triangle");
let rr = match &app.session.active().unwrap().doc.layer(rect).unwrap().content {
LayerContent::Shape(sh) => match sh.live {
Some(LiveShape::Rect { radii, .. }) => radii,
_ => panic!(),
},
_ => panic!(),
};
assert_eq!(rr, [4.0; 4]);
assert!((corner_radius_of(&app, tri) - 4.0).abs() < 1e-9);
}
+8 -1
View File
@@ -174,7 +174,14 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then
/// every visible shape layer.
pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
let pen = app.ui.pen.as_ref().filter(|p| !p.dragging && !p.knots.is_empty()).map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p)));
let active = app.session.active().map(|st| st.doc.id);
let pen = app
.ui
.pen
.as_ref()
.filter(|_| app.ui.pen_doc.is_none_or(|owner| Some(owner) == active))
.filter(|p| !p.dragging && !p.knots.is_empty())
.map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p)));
let mut out: Vec<_> = pen.into_iter().chain(active_paths(app)).collect();
if let Some(st) = app.session.active() {
for (path, _, l) in st.doc.walk().iter().rev() {
+2
View File
@@ -2914,6 +2914,7 @@ cameraData Lens Objektiv
Duplicate Layer Comp Duplikovat kompozici vrstev
Duplicate Layers Duplikovat vrstvy
Duplicate Path Duplikovat cestu
Rename Path… Přejmenovat cestu…
Duplication Duplikace
Duration Doba trvání
Dust & Scratches Prach a škrábance
@@ -3774,6 +3775,7 @@ navigator Zoom Zvětšení
Medium Blue Středně modrá
Object Finder Hledání objektů
No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivní vrstva tu nemá žádné pixely — zapněte Vzorkovat všechny vrstvy nebo vyberte vrstvu s pixely
Remove after each stroke Odstranit po každém tahu
Apply the removal ({key}) Použít odstranění ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2252,6 +2252,7 @@
Fill Path Pfad füllen
Stroke Path Pfad nachziehen
Delete Path Pfad löschen
Rename Path… Pfad umbenennen…
W B
H H
Ruler Lineal
@@ -3170,6 +3171,7 @@ curves Smooth Glätten
Fuzziness Toleranz
Object Finder Objektsucher
No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Die aktive Ebene hat hier keine Pixel — aktivieren Sie „Alle Ebenen einbeziehen“ oder wählen Sie eine Ebene mit Pixeln
Remove after each stroke Nach jedem Strich entfernen
Apply the removal ({key}) Entfernen anwenden ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2361,6 +2361,7 @@
Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας
Dual Brush Διπλό πινέλο
Duplicate Path Διπλασιασμός διαδρομής
Rename Path… Μετονομασία διαδρομής…
Duplicate style Διπλασιασμός στυλ
Dust & Scratches Σκόνη και γρατζουνιές
Dust & Scratches… Σκόνη και γρατζουνιές…
@@ -3861,6 +3862,7 @@ curves Smooth Εξομάλυνση
Light Gray Ανοιχτό γκρι
Object Finder Εύρεση αντικειμένων
No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Το ενεργό επίπεδο δεν έχει εικονοστοιχεία εδώ — ενεργοποιήστε τη Δειγματοληψία όλων των επιπέδων ή επιλέξτε ένα επίπεδο με εικονοστοιχεία
Remove after each stroke Αφαίρεση μετά από κάθε πινελιά
Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει)
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+2
View File
@@ -2852,6 +2852,7 @@ curves Smooth Suavizar
Light Gray Gris claro
Object Finder Buscador de objetos
No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels La capa activa no tiene píxeles aquí; activa Muestrear todas las capas o selecciona una capa con píxeles
Remove after each stroke Eliminar después de cada trazo
Apply the removal ({key}) Aplicar la eliminación ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela)
@@ -2987,6 +2988,7 @@ curves Smooth Suavizar
Clipping Path Trazado de recorte
New Path Nuevo trazado
Duplicate Path Duplicar trazado
Rename Path… Cambiar nombre del trazado…
Auto-Hide Ocultar automáticamente
X X
Y Y
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2372,6 +2372,7 @@
Clipping Path Tracé de détourage
New Path Nouveau tracé
Duplicate Path Dupliquer le tracé
Rename Path… Renommer le tracé…
Delete Path Supprimer le tracé
Auto-Hide Masquage automatique
W L
@@ -3169,6 +3170,7 @@ curves Smooth Lisser
Light Gray Gris clair
Object Finder Outil de recherche d’objets
No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Le calque actif n’a pas de pixels ici — activez Échantillonner tous les calques ou sélectionnez un calque contenant des pixels
Remove after each stroke Supprimer après chaque trait
Apply the removal ({key}) Appliquer la suppression ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3207,6 +3207,7 @@ cameraRaw Yellow Sárga
Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához.
Duplicate Layer Comp Rétegkompozíció kettőzése
Duplicate Path Útvonal kettőzése
Rename Path… Útvonal átnevezése…
Duplication Kettőzés
East asian Kelet-ázsiai
Edge Extension Szélek kiterjesztése
@@ -4084,6 +4085,7 @@ cameraRaw Yellow Sárga
@plural {n} action|{n} actions {n} művelet
Object Finder Objektumkereső
No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Az aktív rétegnek itt nincsenek képpontjai – kapcsold be a Mintavétel minden rétegből beállítást, vagy válassz képpontokat tartalmazó réteget
Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás
Gradient Preset Options Színátmenet-előbeállítás beállításai
Import .GRD .GRD importálása
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2379,6 +2379,7 @@
Clipping Path Clipping Path
New Path Path Baru
Duplicate Path Duplikat Path
Rename Path… Ganti Nama Path…
Delete Path Hapus Path
Auto-Hide Sembunyi Otomatis
W W
@@ -3172,6 +3173,7 @@ curves Smooth Haluskan
Light Gray Abu-abu Muda
Object Finder Pencari Objek
No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Layer aktif tidak memiliki piksel di sini — aktifkan Sampel Semua Layer atau pilih layer yang berisi piksel
Remove after each stroke Hapus setelah setiap goresan
Apply the removal ({key}) Terapkan penghapusan ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan)
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2978,6 +2978,7 @@ curves Smooth Leviga
Clipping Path Tracciato di ritaglio
New Path Nuovo tracciato
Duplicate Path Duplica tracciato
Rename Path… Rinomina tracciato…
Auto-Hide Nascondi automaticamente
X X
Y Y
@@ -4127,6 +4128,7 @@ navigator Zoom Zoom
{shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK.
Object Finder Ricerca oggetti
No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Il livello attivo non ha pixel qui: attiva Campiona tutti i livelli o seleziona un livello con pixel
Remove after each stroke Rimuovi dopo ogni tratto
Apply the removal ({key}) Applica la rimozione ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla)
Can't render this file because it contains an unexpected character in line 2388 and column 12.
+2
View File
@@ -2885,6 +2885,7 @@ fontWeight Italic 斜体
Duplicate Layer Comp レイヤーカンプを複製
Duplicate Layers レイヤーを複製
Duplicate Path パスを複製
Rename Path… パス名を変更…
Dust & Scratches ダスト&スクラッチ
EXIF EXIF
Export As 書き出し形式を指定
@@ -3774,6 +3775,7 @@ curves Smooth スムーズ
Light Gray ライトグレー
Object Finder オブジェクトファインダー
No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels アクティブなレイヤーのここにはピクセルがありません — 「すべてのレイヤーをサンプル」をオンにするか、ピクセルのあるレイヤーを選択してください
Remove after each stroke ストロークごとに削除
Apply the removal ({key}) 削除を適用 ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1408,6 +1408,7 @@
Rename 이름 바꾸기
Rename Group… 그룹 이름 바꾸기…
Rename Layer… 레이어 이름 바꾸기…
Rename Path… 패스 이름 바꾸기…
Rename Tool Preset… 도구 사전 설정 이름 바꾸기…
Rename… 이름 바꾸기…
Resample 리샘플링
@@ -3363,6 +3364,7 @@ curves Smooth 매끄럽게
Stop 정지
Object Finder 개체 찾기
No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 활성 레이어의 이 위치에는 픽셀이 없습니다 — 모든 레이어 샘플링을 켜거나 픽셀이 있는 레이어를 선택하세요
Remove after each stroke 획마다 제거
Apply the removal ({key}) 제거 적용 ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소)
Can't render this file because it contains an unexpected character in line 2930 and column 12.
+2
View File
@@ -3360,6 +3360,7 @@ cameraRaw Color Kleur
Do you want to save you made to “{name}” before quitting? Wil je de wijzigingen in “{name}” opslaan voordat je afsluit?
Dodge/Burn Tegenhouden/doordrukken
Duplicate Path Pad dupliceren
Rename Path… Pad hernoemen…
Dust & Scratches Stof en krassen
Dynamics Dynamiek
Edit Plane Vlak bewerken
@@ -3923,6 +3924,7 @@ curves Smooth Vloeiend maken
Light Gray Lichtgrijs
Object Finder Objectzoeker
No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels De actieve laag heeft hier geen pixels — zet Alle lagen samplen aan of selecteer een laag met pixels
Remove after each stroke Verwijderen na elke streek
Apply the removal ({key}) Verwijdering toepassen ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -2412,6 +2412,7 @@
Clipping Path Ścieżka przycinająca
New Path Nowa ścieżka
Duplicate Path Duplikuj ścieżkę
Rename Path… Zmień nazwę ścieżki…
Delete Path Usuń ścieżkę
Auto-Hide Ukrywanie automatyczne
W Sz.
@@ -3777,6 +3778,7 @@ curves Smooth Wygładź
Light Gray Jasnoszary
Object Finder Wyszukiwanie obiektów
No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktywna warstwa nie ma tu pikseli — włącz Próbkuj wszystkie warstwy albo wybierz warstwę z pikselami
Remove after each stroke Usuwaj po każdym pociągnięciu
Apply the removal ({key}) Zastosuj usuwanie ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2367,6 +2367,7 @@
Clipping Path Demarcador de corte
New Path Novo demarcador
Duplicate Path Duplicar demarcador
Rename Path… Renomear demarcador…
Delete Path Excluir demarcador
Auto-Hide Ocultar automaticamente
W L
@@ -3953,6 +3954,7 @@ layerLabel No Color Sem cor
Light Gray Cinza-claro
Object Finder Localizador de objetos
No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels A camada ativa não tem pixels aqui — ative Usar amostra de todas as camadas ou selecione uma camada com pixels
Remove after each stroke Remover após cada traço
Apply the removal ({key}) Aplicar a remoção ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1198,6 +1198,7 @@
Clipping Path Обтравочный контур
New Path Новый контур
Duplicate Path Дублировать контур
Rename Path… Переименовать контур…
Delete Path Удалить контур
Current Path Текущий контур
Vector Mask from Current Path Векторная маска из текущего контура
@@ -3115,6 +3116,7 @@ curves Smooth Сгладить
Light Gray Светло-серый
Object Finder Поиск объектов
No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активном слое здесь нет пикселей — включите «По всем слоям» или выберите слой с пикселями
Remove after each stroke Удалять после каждого штриха
Apply the removal ({key}) Применить удаление ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2353,6 +2353,7 @@
Clipping Path Putanja isecanja
New Path Nova putanja
Duplicate Path Dupliraj putanju
Rename Path… Preimenuj putanju…
Delete Path Obriši putanju
Auto-Hide Automatski sakrij
W Š
@@ -4199,6 +4200,7 @@ shape Appearance Izgled
Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje
Object Finder Pronalazač objekata
No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovde nema piksela — uključite Uzorkuj sve slojeve ili izaberite sloj sa pikselima
Clear Brush Controls Obriši kontrole četkice
Reset All Locked Settings Vrati sva zaključana podešavanja
Copy Texture to Other Tools Kopiraj teksturu u druge alatke
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3339,6 +3339,7 @@ cameraRaw Color Renk
Do you want to save you made to “{name}” before quitting? Çıkmadan önce “{name}” belgesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz?
Dodge/Burn Soldurma/Yakma
Duplicate Path Yolu Çoğalt
Rename Path… Yolu Yeniden Adlandır…
Dust & Scratches Toz ve Çizikler
Dynamics Dinamik
Edit Plane Düzlemi Düzenle
@@ -3898,6 +3899,7 @@ cameraData Lens Lens
@plural {n} action|{n} actions {n} eylem
Object Finder Nesne Bulucu
No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Etkin katmanda burada piksel yok — Tüm Katmanları Örnekle seçeneğini açın veya piksel içeren bir katman seçin
Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın
Gradient Preset Options Degrade Ön Ayarı Seçenekleri
Import .GRD .GRD İçe Aktar
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -1172,6 +1172,7 @@
Clipping Path Відсічний контур
New Path Новий контур
Duplicate Path Дублювати контур
Rename Path… Перейменувати контур…
Delete Path Видалити контур
Current Path Поточний контур
Vector Mask from Current Path Векторна маска з поточного контуру
@@ -3862,6 +3863,7 @@ curves Smooth Згладити
Light Gray Світло-сірий
Object Finder Пошук об’єктів
No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активному шарі тут немає пікселів — увімкніть «Зразок з усіх шарів» або виберіть шар із пікселями
Remove after each stroke Видаляти після кожного штриха
Apply the removal ({key}) Застосувати видалення ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує)
Can't render this file because it contains an unexpected character in line 1396 and column 18.
+2
View File
@@ -1273,6 +1273,7 @@
Stroke Path Đi nét đường dẫn
Save Path Lưu đường dẫn
Duplicate Path Nhân bản đường dẫn
Rename Path… Đổi tên đường dẫn…
Delete Path Xóa đường dẫn
Delete Vector Mask Xóa mặt nạ vector
Draw with the Pen tool (P) or make a work path from a selection. Vẽ bằng công cụ Bút (P) hoặc tạo đường dẫn làm việc từ vùng chọn.
@@ -3608,6 +3609,7 @@ curves Smooth Làm mượt
Response curve Đường cong phản hồi
Object Finder Trình tìm đối tượng
No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Lớp đang hoạt động không có điểm ảnh ở đây — hãy bật Lấy mẫu mọi lớp hoặc chọn một lớp có điểm ảnh
Remove after each stroke Xóa sau mỗi nét vẽ
Apply the removal ({key}) Áp dụng việc xóa ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1449,6 +1449,7 @@
Rename 重命名
Rename Group… 重命名组…
Rename Layer… 重命名图层…
Rename Path… 重命名路径…
Rename Tool Preset… 重命名工具预设…
Rename… 重命名…
Resample 重新采样
@@ -2809,6 +2810,7 @@ curves Smooth 平滑
Stop 停止
Object Finder 对象查找程序
No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 当前图层在此处没有像素 — 请打开“对所有图层采样”或选择有像素的图层
Remove after each stroke 每次描边后移除
Apply the removal ({key}) 应用移除 ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消)
Can't render this file because it contains an unexpected character in line 2358 and column 12.
+2
View File
@@ -1430,6 +1430,7 @@
Rename 重新命名
Rename Group… 重新命名群組…
Rename Layer… 重新命名圖層…
Rename Path… 重新命名路徑…
Rename Tool Preset… 重新命名工具預設集…
Rename… 重新命名…
Resample 重新取樣
@@ -2773,6 +2774,7 @@ curves Smooth 平滑
Stop 停止
Object Finder 物件尋找器
No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形
The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 作用中圖層在此處沒有像素 — 請開啟「取樣全部圖層」或選取有像素的圖層
Remove after each stroke 每次描邊後移除
Apply the removal ({key}) 套用移除 ({key})
Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12 -9
View File
@@ -1261,6 +1261,9 @@ impl PhotocraftApp {
}
fn run_command(&mut self, id: &str, params: Value) -> Result<Value, String> {
if !vector_ui::cancel_stale(self) {
return Err(self.ui.status.clone());
}
if matches!(id, "paint.setSymmetry" | "paint.symmetryFromPath" | "paint.symmetryDisable") {
self.ui.symmetry_transform = None;
}
@@ -1333,10 +1336,6 @@ impl PhotocraftApp {
id,
"layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath"
) && on_active_layer;
// A draft Pen path belongs to the current document, not the one about to be created.
if id == "file.new" && self.ui.pen.is_some() {
vector_ui::pen_commit(self, false);
}
let r = if id == "actions.play" {
actions::play(self, &params)
} else if photocraft_engine::actions_cmds::shell_view_command(id) {
@@ -1425,18 +1424,18 @@ impl PhotocraftApp {
self.perf.command_ms = gpu_canvas::now_ms() - t0;
match &r {
Ok(_) => {
if self.ui.status_error {
self.ui.status.clear();
self.ui.status_error = false;
}
self.sync_views();
let removed = self.session.take_samplers_removed();
if removed > 0 {
if removed > 0 && !self.ui.status_error {
// A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color
// Sampler points that fell outside the new canvas (#1046).
self.ui.status = crate::i18n::trn(crate::i18n::current(), removed as u64, "{n} sample point removed", "{n} sample points removed");
self.ui.status_error = false;
}
if self.ui.status_error {
self.ui.status.clear();
self.ui.status_error = false;
}
}
Err(e) => {
self.ui.status = e.clone();
@@ -1470,6 +1469,8 @@ impl PhotocraftApp {
crate::canvas::polygon_cancel_stale(self);
crate::crop_ui::cancel_stale(self);
crate::perspective_crop_ui::cancel_stale(self);
// A pen path under construction belongs to the document it was started on too.
vector_ui::cancel_stale(self);
let ids: Vec<DocId> = self.session.documents().iter().map(|d| d.doc.id).collect();
// Where the document of view `i` is now. Views not tracked yet keep their index.
let now = |i: usize| match self.view_docs.get(i) {
@@ -3768,3 +3769,5 @@ mod channel_tint_tests {
#[cfg(test)]
mod options_hit_area_tests;
#[cfg(test)]
mod select_and_mask_button_tests;
+157 -1
View File
@@ -13,12 +13,16 @@ use crate::canvas::ViewXform;
use crate::state::Tool;
fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> {
harness_with_step_dt(tool, 0.25)
}
fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, 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.rulers = false;
app.ui.tool = tool;
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(step_dt).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
@@ -147,6 +151,57 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() {
assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag");
}
#[test]
fn object_selection_keeps_the_mode_from_the_mouse_press() {
for dragged in [false, true] {
for held in [false, true] {
for at_press in [Modifiers::SHIFT, Modifiers::ALT] {
let mut h = harness_with_step_dt(Tool::ObjectSelection, 1.0 / 60.0);
h.state_mut().ui.extras.snap = false;
h.state_mut().run("paint.pencil", json!({"points": [[220, 150]], "size": 60, "color": "#ff0000"})).unwrap();
let st = h.state().session.active().unwrap();
let doc = st.doc.clone();
let target = st.active_layer;
let region = photocraft_engine::smartselect_cmds::find_object(&doc, target, false, 220.0, 150.0).expect("the red disc is detectable");
h.state_mut().session.object_finder.insert(&doc, target, std::sync::Arc::new(region));
if at_press.alt {
h.state_mut().run("select.all", json!({})).unwrap();
} else {
h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
}
let start_doc = if dragged { [180.0, 110.0] } else { [220.0, 150.0] };
mods(&mut h, at_press);
move_to(&mut h, start_doc[0], start_doc[1]);
let start = screen(&h, start_doc[0], start_doc[1]);
button(&mut h, start, true, at_press);
assert!(h.state().drag.is_none(), "the press is still below egui's drag threshold");
let during = if held { at_press } else { Modifiers::NONE };
mods(&mut h, during);
let end_doc = if dragged { [260.0, 190.0] } else { start_doc };
release_at(&mut h, end_doc[0], end_doc[1], during);
let app = h.state();
assert!(!app.ui.status_error, "{}", app.ui.status);
let (command, params) = app.session.journal.last().expect("the gesture dispatched a command");
assert_eq!(command, "select.object", "the canvas dispatched Object Selection");
let expected = if at_press.alt { "subtract" } else { "add" };
assert_eq!(params["mode"], expected, "dragged={dragged}, held={held}");
let sel = app.session.active().unwrap().doc.selection.as_ref().expect("the old selection survives outside the object");
assert!(sel.sample_channel(20, 20, 0) > 0.99, "the gesture must preserve selection outside the object");
assert_eq!(sel.sample_channel(220, 150, 0) > 0.5, !at_press.alt, "the disc follows the requested mode");
mods(&mut h, Modifiers::NONE);
move_to(&mut h, 220.0, 150.0);
let next = screen(&h, 220.0, 150.0);
button(&mut h, next, true, Modifiers::NONE);
release_at(&mut h, 220.0, 150.0, Modifiers::NONE);
let (_, params) = h.state().session.journal.last().unwrap();
assert_eq!(params["mode"], "replace", "the next unmodified click must not reuse the previous mode");
let sel = h.state().session.active().unwrap().doc.selection.as_ref().unwrap();
assert!(sel.sample_channel(20, 20, 0) < 0.01 && sel.sample_channel(220, 150, 0) > 0.99);
}
}
}
}
/// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis
/// snapped to the pixel grid on its own. The readout and the selection are square, and the
/// Elliptical Marquee's circle too.
@@ -164,6 +219,107 @@ fn shift_square_pressed_between_pixels_has_equal_sides() {
}
}
/// #6015: Fixed Size pressed between pixels selected one pixel more on each side (10 × 10 became
/// 11 × 11, 1 × 1 became 2 × 2). The readout and the selection are the entered size, and a click
/// without a drag places it too, as in Photoshop.
#[test]
fn fixed_size_selects_exactly_the_entered_size() {
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
for (w, ht) in [(10, 10), (1, 1), (64, 32)] {
let mut h = harness(tool);
let o = &mut h.state_mut().ui.tool_options;
o.marquee_style = "fixedSize".into();
(o.marquee_width, o.marquee_height) = (w as f32, ht as f32);
press_at(&mut h, 50.4, 60.6, Modifiers::NONE);
assert!(readout(&h, w, ht), "{tool:?} {w} × {ht}: the readout shows the entered size");
release_at(&mut h, 90.0, 90.0, Modifiers::NONE);
assert_eq!(selection(&h), Rect::new(90, 90, 90 + w, 90 + ht), "{tool:?} {w} × {ht}: dragged to the release point");
// A click: the button goes down and up at one point.
h.state_mut().run("select.deselect", json!({})).unwrap();
let p = screen(&h, 120.6, 100.4);
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
button(&mut h, p, true, Modifiers::NONE);
button(&mut h, p, false, Modifiers::NONE);
h.run_steps(2);
assert_eq!(selection(&h), Rect::new(121, 100, 121 + w, 100 + ht), "{tool:?} {w} × {ht}: clicked");
}
}
}
/// Fixed Size places a box rather than sizing it: the pointer positions it in every direction,
/// including crossing the press point, without flipping by the full width or height.
#[test]
fn fixed_size_drag_positions_the_box_in_every_direction() {
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
for zoom in [1.0, 16.0] {
let mut h = harness(tool);
h.state_mut().ui.extras.snap = false;
let o = &mut h.state_mut().ui.tool_options;
o.marquee_style = "fixedSize".into();
(o.marquee_width, o.marquee_height) = (11.0, 11.0);
h.state_mut().ui.views[0].zoom = zoom;
h.state_mut().ui.views[0].center = [60.0, 70.0];
h.run_steps(2);
press_at(&mut h, 50.4, 60.6, Modifiers::NONE);
for (x, y) in [(58.4, 66.6), (49.4, 59.6), (49.4, 66.6), (58.4, 59.6), (59.4, 60.6)] {
move_to(&mut h, x, y);
let app = h.state();
let r = crate::canvas::marquee_preview_px(&app.ui.tool_options, app.drag.as_ref().unwrap(), zoom).unwrap();
let (x, y) = (f64::from(x).round(), f64::from(y).round());
assert_eq!(r, [x, y, x + 11.0, y + 11.0], "{tool:?} at zoom {zoom}: follows the pointer");
assert!(readout(&h, 11, 11), "the live dimensions stay 11 × 11");
}
// Commit the release position even if there was no move frame at this point.
let p = screen(&h, 60.4, 61.6);
button(&mut h, p, false, Modifiers::NONE);
h.run_steps(2);
assert_eq!(selection(&h), Rect::new(60, 62, 71, 73), "{tool:?}: release commits the final position");
}
}
}
#[test]
fn fixed_size_drag_inside_a_completed_box_still_moves_its_outline() {
for snap in [false, true] {
for zoom in [1.0, 26.730814] {
let mut h = harness(Tool::RectMarquee);
h.state_mut().ui.extras.snap = snap;
let o = &mut h.state_mut().ui.tool_options;
o.marquee_style = "fixedSize".into();
(o.marquee_width, o.marquee_height) = (11.0, 11.0);
h.state_mut().ui.views[0].zoom = zoom;
h.state_mut().ui.views[0].center = [112.0, 110.0];
h.run_steps(2);
press_at(&mut h, 100.4, 100.6, Modifiers::NONE);
release_at(&mut h, 108.4, 106.6, Modifiers::NONE);
let r = selection(&h);
let (x, y) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0);
press_at(&mut h, x, y, Modifiers::NONE);
release_at(&mut h, x + 2.0, y + 1.0, Modifiers::NONE);
assert_eq!(selection(&h), r.translate(2, 1), "zoom {zoom}, snap {snap}");
}
}
}
#[test]
fn fixed_size_alt_centers_the_box_at_the_current_pointer() {
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
let mut h = harness(tool);
h.state_mut().ui.extras.snap = false;
let o = &mut h.state_mut().ui.tool_options;
o.marquee_style = "fixedSize".into();
(o.marquee_width, o.marquee_height) = (11.0, 11.0);
press_at(&mut h, 50.4, 60.6, Modifiers::NONE);
mods(&mut h, Modifiers::ALT);
move_to(&mut h, 100.4, 100.6);
assert!(readout(&h, 11, 11));
release_at(&mut h, 110.4, 90.6, Modifiers::ALT);
assert_eq!(selection(&h), Rect::new(105, 85, 116, 96), "{tool:?}: Alt centres the moving box");
}
}
/// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee.
#[test]
fn show_transformation_values_off_hides_the_readout() {
+36 -2
View File
@@ -16,7 +16,7 @@ use std::sync::Arc;
use egui::{Color32, TextureOptions, pos2};
use photocraft_algo::selection::Region;
use photocraft_doc::{DocId, Document, LayerId};
use photocraft_engine::smartselect_cmds::{find_object, sampled_target};
use photocraft_engine::smartselect_cmds::{empty_sampled_area, find_object, sampled_target};
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
@@ -57,6 +57,13 @@ pub(crate) struct Update {
pub shown: Option<Arc<Region>>,
/// A lookup is pending or running: repaint soon to pick it up.
pub busy: bool,
/// Why nothing can be found here: [`empty_layer_hint`] when the sampled layer has no pixels under the pointer.
pub hint: Option<&'static str>,
}
/// The status-bar hint while hovering where the sampled layer has no pixels (the click reports the same as an error).
fn empty_layer_hint() -> &'static str {
tl!("The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels")
}
/// Whether the preview applies now (`pressed`: a pointer button is down).
@@ -87,6 +94,11 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo
app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None });
}
let doc_arc = Arc::clone(&st.doc);
// The click reports an empty sampled layer; the hover says so too instead of showing nothing.
let here = photocraft_geom::Rect::from_xywh(px[0], px[1], 1, 1);
if doc_arc.bounds().contains(px[0], px[1]) && empty_sampled_area(&doc_arc, layer, all_layers, here).is_some() {
return Update { shown: None, busy: false, hint: Some(empty_layer_hint()) };
}
let Some(f) = app.object_finder.as_mut() else { return Update::default() };
if f.at != px {
f.at = px;
@@ -121,7 +133,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo
f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at));
}
let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE);
Update { shown: f.shown.clone(), busy }
Update { shown: f.shown.clone(), busy, hint: None }
}
fn spawn(doc: Arc<Document>, layer: Option<LayerId>, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job {
@@ -193,6 +205,15 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf
if up.busy {
ctx.request_repaint_after(std::time::Duration::from_millis(40));
}
match up.hint {
Some(h) if app.ui.status != h => {
app.ui.status = h.to_owned();
app.ui.status_error = false;
}
// The pointer moved on: drop our hint, never another message.
None if app.ui.status == empty_layer_hint() => app.ui.status.clear(),
_ => {}
}
let Some(r) = up.shown else { return };
let Some(f) = app.object_finder.as_mut() else { return };
let key = Arc::as_ptr(&r) as usize;
@@ -271,6 +292,19 @@ mod tests {
assert!(hover(&mut app, [4.0, 4.0]).shown.is_none());
}
#[test]
fn hovering_an_empty_active_layer_gives_a_hint_not_silence() {
let mut app = disc_app();
app.session.execute("layer.new.layer", json!({})).unwrap();
let u = update(&mut app, Some([100.0, 80.0]), false, 100.0);
assert!(u.shown.is_none() && !u.busy);
assert_eq!(u.hint, Some(empty_layer_hint()));
// Sample All Layers on: no hint, the object is looked up.
app.ui.tool_options.sample_all.object_selection = true;
assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none());
assert!(hover(&mut app, [100.0, 80.0]).shown.is_some());
}
#[test]
fn preview_clears_on_leave_tool_switch_button_option_and_edits() {
let mut app = disc_app();
+43 -9
View File
@@ -1230,9 +1230,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
});
widgets::vline(ui, 22.0);
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
}
select_and_mask_button(app, ui);
}
Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso if t.pro => {
selection_mode_buttons(app, ui);
@@ -1257,9 +1255,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
widgets::vline(ui, 22.0);
}
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
}
select_and_mask_button(app, ui);
if crate::lasso_ui::active(app) || (app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty()) {
hint(
ui,
@@ -1294,9 +1290,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let r = app.run("select.subject", json!({}));
crate::retouch_ui::report_smart_result(app, r, tl!("No subject found — try Object Selection or the Lasso"));
}
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
}
select_and_mask_button(app, ui);
}
Tool::PaintBucket if t.pro => {
opt_label(ui, tl!("Fill"));
@@ -2553,6 +2547,7 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
app.session.tools.foreground = hsva_srgb(hsva);
ui.data_mut(|d| d.insert_temp(key, hsva));
crate::type_tool::foreground_changed(app);
crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui));
}
}
@@ -4260,6 +4255,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
} else {
app.session.tools.foreground = hsva_srgb(hsva);
crate::type_tool::foreground_changed(app);
crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui));
}
ui.data_mut(|d| d.insert_temp(key, (hsva.h, hsva.s)));
}
@@ -4273,6 +4269,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
} else {
app.session.tools.foreground = c;
crate::type_tool::foreground_changed(app);
crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui));
}
// Keep the hue for greys, so the field marker doesn't jump to red; the saturation with
// it, so a later black lands on the corner it was last dragged to (#3228).
@@ -4790,6 +4787,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
}
}
/// The options bar's Select and Mask… button: greyed out, as the menu item is, until there is a
/// selection (or a layer mask) to refine (#7520).
fn select_and_mask_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let enabled = crate::menus::is_enabled(app, "select.selectAndMask");
let clicked = ui.add_enabled_ui(enabled, |ui| widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked()).inner;
if clicked {
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
}
}
/// New / add / subtract / intersect selection buttons (shared by selection tools).
fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.x = 2.0;
@@ -5191,6 +5198,33 @@ mod color_tests {
}
}
/// #5568: with a shape layer selected, the Color panel recolours its fill (keeping the fill's
/// opacity): a click is one undo step and a whole drag across the field is one more.
#[test]
fn color_panel_recolours_the_selected_shape_fill() {
use photocraft_doc::{Fill, LayerContent, LayerId};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": [0.2, 0.4, 0.6, 0.5]})).unwrap()["layer"].as_u64().unwrap());
let mut h = field_harness(app);
let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh.fill.clone(),
_ => panic!("not a shape layer"),
};
let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len();
let before = steps(h.state());
let min = h.ctx.input(|i| i.viewport_rect()).min;
// The field's top-left: no saturation, full brightness.
click(&mut h, min + vec2(60.0, 10.0));
let fg = h.state().session.tools.foreground;
assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5))));
assert_eq!(steps(h.state()), before + 1);
drag(&mut h, min + vec2(120.0, 40.0), min + vec2(250.0, 120.0));
let fg = h.state().session.tools.foreground;
assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5))));
assert_eq!(steps(h.state()), before + 2, "the whole drag is one undo step");
}
/// Press at `from`, move to `to` in six steps and release, as egui sees a drag.
fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.hover_at(from);
+3 -1
View File
@@ -248,7 +248,8 @@ fn clicking_shape_with_path_selection_selects_its_layer() {
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer);
}
/// Work paths remain visible (outline) regardless of the active tool. Before the fix,
/// A work path selected in the Paths panel remains visible (outline) regardless of the active tool
/// (one that is not selected is hidden, #6551). Before the fix,
/// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move,
/// etc. This test renders the canvas with a Brush tool active and a work path present, then
/// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn.
@@ -259,6 +260,7 @@ fn path_overlay_drawn_for_non_vector_tools() {
app.sync_views();
// Place a work path whose top edge runs exactly along y=10 in document space.
app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap();
app.ui.selected_path = Some("work".into());
// Switch to a non-vector tool — before the fix this hid the path outline.
app.ui.tool = Tool::Brush;
+28 -1
View File
@@ -1,5 +1,5 @@
//! The Pen's modifiers while drawing, through the real canvas (#3306): ⌥/Alt breaks the handle
//! being pulled out and grabs the last anchor's outgoing handle alone, and dragging on the first
//! being pulled out and grabs either of the last anchor's handles alone, and dragging on the first
//! anchor closes the path with that anchor's handles pulled out.
use egui::{Key, Modifiers, PointerButton, Pos2, vec2};
@@ -122,6 +122,33 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() {
assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0]));
}
/// #6077: both handles of the last anchor are drawn, so ⌥/Alt grabs either one, as in Photoshop.
/// Dragging the incoming handle turns it alone; the outgoing handle and the anchors stay put.
#[test]
fn alt_drag_turns_the_last_anchors_incoming_handle_alone() {
let mut h = harness();
click(&mut h, [20.0, 20.0]);
drag(&mut h, &[[100.0, 20.0], [120.0, 40.0]], None);
let k = h.state().ui.pen.clone().unwrap().knots[1];
assert!(near(k[1], [80.0, 0.0]) && near(k[2], [120.0, 40.0]), "smooth anchor pulled out: {k:?}");
drag(&mut h, &[[80.0, 0.0], [60.0, 30.0]], Some(0));
let pen = h.state().ui.pen.clone().unwrap();
assert_eq!(pen.knots.len(), 2, "no new anchor on the handle");
let k = pen.knots[1];
assert!(near(k[0], [100.0, 20.0]), "the anchor stayed put: {k:?}");
assert!(near(k[1], [60.0, 30.0]), "the incoming handle followed the pointer: {k:?}");
assert!(near(k[2], [120.0, 40.0]), "the outgoing handle stayed put: {k:?}");
click(&mut h, [150.0, 120.0]);
h.key_press(Key::Enter);
h.run_steps(2);
let doc = &h.state().session.active().unwrap().doc;
let k = &doc.work_path.as_ref().unwrap().subpaths[0].knots[1];
assert!(!k.smooth, "the broken handle is a cusp in the work path");
assert!(near([k.in_ctrl.x, k.in_ctrl.y], [60.0, 30.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [120.0, 40.0]));
}
#[test]
fn opening_a_new_document_finishes_the_previous_pen_path() {
let mut h = harness();
@@ -0,0 +1,46 @@
//! #7520: the options bar's Select and Mask… button is greyed out while nothing is selected.
use crate::PhotocraftApp;
use crate::state::Tool;
use egui::vec2;
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use serde_json::json;
fn harness(tool: Tool, select: bool) -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
app.sync_views();
if select {
app.run("select.all", json!({})).unwrap();
}
app.ui.tool = tool;
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
crate::panels::options_bar(app, ui);
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
h
}
#[test]
fn select_and_mask_is_disabled_without_a_selection() {
for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] {
let h = harness(tool, false);
assert!(h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be greyed out with no selection");
}
}
#[test]
fn select_and_mask_is_enabled_with_a_selection() {
for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] {
let h = harness(tool, true);
assert!(!h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be live with a selection");
}
}
+20 -5
View File
@@ -2,11 +2,14 @@
//! opens Photoshop's creation dialog instead of drawing. OK runs the same `shape.create` command as
//! a drag, with the options bar's fill and stroke, at the clicked point (or centred on it).
//! Width and height are stored in document pixels (so automation can set them with
//! `ui.dialog.set`) and shown in the ruler unit.
//! `ui.dialog.set`) and shown in the ruler unit, or in one picked from the Width and Height
//! fields' right-click menu (remembered with the other values).
use egui::{Align2, Sense, pos2, vec2};
use serde_json::{Map, Value, json};
use photocraft_engine::prefs::Unit;
use crate::PhotocraftApp;
use crate::state::{DialogKind, Tool};
use crate::theme::Tokens;
@@ -15,7 +18,7 @@ const MARK: &str = "__createShape";
/// Largest side the dialog accepts, in pixels (Photoshop's document limit).
const MAX_PX: f64 = 300_000.0;
/// Remembered values (document pixels; sides and radii come from the options bar).
const KEYS: [&str; 4] = ["width", "height", "fromCenter", "starRatio"];
const KEYS: [&str; 5] = ["width", "height", "fromCenter", "starRatio", "unit"];
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
@@ -40,6 +43,7 @@ fn valid(key: &str, v: &Value) -> bool {
match key {
"width" | "height" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=MAX_PX).contains(&x)),
"starRatio" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=100.0).contains(&x)),
"unit" => v.as_str().is_some_and(|u| Unit::parse(u).is_some()),
_ => v.is_boolean(),
}
}
@@ -68,6 +72,8 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2]) -> Option<u64> {
f.insert("width".into(), json!(100.0));
f.insert("height".into(), json!(100.0));
f.insert("fromCenter".into(), json!(false));
// The unit Width and Height show (the ruler unit until one is picked with a right-click).
f.insert("unit".into(), json!(app.session.prefs().units_and_rulers.rulers.name()));
match kind {
"rectangle" => {
f.insert("radii".into(), json!([r, r, r, r]));
@@ -160,9 +166,11 @@ fn label(ui: &mut egui::Ui, text: &str) {
pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.spacing_mut().item_spacing.y = 6.0;
let kind = f.get(MARK).and_then(Value::as_str).unwrap_or("").to_string();
// Width and height in the ruler unit (percent of the document's side).
// Width and height in the dialog's unit (percent of the document's side): the ruler unit, or
// the one picked from the fields' right-click menu, as in Photoshop (#7786).
let ur = &app.session.prefs().units_and_rulers;
let (unit, ppi) = (ur.rulers, ur.point_size.per_inch());
let unit = f.get("unit").and_then(Value::as_str).and_then(Unit::parse).unwrap_or(ur.rulers);
let ppi = ur.point_size.per_inch();
let (dpi, size) = app
.session
.active()
@@ -174,10 +182,17 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32;
let mut v = to_unit(px);
let places = unit.decimals() as u32;
if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() {
let resp = crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places);
if resp.changed() {
let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX);
f.insert(key.into(), json!((px * 1000.0).round() / 1000.0));
}
// Right-click: the length units, for both fields (the sizes stay in pixels).
resp.context_menu(|ui| {
if let Some(name) = crate::rulers::unit_menu(ui, unit) {
f.insert("unit".into(), json!(name));
}
});
});
}
match kind.as_str() {
+55 -1
View File
@@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() {
let id = only_dialog(&app);
set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50}));
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 });
assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0 });
press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]);
let id = only_dialog(&app);
@@ -248,3 +248,57 @@ fn the_dialog_body_renders_for_every_tool_and_unit() {
app.ui.close_dialog(id);
}
}
/// The texts a dialog body draws.
fn body_texts(app: &PhotocraftApp, fields: &mut serde_json::Map<String, Value>) -> Vec<String> {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| body(app, ui, fields));
out.textures_delta.clear();
let mut texts = Vec::new();
for clip in &out.shapes {
if let egui::Shape::Text(t) = &clip.shape {
texts.push(t.galley.text().to_string());
}
}
texts
}
/// #7786: Width and Height show in the unit picked from their right-click menu (the ruler unit
/// until then), the sizes stay in pixels, and OK remembers the unit with the other values.
#[test]
fn width_and_height_show_in_the_picked_unit_and_remember_it() {
let mut app = app();
press(&mut app, Tool::Rectangle, [10.0, 10.0], [10.0, 10.0]);
let id = only_dialog(&app);
let mut f = app.ui.dialog_mut(id).unwrap().fields.clone();
assert_eq!(f["unit"], json!("pixels"), "the ruler unit at first");
// Each of Width and Height: its label, its value, its unit.
let sides = |texts: &[String]| -> Vec<(String, String)> {
["Width:", "Height:"]
.iter()
.filter_map(|l| texts.iter().position(|t| t == l))
.filter_map(|i| Some((texts.get(i + 1)?.clone(), texts.get(i + 2)?.clone())))
.collect()
};
let px = ("100".to_string(), "px".to_string());
assert_eq!(sides(&body_texts(&app, &mut f)), [px.clone(), px], "shown in pixels");
// What the menu's Millimeters sets: 100 px at the document's 72 ppi is 35.28 mm.
f.insert("unit".into(), json!("mm"));
let mm = ("35.28".to_string(), "mm".to_string());
assert_eq!(sides(&body_texts(&app, &mut f)), [mm.clone(), mm]);
assert_eq!(f["width"].as_f64(), Some(100.0), "the size stays in pixels");
set(&mut app, id, json!({"unit": "mm", "width": 50}));
crate::dialogs::confirm(&mut app, id).unwrap();
// The next Create Rectangle opens in millimetres, with the remembered size.
press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]);
let id = only_dialog(&app);
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!((f["unit"].as_str(), f["width"].as_f64()), (Some("mm"), Some(50.0)));
// A remembered unit that isn't one falls back to the ruler unit.
app.ui.close_dialog(id);
app.session.prefs.edit(|p| {
p.dialogs.insert("shape.create.rectangle".into(), json!({"unit": "furlongs"}));
});
press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]);
assert_eq!(app.ui.dialogs[0].fields["unit"], json!("pixels"));
}
+35
View File
@@ -82,6 +82,41 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result
app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]}))
}
/// A new foreground colour from the Color panel, a swatch or the Color Picker fills the selected
/// shape layer, keeping the fill's own opacity, as in Photoshop (#5568). Edits sharing `coalesce`
/// (one Color panel drag) are one undo step. Locked shapes and other layers are left alone.
pub(crate) fn foreground_changed(app: &mut PhotocraftApp, coalesce: Option<String>) {
let Some(st) = app.session.active() else { return };
let Some(layer) = st.active_layer else { return };
let Some(LayerContent::Shape(sh)) = st.doc.layer(layer).map(|l| &l.content) else { return };
let locks = st.doc.effective_locks(layer);
if locks.all || locks.pixels {
return;
}
let fg = app.session.tools.foreground;
let alpha = match &sh.fill {
Some(Fill::Solid(c)) => c.alpha,
_ => 1.0,
};
if sh.fill == Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha))) {
return;
}
let mut p = json!({"layer": layer.0, "fill": [fg[0], fg[1], fg[2], alpha]});
if let Some(key) = coalesce {
p["coalesce"] = json!(key);
}
if let Err(e) = app.run(COMMAND, p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
/// The coalesce key of the pointer gesture under way (its press time), so one Color panel drag
/// is one undo step; `None` for clicks and typed values.
pub(crate) fn gesture(ui: &egui::Ui) -> Option<String> {
ui.input(|i| i.pointer.press_start_time()).map(|t| format!("color-panel-fill:{}", t.to_bits()))
}
/// The canvas preview while the picker is open: the document with the shape re-rendered in the
/// picked colour, cached per dialog, revision and colour.
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
+4
View File
@@ -1370,6 +1370,9 @@ pub struct UiState {
/// Pen path under construction.
#[serde(default)]
pub pen: Option<crate::vector_ui::PenPath>,
/// The document the pen path is drawn on; switching away commits it to that Work Path.
#[serde(default)]
pub pen_doc: Option<photocraft_doc::DocId>,
#[serde(default)]
pub stroke_editor: Option<crate::shape_stroke_ui::StrokeEditor>,
/// Direct Selection tool: selected anchors and the drag in progress (#790).
@@ -1576,6 +1579,7 @@ impl Default for UiState {
layer_filter_name: String::new(),
layer_filter_mode: default_layer_filter_mode(),
pen: None,
pen_doc: None,
stroke_editor: None,
direct_selection: Default::default(),
corner_radius: None,
+30 -2
View File
@@ -2,7 +2,8 @@
//! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context
//! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete).
//!
//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type);
//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type or
//! fills the selected shape layer);
//! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The
//! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`,
//! `.act`, `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a
@@ -51,8 +52,10 @@ fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc<Vec<Vec<Color32>>> {
pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) {
let target = if background { "background" } else { "foreground" };
if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background {
// Clicking a swatch while characters are selected recolours them (#364).
// Clicking a swatch while characters are selected recolours them (#364), and with a
// shape layer selected fills the shape (#5568).
crate::type_tool::foreground_changed(app);
crate::shape_fill_ui::foreground_changed(app, None);
}
}
@@ -290,6 +293,31 @@ mod tests {
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)));
}
/// #5568: clicking a swatch with a shape layer selected fills the shape with it, one undo
/// step each, a gradient fill included.
#[test]
fn swatch_click_fills_the_selected_shape() {
use photocraft_doc::{Fill, LayerContent, LayerId};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": {"gradient": {}}})).unwrap()["layer"].as_u64().unwrap());
let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh.fill.clone(),
_ => panic!("not a shape layer"),
};
let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len();
let before = steps(&app);
let names: Vec<String> = app.session.presets.swatches[0].items.iter().take(2).map(|s| s.name.clone()).collect();
use_swatch(&mut app, &names[0], false);
let fg = app.session.tools.foreground;
assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[0]);
assert_eq!(steps(&app), before + 1);
use_swatch(&mut app, &names[1], false);
let fg = app.session.tools.foreground;
assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[1]);
assert_eq!(steps(&app), before + 2, "each swatch click is its own undo step");
}
fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) {
h.hover_at(p);
// Held modifiers, as when the user holds Alt while clicking.
+78
View File
@@ -189,6 +189,84 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain
}
}
#[test]
fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() {
for label in ["Fill color", "Stroke color"] {
let mut app = app("rgb", 32);
let original = [0.123456, 0.345678, 0.567891, 0.372819];
app.run("tools.setColors", json!({"foreground": original, "background": original})).unwrap();
let mut h = harness(app, false);
h.get_by_label(label).click();
h.run_steps(3);
h.get_by_label("#").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
for ch in "12AB".chars() {
h.event(Event::Text(ch.to_string()));
h.run_steps(1);
}
assert_eq!(h.get_by_label("#").value().as_deref(), Some("12AB"));
assert_eq!(h.state().session.tools.foreground, original);
assert_eq!(h.state().session.tools.background, original);
for ch in "EF".chars() {
h.event(Event::Text(ch.to_string()));
h.run_steps(1);
}
let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, original[3]];
let tools = &h.state().session.tools;
assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want);
assert_eq!(if label == "Fill color" { tools.background } else { tools.foreground }, original);
// An invalid/multi-byte value leaves the selected paint untouched.
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
h.event(Event::Text("颜色".into()));
h.run_steps(2);
let tools = &h.state().session.tools;
assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want);
h.key_press(Key::Escape);
h.run_steps(2);
finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE);
let drawn = shape(h.state());
let paint = if label == "Fill color" { drawn.fill.as_ref().and_then(color_of) } else { drawn.stroke.as_ref().and_then(|s| color_of(&s.paint)) };
assert_eq!(paint, Some(want), "the next shape takes the typed colour");
}
}
#[test]
fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() {
for label in ["Fill color", "Stroke color"] {
let mut app = app("rgb", 32);
app.run("tools.setColors", json!({"foreground": [0.2, 0.3, 0.4, 0.6], "background": [0.5, 0.6, 0.7, 0.8]})).unwrap();
app.ui.tool_options.stroke_width = 3.0;
finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE);
let original = shape(&app).clone();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, false);
h.get_by_label(label).click();
h.run_steps(3);
h.get_by_label("#").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
h.event(Event::Paste("#12ABEF".into()));
h.run_steps(2);
let edited = shape(h.state());
let (paint, untouched) = if label == "Fill color" {
(edited.fill.as_ref().and_then(color_of), edited.stroke == original.stroke)
} else {
(edited.stroke.as_ref().and_then(|s| color_of(&s.paint)), edited.fill == original.fill)
};
let alpha = if label == "Fill color" { 0.6 } else { 0.8 };
let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, alpha];
assert_eq!(paint, Some(want));
assert!(untouched, "the other paint stays unchanged");
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(h.state()), &original);
}
}
#[test]
fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() {
let mut app = app("rgb", 32);
+596 -29
View File
@@ -26,6 +26,9 @@ pub struct PenPath {
/// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put.
#[serde(skip)]
pub adjusting_last: bool,
/// ⌥/Alt-dragging the final anchor's incoming handle: it turns alone (#6077).
#[serde(skip)]
pub adjusting_in: bool,
/// Drawn with the Curvature Pen: handles follow the anchors (`curvature_refresh`) and
/// `unlinked` lists its corner points.
#[serde(default)]
@@ -237,7 +240,13 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool {
/// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor
/// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it.
pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
if !cancel_stale(app) {
return;
}
let tol = 6.0 / app.point_zoom().max(0.01) as f64;
if app.ui.pen.is_none() {
app.ui.pen_doc = app.session.active().map(|st| st.doc.id);
}
// A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the
// press then acts on its last anchor, so a drag pulls out the next segment's handle.
if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty())
@@ -246,6 +255,7 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
app.ui.pen = Some(resumed);
}
let pen = app.ui.pen.get_or_insert_with(PenPath::default);
pen.adjusting_in = false;
if let Some(first) = pen.knots.first().map(|k| k[0])
&& on_closing_anchor(pen, x, y, tol)
{
@@ -285,10 +295,14 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
if let Some(pen) = app.ui.pen.as_mut()
&& pen.dragging
{
let adjusting_last = pen.adjusting_last;
let (adjusting_last, adjusting_in) = (pen.adjusting_last, pen.adjusting_in);
if let Some(k) = pen.knots.last_mut() {
let a = k[0];
let [x, y] = shift_45(a, [x, y], shift);
if adjusting_in {
k[1] = [x, y];
return;
}
k[2] = [x, y];
if !adjusting_last {
k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y];
@@ -307,6 +321,9 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] {
/// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one
/// knot rather than undoing an unrelated, already committed document operation.
pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool {
if !cancel_stale(app) {
return false;
}
let Some(pen) = app.ui.pen.as_mut() else { return false };
// A resumed path's own anchors are not the draft's to take back: undoing past the ones added
// since it resumed drops the draft and leaves the path as it was (#6593).
@@ -345,6 +362,7 @@ pub fn pen_up(app: &mut PhotocraftApp) {
let Some(pen) = app.ui.pen.as_mut() else { return };
pen.dragging = false;
pen.adjusting_last = false;
pen.adjusting_in = false;
if closing(pen) {
// A click on the first anchor closes the path and leaves that anchor's handles alone.
if let Some(k) = pen.knots.last_mut()
@@ -368,21 +386,31 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) {
pen.adjusting_last = true;
}
/// ⌥/Alt-press on the last anchor's outgoing handle while drawing grabs that handle alone: the
/// drag turns it and the incoming curve stays put (#3306). False when the press misses it.
/// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the
/// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both.
pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
let Some(pen) = app.ui.pen.as_mut() else { return false };
let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false };
let dist = |p: [f64; 2]| (p[0] - x).hypot(p[1] - y);
if k[2] == k[0] || dist(k[2]) >= tol || dist(k[2]) >= dist(k[0]) {
// Both handles show only while the outgoing one is pulled out (`draw_overlay`).
if k[2] == k[0] {
return false;
}
let grabbed = |h: [f64; 2]| h != k[0] && dist(h) < tol && dist(h) < dist(k[0]);
let incoming = if grabbed(k[2]) && dist(k[2]) <= dist(k[1]) {
false
} else if grabbed(k[1]) {
true
} else {
return false;
};
if !pen.unlinked.contains(&i) {
pen.unlinked.push(i);
}
pen.dragging = true;
pen.adjusting_last = true;
pen.adjusting_in = incoming;
true
}
@@ -446,6 +474,7 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) {
pen.unlinked = knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect();
pen.dragging = false;
pen.adjusting_last = false;
pen.adjusting_in = false;
pen.moving = None;
if pen.curvature {
curvature_refresh(pen, false);
@@ -455,9 +484,50 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) {
}
}
/// Preserve a foreign document's draft as its Work Path before another document can edit it.
/// A failed commit retains the draft and blocks the caller until it can be retried.
pub fn cancel_stale(app: &mut PhotocraftApp) -> bool {
let active = app.session.active().map(|st| st.doc.id);
if app.ui.pen.is_none() {
app.ui.pen_doc = None;
return true;
}
let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true };
if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) {
let Some(mut pen) = app.ui.pen.clone() else { return true };
if pen.curvature {
curvature_refresh(&mut pen, false);
}
let active = app.session.active_index();
app.session.set_active(index);
// Bypass shell targets and sync_views: both belong to the newly active document.
let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)}));
if let Some(index) = active {
app.session.set_active(index);
}
if let Err(e) = result {
app.ui.status = e.to_string();
app.ui.status_error = true;
return false;
}
}
app.ui.pen = None;
app.ui.pen_doc = None;
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
app.ui.direct_selection = Default::default();
}
true
}
/// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode).
pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) {
// The control channel is drained after `sync_views`, so a document switch in this frame has
// not been seen yet: never commit the path into the document it was not drawn on.
if !cancel_stale(app) {
return;
}
let Some(mut pen) = app.ui.pen.take() else { return };
app.ui.pen_doc = None;
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
// Its anchors are no longer the Pen's: the committed path is another path.
app.ui.direct_selection = Default::default();
@@ -688,6 +758,9 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option<usize> {
/// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor
/// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release).
pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) {
if !cancel_stale(app) {
return;
}
if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) {
pen_commit(app, false);
}
@@ -696,6 +769,9 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) {
pen_commit(app, true);
return;
}
if app.ui.pen.is_none() {
app.ui.pen_doc = app.session.active().map(|st| st.doc.id);
}
let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() });
if hit.is_none() {
pen.knots.push([[x, y]; 3]);
@@ -1106,7 +1182,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
// View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays.
// Free Transform Path draws the path through its box instead (transform_tool).
let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none());
if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) {
if app.session.active().is_some_and(|st| st.doc.id == doc.id) && crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) {
// Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790).
crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths);
if paths {
@@ -1117,10 +1193,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers));
}
} else if paths {
// Paths remain visible as outlines with any tool. Path Selection and the Pen tools add
// anchor squares; Direct Selection has its own overlay above.
// The shape's or vector mask's path stays visible as an outline with any tool. Path
// Selection and the Pen tools add anchor squares; Direct Selection has its own overlay
// above. The Work Path is drawn only while it is the path selected in the Paths panel or
// a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived
// with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551).
let anchors = shows_anchors(tool);
if let Some(wp) = &doc.work_path {
if let Some(wp) = &doc.work_path
&& (anchors || app.ui.selected_path.as_deref() == Some("work"))
{
draw_path(wp, anchors);
}
// The saved path selected in the Paths panel is the target path (#6550).
@@ -1142,7 +1223,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
// Pen path in progress, with handles of the last knot and a rubber band to the pointer. While
// ⌘/Ctrl drags it, Direct Selection draws its preview instead.
let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. }));
if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) {
// Only on the document it is drawn on: a view of another document never shows it (#5373).
if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| d == doc.id)) {
let mut pts = Vec::new();
// A close-drag shows the first segment bending with the handle being pulled out.
let mut knots = pen.knots.clone();
@@ -1581,6 +1663,37 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32
out = Some(PaintChange::Color(color));
}
}
ui.horizontal(|ui| {
let label = ui.label(egui::RichText::new("#").color(t.text_dim));
let id = resp.id.with("shape-paint-hex");
let selected = match out {
Some(PaintChange::Color(color)) => color,
_ => original,
};
// Keep incomplete typing across frames; deriving the field from the colour each
// frame would discard every keystroke until all six digits were present.
let mut text = ui
.data(|d| d.get_temp::<([f32; 4], String)>(id))
.filter(|(previous, _)| *previous == selected)
.map_or_else(|| hex(selected).trim_start_matches('#').to_ascii_uppercase(), |(_, text)| text);
let response = crate::widgets::text_field(ui, id, &mut text, "RRGGBB", 90.0, true).labelled_by(label.id);
if response.changed()
&& let Some(rgb) = crate::color_picker_ui::parse_hex(&text)
{
let color = [rgb[0], rgb[1], rgb[2], selected[3]];
out = Some(PaintChange::Color(color));
hsva = picker_color(color);
}
if response.has_focus() {
let color = match out {
Some(PaintChange::Color(color)) => color,
_ => selected,
};
ui.data_mut(|d| d.insert_temp(id, (color, text)));
} else {
ui.data_mut(|d| d.remove::<([f32; 4], String)>(id));
}
});
if let Some(rgb) = crate::screen_picker::button(ui, screen_id) {
out = Some(sampled(rgb));
hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]);
@@ -1729,7 +1842,14 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
});
}
}
photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => {
photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => {
// One radius rounds every vertex of a triangle, polygon or star, as in Photoshop.
ui.horizontal(|ui| {
let mut r = *corner_radius as f32;
if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") {
edit = Some(json!({"cornerRadius": r, "coalesce": key("corner-radius")}));
}
});
ui.horizontal(|ui| {
let mut n = *sides as f32;
if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") {
@@ -1852,6 +1972,32 @@ pub fn paths_footer(ctx: &egui::Context) -> Option<Rect> {
ctx.data(|d| d.get_temp(footer_id()))
}
/// Photoshop's default name for a saved path: the first free "Path N", counting from the number of
/// saved paths.
fn free_path_name(doc: &Document) -> String {
let mut n = doc.paths.len().saturating_add(1);
while doc.paths.iter().any(|p| p.name == format!("Path {n}")) {
n = n.saturating_add(1);
if n == usize::MAX {
break;
}
}
format!("Path {n}")
}
/// A Paths panel name being typed in place: the row's key ("work" or the saved path's name) and
/// the text so far. It lives in egui's temp data, like the Channels panel's rename field.
#[derive(Clone)]
struct PathRename {
key: String,
text: String,
focused: bool,
}
fn rename_id(doc: &Document) -> egui::Id {
egui::Id::new(("path-rename", doc.id.0))
}
/// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a
/// right-click acts on that row even when another path is selected in the panel. `layer` is the
/// layer a `PathRow::Layer` row belongs to (the active layer).
@@ -1867,14 +2013,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document, layer: Option<photocr
("Stroke Path…", "path.stroke", json!({"name": key})),
];
if entry.kind == PathRow::Work {
let mut n = doc.paths.len().saturating_add(1);
while doc.paths.iter().any(|p| p.name == format!("Path {n}")) {
n = n.saturating_add(1);
if n == usize::MAX {
break;
}
}
actions.push(("Save Path", "path.rename", json!({"name": "work", "to": format!("Path {n}")})));
actions.push(("Save Path", "path.rename", json!({"name": "work", "to": free_path_name(doc)})));
} else {
let mut n = 1usize;
while doc.paths.iter().any(|p| p.name == format!("{} copy {n}", entry.name)) {
@@ -1886,6 +2025,10 @@ fn path_context_actions(entry: &PathEntry, doc: &Document, layer: Option<photocr
let copy_name = format!("{} copy {n}", entry.name);
actions.push(("Duplicate Path", "path.set", json!({"name": copy_name, "path": photocraft_engine::vector_cmds::path_json(&entry.path)})));
}
if entry.kind == PathRow::Saved {
// No `to`: the panel asks for the new name in place.
actions.push(("Rename Path…", "path.rename", json!({"name": key})));
}
if entry.kind != PathRow::Layer {
actions.push(("Delete Path", "path.delete", json!({"name": key})));
} else if entry.name.ends_with(" Vector Mask") {
@@ -1916,6 +2059,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ctx.data_mut(|d| d.remove::<PathDrag>(path_drag_key()));
}
let drag = ctx.data(|d| d.get_temp::<PathDrag>(path_drag_key()));
let rename_id = rename_id(&doc);
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y;
let fill = ui.available_height() > footer + 60.0;
@@ -1965,8 +2109,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
0.0,
egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() },
);
let g = ui.painter().layout_job(job);
ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text);
let renaming = ui.ctx().data(|d| d.get_temp::<PathRename>(rename_id)).filter(|e| e.key == key);
if let Some(mut edit) = renaming {
let field = Rect::from_min_max(pos2(r.left() + 43.0, r.center().y - 11.0), pos2(r.right() - 8.0, r.center().y + 11.0));
let id = rename_id.with("field");
let te = ui.put(field, egui::TextEdit::singleline(&mut edit.text).id(id).font(egui::FontId::proportional(12.0)));
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
if !edit.focused {
te.request_focus();
// Like Photoshop, the whole name starts selected so typing replaces it.
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) {
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(edit.text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
edit.focused = true;
ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit));
} else if esc {
ui.ctx().data_mut(|d| d.remove::<PathRename>(rename_id));
} else if enter || te.lost_focus() || !te.has_focus() {
ui.ctx().data_mut(|d| d.remove::<PathRename>(rename_id));
let to = edit.text.trim();
// The Work Path is saved even under its proposed name; an unchanged saved name is left alone.
if !to.is_empty() && (*kind == PathRow::Work || to != name) {
action = Some(("path.rename", json!({"name": key, "to": to})));
}
} else {
ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit));
}
} else {
let g = ui.painter().layout_job(job);
ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text);
}
// Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target.
let modifiers = ui.input(|i| i.modifiers);
let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p));
@@ -1979,10 +2153,18 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
} else if resp.clicked() {
app.ui.selected_path = Some(key.clone());
}
// Double-clicking the Work Path saves it (Photoshop's "Save Path").
if !load && resp.double_clicked() && *kind == PathRow::Work {
let n = doc.paths.len() + 1;
action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")})));
// As in Photoshop, double-clicking a saved path's name renames it, and double-clicking
// the Work Path asks for the name to save it under (#6238).
let proposed = match kind {
PathRow::Work => Some(free_path_name(&doc)),
PathRow::Saved => Some(name.clone()),
PathRow::Layer => None,
};
if !load
&& resp.double_clicked()
&& let Some(text) = proposed
{
ui.ctx().data_mut(|d| d.insert_temp(rename_id, PathRename { key: key.clone(), text, focused: false }));
}
resp.context_menu(|ui| {
crate::widgets::menu_scroll(ui, |ui| {
@@ -1995,6 +2177,9 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if cmd == "path.stroke" {
// Stroke Path… asks for the tool first, as in Photoshop (#2978).
stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string());
} else if cmd == "path.rename" && params.get("to").is_none() {
let edit = PathRename { key: key.clone(), text: name.clone(), focused: false };
ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit));
} else {
action = Some((cmd, params));
}
@@ -2073,11 +2258,20 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if let Some(name) = stroke_dialog {
crate::canvas_tool_menu::stroke_path_dialog(app, &name);
}
if let Some((cmd, p)) = action
&& let Err(e) = app.run(cmd, p)
{
app.ui.status = e;
app.ui.status_error = true;
if let Some((cmd, p)) = action {
let renamed = if cmd == "path.rename" { p.get("to").and_then(Value::as_str).map(str::to_string) } else { None };
match app.run(cmd, p) {
// The renamed (or just saved) path stays the panel's target.
Ok(_) => {
if renamed.is_some() {
app.ui.selected_path = renamed;
}
}
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
}
}
}
}
@@ -2242,6 +2436,61 @@ mod tests {
assert!(shape.and_then(|id| app.session.active().unwrap().doc.layer(id)).is_some(), "one undo step brings it back");
}
/// #6238: double-clicking a saved path's name renames it in place, and double-clicking the
/// Work Path saves it under the name typed (it used to save it as "Path N" straight away).
#[test]
fn double_clicking_a_path_name_renames_it_in_place() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = app();
let path = json!({"subpaths": [{"closed": true, "knots": [
{"anchor": [10, 10], "in": [10, 10], "out": [10, 10]},
{"anchor": [80, 10], "in": [80, 10], "out": [80, 10]},
{"anchor": [80, 80], "in": [80, 80], "out": [80, 80]}
]}]});
app.run("path.set", json!({"name": "First", "path": path})).unwrap();
app.run("path.set", json!({"name": "work", "path": path})).unwrap();
// 60 fps steps, so two clicks a frame apart count as a double-click.
let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| paths_panel(app, ui), app);
h.run_steps(3);
let rename = |h: &mut Harness<'_, PhotocraftApp>, row: &str, text: &str| {
let name = h.get_by_label(row).rect().left_center() + vec2(60.0, 0.0);
h.event(egui::Event::PointerMoved(name));
h.run_steps(30);
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.step();
}
h.run_steps(3);
h.event(egui::Event::Text(text.into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
};
let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.paths.iter().map(|p| p.name.clone()).collect::<Vec<_>>();
rename(&mut h, "First", "Outline");
assert_eq!(names(&h), ["Outline"]);
assert!(h.state().session.active().unwrap().doc.work_path.is_some(), "renaming a saved path leaves the work path alone");
rename(&mut h, "Work Path", "Circle");
assert_eq!(names(&h), ["Outline", "Circle"]);
assert!(h.state().session.active().unwrap().doc.work_path.is_none());
assert_eq!(h.state().ui.selected_path.as_deref(), Some("Circle"));
}
/// #6238: a saved path's context menu offers Rename Path…, as the Layers panel's does.
#[test]
fn saved_path_context_menu_offers_rename() {
let mut app = app();
app.run("path.set", json!({"name": "First", "path": {"subpaths": []}})).unwrap();
app.run("path.set", json!({"name": "work", "path": {"subpaths": []}})).unwrap();
let doc = &app.session.active().unwrap().doc;
let rename = |kind| {
let entry = path_rows(doc, None).into_iter().find(|r| r.kind == kind).unwrap();
path_context_actions(&entry, doc, None).into_iter().find(|(label, _, _)| *label == "Rename Path…").map(|(_, id, p)| (id, p))
};
assert_eq!(rename(PathRow::Saved), Some(("path.rename", json!({"name": "First"}))));
assert_eq!(rename(PathRow::Work), None, "the work path is saved, not renamed");
}
/// How many shapes the path overlay paints for `app` this frame.
fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize {
let doc = app.session.active().unwrap().doc.clone();
@@ -2279,6 +2528,43 @@ mod tests {
assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move));
}
#[test]
fn an_unselected_work_path_is_not_drawn_over_the_canvas() {
// #6551: Ctrl-click Load as Selection leaves the Paths panel's selection alone, yet the Work
// Path stayed drawn over the new selection's edge. Photoshop draws it only while selected
// in the Paths panel (or a path tool is active).
let ctx = egui::Context::default();
let mut app = app();
let path = json!({"subpaths": [{"closed": true, "knots": [
{"anchor": [20, 20], "in": [20, 20], "out": [20, 20]},
{"anchor": [90, 20], "in": [90, 20], "out": [90, 20]},
{"anchor": [90, 90], "in": [90, 90], "out": [90, 90]}
]}]});
app.run("path.set", json!({"name": "work", "path": path})).unwrap();
app.run("path.toSelection", json!({"name": "work", "mode": "replace"})).unwrap();
assert!(app.ui.selected_path.is_none());
for tool in [Tool::Move, Tool::Brush, Tool::Rectangle, Tool::Zoom] {
app.ui.tool = tool;
assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: an unselected Work Path stays off the canvas");
}
// Selected in the Paths panel: drawn with any tool.
app.ui.selected_path = Some("work".into());
for tool in [Tool::Move, Tool::Brush] {
app.ui.tool = tool;
assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected Work Path is drawn");
}
// Another path selected: the Work Path is not the target path.
app.ui.selected_path = Some("Saved".into());
app.ui.tool = Tool::Move;
assert_eq!(overlay_shapes(&app, &ctx), 0);
// A path tool shows the Work Path it edits even when the panel selection was cleared.
app.ui.selected_path = None;
for tool in [Tool::Pen, Tool::PathSelection] {
app.ui.tool = tool;
assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: path tools draw the Work Path");
}
}
#[test]
fn show_target_path_and_extras_hide_the_path_outlines() {
// #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the
@@ -2521,6 +2807,287 @@ mod tests {
pen_up(&mut app);
}
/// #5373: an unfinished path stays with its own document through same-frame switches.
#[test]
fn an_unclosed_path_stays_with_its_document() {
// Both pens claim the document at their first anchor.
let open_path = |app: &mut PhotocraftApp| {
for (x, y) in [(20.0, 20.0), (120.0, 60.0)] {
if app.ui.tool == Tool::CurvaturePen {
curvature_down(app, x, y);
curvature_up(app);
} else {
pen_down(app, x, y, false);
pen_up(app);
}
}
};
let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone();
// File › New commits the path to its original document.
for tool in [Tool::Pen, Tool::CurvaturePen] {
let mut app = app();
app.ui.tool = tool;
open_path(&mut app);
assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document");
app.run("file.new", json!({"width": 300, "height": 150})).unwrap();
assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone");
assert!(work(&app, 0).is_some(), "{tool:?}: the original keeps its Work Path");
assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank");
}
// Two documents; the path below is drawn on the second (active) one.
let mut app = app();
app.ui.tool = Tool::Pen;
app.run("file.new", json!({"width": 300, "height": 150})).unwrap();
open_path(&mut app);
assert!(app.session.set_active(0));
pen_commit(&mut app, false);
assert!(app.ui.pen.is_none());
assert!(work(&app, 0).is_none(), "the active document stays untouched");
assert!(work(&app, 1).is_some(), "the owner keeps its Work Path");
// Staying on its own document, the path still commits there.
open_path(&mut app);
pen_commit(&mut app, false);
assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on");
assert!(work(&app, 1).is_some());
for act in [0, 1, 2] {
open_path(&mut app);
assert!(app.session.set_active(1));
match act {
0 => pen_down(&mut app, 50.0, 50.0, false),
1 => curvature_down(&mut app, 50.0, 50.0),
_ => assert!(!pen_undo_last_point(&mut app), "nothing of this document's to undo"),
}
let knots = app.ui.pen.as_ref().map_or(0, |p| p.knots.len());
assert!(knots <= 1, "act {act}: a new path, not the other document's (knots {knots})");
// The two drawing entry points start a path of their own here; undo has none to take.
let expected = if act == 2 { None } else { app.session.active().map(|st| st.doc.id) };
assert_eq!(app.ui.pen_doc, expected, "act {act}");
app.ui.pen = None;
app.ui.pen_doc = None;
assert!(app.session.set_active(0));
}
open_path(&mut app);
let shapes = |app: &PhotocraftApp| {
let ctx = egui::Context::default();
let doc = app.session.active().unwrap().doc.clone();
let xf = ViewXform {
rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0)),
zoom: 1.0,
center: [100.0, 75.0],
flip: false,
rotation: 0.0,
aspect: 1.0,
};
let mut out = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf, &doc));
out.textures_delta.clear();
out.shapes.len()
};
// Same view, pen present vs absent: the difference is the path itself, nothing else.
let drawn = |app: &mut PhotocraftApp| {
let with_pen = shapes(app);
let pen = app.ui.pen.take();
let without = shapes(app);
app.ui.pen = pen;
with_pen - without
};
assert!(drawn(&mut app) > 0, "its own document draws the path");
assert!(app.session.set_active(1));
assert_eq!(drawn(&mut app), 0, "the other document draws nothing of it");
assert!(app.session.set_active(0));
app.ui.pen = None;
app.ui.pen_doc = None;
open_path(&mut app);
app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen);
app.ui.direct_selection.anchors = vec![[0, 1]];
app.run("file.new", json!({"width": 120, "height": 90})).unwrap();
assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path");
}
#[test]
fn pen_overlay_uses_the_view_document_even_during_direct_selection() {
let mut app = app();
app.ui.tool = Tool::Pen;
pen_down(&mut app, 20.0, 20.0, false);
pen_up(&mut app);
pen_down(&mut app, 80.0, 60.0, false);
pen_up(&mut app);
let owner = app.session.active().unwrap().doc.clone();
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
let other = app.session.active().unwrap().doc.clone();
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
let shapes = |app: &PhotocraftApp, doc: &Document, modifiers| {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }, |ui| {
draw_overlay(app, ui.painter(), &xf, doc)
});
output.textures_delta.clear();
output.shapes.len()
};
for active in [0, 1] {
assert!(app.session.set_active(active));
for tool in [Tool::Pen, Tool::DirectSelection] {
app.ui.tool = tool;
for modifiers in [egui::Modifiers::NONE, egui::Modifiers::COMMAND] {
for (doc, visible) in [(&owner, true), (&other, false)] {
let with_pen = shapes(&app, doc, modifiers);
let pen = app.ui.pen.take();
let without = shapes(&app, doc, modifiers);
app.ui.pen = pen;
assert_eq!(with_pen > without, visible, "active {active}, {tool:?}, {modifiers:?}");
}
}
}
}
}
#[test]
fn failed_stale_pen_commit_preserves_the_draft_until_retry() {
for action in ["pen", "curvature", "undo", "commit", "command"] {
let mut app = app();
app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[5, 5], [10, 10]]}]}})).unwrap();
let original = app.session.active().unwrap().doc.work_path.clone().unwrap();
app.ui.tool = Tool::Pen;
for (x, y) in [(20.0, 20.0), (80.0, 60.0)] {
pen_down(&mut app, x, y, false);
pen_up(&mut app);
}
let draft = app.ui.pen.clone();
let owner = app.ui.pen_doc;
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.ui.tool_options.vector_mode = "shape".into();
app.ui.selected_path = Some("foreign named path".into());
app.session.authorize = Some(|_, _| Err(photocraft_engine::EngineError::Other("test rejection".into())));
match action {
"pen" => pen_down(&mut app, 150.0, 150.0, false),
"curvature" => curvature_down(&mut app, 150.0, 150.0),
"undo" => assert!(!pen_undo_last_point(&mut app)),
"commit" => pen_commit(&mut app, true),
_ => {
assert!(app.run("file.close", json!({})).is_err());
}
}
assert_eq!(app.ui.pen, draft, "{action}: failed save retains all anchors");
assert_eq!(app.ui.pen_doc, owner);
assert_eq!(app.session.active_index(), Some(1));
assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original));
assert!(app.session.documents()[1].doc.work_path.is_none());
assert!(app.ui.status_error);
assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen));
app.session.authorize = None;
app.sync_views();
assert!(app.ui.pen.is_none());
assert_eq!(app.session.active_index(), Some(1));
assert_eq!(app.ui.selected_path.as_deref(), Some("foreign named path"));
let saved = app.session.documents()[0].doc.work_path.clone().unwrap();
assert_eq!(saved.subpaths.len(), original.subpaths.len() + 1);
assert_eq!(saved.subpaths[0], original.subpaths[0]);
app.sync_views();
assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&saved), "retry never duplicates the path");
assert!(app.session.set_active(0));
assert!(app.session.undo());
assert_eq!(app.session.active().unwrap().doc.work_path.as_ref(), Some(&original));
}
}
#[test]
fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() {
let mut app = app();
pen_down(&mut app, 20.0, 20.0, false);
pen_up(&mut app);
pen_down(&mut app, 80.0, 60.0, false);
pen_up(&mut app);
let draft = app.ui.pen.clone();
app.session.authorize = Some(|id, _| if id == "path.set" { Err(photocraft_engine::EngineError::Other("test rejection".into())) } else { Ok(()) });
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
assert!(app.ui.status_error);
assert!(app.ui.status.contains("test rejection"));
assert_eq!(app.ui.pen, draft);
assert_eq!(app.session.active_index(), Some(1));
assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none()));
app.session.authorize = None;
app.sync_views();
assert!(app.ui.pen.is_none());
assert!(app.session.documents()[0].doc.work_path.is_some());
assert!(app.session.documents()[1].doc.work_path.is_none());
}
#[test]
fn new_document_preserves_a_single_pen_anchor_as_a_work_path() {
for tool in [Tool::Pen, Tool::CurvaturePen] {
let mut app = app();
app.ui.tool = tool;
if tool == Tool::Pen {
pen_down(&mut app, 20.0, 30.0, false);
pen_up(&mut app);
} else {
curvature_down(&mut app, 20.0, 30.0);
curvature_up(&mut app);
}
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
assert!(app.ui.pen.is_none());
let path = app.session.documents()[0].doc.work_path.as_ref().unwrap();
assert_eq!(path.subpaths[0].knots.len(), 1);
assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 30.0));
assert!(app.session.active().unwrap().doc.work_path.is_none());
}
}
#[test]
fn stale_pen_commits_to_its_owner_across_document_changes() {
use photocraft_doc::{ColorMode, SampleType, Size};
for tool in [Tool::Pen, Tool::CurvaturePen] {
for action in ["switch", "open", "closeOther", "pen", "curvature", "undo", "commit"] {
let mut app = app();
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.ui.tool = tool;
for (x, y) in [(20.0, 20.0), (80.0, 60.0), (120.0, 30.0)] {
if tool == Tool::Pen {
pen_down(&mut app, x, y, false);
pen_up(&mut app);
} else {
curvature_down(&mut app, x, y);
curvature_up(&mut app);
}
}
let owner = app.ui.pen_doc.unwrap();
let expected = photocraft_engine::vector_cmds::parse_path(&pen_to_json(app.ui.pen.as_ref().unwrap(), false)).unwrap();
app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen);
app.ui.direct_selection.anchors = vec![[0, 1]];
assert!(app.session.set_active(0));
match action {
"open" => {
app.services.import =
Some(Box::new(|name, _, _| Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new()))));
app.open_file("synthetic.png", &[]).unwrap();
}
"closeOther" => {
app.run("file.close", json!({"document": 0})).unwrap();
}
"pen" => pen_down(&mut app, 150.0, 150.0, false),
"curvature" => curvature_down(&mut app, 150.0, 150.0),
"undo" => assert!(!pen_undo_last_point(&mut app)),
"commit" => pen_commit(&mut app, true),
_ => app.sync_views(),
}
let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap();
let path = source.doc.work_path.as_ref().expect("the owner keeps the unfinished path");
assert_eq!(path, &expected, "{tool:?} {action}");
assert!(app.session.documents().iter().filter(|d| d.doc.id != owner).all(|d| d.doc.work_path.is_none()));
if action != "closeOther" {
assert_ne!(app.session.active().unwrap().doc.id, owner, "the active document is restored");
}
assert_eq!(app.ui.direct_selection, Default::default());
let index = app.session.documents().iter().position(|d| d.doc.id == owner).unwrap();
assert!(app.session.set_active(index));
assert!(app.session.undo());
assert!(app.session.active().unwrap().doc.work_path.is_none());
assert!(app.session.redo());
assert!(app.session.active().unwrap().doc.work_path.is_some());
}
}
}
/// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new
/// subpath, instead of replacing it; a saved path that is selected takes it instead; with no
/// path selected, the new path replaces the Work Path, as in Photoshop.
+151 -1
View File
@@ -111,6 +111,101 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P
Path::new(vec![Subpath::polygon(&pts)])
}
/// A rounded vertex: see [`fillet`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Fillet {
/// The arc's centre and its tangent points on the edge to the previous and the next vertex.
pub centre: [f64; 2],
pub from: [f64; 2],
pub to: [f64; 2],
/// The radius used (the one asked for, capped at what the edges allow).
pub radius: f64,
/// Unit direction from the vertex towards the arc's centre, and half the vertex's angle.
pub bisector: [f64; 2],
pub half_angle: f64,
}
/// Where a vertex's rounding sits: the vertex `v` between neighbours `prev` and `next`, rounded
/// with radius `r`, capped so its tangent points stay within half of each edge (two neighbouring
/// roundings never overlap). `None` for `r <= 0` or a degenerate vertex (a zero-length edge, or
/// a straight or folded angle).
pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option<Fillet> {
let unit = |a: [f64; 2], b: [f64; 2]| {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = dx.hypot(dy);
(l > 1e-9).then(|| ([dx / l, dy / l], l))
};
if r.is_nan() || r <= 0.0 {
return None;
}
let (u1, l1) = unit(v, prev)?;
let (u2, l2) = unit(v, next)?;
let cos = (u1[0] * u2[0] + u1[1] * u2[1]).clamp(-1.0, 1.0);
let half = cos.acos() / 2.0;
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-6 {
return None;
}
let r = r.min(l1.min(l2) / 2.0 * half.tan());
let t = r / half.tan();
let (bx, by) = (u1[0] + u2[0], u1[1] + u2[1]);
let bl = bx.hypot(by);
if bl < 1e-9 {
return None;
}
let bisector = [bx / bl, by / bl];
let c = r / half.sin();
Some(Fillet {
centre: [v[0] + bisector[0] * c, v[1] + bisector[1] * c],
from: [v[0] + u1[0] * t, v[1] + u1[1] * t],
to: [v[0] + u2[0] * t, v[1] + u2[1] * t],
radius: r,
bisector,
half_angle: half,
})
}
/// `path` (closed subpaths of corner knots, as [`polygon`] makes) with every vertex rounded by a
/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like
/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp.
pub fn round_corners(path: Path, r: f64) -> Path {
if r.is_nan() || r <= 0.0 {
return path;
}
let subpaths = path
.subpaths
.into_iter()
.map(|sp| {
let n = sp.knots.len();
if n < 3 || !sp.closed {
return sp;
}
let pt = |i: usize| sp.knots.get(i % n).map_or([0.0; 2], |k| [k.anchor.x, k.anchor.y]);
let mut knots = Vec::with_capacity(n * 2);
for i in 0..n {
let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1));
let Some(f) = fillet(prev, v, next, r) else {
knots.push(Knot::corner(v[0], v[1]));
continue;
};
// The arc as one cubic: handles 4/3 tan(sweep/4) r long, along the tangents
// towards the vertex; the sweep is pi minus the vertex's angle.
let sweep = std::f64::consts::PI - 2.0 * f.half_angle;
let h = 4.0 / 3.0 * (sweep / 4.0).tan() * f.radius;
let toward = |a: [f64; 2]| {
let (dx, dy) = (v[0] - a[0], v[1] - a[1]);
let l = dx.hypot(dy).max(1e-12);
Point::new(a[0] + dx / l * h, a[1] + dy / l * h)
};
let (from, to) = (Point::new(f.from[0], f.from[1]), Point::new(f.to[0], f.to[1]));
knots.push(Knot { anchor: from, in_ctrl: from, out_ctrl: toward(f.from), smooth: false });
knots.push(Knot { anchor: to, in_ctrl: toward(f.to), out_ctrl: to, smooth: false });
}
Subpath { knots, ..sp }
})
.collect();
Path { subpaths, ..path }
}
/// A line drawn as a filled bar of `weight` px from `a` to `b`.
pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
@@ -125,7 +220,9 @@ pub fn live_path(s: &LiveShape) -> Path {
match s {
LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii),
LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h),
LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio } => polygon(*x, *y, *w, *h, *sides, *star_ratio),
LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => {
round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius)
}
LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight),
}
}
@@ -134,6 +231,59 @@ pub fn live_path(s: &LiveShape) -> Path {
mod tests {
use super::*;
/// The point at `t` of the cubic leaving `a` towards `b`.
fn at(a: &Knot, b: &Knot, t: f64) -> [f64; 2] {
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
let p = [a.anchor, a.out_ctrl, b.in_ctrl, b.anchor];
[p.iter().zip(w).map(|(p, w)| p.x * w).sum(), p.iter().zip(w).map(|(p, w)| p.y * w).sum()]
}
#[test]
fn a_rounded_triangle_has_one_arc_per_vertex_on_a_true_circle() {
let sharp = polygon(0.0, 0.0, 300.0, 300.0, 3, 1.0);
assert_eq!(sharp.subpaths[0].knots.len(), 3);
let p = round_corners(sharp.clone(), 20.0);
let k = &p.subpaths[0].knots;
assert_eq!(k.len(), 6, "two knots per rounded vertex");
// Each arc stays 20 px from its centre along its length (one cubic, within 0.1 px).
let verts: Vec<[f64; 2]> = sharp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
for i in 0..3 {
let f = fillet(verts[(i + 2) % 3], verts[i], verts[(i + 1) % 3], 20.0).unwrap();
assert!((f.radius - 20.0).abs() < 1e-9);
assert_eq!([k[2 * i].anchor.x, k[2 * i].anchor.y], f.from);
for t in [0.25, 0.5, 0.75] {
let q = at(&k[2 * i], &k[2 * i + 1], t);
let d = (q[0] - f.centre[0]).hypot(q[1] - f.centre[1]);
assert!((d - 20.0).abs() < 0.1, "vertex {i} t {t}: {d}");
}
}
// The edges between the arcs stay straight.
assert_eq!((k[1].out_ctrl, k[2].in_ctrl), (k[1].anchor, k[2].anchor));
}
#[test]
fn huge_radii_are_capped_and_zero_stays_sharp() {
let sharp = polygon(0.0, 0.0, 100.0, 100.0, 6, 1.0);
assert_eq!(round_corners(sharp.clone(), 0.0), sharp);
assert_eq!(round_corners(sharp.clone(), f64::NAN), sharp);
let p = round_corners(sharp, 1.0e9);
// Capped: the tangent points of neighbouring vertices meet halfway along each edge.
let k = &p.subpaths[0].knots;
assert_eq!(k.len(), 12);
for w in k.windows(2).skip(1).step_by(2) {
let d = (w[0].anchor.x - w[1].anchor.x).hypot(w[0].anchor.y - w[1].anchor.y);
assert!(d < 1e-6, "no straight left between arcs: {d}");
}
assert!(k.iter().all(|k| k.anchor.x.is_finite() && k.anchor.y.is_finite()));
}
#[test]
fn a_star_rounds_its_inner_vertices_too() {
let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 });
assert_eq!(p.subpaths[0].knots.len(), 20);
}
#[test]
fn rounded_rect_knots() {
let p = rounded_rect(0.0, 0.0, 100.0, 50.0, [10.0; 4]);
+3 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19; G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19; G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -163,6 +163,7 @@ what an agent can't finish alone.
exist (GrabCut/max-flow selection, Remove tool by non-local patch completion).
- **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has
a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682).
- **Progress (2026-10-17):** classical Object Finder no longer rejects a foreground solely for covering over 60% of the canvas (#6548); canvas-boundary coverage distinguishes it from the surrounding backdrop. This does not add semantic recognition or close #2788.
- **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote).
### G16. Plug-in compatibility
@@ -318,6 +319,7 @@ what an agent can't finish alone.
| 2026-10-20 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar; TYPE-218-2 partial (the visual RTL caret is still missing) |
| 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19); no readiness percentage or estimate changed |
| 2026-10-18 | minor | G13: Russian gained the 1,229 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open |
| 2026-10-17 | minor | Classical Object Finder improvement; learned segmentation remains open |
| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) |
| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
+7 -2
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Move, artboard | Move, Artboard | |
| Marquee | Rectangular, Elliptical, Single Row, Single Column | |
| Lasso | Lasso, Polygonal, Magnetic | |
| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). |
| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Where the active layer has no pixels under the pointer (Sample All Layers off), the status bar says so instead of showing nothing (#6549). Dominant enclosed objects are retained; regions covering most of the canvas boundary are treated as background (#6548). Heuristic, not a learned segmenter (#2788). |
| Crop and slice | Crop, Perspective Crop, Slice, Slice Select | |
| Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | |
| Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | |
@@ -128,7 +128,9 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 |
| On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 |
| Shape Density and Feather (Properties › Masks: a shape's path is its vector mask) | done: Density and Feather sliders for a selected shape layer, one undo step per drag, through `layer.vectorMask.edit`; the fill shows beyond the path at 100 − density %, a feather is the vector mask's Gaussian (Photoshop's 10 px ramp within 1 level); fill and stroke stay editable, the stroke stays crisp; PSD mask parameters and .pcraft round trip (#5846) | TOOL-214-12 |
| On-canvas corner-radius handles on live shapes | done: every selected live rectangle, triangle, polygon and star shows a widget at each arc centre with Path Selection, the Rectangle, Triangle and Polygon tools and Move with Show Transform Controls; a drag rounds every corner of every selected shape to the same radius, ⌥ one corner of a rectangle (a triangle or polygon has one radius), with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 |
| Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) |
| Shape options-bar Fill/Stroke hex input | done | #6176; `vector_ui.rs`, `vector_paint_tests.rs`: typed and pasted hex, partial/invalid input, alpha retention, next-shape defaults and selected-shape Undo |
| Rotated shape selection hints correct | partial | #2512 |
| Curved shape bounds exact | partial | one pixel too wide (#2537) |
@@ -238,8 +240,10 @@ Paragraph; the rest open as in-app floating windows.
|---|---|---|
| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) |
| 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed |
| 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) |
| 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing |
| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) |
| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. |
| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) |
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |
@@ -280,3 +284,4 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. |
| 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) |
| 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) |
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
+1 -1
View File
@@ -192,7 +192,7 @@ id = "TOOL-214-12"
target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance"
status = "done"
issue = 214
note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)"
note = "On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)"
[[item]]
id = "TOOL-214-13"