Start context-menu parity across canvas, paths, and document tabs (#556)
* Add blend modes to Gradient tool * Establish context menu parity contract and first workflows * Align Select menu actions and style selection context rows * Add Pen canvas Make Selection context action * Complete Pen path context menu and symmetry actions * Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate docs/scorecard.md and docs/parity.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate docs/scorecard.md and docs/parity.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8aecbffb5c
commit
b5eed81a25
|
|
@ -215,6 +215,7 @@ fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) {
|
|||
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let fg = brush.color;
|
||||
let symmetry = s.active().and_then(|st| st.symmetry_path.clone());
|
||||
let (id, brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let dmg = s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
|
|
@ -224,7 +225,24 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
|
|||
if auto_erase {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
Ok(render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom))
|
||||
let damage = if let Some(axis) = &symmetry {
|
||||
let reflected = axis.reflect_points(&pts);
|
||||
if crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(&pts, &reflected) {
|
||||
let pre = surf.clone();
|
||||
let mut original = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
original.push(&pts);
|
||||
original.finish();
|
||||
let mut mirror = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
mirror.push(&reflected);
|
||||
mirror.finish();
|
||||
original.composite_union(&mirror, &pre, surf, sel.as_ref(), lock)
|
||||
} else {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
}
|
||||
} else {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
};
|
||||
Ok(damage)
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
|
@ -281,6 +299,8 @@ pub struct LiveStroke {
|
|||
/// Jitter seed to pass to `paint.stroke`.
|
||||
pub seed: u64,
|
||||
renderer: StrokeRenderer,
|
||||
mirror: Option<(crate::symmetry_cmds::SymmetryAxis, StrokeRenderer)>,
|
||||
mirror_distinct: bool,
|
||||
pre: Surface,
|
||||
sel: Option<Surface>,
|
||||
lock: bool,
|
||||
|
|
@ -317,15 +337,18 @@ impl LiveStroke {
|
|||
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
|
||||
}
|
||||
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
let mirror = s.active().and_then(|st| st.symmetry_path.clone()).map(|axis| (axis, StrokeRenderer::new(&brush, Some(surf.format()), zoom)));
|
||||
let pre = surf.clone();
|
||||
let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY };
|
||||
let mut live =
|
||||
Self { doc: std::sync::Arc::new(doc), seed, renderer, mirror, mirror_distinct: false, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY };
|
||||
live.push(&pts)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Everything the stroke has touched so far.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.renderer.bounds().union(&self.tail)
|
||||
let bounds = self.renderer.bounds().union(&self.tail);
|
||||
if self.mirror_distinct { self.mirror.as_ref().map_or(bounds, |(_, renderer)| bounds.union(&renderer.bounds())) } else { bounds }
|
||||
}
|
||||
|
||||
/// Render more points; returns the rectangle that changed. The doc shows the stroke as
|
||||
|
|
@ -334,7 +357,28 @@ impl LiveStroke {
|
|||
/// nothing new appears on release.
|
||||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||||
self.renderer.push(pts);
|
||||
if let Some((axis, mirror)) = &mut self.mirror {
|
||||
let reflected = axis.reflect_points(pts);
|
||||
self.mirror_distinct |= crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(pts, &reflected);
|
||||
mirror.push(&reflected);
|
||||
}
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
if let (true, Some((_, mirror))) = (self.mirror_distinct, &mut self.mirror) {
|
||||
// Preview the finished strokes through one coverage buffer. Shared axis pixels
|
||||
// therefore receive the brush opacity once, exactly like the final commit.
|
||||
let old = self.tail;
|
||||
let mut original_preview = self.renderer.clone();
|
||||
original_preview.finish();
|
||||
let mut mirror_preview = mirror.clone();
|
||||
mirror_preview.finish();
|
||||
let bounds = original_preview.bounds().union(&mirror_preview.bounds()).union(&old);
|
||||
if !bounds.is_empty() {
|
||||
surf.write_region(bounds, &self.pre.read_region(bounds));
|
||||
}
|
||||
let damage = original_preview.composite_union(&mirror_preview, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = bounds;
|
||||
return Ok(damage.union(&bounds));
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
if !old.is_empty() {
|
||||
|
|
|
|||
|
|
@ -941,6 +941,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::transform_cmds::specs());
|
||||
v.extend(crate::vector_cmds::specs());
|
||||
v.extend(crate::smartselect_cmds::specs());
|
||||
v.extend(crate::symmetry_cmds::specs());
|
||||
v.extend(crate::edit_cmds::specs());
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ pub mod smart_cmds;
|
|||
pub mod smartselect_cmds;
|
||||
pub mod snap;
|
||||
pub mod stamp_cmds;
|
||||
pub mod symmetry_cmds;
|
||||
mod timeline_cmds;
|
||||
pub mod transform_cmds;
|
||||
mod trap_cmds;
|
||||
|
|
@ -157,6 +158,8 @@ pub struct DocState {
|
|||
pub channel_view: channel_cmds::ChannelView,
|
||||
/// Select › Isolate Layers: the Layers panel lists only these layers (empty = off; view state).
|
||||
pub isolated_layers: Vec<LayerId>,
|
||||
/// Painting symmetry axis made from the selected path (tool state, not document pixels).
|
||||
pub symmetry_path: Option<symmetry_cmds::SymmetryAxis>,
|
||||
/// Layers panel: layers whose effects list is collapsed under their row (the fx triangle;
|
||||
/// view state, not history). Effects lists start open.
|
||||
pub fx_collapsed: Vec<LayerId>,
|
||||
|
|
@ -180,6 +183,7 @@ impl DocState {
|
|||
coalesce: None,
|
||||
channel_view: Default::default(),
|
||||
isolated_layers: Vec::new(),
|
||||
symmetry_path: None,
|
||||
fx_collapsed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -252,6 +256,9 @@ pub struct Session {
|
|||
pub clipboard: Option<edit_cmds::Clip>,
|
||||
/// Layer › Layer Style › Copy Layer Style: effects, blend mode and fill opacity.
|
||||
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32)>,
|
||||
/// Shape path context menu: copied vector fill and stroke styles.
|
||||
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
|
||||
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
|
||||
/// Colour management: proofing state, monitor profile, display transforms.
|
||||
pub color: color_cmds::ColorState,
|
||||
/// Open Edit Contents documents and the smart objects they update.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
//! Painting symmetry along a path. A sampled Bézier polyline is kept as per-document tool state;
|
||||
//! each brush sample reflects across its nearest segment, including curved paths.
|
||||
|
||||
use photocraft_doc::{LayerContent, vector::Path};
|
||||
use photocraft_paint::StrokePoint;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, commands::CommandSpec};
|
||||
|
||||
// Keep nearest-segment searches bounded for long interactive brush strokes.
|
||||
const MAX_SEGMENTS: usize = 1024;
|
||||
const SAMPLES_PER_CURVE: usize = 12;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SymmetryAxis {
|
||||
pub source: String,
|
||||
pub segments: Vec<[[f64; 2]; 2]>,
|
||||
}
|
||||
|
||||
impl SymmetryAxis {
|
||||
pub fn from_path(source: String, path: &Path) -> Result<Self> {
|
||||
let mut segments = Vec::new();
|
||||
for subpath in &path.subpaths {
|
||||
for [a, b, c, d] in subpath.segments() {
|
||||
let samples = if a == b && c == d { 1 } else { SAMPLES_PER_CURVE };
|
||||
let mut previous = [a.x, a.y];
|
||||
for step in 1..=samples {
|
||||
if segments.len() >= MAX_SEGMENTS {
|
||||
return Err(EngineError::Other("symmetry path is too complex".into()));
|
||||
}
|
||||
let t = step as f64 / samples as f64;
|
||||
let u = 1.0 - t;
|
||||
let next = [
|
||||
u * u * u * a.x + 3.0 * u * u * t * b.x + 3.0 * u * t * t * c.x + t * t * t * d.x,
|
||||
u * u * u * a.y + 3.0 * u * u * t * b.y + 3.0 * u * t * t * c.y + t * t * t * d.y,
|
||||
];
|
||||
if !previous.iter().chain(next.iter()).all(|v| v.is_finite() && v.abs() <= 1_000_000.0) {
|
||||
return Err(EngineError::Other("symmetry path coordinates must be finite and within canvas limits".into()));
|
||||
}
|
||||
if previous != next {
|
||||
segments.push([previous, next]);
|
||||
}
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
if segments.is_empty() {
|
||||
return Err(EngineError::Other("symmetry path needs at least one nonzero segment".into()));
|
||||
}
|
||||
Ok(Self { source, segments })
|
||||
}
|
||||
|
||||
pub fn reflect(&self, point: StrokePoint) -> StrokePoint {
|
||||
let mut best = None::<(f64, [f64; 2], [f64; 2])>;
|
||||
for &[a, b] in &self.segments {
|
||||
let vx = b[0] - a[0];
|
||||
let vy = b[1] - a[1];
|
||||
let len2 = vx * vx + vy * vy;
|
||||
if len2 <= f64::EPSILON {
|
||||
continue;
|
||||
}
|
||||
let t = (((point.x - a[0]) * vx + (point.y - a[1]) * vy) / len2).clamp(0.0, 1.0);
|
||||
let q = [a[0] + vx * t, a[1] + vy * t];
|
||||
let dist2 = (point.x - q[0]).powi(2) + (point.y - q[1]).powi(2);
|
||||
if best.is_none_or(|(distance, _, _)| dist2 < distance) {
|
||||
best = Some((dist2, q, [vx, vy]));
|
||||
}
|
||||
}
|
||||
let Some((_, q, tangent)) = best else { return point };
|
||||
let len2 = tangent[0] * tangent[0] + tangent[1] * tangent[1];
|
||||
let dx = point.x - q[0];
|
||||
let dy = point.y - q[1];
|
||||
let along = (dx * tangent[0] + dy * tangent[1]) / len2;
|
||||
let mut mirrored = point;
|
||||
mirrored.x = q[0] + 2.0 * along * tangent[0] - dx;
|
||||
mirrored.y = q[1] + 2.0 * along * tangent[1] - dy;
|
||||
mirrored
|
||||
}
|
||||
|
||||
pub fn reflect_points(&self, points: &[StrokePoint]) -> Vec<StrokePoint> {
|
||||
points.iter().copied().map(|p| self.reflect(p)).collect()
|
||||
}
|
||||
|
||||
/// A stroke lying exactly on the symmetry path needs only one render pass. Rendering its
|
||||
/// reflected copy would compound semi-transparent paint along the axis.
|
||||
pub fn has_distinct_mirror(points: &[StrokePoint], reflected: &[StrokePoint]) -> bool {
|
||||
points.iter().zip(reflected).any(|(a, b)| (a.x - b.x).abs() > 1.0e-6 || (a.y - b.y).abs() > 1.0e-6)
|
||||
}
|
||||
}
|
||||
|
||||
fn active_path(s: &Session, name: &str) -> Result<Path> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if name == "work" {
|
||||
return st.doc.work_path.clone().ok_or_else(|| EngineError::Other("no work path".into()));
|
||||
}
|
||||
if name == "layer" {
|
||||
let id = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
return match &layer.content {
|
||||
LayerContent::Shape(shape) => Ok(shape.path.clone()),
|
||||
_ => layer.vector_mask.as_ref().map(|mask| mask.path.clone()).ok_or_else(|| EngineError::Other("active layer has no vector path".into())),
|
||||
};
|
||||
}
|
||||
st.doc.paths.iter().find(|path| path.name == name).map(|path| path.path.clone()).ok_or_else(|| EngineError::Other(format!("no path named `{name}`")))
|
||||
}
|
||||
|
||||
fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = p.get("name").and_then(Value::as_str).unwrap_or("work");
|
||||
let axis = SymmetryAxis::from_path(name.to_owned(), &active_path(s, name)?)?;
|
||||
let count = axis.segments.len();
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.symmetry_path = Some(axis);
|
||||
Ok(json!({"enabled": true, "source": name, "segments": count}))
|
||||
}
|
||||
|
||||
fn disable(s: &mut Session) -> Result<Value> {
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.symmetry_path = None;
|
||||
Ok(json!({"enabled": false}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "paint.symmetryFromPath",
|
||||
label: "Make Symmetry Path",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"name":"work"|"layer"|savedPathName="work"} — mirror Brush, Pencil and Eraser strokes across the path"##,
|
||||
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
|
||||
run: enable,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "paint.symmetryDisable",
|
||||
label: "Disable Symmetry Path",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: |s| s.active().filter(|st| st.symmetry_path.is_some()).map(|_| ()).ok_or_else(|| "no active symmetry path".into()),
|
||||
run: |s, _| disable(s),
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::vector::Subpath;
|
||||
|
||||
#[test]
|
||||
fn curved_axis_reflects_near_local_segment() {
|
||||
let mut subpath = Subpath::polyline(&[(10.0, 0.0), (10.0, 20.0)]);
|
||||
subpath.knots[0].out_ctrl = photocraft_geom::Point::new(20.0, 0.0);
|
||||
subpath.knots[1].in_ctrl = photocraft_geom::Point::new(20.0, 20.0);
|
||||
let axis = SymmetryAxis::from_path("work".into(), &Path::new(vec![subpath])).unwrap();
|
||||
assert!(axis.segments.len() > 1);
|
||||
let reflected = axis.reflect(StrokePoint::new(20.0, 10.0, 1.0));
|
||||
assert!(reflected.x > 10.0 && reflected.x < 18.0, "curve at mid-height is near x=17.5: {reflected:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_empty_path_fail_gracefully() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
assert!(s.execute("paint.symmetryFromPath", json!({"name": "missing"})).is_err());
|
||||
assert!(s.execute("paint.symmetryFromPath", json!({"name": "work"})).is_err());
|
||||
assert!(s.execute("paint.symmetryDisable", json!({})).is_err());
|
||||
let long = Subpath::polyline(&(0..=MAX_SEGMENTS + 1).map(|x| (x as f64, 0.0)).collect::<Vec<_>>());
|
||||
assert!(SymmetryAxis::from_path("long".into(), &Path::new(vec![long])).is_err(), "path complexity remains bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry_paints_mirrored_pixels_and_disable_stops_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||||
s.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [40, 60]]}]}})).unwrap();
|
||||
s.execute("paint.symmetryFromPath", json!({"name": "work"})).unwrap();
|
||||
let params = json!({"points": [[20, 20], [30, 30]], "size": 5, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0});
|
||||
let mut live =
|
||||
crate::brush_cmds::LiveStroke::begin(&s, &json!({"points": [[20, 20]], "size": 5, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap();
|
||||
live.push(&[StrokePoint::new(30.0, 30.0, 1.0)]).unwrap();
|
||||
s.execute("paint.stroke", params).unwrap();
|
||||
let committed = &s.active().unwrap().doc;
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let surface = committed.layer(id).unwrap().surface().unwrap();
|
||||
assert!(surface.rgba(20, 20)[1] < 0.2);
|
||||
assert!(surface.rgba(60, 20)[1] < 0.2);
|
||||
let preview = live.doc.layer(id).unwrap().surface().unwrap();
|
||||
for (x, y) in [(20, 20), (60, 20), (30, 30), (50, 30), (40, 20)] {
|
||||
let expected = surface.rgba(x, y);
|
||||
let actual = preview.rgba(x, y);
|
||||
assert!((0..4).all(|i| (expected[i] - actual[i]).abs() < 0.02), "preview/commit at ({x},{y}): {actual:?} vs {expected:?}");
|
||||
}
|
||||
s.execute("paint.symmetryDisable", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[15, 35]], "size": 5, "hardness": 1.0, "color": "#0000ff"})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().rgba(65, 35)[0] > 0.8, "white remains without a mirrored blue stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_on_axis_is_not_painted_twice_at_half_opacity() {
|
||||
let mut plain = Session::new();
|
||||
plain.execute("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||||
let mut symmetric = Session::new();
|
||||
symmetric.execute("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||||
symmetric.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [40, 60]]}]}})).unwrap();
|
||||
symmetric.execute("paint.symmetryFromPath", json!({"name": "work"})).unwrap();
|
||||
let params = json!({"points": [[40, 15], [40, 40]], "size": 8, "hardness": 1.0, "opacity": 0.5, "color": "#000000", "smoothing": 0.0});
|
||||
let mut preview = crate::brush_cmds::LiveStroke::begin(
|
||||
&symmetric,
|
||||
&json!({"points": [[40, 15]], "size": 8, "hardness": 1.0, "opacity": 0.5, "color": "#000000", "smoothing": 0.0}),
|
||||
)
|
||||
.unwrap();
|
||||
preview.push(&[StrokePoint::new(40.0, 40.0, 1.0)]).unwrap();
|
||||
plain.execute("paint.stroke", params.clone()).unwrap();
|
||||
symmetric.execute("paint.stroke", params).unwrap();
|
||||
let pixels = |session: &Session| {
|
||||
let st = session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(40, 28)
|
||||
};
|
||||
let expected = pixels(&plain);
|
||||
let actual = pixels(&symmetric);
|
||||
let preview_pixel = {
|
||||
let st = symmetric.active().unwrap();
|
||||
preview.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(40, 28)
|
||||
};
|
||||
assert!(expected[0] > 0.2 && expected[0] < 0.8, "half opacity should produce a gray stroke: {expected:?}");
|
||||
assert!((0..4).all(|i| (actual[i] - expected[i]).abs() < 0.02), "symmetry must not darken on-axis paint: {actual:?} vs {expected:?}");
|
||||
assert!((0..4).all(|i| (preview_pixel[i] - actual[i]).abs() < 0.02), "preview must match commit: {preview_pixel:?} vs {actual:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_axis_and_off_axis_stroke_unions_coverage_once() {
|
||||
let mut plain = Session::new();
|
||||
plain.execute("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||||
let mut symmetric = Session::new();
|
||||
symmetric.execute("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||||
symmetric.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [40, 60]]}]}})).unwrap();
|
||||
symmetric.execute("paint.symmetryFromPath", json!({"name": "work"})).unwrap();
|
||||
let params = json!({"points": [[40, 10], [40, 25], [30, 40]], "size": 7, "hardness": 1.0, "opacity": 0.5, "color": "#000000", "smoothing": 0.0});
|
||||
let preview = crate::brush_cmds::LiveStroke::begin(&symmetric, ¶ms).unwrap();
|
||||
plain.execute("paint.stroke", params.clone()).unwrap();
|
||||
symmetric.execute("paint.stroke", params).unwrap();
|
||||
let pixel = |session: &Session, x, y| {
|
||||
let st = session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
|
||||
};
|
||||
let axis_plain = pixel(&plain, 40, 18);
|
||||
let axis_symmetric = pixel(&symmetric, 40, 18);
|
||||
assert!(
|
||||
(0..4).all(|i| (axis_plain[i] - axis_symmetric[i]).abs() < 0.02),
|
||||
"shared axis must have one opacity ceiling: {axis_plain:?} vs {axis_symmetric:?}"
|
||||
);
|
||||
assert!(pixel(&symmetric, 50, 40)[0] < 0.9, "the off-axis part must be mirrored");
|
||||
let st = symmetric.active().unwrap();
|
||||
let preview_surface = preview.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
for (x, y) in [(40, 18), (30, 40), (50, 40)] {
|
||||
let expected = pixel(&symmetric, x, y);
|
||||
let actual = preview_surface.rgba(x, y);
|
||||
assert!((0..4).all(|i| (expected[i] - actual[i]).abs() < 0.02), "preview/commit at ({x},{y}): {actual:?} vs {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@
|
|||
use photocraft_algo::selection::{self as sel, SelectionMode};
|
||||
use photocraft_color::{BlendMode, Color};
|
||||
use photocraft_doc::{
|
||||
Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer,
|
||||
ShapeStroke, StrokeAlign, Subpath, VectorMask,
|
||||
ClippingPath, Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp,
|
||||
ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask,
|
||||
};
|
||||
use photocraft_geom::{Affine, Point, Rect};
|
||||
use photocraft_vector as vector;
|
||||
|
|
@ -49,6 +49,66 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
|||
s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into())
|
||||
}
|
||||
|
||||
fn active_shape(s: &Session) -> std::result::Result<&ShapeLayer, String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let id = d.active_layer.ok_or("no active layer")?;
|
||||
let layer = d.doc.layer(id).ok_or("no active layer")?;
|
||||
match &layer.content {
|
||||
LayerContent::Shape(shape) => Ok(shape),
|
||||
_ => Err("active layer is not a shape layer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_shape_fill(s: &Session) -> std::result::Result<(), String> {
|
||||
active_shape(s)?.fill.as_ref().map(|_| ()).ok_or_else(|| "shape has no fill".into())
|
||||
}
|
||||
|
||||
fn has_shape_stroke(s: &Session) -> std::result::Result<(), String> {
|
||||
active_shape(s)?.stroke.as_ref().map(|_| ()).ok_or_else(|| "shape has no stroke".into())
|
||||
}
|
||||
|
||||
fn can_paste_shape_fill(s: &Session) -> std::result::Result<(), String> {
|
||||
active_shape(s)?;
|
||||
s.path_fill_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape fill copied".into())
|
||||
}
|
||||
|
||||
fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), String> {
|
||||
active_shape(s)?;
|
||||
s.path_stroke_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape stroke copied".into())
|
||||
}
|
||||
|
||||
fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?;
|
||||
s.path_fill_clipboard = Some(fill);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?;
|
||||
s.path_stroke_clipboard = Some(stroke);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?;
|
||||
let id = layer_id(s, p)?;
|
||||
with_shape(s, id, "Paste Fill", |shape, _| {
|
||||
shape.fill = Some(fill);
|
||||
Ok(())
|
||||
})?;
|
||||
shape_info(s, id)
|
||||
}
|
||||
|
||||
fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?;
|
||||
let id = layer_id(s, p)?;
|
||||
with_shape(s, id, "Paste Complete Stroke", |shape, _| {
|
||||
shape.stroke = Some(stroke);
|
||||
Ok(())
|
||||
})?;
|
||||
shape_info(s, id)
|
||||
}
|
||||
|
||||
fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
|
||||
match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(id) => Ok(LayerId(id)),
|
||||
|
|
@ -712,6 +772,100 @@ fn path_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn clipping_path_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.clippingPath.set";
|
||||
let name = p.get("name").and_then(Value::as_str).filter(|name| !name.is_empty()).ok_or_else(|| bad(CMD, "missing saved path `name`"))?.to_owned();
|
||||
let flatness = match p.get("flatness") {
|
||||
Some(value) => {
|
||||
value.as_f64().filter(|value| value.is_finite() && (0.0..=100.0).contains(value)).ok_or_else(|| bad(CMD, "`flatness` must be between 0 and 100"))?
|
||||
as f32
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
s.edit("Clipping Path", |doc, _| {
|
||||
if !doc.paths.iter().any(|path| path.name == name) {
|
||||
return Err(EngineError::Other(format!("no saved path named \"{name}\"")));
|
||||
}
|
||||
doc.clipping_path = Some(ClippingPath { name: name.clone(), flatness });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name, "flatness": flatness }))
|
||||
}
|
||||
|
||||
fn clipping_path_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.edit("Clear Clipping Path", |doc, _| {
|
||||
doc.clipping_path.take().ok_or_else(|| EngineError::Other("no clipping path".into()))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn path_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.transform";
|
||||
let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned();
|
||||
let layer_id = if name == "layer" { Some(layer_id(s, p)?) } else { None };
|
||||
let transform = if let Some(values) = p.get("matrix") {
|
||||
let values = values.as_array().filter(|values| values.len() == 6).ok_or_else(|| bad(CMD, "`matrix` must contain six numbers"))?;
|
||||
let mut m = [0.0; 6];
|
||||
for (slot, value) in m.iter_mut().zip(values) {
|
||||
*slot = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or_else(|| bad(CMD, "matrix entries must be finite numbers with magnitude at most 1000000"))?;
|
||||
}
|
||||
Affine { m }
|
||||
} else {
|
||||
let number = |key: &str, default: f64| -> Result<f64> {
|
||||
match p.get(key) {
|
||||
Some(value) => value
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && n.abs() <= 1_000_000.0)
|
||||
.ok_or_else(|| bad(CMD, format!("`{key}` must be a finite number with magnitude at most 1000000"))),
|
||||
None => Ok(default),
|
||||
}
|
||||
};
|
||||
if !["translateX", "translateY", "scaleX", "scaleY", "angle"].iter().any(|key| p.get(*key).is_some()) {
|
||||
return Err(bad(CMD, "pass `matrix` or translate/scale/angle parameters"));
|
||||
}
|
||||
let (tx, ty) = (number("translateX", 0.0)?, number("translateY", 0.0)?);
|
||||
let (sx, sy) = (number("scaleX", 1.0)?, number("scaleY", 1.0)?);
|
||||
let angle = number("angle", 0.0)?;
|
||||
let path = resolve_path(s, Some(&name))?;
|
||||
let (x0, y0, x1, y1) = path.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?;
|
||||
let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
|
||||
Affine::translate(-cx, -cy)
|
||||
.then(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] })
|
||||
.then(&Affine::rotate(angle.to_radians()))
|
||||
.then(&Affine::translate(cx + tx, cy + ty))
|
||||
};
|
||||
let m = transform.m;
|
||||
if let Some(id) = layer_id
|
||||
&& s.active().and_then(|d| d.doc.layer(id)).is_some_and(|layer| matches!(layer.content, LayerContent::Shape(_)))
|
||||
{
|
||||
with_shape(s, id, "Transform Path", |shape, _| {
|
||||
transform_shape(shape, &transform);
|
||||
shape.psd_raw = None;
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "name": name, "matrix": m }));
|
||||
}
|
||||
s.edit("Transform Path", |doc, _| {
|
||||
if let Some(id) = layer_id {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into()))?;
|
||||
vm.path = vm.path.transform(&transform);
|
||||
} else if is_work(Some(&name)) {
|
||||
let path = doc.work_path.as_mut().ok_or_else(|| EngineError::Other("no work path".into()))?;
|
||||
*path = path.transform(&transform);
|
||||
} else {
|
||||
let path = doc.paths.iter_mut().find(|path| path.name == name).ok_or_else(|| EngineError::Other(format!("no path named \"{name}\"")))?;
|
||||
path.path = path.path.transform(&transform);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name, "matrix": m }))
|
||||
}
|
||||
|
||||
fn path_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.rename";
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
|
|
@ -1154,6 +1308,41 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
path_set
|
||||
),
|
||||
spec!("path.delete", "Delete Path", [], r##"{"name":str|"work"="work"}"##, has_doc, path_delete),
|
||||
spec!(
|
||||
"path.transform",
|
||||
"Free Transform Path",
|
||||
[],
|
||||
r##"{"name":str|"work"|"layer"="work","layer":id? (with layer target),"matrix":[a,b,c,d,e,f] | "translateX":px?,"translateY":px?,"scaleX":factor?,"scaleY":factor?,"angle":degrees?} (numeric transforms pivot on path bounds center)"##,
|
||||
has_doc,
|
||||
path_transform
|
||||
),
|
||||
spec!(
|
||||
"path.clippingPath.set",
|
||||
"Clipping Path",
|
||||
[],
|
||||
r##"{"name":savedPathName,"flatness":0..100=0} (PSD export clipping path)"##,
|
||||
has_doc,
|
||||
clipping_path_set
|
||||
),
|
||||
spec!("path.clippingPath.clear", "Clear Clipping Path", [], r##"{}"##, has_doc, clipping_path_clear),
|
||||
spec!("path.style.copyFill", "Copy Fill", [], r##"{} (copies active shape layer fill)"##, has_shape_fill, copy_shape_fill),
|
||||
spec!("path.style.copyStroke", "Copy Complete Stroke", [], r##"{} (copies active shape layer stroke)"##, has_shape_stroke, copy_shape_stroke),
|
||||
spec!(
|
||||
"path.style.pasteFill",
|
||||
"Paste Fill",
|
||||
[],
|
||||
r##"{"layer":id?} (pastes copied fill onto active shape layer)"##,
|
||||
can_paste_shape_fill,
|
||||
paste_shape_fill
|
||||
),
|
||||
spec!(
|
||||
"path.style.pasteStroke",
|
||||
"Paste Complete Stroke",
|
||||
[],
|
||||
r##"{"layer":id?} (pastes copied stroke onto active shape layer)"##,
|
||||
can_paste_shape_stroke,
|
||||
paste_shape_stroke
|
||||
),
|
||||
spec!("path.rename", "Rename Path", [], r##"{"name":str|"work"="work","to":str} (renaming the work path saves it)"##, has_doc, path_rename),
|
||||
// ⌘↩ / Ctrl+Enter, as in Photoshop (the UI loads the path selected in the Paths panel, #306).
|
||||
CommandSpec {
|
||||
|
|
|
|||
|
|
@ -278,6 +278,74 @@ fn fill_and_stroke_path_on_pixel_layer() {
|
|||
assert!(s.execute("path.fill", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_path_requires_saved_path_and_tracks_rename_delete() {
|
||||
let mut s = session(60, 60, 8);
|
||||
assert!(s.execute("path.clippingPath.set", json!({})).is_err());
|
||||
assert!(s.execute("path.clippingPath.set", json!({"name":"missing"})).is_err());
|
||||
s.execute("path.set", json!({"name":"Cutout","path":{"subpaths":[{"knots":[[1,1],[20,1],[20,20]]}]}})).unwrap();
|
||||
assert!(s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":-1})).is_err());
|
||||
assert!(s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":"bad"})).is_err());
|
||||
s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":2.5})).unwrap();
|
||||
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Cutout");
|
||||
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().flatness, 2.5);
|
||||
s.execute("path.rename", json!({"name":"Cutout","to":"Final"})).unwrap();
|
||||
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Final");
|
||||
s.execute("path.clippingPath.clear", json!({})).unwrap();
|
||||
assert!(doc(&s).clipping_path.is_none());
|
||||
assert!(s.undo());
|
||||
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Final");
|
||||
s.execute("path.delete", json!({"name":"Final"})).unwrap();
|
||||
assert!(doc(&s).clipping_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_work_saved_and_shape_paths() {
|
||||
let mut s = session(100, 100, 8);
|
||||
let path = json!({"subpaths":[{"knots":[[4,5],[20,5],[20,20]]}]});
|
||||
s.execute("path.set", json!({"path":path})).unwrap();
|
||||
assert!(s.execute("path.transform", json!({})).is_err());
|
||||
assert!(s.execute("path.transform", json!({"matrix":[1,0,0,1,"bad",0]})).is_err());
|
||||
assert!(s.execute("path.transform", json!({"matrix":[1,0,0,1,1e30,0]})).is_err());
|
||||
s.execute("path.transform", json!({"matrix":[1,0,0,1,10,7]})).unwrap();
|
||||
assert_eq!(doc(&s).work_path.as_ref().unwrap().subpaths[0].knots[0].anchor, Point::new(14.0, 12.0));
|
||||
s.execute("path.set", json!({"name":"Saved","path":path})).unwrap();
|
||||
s.execute("path.transform", json!({"name":"Saved","matrix":[2,0,0,2,0,0]})).unwrap();
|
||||
assert_eq!(doc(&s).paths[0].path.subpaths[0].knots[0].anchor, Point::new(8.0, 10.0));
|
||||
s.execute("path.transform", json!({"name":"Saved","translateX":3,"translateY":-2})).unwrap();
|
||||
assert_eq!(doc(&s).paths[0].path.subpaths[0].knots[0].anchor, Point::new(11.0, 8.0));
|
||||
let id = s.execute("shape.create", json!({"kind":"rect","rect":[10,10,20,20]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("path.transform", json!({"name":"layer","layer":id,"matrix":[1,0,0,1,5,0]})).unwrap();
|
||||
assert_eq!(shape(&s, id).path.subpaths[0].knots[0].anchor.x, 15.0);
|
||||
assert!(shape(&s, id).cache.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_paste_shape_fill_and_stroke() {
|
||||
let mut s = session(100, 100, 8);
|
||||
assert!(!s.is_enabled("path.style.copyFill"));
|
||||
assert!(!s.is_enabled("path.style.pasteFill"));
|
||||
assert!(s.execute("path.style.copyFill", json!({})).is_err());
|
||||
assert!(s.execute("path.style.pasteFill", json!({})).is_err());
|
||||
let source =
|
||||
s.execute("shape.create", json!({"kind":"rect","rect":[10,10,20,20],"fill":"#ff0000","stroke":{"width":3,"color":"#0000ff"}})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(s.is_enabled("path.style.copyFill"));
|
||||
assert!(s.is_enabled("path.style.copyStroke"));
|
||||
s.execute("path.style.copyFill", json!({})).unwrap();
|
||||
s.execute("path.style.copyStroke", json!({})).unwrap();
|
||||
let target = s.execute("shape.create", json!({"kind":"rect","rect":[40,40,20,20],"fill":"#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.is_enabled("path.style.pasteFill"));
|
||||
assert!(s.is_enabled("path.style.pasteStroke"));
|
||||
s.execute("path.style.pasteFill", json!({})).unwrap();
|
||||
s.execute("path.style.pasteStroke", json!({})).unwrap();
|
||||
assert_eq!(shape(&s, source).fill, shape(&s, target).fill);
|
||||
assert_eq!(shape(&s, source).stroke, shape(&s, target).stroke);
|
||||
assert!(s.undo());
|
||||
assert!(shape(&s, target).stroke.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_mask_commands_and_compositing() {
|
||||
let mut s = session(100, 100, 8);
|
||||
|
|
|
|||
|
|
@ -365,6 +365,30 @@ impl CoverageMap {
|
|||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union two passes of the same brush stroke without applying opacity twice where they
|
||||
/// overlap. The stronger coverage wins, including its per-dab colour when present.
|
||||
fn union_max(&mut self, other: &Self) {
|
||||
self.bounds = self.bounds.union(&other.bounds);
|
||||
for (&key, source) in &other.tiles {
|
||||
let Some(target) = self.tiles.get_mut(&key) else {
|
||||
self.tiles.insert(key, source.clone());
|
||||
self.dirty.insert(key);
|
||||
continue;
|
||||
};
|
||||
for (index, (dst, src)) in target.cov.iter_mut().zip(&source.cov).enumerate() {
|
||||
if *src > *dst {
|
||||
*dst = *src;
|
||||
let start = index.saturating_mul(self.nc);
|
||||
let end = start.saturating_add(self.nc);
|
||||
if let (Some(dst_color), Some(src_color)) = (target.col.get_mut(start..end), source.col.get(start..end)) {
|
||||
dst_color.copy_from_slice(src_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dirty.insert(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -388,6 +412,16 @@ pub struct StrokeRenderer {
|
|||
}
|
||||
|
||||
impl StrokeRenderer {
|
||||
/// Composite the union of this stroke and a mirrored pass as one stroke. This keeps
|
||||
/// overlapping dabs on a symmetry axis under one opacity ceiling at every bit depth.
|
||||
pub fn composite_union(&self, other: &Self, pre: &Surface, target: &mut Surface, selection: Option<&Surface>, lock_transparency: bool) -> Rect {
|
||||
let mut merged = self.clone();
|
||||
merged.cov.union_max(&other.cov);
|
||||
if let (Some(to), Some(from)) = (&mut merged.dual, &other.dual) {
|
||||
to.union_max(from);
|
||||
}
|
||||
merged.composite(pre, target, selection, lock_transparency, true)
|
||||
}
|
||||
/// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing.
|
||||
pub fn new(brush: &BrushSettings, fmt: Option<PixelFormat>, zoom: f32) -> Self {
|
||||
let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
//!
|
||||
//! `--script` is a JSON array of `[method, params]` control-protocol calls (see
|
||||
//! docs/control-protocol.md), applied in order with a few frames between them.
|
||||
//! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel
|
||||
//! and document-tab menus that are outside the document-coordinate control pointer.
|
||||
//! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script.
|
||||
|
||||
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
|
|
@ -111,6 +114,18 @@ fn main() {
|
|||
eprintln!("{:>8.1} ms {label}", t0.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
for (flag, button) in [("--click-at", egui::PointerButton::Primary), ("--right-click-at", egui::PointerButton::Secondary)] {
|
||||
let Some((x, y)) = arg(&args, flag).and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::<f32>().ok()?, y.parse::<f32>().ok()?)))) else {
|
||||
continue;
|
||||
};
|
||||
let pos = egui::pos2(x, y);
|
||||
harness.event(egui::Event::PointerMoved(pos));
|
||||
harness.step();
|
||||
harness.event(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
harness.run_steps(4);
|
||||
}
|
||||
let t_settle = std::time::Instant::now();
|
||||
while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) {
|
||||
harness.step();
|
||||
|
|
|
|||
|
|
@ -940,6 +940,8 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
let active = app.session.active_index();
|
||||
let mut activate = None;
|
||||
let mut close = None;
|
||||
let mut tab_action = None;
|
||||
let tab_count = app.session.documents().len();
|
||||
let (mut focus_open, mut cancel_open) = (None, None);
|
||||
let focused_open = app.jobs.focus.is_some();
|
||||
egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| {
|
||||
|
|
@ -974,6 +976,9 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
tab_action = tab_context_menu(ui, i, tab_count);
|
||||
});
|
||||
}
|
||||
// Files opening in the background: a tab with a progress underline; × cancels.
|
||||
for (job, name, frac) in crate::jobs_ui::open_tabs(app) {
|
||||
|
|
@ -1010,6 +1015,33 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
if let Some(i) = close {
|
||||
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
|
||||
}
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// All tab actions go through the same guarded File commands as the menu bar, including the
|
||||
/// unsaved-changes prompt. The clicked tab is explicit even when another document is active.
|
||||
fn tab_context_items(index: usize, count: usize) -> [(&'static str, &'static str, serde_json::Value, bool); 3] {
|
||||
[
|
||||
("Close", "file.close", json!({"document": index}), true),
|
||||
("Close Others", "file.closeOthers", json!({"document": index}), count > 1),
|
||||
("Close All", "file.closeAll", json!({}), true),
|
||||
]
|
||||
}
|
||||
|
||||
fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize) -> Option<(&'static str, serde_json::Value)> {
|
||||
ui.set_min_width(170.0);
|
||||
for (label, id, params, enabled) in tab_context_items(index, count) {
|
||||
if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() {
|
||||
ui.close();
|
||||
return Some((id, params));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply tab-strip clicks: a document tab shows that document, an opening tab its progress, and
|
||||
|
|
@ -1037,6 +1069,8 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let active = app.session.active_index().filter(|_| app.jobs.focus.is_none());
|
||||
let (mut activate, mut close) = (None, None);
|
||||
let mut tab_action = None;
|
||||
let tab_count = app.session.documents().len();
|
||||
let (strip, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
let mut x = strip.left();
|
||||
|
|
@ -1069,6 +1103,9 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
tab_action = tab_context_menu(ui, i, tab_count);
|
||||
});
|
||||
x = r.right();
|
||||
}
|
||||
// Files opening in the background (#210): "name (Opening… 45%)" with a progress underline.
|
||||
|
|
@ -1101,6 +1138,12 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
if let Some(i) = close {
|
||||
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
|
||||
}
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop-style zoom label: "33.3", "100", "12.5".
|
||||
|
|
@ -1590,8 +1633,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
let d = xf.to_doc(p);
|
||||
app.ui.canvas_tool_menu = None;
|
||||
crate::layer_pick_ui::open(app, [p.x, p.y], d[0], d[1]);
|
||||
}
|
||||
if response.secondary_clicked()
|
||||
&& !crate::layer_pick_ui::is_gesture(tool, mods)
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
crate::canvas_tool_menu::open(app, tool, [p.x, p.y]);
|
||||
}
|
||||
// The (temporary) Hand pans above; its gestures never reach the tool underneath.
|
||||
if tool == Tool::Hand {
|
||||
(buttons.started, buttons.dragged, buttons.stopped) = (false, false, false);
|
||||
|
|
@ -1704,6 +1754,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
draw_transform_controls(app, &painter, &xf);
|
||||
crate::paint_mouse::show_picker(app, &ctx);
|
||||
crate::layer_pick_ui::show(app, &ctx);
|
||||
crate::canvas_tool_menu::show(app, &ctx);
|
||||
crate::snap_ui::draw(app, &painter, &xf);
|
||||
if border == photocraft_engine::prefs::CanvasBorder::Line {
|
||||
painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside);
|
||||
|
|
@ -2522,6 +2573,30 @@ fn hex(c: [f32; 4]) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tab_context_uses_clicked_document_and_existing_close_commands() {
|
||||
let items = tab_context_items(2, 3);
|
||||
assert_eq!(items.iter().map(|(_, id, _, _)| *id).collect::<Vec<_>>(), ["file.close", "file.closeOthers", "file.closeAll"]);
|
||||
assert_eq!(items[0].2, json!({"document": 2}));
|
||||
assert_eq!(items[1].2, json!({"document": 2}));
|
||||
assert_eq!(items[2].2, json!({}));
|
||||
assert!(!tab_context_items(0, 1)[1].3);
|
||||
assert!(items.iter().all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_close_others_prompts_for_unsaved_nonactive_document() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "name": "Keep"})).unwrap();
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "name": "Edited"})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
let (_, id, params, _) = tab_context_items(0, 2)[1].clone();
|
||||
crate::menus::invoke(&mut app, &egui::Context::default(), id, params).unwrap();
|
||||
assert!(app.discard.is_some(), "close others must ask before discarding the edited tab");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_start_screen_hint_does_not_claim_file_drop_works() {
|
||||
assert_ne!(start_screen_drop_hint(true), start_screen_drop_hint(false));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,548 @@
|
|||
//! Canvas context actions for selection and Pen tools.
|
||||
|
||||
use egui::Context;
|
||||
use photocraft_doc::LayerContent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{PhotocraftApp, state::Tool};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CanvasToolMenu {
|
||||
pub pos: [f32; 2],
|
||||
pub tool: Tool,
|
||||
pub has_selection: bool,
|
||||
#[serde(default)]
|
||||
pub has_path: bool,
|
||||
#[serde(default)]
|
||||
pub path_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Photoshop's Pen context menu order. `None` is a separator. Rows whose operation is not
|
||||
/// applicable to the current path or layer stay visible and disabled.
|
||||
pub const PEN_MENU: &[Option<(&str, &str)>] = &[
|
||||
Some(("Create Vector Mask", "layer.vectorMask.fromPath")),
|
||||
Some(("Delete Path", "path.delete")),
|
||||
None,
|
||||
Some(("Define Custom Shape…", "edit.defineCustomShape")),
|
||||
None,
|
||||
Some(("Make Selection…", "path.toSelection")),
|
||||
Some(("New Guides From Shape", "view.newGuidesFromShape")),
|
||||
Some(("Fill Path…", "path.fill")),
|
||||
Some(("Stroke Path…", "path.stroke")),
|
||||
None,
|
||||
Some(("Clipping Path…", "path.clippingPath.set")),
|
||||
None,
|
||||
Some(("Free Transform Path", "path.transform")),
|
||||
None,
|
||||
Some(("Unite Shapes", "layer.combineShapes.unite")),
|
||||
Some(("Subtract Front Shape", "layer.combineShapes.subtractFrontShape")),
|
||||
Some(("Unite Shapes at Overlap", "layer.combineShapes.intersectShapeAreas")),
|
||||
Some(("Subtract Shapes at Overlap", "layer.combineShapes.excludeOverlappingShapes")),
|
||||
None,
|
||||
Some(("Copy Fill", "path.style.copyFill")),
|
||||
Some(("Copy Complete Stroke", "path.style.copyStroke")),
|
||||
None,
|
||||
Some(("Paste Fill", "path.style.pasteFill")),
|
||||
Some(("Paste Complete Stroke", "path.style.pasteStroke")),
|
||||
None,
|
||||
Some(("Isolate Layers", "select.isolateLayers")),
|
||||
None,
|
||||
Some(("Make Symmetry Path", "paint.symmetryFromPath")),
|
||||
Some(("Disable Symmetry Path", "paint.symmetryDisable")),
|
||||
];
|
||||
|
||||
/// Tools whose plain canvas right-click offers selection actions.
|
||||
pub fn applies(tool: Tool) -> bool {
|
||||
matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand | Tool::ObjectSelection | Tool::Pen)
|
||||
}
|
||||
|
||||
/// Command ids are shared with the Select menu. Disabled actions remain visible.
|
||||
pub fn entries(has_selection: bool) -> &'static [(&'static str, &'static str)] {
|
||||
if has_selection {
|
||||
&[
|
||||
("Deselect", "select.deselect"),
|
||||
("Inverse Selection", "select.inverse"),
|
||||
("Feather…", "select.modify.feather"),
|
||||
("Select and Mask…", "select.selectAndMask"),
|
||||
("Transform Selection", "select.transformSelection"),
|
||||
]
|
||||
} else {
|
||||
&[("Reselect", "select.reselect")]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_entries(menu: &CanvasToolMenu) -> &'static [(&'static str, &'static str)] {
|
||||
if menu.tool == Tool::Pen { &[] } else { entries(menu.has_selection) }
|
||||
}
|
||||
|
||||
pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool {
|
||||
if menu.tool != Tool::Pen {
|
||||
return crate::menus::is_enabled(app, command);
|
||||
}
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
let layer = st.active_layer.and_then(|id| st.doc.layer(id));
|
||||
let can_paint = layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !l.locks.all);
|
||||
let shape_layer = layer.and_then(|l| match &l.content {
|
||||
LayerContent::Shape(sh) => Some(sh),
|
||||
_ => None,
|
||||
});
|
||||
let shape = shape_layer.is_some();
|
||||
let can_mask = layer.is_some_and(|l| !matches!(l.content, LayerContent::Shape(_)) && !l.locks.all);
|
||||
let saved = menu.path_name.as_deref().is_some_and(|n| n != "work" && n != "layer");
|
||||
let has_path = menu.has_path;
|
||||
let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
|
||||
let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || app.ui.vector_mask_target);
|
||||
match command {
|
||||
"layer.vectorMask.fromPath" => has_path && can_mask && !pending_layer,
|
||||
"path.delete" => has_path && !pending_layer && (pending || menu.path_name.as_deref() != Some("layer")),
|
||||
"edit.defineCustomShape" | "path.toSelection" | "path.transform" | "paint.symmetryFromPath" => has_path,
|
||||
"view.newGuidesFromShape" => shape && !pending,
|
||||
"path.fill" | "path.stroke" => has_path && can_paint && !pending_layer,
|
||||
"path.clippingPath.set" => saved && !pending,
|
||||
"layer.combineShapes.unite"
|
||||
| "layer.combineShapes.subtractFrontShape"
|
||||
| "layer.combineShapes.intersectShapeAreas"
|
||||
| "layer.combineShapes.excludeOverlappingShapes" => !pending && shape_layer.is_some_and(|sh| sh.path.subpaths.len() > 1),
|
||||
"path.style.copyFill" => !pending && shape_layer.is_some_and(|sh| sh.fill.is_some()),
|
||||
"path.style.copyStroke" => !pending && shape_layer.is_some_and(|sh| sh.stroke.is_some()),
|
||||
"path.style.pasteFill" => !pending && shape && app.session.path_fill_clipboard.is_some(),
|
||||
"path.style.pasteStroke" => !pending && shape && app.session.path_stroke_clipboard.is_some(),
|
||||
"select.isolateLayers" => layer.is_some(),
|
||||
"paint.symmetryDisable" => st.symmetry_path.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(app: &mut PhotocraftApp, tool: Tool, pos: [f32; 2]) -> bool {
|
||||
if !applies(tool) || !pos.iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
app.ui.brush_picker = None;
|
||||
app.ui.layer_menu = None;
|
||||
let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some());
|
||||
let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
|
||||
let path_name = crate::vector_ui::active_path_name(app);
|
||||
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
|
||||
let Some(menu) = app.ui.canvas_tool_menu.take() else { return };
|
||||
if menu.tool == Tool::Pen {
|
||||
if !PEN_MENU.iter().flatten().any(|(_, id)| *id == command) || !entry_enabled(app, &menu, command) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = choose_pen(app, ctx, &menu, command) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !entries(menu.has_selection).iter().any(|&(_, id)| id == command) || !crate::menus::is_enabled(app, command) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, command, json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) {
|
||||
let spec = match command {
|
||||
"edit.defineCustomShape" => r##"{"name":text}"##,
|
||||
"path.toSelection" => r##"{"feather":0..250=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##,
|
||||
"path.fill" => r##"{"color":text,"opacity":0..100=100,"feather":0..250=0,"antiAlias":bool=true}"##,
|
||||
"path.stroke" => r##"{"tool":"brush|pencil|eraser"="brush","size":1..500=10,"color":text,"opacity":0..100=100}"##,
|
||||
"path.clippingPath.set" => r##"{"flatness":0..100=0}"##,
|
||||
"path.transform" => r##"{"translateX":-30000..30000=0,"translateY":-30000..30000=0,"scaleX":0.01..100=1,"scaleY":0.01..100=1,"angle":-360..360=0}"##,
|
||||
_ => "{}",
|
||||
};
|
||||
let mut fields = serde_json::Map::new();
|
||||
if command == "edit.defineCustomShape" {
|
||||
fields.insert("path".into(), json!(name));
|
||||
fields.insert("name".into(), json!(""));
|
||||
} else {
|
||||
fields.insert("name".into(), json!(name));
|
||||
}
|
||||
fields.insert("__command".into(), json!(command));
|
||||
fields.insert("__label".into(), json!(label));
|
||||
fields.insert("__filter".into(), json!(true));
|
||||
fields.insert("__spec".into(), json!(spec));
|
||||
if matches!(command, "path.fill" | "path.stroke") {
|
||||
let fg = app.session.tools.foreground;
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", byte(fg[0]), byte(fg[1]), byte(fg[2]))));
|
||||
}
|
||||
if command == "path.transform" {
|
||||
for (key, value) in [("translateX", 0.0), ("translateY", 0.0), ("scaleX", 1.0), ("scaleY", 1.0), ("angle", 0.0)] {
|
||||
fields.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, fields);
|
||||
}
|
||||
|
||||
fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
|
||||
if command == "select.isolateLayers" {
|
||||
crate::menus::invoke(app, ctx, command, json!({}))?;
|
||||
return Ok(());
|
||||
}
|
||||
if command == "paint.symmetryDisable" {
|
||||
app.run(command, json!({}))?;
|
||||
return Ok(());
|
||||
}
|
||||
let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
|
||||
let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || app.ui.vector_mask_target);
|
||||
if pending {
|
||||
crate::vector_ui::pen_commit(app, false);
|
||||
}
|
||||
let name = if pending { Some(if pending_layer { "layer" } else { "work" }.to_string()) } else { menu.path_name.clone() }
|
||||
.ok_or_else(|| "No path is available for this action".to_string())?;
|
||||
match command {
|
||||
"edit.defineCustomShape" | "path.toSelection" | "path.fill" | "path.stroke" | "path.clippingPath.set" | "path.transform" => {
|
||||
let label = PEN_MENU.iter().flatten().find(|(_, id)| *id == command).map_or(command, |(label, _)| *label);
|
||||
pen_dialog(app, command, label, &name);
|
||||
}
|
||||
_ => {
|
||||
app.run(command, json!({"name": name}))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
let Some(menu) = app.ui.canvas_tool_menu.clone() else { return };
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || app.ui.tool != menu.tool {
|
||||
app.ui.canvas_tool_menu = None;
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("canvas-selection-menu");
|
||||
let screen = ctx.content_rect();
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(210.0, 140.0), |r| r.size());
|
||||
let pos = egui::pos2(menu.pos[0].min(screen.right() - size.x).max(screen.left()), menu.pos[1].min(screen.bottom() - size.y).max(screen.top()));
|
||||
let mut selected = None;
|
||||
let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
|
||||
egui::Frame::menu(ui.style()).show(ui, |ui| {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let v = &mut ui.style_mut().visuals;
|
||||
v.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
|
||||
v.widgets.inactive.bg_stroke = egui::Stroke::NONE;
|
||||
v.widgets.hovered.weak_bg_fill = t.accent;
|
||||
v.widgets.hovered.bg_fill = t.accent;
|
||||
v.widgets.hovered.bg_stroke = egui::Stroke::NONE;
|
||||
v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||
v.widgets.hovered.corner_radius = egui::CornerRadius::same(3);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_width(200.0);
|
||||
if menu.tool == Tool::Pen {
|
||||
for row in PEN_MENU {
|
||||
if let Some((label, command)) = row {
|
||||
let item = egui::Button::selectable(false, tl!(label)).min_size(egui::vec2(200.0, 20.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(*command);
|
||||
}
|
||||
} else {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &(label, command) in menu_entries(&menu) {
|
||||
let item = egui::Button::selectable(false, tl!(&label)).min_size(egui::vec2(200.0, 22.0));
|
||||
if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() {
|
||||
selected = Some(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(command) = selected {
|
||||
choose(app, ctx, command);
|
||||
} else if ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p))) {
|
||||
app.ui.canvas_tool_menu = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn work_path(app: &mut PhotocraftApp) {
|
||||
app.run(
|
||||
"path.set",
|
||||
json!({"name": "work", "path": {"subpaths": [{"closed": true, "knots": [
|
||||
{"anchor": [2, 2], "in": [2, 2], "out": [2, 2]},
|
||||
{"anchor": [20, 2], "in": [20, 2], "out": [20, 2]},
|
||||
{"anchor": [20, 20], "in": [20, 20], "out": [20, 20]}
|
||||
]}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn choose_open(app: &mut PhotocraftApp, command: &str) {
|
||||
assert!(open(app, Tool::Pen, [10.0, 10.0]));
|
||||
choose(app, &Context::default(), command);
|
||||
}
|
||||
|
||||
fn harness(mut app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
app.sync_views();
|
||||
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, modifiers: Modifiers) {
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers });
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_gesture_preserves_selection_and_command_override() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Lasso;
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 8, "height": 8})).unwrap();
|
||||
let mut h = harness(app);
|
||||
let center = h.state().last_canvas_rect.center();
|
||||
let revision = h.state().session.active().unwrap().revision;
|
||||
right_click(&mut h, center, Modifiers::NONE);
|
||||
assert!(h.state().ui.canvas_tool_menu.is_some());
|
||||
assert_eq!(h.state().session.active().unwrap().revision, revision, "right-click cannot alter selection");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.canvas_tool_menu.is_none());
|
||||
right_click(&mut h, center, Modifiers::COMMAND);
|
||||
assert!(h.state().ui.canvas_tool_menu.is_none(), "command right-click belongs to layer picker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_menu_routes_actions_through_commands() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::RectMarquee;
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 8, "height": 8})).unwrap();
|
||||
assert!(open(&mut app, Tool::RectMarquee, [10.0, 10.0]));
|
||||
assert!(entries(true).iter().any(|(_, id)| *id == "select.inverse"));
|
||||
let before = app.session.journal.len();
|
||||
choose(&mut app, &Context::default(), "select.inverse");
|
||||
assert!(app.ui.canvas_tool_menu.is_none());
|
||||
assert!(app.session.journal[before..].iter().any(|(id, _)| id == "select.inverse"));
|
||||
let before = app.session.journal.len();
|
||||
assert!(open(&mut app, Tool::RectMarquee, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "select.deselect");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_none());
|
||||
assert!(app.session.journal[before..].iter().any(|(id, _)| id == "select.deselect"));
|
||||
assert_eq!(entries(false), &[("Reselect", "select.reselect")]);
|
||||
assert!(open(&mut app, Tool::RectMarquee, [10.0, 10.0]));
|
||||
choose(&mut app, &Context::default(), "select.reselect");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
assert!(app.session.journal.iter().any(|(id, _)| id == "select.reselect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_tools_and_nonfinite_positions_do_not_open_menu() {
|
||||
let mut app = app();
|
||||
assert!(!open(&mut app, Tool::Brush, [1.0, 1.0]));
|
||||
assert!(!open(&mut app, Tool::Lasso, [f32::NAN, 1.0]));
|
||||
assert!(app.ui.canvas_tool_menu.is_none());
|
||||
assert!(open(&mut app, Tool::MagicWand, [1.0, 1.0]));
|
||||
let before = app.session.journal.len();
|
||||
choose(&mut app, &Context::default(), "file.new");
|
||||
assert_eq!(app.session.journal.len(), before, "context menu cannot invoke an unrelated command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_pen_path_right_click_makes_selection() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] {
|
||||
crate::vector_ui::pen_down(&mut app, x, y);
|
||||
crate::vector_ui::pen_up(&mut app);
|
||||
}
|
||||
crate::vector_ui::pen_commit(&mut app, true);
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
let mut h = harness(app);
|
||||
let center = h.state().last_canvas_rect.center();
|
||||
right_click(&mut h, center, Modifiers::NONE);
|
||||
assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == Tool::Pen && m.has_path));
|
||||
let ctx = h.ctx.clone();
|
||||
choose(h.state_mut(), &ctx, "path.toSelection");
|
||||
let dialog = h.state().ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some());
|
||||
assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("path.toSelection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_menu_finishes_in_progress_path_before_selection() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] {
|
||||
crate::vector_ui::pen_down(&mut app, x, y);
|
||||
crate::vector_ui::pen_up(&mut app);
|
||||
}
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.as_ref().unwrap();
|
||||
assert!(entry_enabled(&app, menu, "path.toSelection"));
|
||||
choose(&mut app, &Context::default(), "path.toSelection");
|
||||
let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_menu_has_all_reference_rows_in_order_and_guards_unavailable_actions() {
|
||||
let labels: Vec<_> = PEN_MENU.iter().map(|row| row.map(|(label, _)| label)).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
Some("Create Vector Mask"),
|
||||
Some("Delete Path"),
|
||||
None,
|
||||
Some("Define Custom Shape…"),
|
||||
None,
|
||||
Some("Make Selection…"),
|
||||
Some("New Guides From Shape"),
|
||||
Some("Fill Path…"),
|
||||
Some("Stroke Path…"),
|
||||
None,
|
||||
Some("Clipping Path…"),
|
||||
None,
|
||||
Some("Free Transform Path"),
|
||||
None,
|
||||
Some("Unite Shapes"),
|
||||
Some("Subtract Front Shape"),
|
||||
Some("Unite Shapes at Overlap"),
|
||||
Some("Subtract Shapes at Overlap"),
|
||||
None,
|
||||
Some("Copy Fill"),
|
||||
Some("Copy Complete Stroke"),
|
||||
None,
|
||||
Some("Paste Fill"),
|
||||
Some("Paste Complete Stroke"),
|
||||
None,
|
||||
Some("Isolate Layers"),
|
||||
None,
|
||||
Some("Make Symmetry Path"),
|
||||
Some("Disable Symmetry Path"),
|
||||
]
|
||||
);
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.as_ref().unwrap();
|
||||
assert!(!entry_enabled(&app, menu, "path.toSelection"));
|
||||
let before = app.session.journal.len();
|
||||
choose(&mut app, &Context::default(), "path.toSelection");
|
||||
assert_eq!(app.session.journal.len(), before);
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_menu_creates_mask_deletes_path_and_opens_named_path_dialogs() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
work_path(&mut app);
|
||||
choose_open(&mut app, "layer.vectorMask.fromPath");
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some()));
|
||||
choose_open(&mut app, "edit.defineCustomShape");
|
||||
let id = app.ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
let fields = &app.ui.dialogs.last().unwrap().fields;
|
||||
assert_eq!(fields.get("path"), Some(&json!("work")));
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!("Menu Shape"));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert!(app.session.edit_state.custom_shapes.iter().any(|s| s.name == "Menu Shape"));
|
||||
choose_open(&mut app, "path.fill");
|
||||
let id = app.ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
assert_eq!(app.ui.dialogs.last().unwrap().fields.get("name"), Some(&json!("work")));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.fill"));
|
||||
choose_open(&mut app, "path.delete");
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_menu_saved_path_clipping_and_numeric_transform() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
work_path(&mut app);
|
||||
app.run("path.rename", json!({"name": "work", "to": "Cutout"})).unwrap();
|
||||
app.ui.selected_path = Some("Cutout".into());
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
assert!(entry_enabled(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), "path.clippingPath.set"));
|
||||
choose(&mut app, &Context::default(), "path.clippingPath.set");
|
||||
let id = app.ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("flatness".into(), json!(2.0));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.clipping_path.as_ref().map(|p| p.name.as_str()), Some("Cutout"));
|
||||
choose_open(&mut app, "path.transform");
|
||||
let id = app.ui.dialogs.last().map(|d| d.id).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("translateX".into(), json!(5.0));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.transform"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_shape_menu_enables_shape_ops_and_style_copy_only_when_applicable() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
app.run(
|
||||
"shape.create",
|
||||
json!({"kind":"path", "path":{"subpaths":[
|
||||
{"closed":true,"knots":[[2,2],[20,2],[20,20]]},
|
||||
{"closed":true,"knots":[[5,5],[10,5],[10,10]]}
|
||||
]}, "fill":"#ff0000", "stroke":{"width":2,"color":"#000000"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.as_ref().unwrap();
|
||||
assert!(entry_enabled(&app, menu, "layer.combineShapes.unite"));
|
||||
assert!(entry_enabled(&app, menu, "path.style.copyFill"));
|
||||
assert!(!entry_enabled(&app, menu, "path.style.pasteFill"));
|
||||
assert!(!entry_enabled(&app, menu, "path.fill"));
|
||||
choose(&mut app, &Context::default(), "path.style.copyFill");
|
||||
assert!(app.session.path_fill_clipboard.is_some());
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
assert!(entry_enabled(&app, app.ui.canvas_tool_menu.as_ref().unwrap(), "path.style.pasteFill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_shape_path_disables_actions_that_would_target_the_previous_layer() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Pen;
|
||||
app.ui.tool_options.vector_mode = "shape".into();
|
||||
for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] {
|
||||
crate::vector_ui::pen_down(&mut app, x, y);
|
||||
crate::vector_ui::pen_up(&mut app);
|
||||
}
|
||||
assert!(open(&mut app, Tool::Pen, [10.0, 10.0]));
|
||||
let menu = app.ui.canvas_tool_menu.as_ref().unwrap();
|
||||
for id in ["layer.vectorMask.fromPath", "path.delete", "path.fill", "path.stroke", "path.clippingPath.set", "view.newGuidesFromShape"] {
|
||||
assert!(!entry_enabled(&app, menu, id), "{id} must not act on the layer replaced by the pending shape");
|
||||
}
|
||||
assert!(entry_enabled(&app, menu, "path.toSelection"));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
//! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`
|
||||
//! - `ui.dialog.apply {dialog}`: commit Preferences changes without closing the dialog
|
||||
//! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` = the right button: opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase)
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` opens the tool's canvas context menu or Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase)
|
||||
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points
|
||||
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input
|
||||
//! - `ui.resize {width, height}`: resize the main window
|
||||
|
|
@ -139,6 +139,25 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
|||
let u = |k: &str| p.get(k).and_then(Value::as_u64);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
|
||||
match req.method.as_str() {
|
||||
"ui.context.choose" => {
|
||||
let Some(id) = s("id") else { return err("missing `id`") };
|
||||
let Some(menu) = app.ui.canvas_tool_menu.as_ref() else { return err("no canvas context menu is open") };
|
||||
let listed = if menu.tool == crate::state::Tool::Pen {
|
||||
crate::canvas_tool_menu::PEN_MENU.iter().flatten().any(|(_, command)| *command == id)
|
||||
} else {
|
||||
crate::canvas_tool_menu::menu_entries(menu).iter().any(|(_, command)| *command == id)
|
||||
};
|
||||
if !listed || !crate::canvas_tool_menu::entry_enabled(app, menu, id) {
|
||||
return err("context action is unavailable");
|
||||
}
|
||||
if let Some(authorize) = app.services.automation_command.as_ref()
|
||||
&& let Err(error) = authorize(id, &json!({}))
|
||||
{
|
||||
return err(error);
|
||||
}
|
||||
crate::canvas_tool_menu::choose(app, ctx, id);
|
||||
ok(json!({"command": id, "dialog": app.ui.dialogs.last().map(|d| d.id)}))
|
||||
}
|
||||
"engine.execute" | "ui.menu.invoke" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||||
|
|
@ -414,10 +433,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
|||
// Right-click with the Move tool, or ⌘/Ctrl+right-click: list the layers there.
|
||||
if crate::layer_pick_ui::is_gesture(app.ui.tool, mods) {
|
||||
if down {
|
||||
app.ui.canvas_tool_menu = None;
|
||||
crate::layer_pick_ui::open(app, screen_point(app, x, y), x, y);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if crate::canvas_tool_menu::applies(app.ui.tool) {
|
||||
if down {
|
||||
crate::canvas_tool_menu::open(app, app.ui.tool, screen_point(app, x, y));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !crate::paint_mouse::pointer_secondary(app, down, mods, screen_point(app, x, y)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -564,6 +590,22 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
|||
"toolOptions": app.ui.tool_options,
|
||||
"textEdit": app.ui.text_edit,
|
||||
"layerMenu": app.ui.layer_menu,
|
||||
"canvasToolMenu": app.ui.canvas_tool_menu.as_ref().map(|menu| {
|
||||
json!({
|
||||
"pos": menu.pos,
|
||||
"tool": menu.tool,
|
||||
"entries": if menu.tool == crate::state::Tool::Pen {
|
||||
crate::canvas_tool_menu::PEN_MENU.iter().map(|row| match row {
|
||||
Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}),
|
||||
None => json!({"separator": true}),
|
||||
}).collect::<Vec<_>>()
|
||||
} else {
|
||||
crate::canvas_tool_menu::menu_entries(menu).iter().map(|&(label, id)| {
|
||||
json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)})
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
}),
|
||||
"panels": app.ui.panels,
|
||||
"views": app.ui.views,
|
||||
"dialogs": dialogs,
|
||||
|
|
@ -627,6 +669,57 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_pointer_opens_agent_visible_selection_menu() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("select.rect", json!({"x": 1, "y": 1, "width": 8, "height": 8})).unwrap();
|
||||
let result = call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.pointer",
|
||||
json!({
|
||||
"tool": "RectMarquee", "button": "secondary",
|
||||
"events": [{"kind": "down", "x": 4, "y": 4}, {"kind": "up", "x": 4, "y": 4}]
|
||||
}),
|
||||
);
|
||||
assert_eq!(result.get("ok"), Some(&Value::Bool(true)));
|
||||
let inspected = call(&mut app, &ctx, "ui.inspect", json!({}));
|
||||
let entries = inspected.pointer("/result/canvasToolMenu/entries").and_then(Value::as_array).unwrap();
|
||||
assert!(entries.iter().any(|entry| entry.get("id") == Some(&json!("select.inverse")) && entry.get("enabled") == Some(&json!(true))));
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some(), "right-click must not edit selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_can_inspect_and_choose_pen_make_selection_from_full_context_menu() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("path.set", json!({"name":"work","path":{"subpaths":[{"closed":true,"knots":[[2,2],[20,2],[20,20]]}]}})).unwrap();
|
||||
let expected_pos = screen_point(&app, 8.0, 8.0);
|
||||
let result = call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.pointer",
|
||||
json!({
|
||||
"tool": "Pen", "button": "secondary", "events": [{"kind":"down","x":8,"y":8},{"kind":"up","x":8,"y":8}]
|
||||
}),
|
||||
);
|
||||
assert_eq!(result["ok"], true);
|
||||
let inspected = call(&mut app, &ctx, "ui.inspect", json!({}));
|
||||
assert_eq!(app.ui.canvas_tool_menu.as_ref().unwrap().pos, expected_pos);
|
||||
let entries = inspected.pointer("/result/canvasToolMenu/entries").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(entries.iter().filter(|row| row.get("separator") == Some(&json!(true))).count(), 9);
|
||||
assert!(entries.iter().any(|row| row.get("id") == Some(&json!("path.toSelection")) && row.get("enabled") == Some(&json!(true))));
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id":"file.new"}))["ok"], false);
|
||||
let chosen = call(&mut app, &ctx, "ui.context.choose", json!({"id":"path.toSelection"}));
|
||||
assert_eq!(chosen["ok"], true);
|
||||
let dialog = chosen["result"]["dialog"].as_u64().unwrap();
|
||||
assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":dialog}))["ok"], true);
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_execute_runs_with_defaults_but_menu_invoke_opens_the_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ pub mod brushes_tab;
|
|||
mod camera_raw_scope_ui;
|
||||
pub mod camera_raw_ui;
|
||||
pub mod canvas;
|
||||
pub mod canvas_tool_menu;
|
||||
pub mod channel_view;
|
||||
pub mod channels_panel;
|
||||
pub mod chrome_ui;
|
||||
|
|
|
|||
|
|
@ -918,6 +918,28 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn select_menu_contains_every_selection_context_action_and_more() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap();
|
||||
let items = menu_items(&app);
|
||||
let top: Vec<_> = items.iter().filter(|i| i.path.len() == 1 && i.path.first().is_some_and(|p| p == "Select")).collect();
|
||||
// Context actions live somewhere under Select (Feather stays in Select > Modify, as in the
|
||||
// reference menus).
|
||||
let select: Vec<_> = items.iter().filter(|i| i.path.first().is_some_and(|p| p == "Select")).collect();
|
||||
for &(label, id) in crate::canvas_tool_menu::entries(true).iter().chain(crate::canvas_tool_menu::entries(false)) {
|
||||
assert!(select.iter().any(|i| i.id == id && i.label == label), "Select menu missing {label} ({id})");
|
||||
}
|
||||
assert!(top.iter().any(|i| i.id == "select.all"));
|
||||
assert!(top.iter().any(|i| i.id == "select.colorRange"));
|
||||
assert!(
|
||||
items.iter().any(|i| i.id == "select.modify.feather" && i.path.iter().map(String::as_str).eq(["Select", "Modify"])),
|
||||
"keep the Photoshop Modify route"
|
||||
);
|
||||
assert!(!top.iter().any(|i| i.id == "select.modify.feather"), "no extra top-level Feather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_bar_labels_have_horizontal_padding_and_open_menus() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
|
|
|||
|
|
@ -632,6 +632,9 @@ pub struct UiState {
|
|||
/// ⌘/Ctrl+right-click with any tool (`layer_pick_ui`, #307).
|
||||
#[serde(default)]
|
||||
pub layer_menu: Option<crate::layer_pick_ui::LayerMenu>,
|
||||
/// Selection-tool context menu opened by a plain canvas right-click.
|
||||
#[serde(default)]
|
||||
pub canvas_tool_menu: Option<crate::canvas_tool_menu::CanvasToolMenu>,
|
||||
/// Smoothing is a per-tool option (Brush and Eraser each keep theirs): the tool whose
|
||||
/// smoothing the session brush holds, and the other tools' saved values.
|
||||
#[serde(default)]
|
||||
|
|
@ -740,6 +743,7 @@ impl Default for UiState {
|
|||
vector_mask_target: false,
|
||||
brush_picker: None,
|
||||
layer_menu: None,
|
||||
canvas_tool_menu: None,
|
||||
smoothing_tool: None,
|
||||
tool_smoothing: Vec::new(),
|
||||
clone_source: None,
|
||||
|
|
|
|||
|
|
@ -679,6 +679,47 @@ pub fn paths_footer(ctx: &egui::Context) -> Option<Rect> {
|
|||
ctx.data(|d| d.get_temp(footer_id()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, &'static str, Value)> {
|
||||
let key = match entry.kind {
|
||||
PathRow::Work => "work".to_string(),
|
||||
PathRow::Layer => "layer".to_string(),
|
||||
PathRow::Saved => entry.name.clone(),
|
||||
};
|
||||
let mut actions = vec![
|
||||
("Make Selection", "path.toSelection", json!({"name": key})),
|
||||
("Fill Path", "path.fill", json!({"name": key})),
|
||||
("Stroke Path", "path.stroke", json!({"name": key, "tool": "brush"})),
|
||||
];
|
||||
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}")})));
|
||||
} else {
|
||||
let mut n = 1usize;
|
||||
while doc.paths.iter().any(|p| p.name == format!("{} copy {n}", entry.name)) {
|
||||
n = n.saturating_add(1);
|
||||
if n == usize::MAX {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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::Layer {
|
||||
actions.push(("Delete Path", "path.delete", json!({"name": key})));
|
||||
} else if entry.name.ends_with(" Vector Mask") {
|
||||
actions.push(("Delete Vector Mask", "layer.vectorMask.delete", json!({})));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
|
|
@ -733,6 +774,18 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
let n = doc.paths.len() + 1;
|
||||
action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")})));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let entry = PathEntry { name: name.clone(), path: path.clone(), kind: *kind };
|
||||
let can_paint = app.session.active().and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))).is_some_and(|l| l.surface().is_some());
|
||||
for (label, cmd, params) in path_context_actions(&entry, &doc) {
|
||||
let enabled = !matches!(cmd, "path.fill" | "path.stroke") || can_paint;
|
||||
if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() {
|
||||
action = Some((cmd, params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
},
|
||||
|
|
@ -790,6 +843,49 @@ mod tests {
|
|||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_context_menu_uses_the_clicked_row_and_dispatches_commands() {
|
||||
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": "Second", "path": path})).unwrap();
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap();
|
||||
let entries = path_context_actions(&first, doc);
|
||||
assert_eq!(entries[0].1, "path.toSelection");
|
||||
assert_eq!(entries[0].2["name"], "First");
|
||||
assert!(entries.iter().all(|(_, id, _)| photocraft_engine::commands::find(id).is_some()));
|
||||
let (_, id, params) = entries.into_iter().find(|(_, id, _)| *id == "path.toSelection").unwrap();
|
||||
app.run(id, params).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.toSelection"));
|
||||
|
||||
app.run("path.set", json!({"name": "First copy 1", "path": path})).unwrap();
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap();
|
||||
let (_, _, duplicate) = path_context_actions(&first, doc).into_iter().find(|(_, id, _)| *id == "path.set").unwrap();
|
||||
assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_path_context_save_and_layer_path_deletion_rules() {
|
||||
let mut app = app();
|
||||
let path = json!({"subpaths": []});
|
||||
app.run("path.set", json!({"name": "work", "path": path})).unwrap();
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let work = path_rows(doc, None).into_iter().find(|r| r.kind == PathRow::Work).unwrap();
|
||||
let entries = path_context_actions(&work, doc);
|
||||
assert!(entries.iter().any(|(_, id, _)| *id == "path.rename"));
|
||||
assert!(!entries.iter().any(|(_, id, _)| *id == "path.set"));
|
||||
let layer = PathEntry { kind: PathRow::Layer, ..work };
|
||||
let entries = path_context_actions(&layer, doc);
|
||||
assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_drag_creates_layers_with_modifiers() {
|
||||
let mut app = app();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
# Context-menu inventory and parity contract
|
||||
|
||||
This is the source-code inventory of PhotoCraft's right-click behavior at the branch point, captured 2026-10-06. It distinguishes a menu from another secondary-button gesture. The initial selection, Paths row, and document-tab menus added by this PR are called out separately. Photoshop comparison should be checked against an identified Photoshop version and platform before claiming exact parity; the current Layers menu explicitly targets Photoshop 2026 order.
|
||||
|
||||
## Canvas: every tool
|
||||
|
||||
The shared rule is **Command/Ctrl + right-click** on opaque document pixels → a topmost-first list of visible layers at that point; choosing a row dispatches `layer.select`. The Move tool gets that menu on plain right-click too. The list comes from `layer.pickAt` and is omitted over fully transparent pixels. On the remaining tools, plain right-click depends on the tool group below. The canvas behavior lives in `canvas.rs`, `paint_mouse.rs`, `layer_pick_ui.rs`, and `brush_resize.rs`.
|
||||
|
||||
| Tools | Plain right-click today | Other secondary gesture | Parity work |
|
||||
|---|---|---|---|
|
||||
| Move | Layer-under-pointer list (`layer.pickAt`, `layer.select`) | Command/Ctrl gives the same list | Verify Photoshop modifier and auto-select behavior; test overlaps, masks, groups, transformed pixels, empty areas. |
|
||||
| Brush, Pencil, Eraser, Background Eraser | Brush Preset picker (size, hardness, presets) | Alt/Option + right-drag changes size/hardness via `tools.setBrush`; Brush/Eraser can right-drag erase via `tools.rightClickWithPaintingTools=erase` | Verify whether context should expose brush presets, settings, or tool-specific eraser controls. Keep erase preference and modifier priority deterministic. |
|
||||
| Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush Preset picker | Alt/Option + right-drag changes size/hardness | Compare each tool's Photoshop picker and source/retouch options; use existing brush command route. |
|
||||
| Quick Selection | Brush Preset picker | Alt/Option + right-drag changes size/hardness | Check selection-tool brush options and whether mode controls belong in picker. |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magic Wand, Object Selection | No plain secondary action at branch point; this PR adds selection context actions | Command/Ctrl opens layer list | Reuse Select command IDs with exact enablement; compare menu placement and order with a current Photoshop capture. |
|
||||
| Crop, Slice, Slice Select | No plain secondary action | Command/Ctrl opens layer list | Define crop/straighten commit and cancel, aspect/preset, slice selection/edit behavior separately. |
|
||||
| Eyedropper, Ruler, Note, Count | No plain secondary action | Command/Ctrl opens layer list | Check tool-specific sampling, measurement, annotation, and count menus. |
|
||||
| Gradient, Paint Bucket | No plain secondary action | Command/Ctrl opens layer list | Expose gradient preset and fill settings through commands if Photoshop's context UI provides them; avoid painting on right-click. |
|
||||
| Type | No plain secondary action | Command/Ctrl opens layer list | Test text edit versus non-edit context, spelling, type options, and selection behavior. |
|
||||
| Pen, Path Selection | No plain secondary action | Command/Ctrl opens layer list | High-priority path-point context contract: add/delete/convert point, close path, make selection, fill/stroke path, path operations when applicable. |
|
||||
| Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | No plain secondary action | Command/Ctrl opens layer list | Define shape versus path context, point edit, fill/stroke, and transform entries. |
|
||||
| Hand, Zoom | No plain secondary action | Command/Ctrl opens layer list | Verify Photoshop's zoom/fit/actual-size canvas menu and add corresponding view commands. |
|
||||
|
||||
Canvas invariant: secondary click must not accidentally start a paint stroke, commit a crop, or edit the document; every mutating menu action dispatches a command. Escape/outside click closes a popup. Existing `ui.pointer` supports a secondary button; UI tests should exercise both native and control-driven gestures.
|
||||
|
||||
## Other right-click surfaces
|
||||
|
||||
| Surface | Current entries or behavior | Command route / gap |
|
||||
|---|---|---|
|
||||
| Layers panel row | Blending Options; Duplicate/Delete; Group from Layers; Quick Export PNG / Export As; Artboard/Frame from Layers; Convert to Smart Object; content-specific Rasterize; Add/Disable/Apply/Delete Layer Mask; Create/Release Clipping Mask; Link/Select Linked; Copy/Paste/Clear Layer Style; Merge Layers/Down, Merge Visible, Flatten Image; Rename Layer. Multi-select changes duplicate/delete/merge labels and target. | `layer_menu_ui::entries` supplies IDs for all except inline rename, then `menus::invoke` or engine dispatch. Missing or disabled commands are filtered/greyed. Check menu visibility and enabled state for groups, backgrounds, masks, type, adjustment/fill, smart objects, and multi-selection. |
|
||||
| Channels panel row | Alpha: Duplicate/Delete/Rename, spot conversion or merge, selection-display and overlay controls. Quick Mask: Exit, display and overlay. Composite/color: Duplicate. Layer mask: Enable/Disable, Delete. Common: New Channel, New Spot Channel, Split, Merge. | `channel.*`, `layer.layerMask.*`, `select.editInQuickMaskMode`, `ui.renameChannel`. Check selection loading/saving, channel options, and per-row enablement against Photoshop. |
|
||||
| Brushes panel preset | Rename Brush, Delete Brush. | Routed through `brush.presets.*` panel actions. Check duplicate/export/import and library organization. |
|
||||
| Brushes panel group | Rename Group, Delete Group. | Routed through preset panel actions; confirm group deletion consequences. |
|
||||
| Generic presets group (gradients, patterns, swatches, etc.) | Rename Group, Delete Group. | `preset_panels.rs`; verify each collection's appropriate create, export/import, and delete semantics. |
|
||||
| Generic preset item | Rename, Delete, Move to another group. | `preset_panels.rs`; behavior is generic across preset collections. |
|
||||
| Tool Presets item | Rename Tool Preset, Delete Tool Preset. | `tool.presets.edit`; compare current-tool filtering and preset application. |
|
||||
| Toolbar grouped tool slot | Right-click or long-press opens its tool flyout. | Sets active `Tool` UI state. Single-tool slots have no flyout. |
|
||||
| Toolbar Screen Mode | Standard, Full Screen With Menu Bar, Full Screen. | `view.screenMode.*` via `menus::invoke`. |
|
||||
| Color chip in color panel | Right-click sets background color (left-click sets foreground). | Direct UI state behavior; verify foreground/background target commands. |
|
||||
| Wide Angle constraint | Right-click deletes that constraint. | Tool-local action; no menu. |
|
||||
| Camera Raw point curve | Right-click deletes a curve point. | Tool-local action; no menu. |
|
||||
| Document tab / loading tab | No context menu at branch point. This PR adds Close, Close Others, Close All for document tabs. Left click activates; close icon closes/cancels. | Existing guarded File commands handle unsaved documents; reveal file and tab layout remain open. |
|
||||
| Paths panel row | Make Selection, Fill/Stroke Path, Save Work Path or Duplicate Path, Delete Path or Vector Mask as applicable (added in this PR). | `path.*` and `layer.vectorMask.delete`; direct action on the clicked path. |
|
||||
| History, Properties and empty panel areas | No `context_menu` handler found in UI source at this audit. | Audit each panel's Photoshop menu and prioritize its useful commands; do not invent dead entries. |
|
||||
|
||||
## Contract for implementation
|
||||
|
||||
1. Record a Photoshop reference matrix by version, operating system, surface, tool and state (empty/selection/path/text edit/multi-layer). Capture exact item labels, order, separators, submenus, disabled conditions, and modifier variants. Mark observations versus inference.
|
||||
2. Encode menu descriptors as data with predicate tests. Route document edits through existing or new engine command IDs; view-only actions can stay in UI. A menu on the canvas must receive the document coordinate and relevant target from hit testing, so actions operate on what was clicked rather than silently on an unrelated active object.
|
||||
3. Prioritize shared canvas menus, then high-value tool-specific interactions (path, selection, type, crop/zoom), then panel and tab gaps. Reuse Layers/Channels implementations where their command contract already exists. Preserve the existing Move/layer pick, brush picker, erase preference, Alt/Option brush resize, and toolbar flyout gestures.
|
||||
4. Test each descriptor's item order and enablement, dispatch with expected params, empty/invalid states, modifiers, and action result. Add UI gesture tests for right-click opening, target selection, Escape/outside dismissal, and the absence of accidental edits. Validate native and web builds.
|
||||
5. Capture screenshots from the running app for representative tool groups and panel states, with test fixture documents rather than personal photos. A screenshot proves visible menu composition; command tests prove its behavior.
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# Context-menu parity contract
|
||||
|
||||
Status: implementation contract, updated 2026-10-07. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html).
|
||||
|
||||
## Dispatch contract
|
||||
|
||||
1. Hit-test the target before opening: modal operation/handle, text selection, canvas content, panel row or icon, tab, then empty workspace. A generic canvas menu must not swallow a more specific target.
|
||||
2. Snapshot the target and selection when the menu opens. A row in an existing multiselection acts on that set; a row outside it becomes the target before its command runs. Apply the same rule to layers, channels, paths, and presets.
|
||||
3. Resolve items from a pure menu model keyed by target kind, active tool, document state, platform, and active operation. The model supplies label, command id, params, enabled state, and reason. Every mutating item dispatches an engine command; view-only items may update shell state. An unavailable feature is disabled with a reason or omitted, never a clickable no-op.
|
||||
4. Preserve primary-button tool behavior. Secondary click only opens a menu unless a right-button drag gesture or the user's explicit right-erase preference owns the gesture. A drag must not briefly open a menu, and opening a menu must never paint, transform, sample, or commit.
|
||||
5. Anchor at the pointer and clamp to the viewport; support keyboard context-menu key/Shift+F10 where available, Escape/outside click, separators only between nonempty groups, checked/radio state, keyboard navigation, and screen-reader labels.
|
||||
6. Use the same command id and enablement as the menu bar/options bar. Provide UI inspection data and control-channel operations to select a menu item without pixel coordinates, while retaining real pointer tests and screenshots.
|
||||
7. Test each menu model independently for conditional rows and wrong-state safety; test actual secondary-click routing, menu selection, undo/redo, and no accidental primary action.
|
||||
|
||||
## Canvas tool matrix
|
||||
|
||||
The “PhotoCraft at branch point” column records behavior before this PR. Picker means the Brush Preset quick picker in paint_mouse.rs; Layers means the hit-tested layer list in layer_pick_ui.rs. Command/Ctrl + secondary-click opens the layer list with every tool, and Move opens it without a modifier. Exact Photoshop secondary-click items beyond Adobe-documented rows are **proposed** until captured from current Photoshop.
|
||||
|
||||
| Tool(s) in PhotoCraft | Photoshop / desired contextual action | PhotoCraft at branch point | Priority, evidence |
|
||||
|---|---|---|---|
|
||||
| Move | Choose a layer at the point; an auto-select choice also exists in the options bar | Layers | P0; [Adobe layer movement](https://helpx.adobe.com/photoshop/using/moving-stacking-locking-layers.html); exact pop-up layout requires capture |
|
||||
| Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magic Wand, Quick Selection, Object Selection | Active selection: Deselect, Inverse Selection, Feather, Transform Selection, Select and Mask, Fill/Stroke where applicable **(inferred context placement)** | Quick Selection: Picker; others: no tool menu. **This PR adds an initial selection menu for the other six tools; Quick Selection keeps its picker.** The Select menu exposes these same selection commands (Feather under Select > Modify, as in the reference menus). | P1; capture each selection state |
|
||||
| Crop | During crop: ratio/preset, overlay, clear/reset, commit/cancel **(inferred context placement)** | No tool menu | P1; pending crop target |
|
||||
| Eyedropper | Sample-size/source choices **(inferred)** | No tool menu | P2; [Adobe illustration uses Eyedropper](https://helpx.adobe.com/photoshop/using/panels-menus.html), but does not publish item text |
|
||||
| Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 |
|
||||
| Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) |
|
||||
| Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 |
|
||||
| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | No tool menu | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) |
|
||||
| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | No tool menu | P1; view actions stay out of document history |
|
||||
| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas |
|
||||
| Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 |
|
||||
| Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 |
|
||||
| Any tool while Free Transform is active | Scale, Rotate, Skew, Distort, Perspective, Warp, rotate/flip variants, commit/cancel **(inferred exact context placement)** | Missing per docs/scorecard.md TOOL-214-5 | P0; transform operations [documented by Adobe](https://helpx.adobe.com/photoshop/desktop/crop-resize-transform/transform-manipulate-reshape/adjust-scale-rotation-and-perspective.html); type restrictions [documented here](https://helpx.adobe.com/photoshop/using/creating-type.html) |
|
||||
| Any tool with Command/Ctrl + secondary click | List visible layers with pixels under pointer | Layers | P0; preserve existing route |
|
||||
|
||||
The layer-list menu on an empty pixel or all-hidden hit should be empty/closed; it must not select a transparent or hidden layer. The right-erase preference is an alternate PhotoCraft behavior, and its selected state takes precedence over the Brush picker only for Brush/Eraser.
|
||||
|
||||
## Pen canvas reference capture (2026-10-07)
|
||||
|
||||
The user supplied a Photoshop screenshot of a closed path in Path mode. The table preserves every row in its order; separator boundaries appear between the groups. Gray rows in that capture remain visible but disabled for a plain work path. Enablement changes with the active shape layer, saved path, copied style, and symmetry state. Ellipsis rows open a parameter dialog before changing the document.
|
||||
|
||||
| Group | Rows in screenshot order | PhotoCraft action and applicability |
|
||||
|---|---|---|
|
||||
| Path | Create Vector Mask; Delete Path | `layer.vectorMask.fromPath` on an eligible layer; `path.delete` on a work/saved path |
|
||||
| Preset | Define Custom Shape… | `edit.defineCustomShape` with a name dialog |
|
||||
| Selection/paint | Make Selection…; New Guides From Shape; Fill Path…; Stroke Path… | `path.toSelection` dialog; `view.newGuidesFromShape` for shape layers; `path.fill` and `path.stroke` dialogs on pixel layers |
|
||||
| Export | Clipping Path… | `path.clippingPath.set` on a saved path, with flatness parameter |
|
||||
| Geometry | Free Transform Path | `path.transform` with translation, scale and rotation parameters |
|
||||
| Shape operation | Unite Shapes; Subtract Front Shape; Unite Shapes at Overlap; Subtract Shapes at Overlap | The four `layer.combineShapes.*` operations on shape layers with multiple components |
|
||||
| Copy style | Copy Fill; Copy Complete Stroke | `path.style.copyFill` / `copyStroke` when the shape has that style |
|
||||
| Paste style | Paste Fill; Paste Complete Stroke | `path.style.pasteFill` / `pasteStroke` when a matching style has been copied |
|
||||
| Layer view | Isolate Layers | `select.isolateLayers` for an active layer |
|
||||
| Symmetry | Make Symmetry Path; Disable Symmetry Path | `paint.symmetryFromPath` on a path; `paint.symmetryDisable` when symmetry is active |
|
||||
|
||||
The reference does not establish Photoshop's behavior for right-clicks on a single anchor, a segment, or empty canvas; these still need separate captures. PhotoCraft's disabled rows do not call a command.
|
||||
|
||||
## Panel and workspace matrix
|
||||
|
||||
| Surface / target | Desired options and state | PhotoCraft now | Source and confidence |
|
||||
|---|---|---|---|
|
||||
| Layers panel row | Blending Options, Duplicate/Delete, Group, export, convert/rasterize, mask, clipping, link, style copy/paste/clear, merge/flatten, Rename; filter by layer kind and selection | Implemented in layer_menu_ui.rs; item enablement uses menu commands | PhotoCraft inventory. Adobe documents layer rename/delete, color tagging, rasterization, and flattening [here](https://helpx.adobe.com/photoshop/using/layers.html); exact combined ordering **inferred** without a live capture |
|
||||
| Layers panel color label / thumbnail / mask thumbnail / effect row | Color label; context actions specific to hit icon | Generic row on some targets; inspect before extending | Adobe explicitly documents right-click on layer/group to assign a color ([layers](https://helpx.adobe.com/photoshop/using/layers.html)); other icon menus need capture |
|
||||
| Channels panel row | Duplicate/Delete/Rename, channel display and overlay color, spot conversion; new/split/merge | Implemented in channels_panel.rs | PhotoCraft inventory; exact Photoshop order **inferred** |
|
||||
| Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus |
|
||||
| Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity |
|
||||
| Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All added in this PR; other options remain open | **Inferred** exact list; P1 |
|
||||
| Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture |
|
||||
| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Missing per docs/scorecard.md UI-217-8 | P1; **inferred** exact spelling list |
|
||||
| Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label |
|
||||
| Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) |
|
||||
|
||||
## Delivery slices and acceptance
|
||||
|
||||
1. Core routing: target hit-test, menu model, keyboard accessibility, control inspection, conflict rules. Existing Move layer list and painting picker remain functional.
|
||||
2. Canvas high frequency: selection, Free Transform, Type, Hand/Zoom, Gradient/Paint Bucket. Add rows only where the underlying command works. Record exact Photoshop label/order/disabled-state captures before declaring 1:1 parity.
|
||||
3. Panels: Paths row actions, document tabs and distinct layer eye/thumbnail/mask/effect targets; finish Brushes export and observed channels/preset gaps. Adobe [documents the eye-icon visibility gesture](https://helpx.adobe.com/content/dam/help/en/photoshop/using/default-keyboard-shortcuts/photoshop-keyboard-shortcuts.pdf).
|
||||
4. Long tail: shape/path, crop, metadata and specialist tools. Keep a per-tool fixture with no selection, active selection, masked/locked layer, and active operation where applicable.
|
||||
|
||||
Each slice needs pure model tests (including every item dispatching a known command), UI pointer tests for secondary click and modifier precedence, command/undo tests, a disabled-state test, and screenshots from PhotoCraft with the menu actually open. Compare labels, order, and state against dated Photoshop screenshots; a screenshot of a different tool or menu bar is insufficient evidence. Maintain a checklist beside each captured reference: Photoshop version/platform, target description, tool, selection/document state, modifiers, and exact items observed.
|
||||
|
|
@ -43,7 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
|||
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
|
||||
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu instead (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. Both menus open at the screen position of the pressed document point, like a mouse right-click's. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven.
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven.
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
|||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 6 | 13 | 11 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 135 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 60 of 135 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 3 | 1 | 3 | never-crash lints on 26 of 26 crates; panic_hunt present |
|
||||
|
|
@ -165,9 +165,9 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipbo
|
|||
| UI-217-3 | File > Open starts in the last-used folder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | services.rs:110 FileDialog::new() without set_directory |
|
||||
| UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* |
|
||||
| UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs |
|
||||
| UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:674-770 tabs click/close only |
|
||||
| UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | partial | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All context menu with unsaved prompt; overflow, Reveal, drag reorder still missing |
|
||||
| UI-217-7 | Command palette shows recently run commands on an empty query | missing | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs:83-108 fuzzy hits only |
|
||||
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs:67 right-click opens the brush picker only |
|
||||
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; other tools and text spelling menus missing |
|
||||
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | missing | [#217](https://github.com/storytold/photocraft/issues/217) | prefs.rs:61 UiScale unused; input_tests.rs:71 asserts zoom_factor stays 1.0 |
|
||||
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
|
||||
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs:28 five themes |
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ note = "No floating groups in dock.rs/panels.rs"
|
|||
[[item]]
|
||||
id = "UI-217-6"
|
||||
target = "Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "canvas.rs:674-770 tabs click/close only"
|
||||
note = "canvas.rs: Close/Others/All context menu with unsaved prompt; overflow, Reveal, drag reorder still missing"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-7"
|
||||
|
|
@ -59,7 +59,7 @@ id = "UI-217-8"
|
|||
target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "paint_mouse.rs:67 right-click opens the brush picker only"
|
||||
note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; other tools and text spelling menus missing"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-9"
|
||||
|
|
|
|||
Loading…
Reference in New Issue