mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 20:36:18 +02:00
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:
commit
ba120dc48e
56 files changed
+2103
-212
No files matched your search
+3
-1
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(®, &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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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(®ion), region.bbox, d.bounds(), &REFINE)
|
||||
let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| {
|
||||
matting::refine_mask(smp, &matting::region_reader(®ion), 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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, ¶ms)
|
||||
} 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;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user