mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge approved PhotoCraft PR #3464: fix(vector): target selected saved paths and own Pen gestures
Port behavior and regression coverage into current modules; preserve current upstream behavior. Reconcile captured Pen and Freeform targets with current resumed-subpath, Type Path, Path Operation and draft recovery behavior. Valid source-owned tab switches commit to captured original target; invalid snapshots cancel with a diagnostic; refused commits retain draft and suppress automatic retries. Preserve current Saved(index) API and hidden-work-path protections, register all new ownership/gesture tests and add original-target tab-switch/deleted-target cases. Includes verified compile/API corrections from prior checkpoints.
This commit is contained in:
@@ -699,7 +699,6 @@ fn windows_root_case_variants_keep_io_inside_the_grant() {
|
||||
std::fs::write(inside.join("notes.bin"), b"keep").unwrap();
|
||||
assert!(workspace.write(&configured.join("notes.bin").to_string_lossy(), b"no").is_err());
|
||||
assert_eq!(std::fs::read(inside.join("notes.bin")).unwrap(), b"keep");
|
||||
|
||||
}
|
||||
|
||||
/// Unix roots with different case must not become aliases of the granted prefix.
|
||||
|
||||
@@ -658,8 +658,6 @@ fn hidden_base_hides_clipping_group() {
|
||||
assert!(close4(px(&d, 1, 1), [1.0; 4]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn isolated_group_differs_from_pass_through() {
|
||||
// A Multiply layer inside a group: pass-through multiplies with the background,
|
||||
|
||||
@@ -44,7 +44,7 @@ pub struct LayerExportReport {
|
||||
pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<LayerExportReport> {
|
||||
let cmd = "file.export.layersToFiles";
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
|
||||
let trim=p.get("trim").and_then(Value::as_bool).unwrap_or(false);
|
||||
let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false);
|
||||
let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
// Callers outside `Session::execute` (headless workspace exports) carry the session's gate
|
||||
// into `encode`, so a PSD embeds no file a linked smart object names.
|
||||
@@ -74,19 +74,21 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&
|
||||
only.clipped = false;
|
||||
one.layers = vec![only];
|
||||
if trim {
|
||||
let mut bounds=Rect::EMPTY;
|
||||
let _=photocraft_compose::render_bands(&one,one.bounds(),0,|band|->std::result::Result<(),()> {
|
||||
let width=band.rect.width() as usize;
|
||||
for (i,rgba) in band.px.iter().enumerate() {
|
||||
if rgba[3]>0.0 && width>0 {
|
||||
let x=band.rect.x0+(i%width) as i32;
|
||||
let y=band.rect.y0+(i/width) as i32;
|
||||
bounds=bounds.union(&Rect::new(x,y,x+1,y+1));
|
||||
let mut bounds = Rect::EMPTY;
|
||||
let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> {
|
||||
let width = band.rect.width() as usize;
|
||||
for (i, rgba) in band.px.iter().enumerate() {
|
||||
if rgba[3] > 0.0 && width > 0 {
|
||||
let x = band.rect.x0 + (i % width) as i32;
|
||||
let y = band.rect.y0 + (i / width) as i32;
|
||||
bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if !bounds.is_empty() && bounds!=one.bounds(){crate::image_cmds::crop_doc(&mut one,cmd,bounds,true,[0.0;4])?;}
|
||||
if !bounds.is_empty() && bounds != one.bounds() {
|
||||
crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?;
|
||||
}
|
||||
}
|
||||
|
||||
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name));
|
||||
|
||||
@@ -160,7 +160,6 @@ fn selection_transparency_lock_and_mask_targets_apply_to_all_passes() {
|
||||
assert!(s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().is_empty());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn streaming_across_dual_axes_keeps_one_opacity_and_matches_commit() {
|
||||
let points = [[40.0, 30.0], [40.0, 20.0], [35.0, 20.0], [45.0, 40.0]];
|
||||
|
||||
@@ -79,26 +79,32 @@ pub(super) fn path_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?;
|
||||
s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| {
|
||||
let merge = |old: Option<&Path>, new: Path| match (old, append) {
|
||||
(Some(o), Some(op)) => {
|
||||
let mut o = o.clone();
|
||||
o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
|
||||
sp.op = op;
|
||||
sp
|
||||
}));
|
||||
o
|
||||
}
|
||||
_ => new,
|
||||
let merge = |old: Option<&Path>, new: Path| -> Result<Path> {
|
||||
let merged = match (old, append) {
|
||||
(Some(o), Some(op)) => {
|
||||
let mut o = o.clone();
|
||||
o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
|
||||
sp.op = op;
|
||||
sp
|
||||
}));
|
||||
o
|
||||
}
|
||||
_ => new,
|
||||
};
|
||||
// Appending small Pen components must not bypass the complete path's bounds and
|
||||
// tessellation budget. Validate before changing the document or its history.
|
||||
vector::flatten::validate_path(&merged).map_err(|e| bad(CMD, e))?;
|
||||
Ok(merged)
|
||||
};
|
||||
if is_work(name.as_deref()) {
|
||||
doc.work_path = Some(merge(doc.work_path.as_ref(), path));
|
||||
doc.work_path = Some(merge(doc.work_path.as_ref(), path)?);
|
||||
} else {
|
||||
let n = name.clone().unwrap_or_default();
|
||||
match doc.paths.iter_mut().find(|q| q.name == n) {
|
||||
Some(q) => {
|
||||
q.path = merge(Some(&q.path), path);
|
||||
q.path = merge(Some(&q.path), path)?;
|
||||
}
|
||||
None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }),
|
||||
None => doc.paths.push(NamedPath { name: n, path: merge(None, path)?, psd_raw: None }),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::command_hints;
|
||||
use super::LayerId;
|
||||
use crate::command_hints;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_work_and_saved_components_preserve_existing_geometry_and_undo() {
|
||||
for name in ["work", "Saved drawing"] {
|
||||
let mut session = session();
|
||||
let first = json!({"subpaths": [{"closed": false, "knots": [[20, 20], [80, 20]]}]});
|
||||
let second = json!({"subpaths": [{"closed": false, "knots": [[30, 60], [90, 60]]}]});
|
||||
session.execute("path.set", json!({"name": name, "path": first})).unwrap();
|
||||
let before = session.execute("path.info", json!({"name": name})).unwrap();
|
||||
session.execute("path.set", json!({"name": name, "path": second, "op": "combine"})).unwrap();
|
||||
let after = session.execute("path.info", json!({"name": name})).unwrap();
|
||||
assert_eq!(after["path"]["subpaths"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(after["path"]["subpaths"][0], before["path"]["subpaths"][0]);
|
||||
session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before);
|
||||
session.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_valid_components_cannot_exceed_total_path_budget() {
|
||||
let mut session = session();
|
||||
let subpath = json!({"closed": false, "knots": []});
|
||||
session.execute("path.set", json!({"path": {"subpaths": vec![subpath.clone(); 2_049]}})).unwrap();
|
||||
let before = session.active().unwrap().doc.work_path.clone();
|
||||
let history = session.active().unwrap().history.entries().len();
|
||||
let error = session.execute("path.set", json!({"path": {"subpaths": vec![subpath; 2_048]}, "op": "combine"})).unwrap_err();
|
||||
assert!(error.to_string().contains("4096"));
|
||||
assert_eq!(session.active().unwrap().doc.work_path, before);
|
||||
assert_eq!(session.active().unwrap().history.entries().len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_path_coordinates_leave_work_and_saved_paths_unchanged() {
|
||||
for name in ["work", "Saved drawing"] {
|
||||
let mut session = session();
|
||||
session.execute("path.set", json!({"name": name, "path": {"subpaths": [{"knots": [[10, 10], [20, 20]]}]}})).unwrap();
|
||||
let before = session.execute("path.info", json!({"name": name})).unwrap();
|
||||
let history = session.active().unwrap().history.entries().len();
|
||||
assert!(
|
||||
session.execute("path.set", json!({"name": name, "op": "combine", "path": {"subpaths": [{"knots": [[-1.0e308, 10], [1.0e308, 20]]}]}})).is_err()
|
||||
);
|
||||
assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before);
|
||||
assert_eq!(session.active().unwrap().history.entries().len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_straight_work_and_saved_paths_append_and_undo_without_shape_coordinate_limits() {
|
||||
for name in ["work", "Saved drawing"] {
|
||||
let mut session = session();
|
||||
let large = json!({"subpaths": [{"closed": true, "knots": [[0.0, 0.0], [3.0e9, 0.0], [3.0e9, 1.0e9]]}]});
|
||||
let before = session.active().unwrap().doc.clone();
|
||||
let history = session.active().unwrap().history.entries().len();
|
||||
session.execute("path.set", json!({"name":name,"path":large})).unwrap();
|
||||
let first = session.execute("path.info", json!({"name":name})).unwrap();
|
||||
assert_eq!(first["path"]["subpaths"][0]["knots"][1]["anchor"], json!([3.0e9, 0.0]));
|
||||
assert_eq!(session.active().unwrap().history.entries().len(), history + 1);
|
||||
session.execute("path.set", json!({"name":name,"op":"combine","path":{"subpaths":[{"knots":[[10,20],[80,20]]}]}})).unwrap();
|
||||
let appended = session.execute("path.info", json!({"name":name})).unwrap();
|
||||
assert_eq!(appended["path"]["subpaths"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(appended["path"]["subpaths"][0], first["path"]["subpaths"][0]);
|
||||
session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first);
|
||||
session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().doc.work_path, before.work_path);
|
||||
assert_eq!(session.active().unwrap().doc.paths, before.paths);
|
||||
session.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first);
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ fn cmyk_merged_composite_reads_through_the_embedded_profile() {
|
||||
if depth == 8 { data.push(v as u8) } else { data.extend((v as u16).to_be_bytes()) }
|
||||
}
|
||||
}
|
||||
f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, ..Default::default() };
|
||||
f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, stored: None };
|
||||
f.resources.push(ImageResource::new(1039, profile.to_bytes().to_vec()));
|
||||
let merged = merged_composite(&f).unwrap();
|
||||
let mut seen_far = false;
|
||||
|
||||
@@ -294,4 +294,6 @@ mod nonfinite_tests {
|
||||
}
|
||||
|
||||
/// Bounded finite affine terms accepted before type layout.
|
||||
pub fn usable_tysh_transform(matrix:&[f64;6])->bool {transform_is_safe(&Affine{m:*matrix})}
|
||||
pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool {
|
||||
transform_is_safe(&Affine { m: *matrix })
|
||||
}
|
||||
|
||||
@@ -298,7 +298,9 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
|
||||
layer.transform.m = corrected;
|
||||
}
|
||||
}
|
||||
if !super::usable_tysh_transform(&layer.transform.m) {return None;}
|
||||
if !super::usable_tysh_transform(&layer.transform.m) {
|
||||
return None;
|
||||
}
|
||||
layer.sync_summary();
|
||||
Some(layer)
|
||||
}
|
||||
@@ -359,7 +361,9 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
|
||||
return None;
|
||||
}
|
||||
let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f];
|
||||
if !shifted.iter().all(|v|v.is_finite()) {return None;}
|
||||
if !shifted.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let origin_distance = e.abs().max(f.abs());
|
||||
let shifted_distance = shifted[0].abs().max(shifted[1].abs());
|
||||
// A local bounding-box origin in the thousands and a transform origin beyond a normal canvas
|
||||
@@ -795,8 +799,8 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [
|
||||
#[cfg(test)]
|
||||
mod tysh_geometry_tests {
|
||||
use super::*;
|
||||
use crate::psd::{usable_tysh_transform, write_tysh};
|
||||
use photocraft_psd::descriptor::UnicodeString;
|
||||
use crate::psd::{usable_tysh_transform, write_tysh};
|
||||
use photocraft_psd::descriptor::UnicodeString;
|
||||
|
||||
#[test]
|
||||
fn overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform() {
|
||||
|
||||
@@ -47,6 +47,7 @@ impl PhotocraftApp {
|
||||
monitors: Default::default(),
|
||||
checker: None,
|
||||
drag: None,
|
||||
cancelled_vector_drag: false,
|
||||
move_blocked: false,
|
||||
tool_override: None,
|
||||
live_stroke: None,
|
||||
@@ -98,6 +99,7 @@ impl PhotocraftApp {
|
||||
tab_strip: None,
|
||||
drop_places: Default::default(),
|
||||
view_docs: Vec::new(),
|
||||
path_document: None,
|
||||
fps: 0.0,
|
||||
last_frame_time: 0.0,
|
||||
thumbs: HashMap::new(),
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PhotocraftApp {
|
||||
}
|
||||
|
||||
pub fn sync_views(&mut self) {
|
||||
// The Paths panel selection belongs to the active document. Ordinary edits replace the
|
||||
// snapshot and advance its revision; a replaced snapshot without a newer revision is a
|
||||
// reopened document, even with the same ID.
|
||||
let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc)));
|
||||
let same_document = match (&self.path_document, &document) {
|
||||
(Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_document {
|
||||
self.ui.selected_path = None;
|
||||
}
|
||||
self.path_document = document;
|
||||
vector_ui::cancel_stale_pen(self);
|
||||
crate::direct_select::cancel_stale(self);
|
||||
canvas::cancel_stale_vector_drag(self);
|
||||
type_transform::cancel_stale(self);
|
||||
crate::cam_transform::cancel_stale(self);
|
||||
crate::lasso_ui::cancel_stale(self);
|
||||
|
||||
@@ -61,9 +61,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
// So does selecting another layer or document by any route (#6483).
|
||||
type_tool::end_if_left(self);
|
||||
if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) {
|
||||
vector_ui::pen_end(self);
|
||||
}
|
||||
vector_ui::finish_pen_if_left(self);
|
||||
transform_tool::end_if_left(self);
|
||||
shape_stroke_ui::sync_editor(self, ctx);
|
||||
prefs_ui::tick(self, ctx);
|
||||
|
||||
@@ -1656,13 +1656,14 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie
|
||||
// Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there.
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let before = view.center;
|
||||
let mode=app.session.prefs().interface.scrollbar_visibility;
|
||||
let visible=match mode {
|
||||
photocraft_engine::prefs::ScrollbarVisibility::Hidden=>false,
|
||||
photocraft_engine::prefs::ScrollbarVisibility::AutoHide=>ui.rect_contains_pointer(rect)||ui.input(|i|i.pointer.any_down()),
|
||||
_=>true,
|
||||
let mode = app.session.prefs().interface.scrollbar_visibility;
|
||||
let visible = match mode {
|
||||
photocraft_engine::prefs::ScrollbarVisibility::Hidden => false,
|
||||
photocraft_engine::prefs::ScrollbarVisibility::AutoHide => ui.rect_contains_pointer(rect) || ui.input(|i| i.pointer.any_down()),
|
||||
_ => true,
|
||||
};
|
||||
let over_bars = visible && app.ui.view.shows_scrollbars()
|
||||
let over_bars = visible
|
||||
&& app.ui.view.shows_scrollbars()
|
||||
&& crate::scrollbars::show(ui, rect, view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx)));
|
||||
ctx.data_mut(|d| d.insert_temp(bars_id, over_bars));
|
||||
if view.center != before {
|
||||
@@ -1964,7 +1965,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
if middle_rotate && response.dragged() && !stroke_dismissal {
|
||||
if let Some(pointer) = response.interact_pointer_pos()
|
||||
&& crate::rotate_view::middle_drag(view, &xf, pointer, response.drag_delta().x)
|
||||
&& crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x)
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -2632,3 +2633,12 @@ mod lasso_preview_tests {
|
||||
assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use drag::cancel_stale_vector_drag;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pen_gesture_owner_tests.rs"]
|
||||
mod pen_gesture_owner_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "pen_path_visual_tests.rs"]
|
||||
mod pen_path_visual_tests;
|
||||
|
||||
@@ -211,5 +211,9 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option
|
||||
sample_color(app, x, y, "all")
|
||||
}
|
||||
|
||||
pub(crate) fn alt_loads_mixer(tool:Tool,mods:egui::Modifiers)->bool {mods.alt && !mods.ctrl && tool==Tool::MixerBrush}
|
||||
pub(crate) fn alt_picks(tool:Tool,mods:egui::Modifiers)->bool {alt_samples(tool,mods)||alt_loads_mixer(tool,mods)}
|
||||
pub(crate) fn alt_loads_mixer(tool: Tool, mods: egui::Modifiers) -> bool {
|
||||
mods.alt && !mods.ctrl && tool == Tool::MixerBrush
|
||||
}
|
||||
pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool {
|
||||
alt_samples(tool, mods) || alt_loads_mixer(tool, mods)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ pub struct Drag {
|
||||
/// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`.
|
||||
pub sel_axis: Option<[f64; 2]>,
|
||||
pub lasso: Option<crate::lasso_ui::Lasso>,
|
||||
/// Freeform Pen and Path Selection gestures belong to their source document and target.
|
||||
pub(super) vector_owner: Option<VectorGestureOwner>,
|
||||
pub(super) pen_owner: Option<crate::vector_ui::PenOwner>,
|
||||
}
|
||||
|
||||
impl Drag {
|
||||
@@ -49,6 +52,19 @@ impl Drag {
|
||||
sel_move: None,
|
||||
sel_axis: None,
|
||||
lasso: None,
|
||||
vector_owner: None,
|
||||
pen_owner: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector drags capture their owner at the press, the Freeform Pen also its destination, before
|
||||
/// later ⌥-clicks or a temporary Direct Selection can finish them.
|
||||
pub(crate) fn capture_vector_owners(&mut self, app: &PhotocraftApp) {
|
||||
if matches!(self.tool, Tool::FreeformPen | Tool::PathSelection) {
|
||||
self.vector_owner = VectorGestureOwner::capture(app);
|
||||
}
|
||||
if self.tool == Tool::FreeformPen {
|
||||
self.pen_owner = crate::vector_ui::capture_pen_owner(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,3 +342,58 @@ pub(crate) fn draw_readout<const N: usize>(ctx: &egui::Context, id: &str, cursor
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The document, revision and target a vector drag started on.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct VectorGestureOwner {
|
||||
doc: photocraft_doc::DocId,
|
||||
document: std::sync::Weak<Document>,
|
||||
revision: u64,
|
||||
layer: Option<photocraft_doc::LayerId>,
|
||||
selected_path: Option<String>,
|
||||
vector_mask: bool,
|
||||
mode: String,
|
||||
tool: Tool,
|
||||
}
|
||||
|
||||
/// Cancel a vector drag before it can preview or finish on another document or target; the rest of
|
||||
/// its pointer events go nowhere. True when one was cancelled.
|
||||
pub(crate) fn cancel_stale_vector_drag(app: &mut PhotocraftApp) -> bool {
|
||||
let stale = app.drag.as_ref().and_then(|d| d.vector_owner.as_ref()).is_some_and(|owner| !owner.valid(app));
|
||||
if stale {
|
||||
app.drag = None;
|
||||
app.cancelled_vector_drag = true;
|
||||
}
|
||||
stale
|
||||
}
|
||||
|
||||
impl VectorGestureOwner {
|
||||
fn capture(app: &PhotocraftApp) -> Option<Self> {
|
||||
let st = app.session.active()?;
|
||||
Some(Self {
|
||||
doc: st.doc.id,
|
||||
document: std::sync::Arc::downgrade(&st.doc),
|
||||
revision: st.revision,
|
||||
layer: st.active_layer,
|
||||
selected_path: app.ui.selected_path.clone(),
|
||||
vector_mask: app.ui.vector_mask_target,
|
||||
mode: app.ui.tool_options.vector_mode.clone(),
|
||||
tool: app.active_tool(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn valid(&self, app: &PhotocraftApp) -> bool {
|
||||
Self::capture(app).is_some_and(|now| {
|
||||
self.doc == now.doc
|
||||
&& self.document.ptr_eq(&now.document)
|
||||
&& self.revision == now.revision
|
||||
&& self.layer == now.layer
|
||||
&& self.selected_path == now.selected_path
|
||||
&& self.vector_mask == now.vector_mask
|
||||
&& self.mode == now.mode
|
||||
&& self.tool == now.tool
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use photocraft_doc::Document;
|
||||
|
||||
@@ -13,6 +13,9 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
app.live_stroke = None;
|
||||
return;
|
||||
}
|
||||
if d.vector_owner.as_ref().is_some_and(|owner| !owner.valid(app)) {
|
||||
return;
|
||||
}
|
||||
let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
|
||||
let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]);
|
||||
end[0] = sx;
|
||||
@@ -78,7 +81,11 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
match d.tool {
|
||||
Tool::ObjectSelection => crate::retouch_ui::finish_object_selection(app, d.start, [end[0], end[1]], d.modifiers),
|
||||
Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())),
|
||||
Tool::FreeformPen => {
|
||||
if let Some(owner) = d.pen_owner {
|
||||
crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner);
|
||||
}
|
||||
}
|
||||
// Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead.
|
||||
t if crate::vector_ui::is_shape_tool(t)
|
||||
&& crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom())
|
||||
|
||||
@@ -21,14 +21,8 @@ fn middle_drag_rotates_about_the_pointer_without_panning() {
|
||||
#[test]
|
||||
fn middle_drag_ignores_invalid_or_stationary_input() {
|
||||
let mut view = View::default();
|
||||
let xf = ViewXform {
|
||||
rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)),
|
||||
zoom: 1.0,
|
||||
center: view.center,
|
||||
flip: false,
|
||||
rotation: 0.0,
|
||||
aspect: 1.0,
|
||||
};
|
||||
let xf =
|
||||
ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), zoom: 1.0, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0));
|
||||
assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN));
|
||||
assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cancel_stale_vector_drag;
|
||||
use super::cursors::{alt_eyedropper, sample_eyedropper};
|
||||
use super::drag::Drag;
|
||||
use super::gesture::{finish_gesture, polygon_click, selection_mode};
|
||||
@@ -296,6 +297,17 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
|
||||
|
||||
/// Tool state machine. Shared by mouse input and automation.
|
||||
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
cancel_stale_vector_drag(app);
|
||||
if app.cancelled_vector_drag {
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => app.cancelled_vector_drag = false,
|
||||
ToolEvent::Move { .. } => return,
|
||||
ToolEvent::Up { .. } => {
|
||||
app.cancelled_vector_drag = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((armed, mods)) = preflight_tool_event(app, ev, mods) else { return };
|
||||
let Some((raw, ev, mods)) = filter_event_before_tool(app, ev, mods, armed) else { return };
|
||||
let tool = event_tool(app, mods);
|
||||
@@ -479,7 +491,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
// ⌥ flips Dodge/Burn and Blur/Sharpen for this stroke.
|
||||
let tool = crate::retouch_ui::alt_flipped(tool, mods.alt);
|
||||
app.drag = Some(Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) });
|
||||
let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) };
|
||||
drag.capture_vector_owners(app);
|
||||
app.drag = Some(drag);
|
||||
app.trail = None;
|
||||
app.stylus.begin_stroke();
|
||||
if from.is_some() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::METHODS;
|
||||
use super::inspect::inspect;
|
||||
use super::params::{
|
||||
BUTTONS, KEY_MODIFIERS, MAX_CLICKS, POINTER_MODIFIERS, UI_SET_FIELDS, bool_field, err, f32_field, f32_range, input_choice, input_modifiers, input_number,
|
||||
@@ -69,7 +70,7 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe
|
||||
let p = &req.params;
|
||||
let Some(id) = str_param(p, "command").or(str_param(p, "id")) else { return err("missing `command`") };
|
||||
if req.method == "engine.execute" && METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() {
|
||||
return err(photocraft_engine::command_hints::protocol_method_as_command(id));
|
||||
return err(photocraft_engine::command_hints::protocol_method_as_command(id));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||||
let wait = wait_param(p);
|
||||
|
||||
@@ -148,6 +148,8 @@
|
||||
# Art History Brush (#213).
|
||||
|
||||
# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic)
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… Možnosti masky…
|
||||
Layer Mask Display Options… Možnosti zobrazení masky vrstvy…
|
||||
Channel Color Barva kanálu
|
||||
@@ -1234,7 +1236,6 @@
|
||||
Dots Tečky
|
||||
Drag a rectangle around the object Táhněte obdélník kolem objektu
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím na omezení ho změníte nebo odstraníte.
|
||||
Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: vybrat bod · Tažení: přesunout bod, úchyt nebo segment · {shift}+klik: přidat · {alt}+klik: celá podcesta
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru.
|
||||
Drop an image or PSD anywhere to open it. Přetáhněte sem obrázek nebo PSD a otevře se.
|
||||
@@ -1959,11 +1960,9 @@
|
||||
Slice Select Tool Nástroj výběr řezu
|
||||
Slice Tool Nástroj řez
|
||||
Smart Filters Chytré filtry
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up Přesunout nahoru
|
||||
Move Down Přesunout dolů
|
||||
Delete Smart Filter Odstranit chytrý filtr
|
||||
|
||||
Smudge Tool Nástroj rozmazání
|
||||
Soft Měkké
|
||||
Soften Změkčení
|
||||
@@ -4179,8 +4178,6 @@ navigator Zoom Zvětšení
|
||||
layers Fill Krytí výplně
|
||||
layers Fill: Krytí výplně:
|
||||
cameraRaw Dehaze Odstranění oparu
|
||||
Move Up Přesunout nahoru
|
||||
Move Down Přesunout dolů
|
||||
Previous Brush Předchozí štětec
|
||||
Next Brush Další štětec
|
||||
First Brush První štětec
|
||||
@@ -4277,3 +4274,4 @@ cameraRaw Dehaze Odstranění oparu
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}
|
||||
Scrollbar Visibility Viditelnost posuvníků
|
||||
Drag to move the selected path Tažením přesunete vybranou cestu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2458,7 +2458,7 @@
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Σύρετε ή κάντε κλικ για πολυγωνικά σημεία · κρατήστε Alt κατά το λάσο για ευθείες · {add} προσθήκη · {sub} αφαίρεση πριν από τη σχεδίαση
|
||||
Drag to draw a gradient Σύρετε για να σχεδιάσετε διαβάθμιση
|
||||
Drag to move the active layer Σύρετε για μετακίνηση του ενεργού επιπέδου
|
||||
Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας
|
||||
Drag to move the selected path Σύρετε για μετακίνηση της επιλεγμένης διαδρομής
|
||||
Dual Brush Διπλό πινέλο
|
||||
Duplicate Path Διπλασιασμός διαδρομής
|
||||
Rename Path… Μετονομασία διαδρομής…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -82,6 +82,8 @@
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
# Pen draft after a refused commit (#7648)
|
||||
# Art History Brush (#213).
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… Opciones de máscara…
|
||||
Layer Mask Display Options… Opciones de visualización de máscara de capa…
|
||||
Channel Color Color del canal
|
||||
@@ -1168,7 +1170,6 @@
|
||||
Dots Puntos
|
||||
Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho en una restricción para cambiarla o eliminarla.
|
||||
Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el trazado de trabajo
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleccionar punto · Arrastrar: mover punto, manejador o segmento · {shift}-clic: añadir · {alt}-clic: subtrazado completo
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Dibuje con la herramienta Pluma (P) o cree un trazado de trabajo a partir de una selección.
|
||||
Drop an image or PSD anywhere to open it. Suelte una imagen o un PSD en cualquier lugar para abrirlo.
|
||||
@@ -1890,11 +1891,9 @@
|
||||
Slice Select Tool Herramienta Selección de sector
|
||||
Slice Tool Herramienta Sector
|
||||
Smart Filters Filtros inteligentes
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up Mover arriba
|
||||
Move Down Mover abajo
|
||||
Delete Smart Filter Eliminar filtro inteligente
|
||||
|
||||
Smudge Tool Herramienta Dedo
|
||||
Soft Suave
|
||||
Soften Suavizar
|
||||
@@ -4346,8 +4345,6 @@ navigator Zoom Zoom
|
||||
The fastest and the softest, with colour fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino.
|
||||
“{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo?
|
||||
The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}».
|
||||
Move Up Mover arriba
|
||||
Move Down Mover abajo
|
||||
Previous Brush Pincel anterior
|
||||
Next Brush Pincel siguiente
|
||||
First Brush Primer pincel
|
||||
@@ -4442,3 +4439,4 @@ navigator Zoom Zoom
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Cantidad de formas importadas de {file}: {n}|Cantidad de formas importadas de {file}: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Cantidad de formas importadas de {group}: {n}|Cantidad de formas importadas de {group}: {n}
|
||||
Scrollbar Visibility Visibilidad de las barras de desplazamiento
|
||||
Drag to move the selected path Arrastre para mover el trazado seleccionado
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -81,6 +81,8 @@
|
||||
|
||||
# Pen draft after a refused commit (#7648)
|
||||
# Art History Brush (#213).
|
||||
# Smart Filter row actions.
|
||||
|
||||
Paper Width Papír szélessége
|
||||
Paper Height Papír magassága
|
||||
@id edit.purge.undo Visszavonási adatok
|
||||
@@ -1731,11 +1733,9 @@ fontWeight Thin Hajszálvékony
|
||||
Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg)
|
||||
Skip Kihagy
|
||||
Smart Filters Intelligens szűrők
|
||||
# Smart Filter row actions.
|
||||
Move Up Mozgatás felfelé
|
||||
Move Down Mozgatás lefelé
|
||||
Delete Smart Filter Intelligens szűrő törlése
|
||||
|
||||
Soft Light Lágy fény
|
||||
Soften Lágyítás
|
||||
Source Forrás
|
||||
@@ -4347,8 +4347,6 @@ cameraRaw Yellow Sárga
|
||||
The fastest and the softest, with colour fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel.
|
||||
“{name}” already exists. Replace it? „{name}” már létezik. Lecseréli?
|
||||
The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”.
|
||||
Move Up Mozgatás felfelé
|
||||
Move Down Mozgatás lefelé
|
||||
Previous Brush Előző ecset
|
||||
Next Brush Következő ecset
|
||||
First Brush Első ecset
|
||||
@@ -4440,3 +4438,4 @@ cameraRaw Yellow Sárga
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} A(z) {file} forrásból importált alakzatok száma: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} A(z) {group} forrásból importált alakzatok száma: {n}
|
||||
Scrollbar Visibility Görgetősávok láthatósága
|
||||
Drag to move the selected path Húzza a kijelölt útvonal mozgatásához
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1215,6 +1215,7 @@
|
||||
Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro su un vincolo per modificarlo o eliminarlo.
|
||||
Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro
|
||||
Drag to move the selected path Trascina per spostare il tracciato selezionato
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione.
|
||||
Drop an image or PSD anywhere to open it. Trascina un'immagine o PSD ovunque per aprirlo.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -142,6 +142,8 @@
|
||||
|
||||
# Pen draft after a refused commit (#7648)
|
||||
# Art History Brush (#213).
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… マスクオプション…
|
||||
Layer Mask Display Options… レイヤーマスクの表示オプション…
|
||||
Channel Color チャンネルカラー
|
||||
@@ -1223,7 +1225,6 @@
|
||||
Dots ドット
|
||||
Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 画像上をドラッグして制約を追加。Shift で水平/垂直、制約を右クリックで変更・削除。
|
||||
Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath クリック: アンカーポイントを選択 · ドラッグ: ポイント、ハンドル、セグメントを移動 · {shift}+クリック: 追加 · {alt}+クリック: サブパス全体
|
||||
Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。
|
||||
Drop an image or PSD anywhere to open it. 画像や PSD をここにドロップすると開きます。
|
||||
@@ -1943,11 +1944,9 @@
|
||||
Slice Select Tool スライス選択ツール
|
||||
Slice Tool スライスツール
|
||||
Smart Filters スマートフィルター
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up 上へ移動
|
||||
Move Down 下へ移動
|
||||
Delete Smart Filter スマートフィルターを削除
|
||||
|
||||
Smudge Tool 指先ツール
|
||||
Soft ソフト
|
||||
Soften ぼかし
|
||||
@@ -4111,8 +4110,6 @@ curves Smooth スムーズ
|
||||
The fastest and the softest, with colour fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。
|
||||
“{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか?
|
||||
The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。
|
||||
Move Up 上へ移動
|
||||
Move Down 下へ移動
|
||||
Previous Brush 前のブラシ
|
||||
Next Brush 次のブラシ
|
||||
First Brush 最初のブラシ
|
||||
@@ -4207,3 +4204,4 @@ curves Smooth スムーズ
|
||||
Choose a custom shape file (.csh). カスタムシェイプファイル (.csh) を選択してください。
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} から読み込んだシェイプ数: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} から読み込んだシェイプ数: {n}
|
||||
Drag to move the selected path ドラッグして選択したパスを移動
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -113,6 +113,8 @@
|
||||
# Art History Brush (#213).
|
||||
|
||||
# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic)
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… 마스크 옵션…
|
||||
Layer Mask Display Options… 레이어 마스크 표시 옵션…
|
||||
Channel Color 채널 색상
|
||||
@@ -1200,7 +1202,6 @@
|
||||
Dots 점
|
||||
Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 제약 조건을 오른쪽 클릭하여 변경 또는 삭제.
|
||||
Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 클릭: 기준점 선택 · 드래그: 기준점, 핸들 또는 세그먼트 이동 · {shift}-클릭: 추가 · {alt}-클릭: 하위 패스 전체
|
||||
Draw with the Pen tool (P) or make a work path from a selection. 펜 도구(P)로 그리거나 선택 영역에서 작업 패스를 만드세요.
|
||||
Drop an image or PSD anywhere to open it. 이미지나 PSD를 아무 곳에나 놓아 여세요.
|
||||
@@ -1919,11 +1920,9 @@
|
||||
Slice Select Tool 분할 영역 선택 도구
|
||||
Slice Tool 분할 영역 도구
|
||||
Smart Filters 스마트 필터
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up 위로 이동
|
||||
Move Down 아래로 이동
|
||||
Delete Smart Filter 스마트 필터 삭제
|
||||
|
||||
Smudge Tool 손가락 도구
|
||||
Soft 부드럽게
|
||||
Soften 부드럽게 하기
|
||||
@@ -3645,8 +3644,6 @@ curves Smooth 매끄럽게
|
||||
The fastest and the softest, with colour fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음.
|
||||
“{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까?
|
||||
The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}”
|
||||
Move Up 위로 이동
|
||||
Move Down 아래로 이동
|
||||
Previous Brush 이전 브러시
|
||||
Next Brush 다음 브러시
|
||||
First Brush 첫 번째 브러시
|
||||
@@ -3751,3 +3748,4 @@ curves Smooth 매끄럽게
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group}에서 가져온 모양 수: {n}
|
||||
Scrollbar Visibility 스크롤 막대 표시
|
||||
Hidden 숨김
|
||||
Drag to move the selected path 드래그하여 선택한 패스 이동
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -115,6 +115,8 @@
|
||||
|
||||
# Pen draft after a refused commit (#7648)
|
||||
# Art History Brush (#213).
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… Opções de máscara…
|
||||
Layer Mask Display Options… Opções de exibição de máscara de camada…
|
||||
Channel Color Cor do canal
|
||||
@@ -1180,7 +1182,6 @@
|
||||
Dots Pontos
|
||||
Drag a rectangle around the object Arraste um retângulo ao redor do objeto
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito em uma restrição para alterá-la ou excluí-la.
|
||||
Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clique: selecionar ponto · Arrastar: mover ponto, alça ou segmento · {shift}-clique: adicionar · {alt}-clique: subdemarcador inteiro
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Desenhe com a ferramenta Caneta (P) ou crie um demarcador de trabalho a partir de uma seleção.
|
||||
Drop an image or PSD anywhere to open it. Solte uma imagem ou um PSD em qualquer lugar para abri-lo.
|
||||
@@ -1892,11 +1893,9 @@
|
||||
Slice Select Tool Ferramenta Seleção de fatia
|
||||
Slice Tool Ferramenta Fatia
|
||||
Smart Filters Filtros inteligentes
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up Mover para cima
|
||||
Move Down Mover para baixo
|
||||
Delete Smart Filter Excluir filtro inteligente
|
||||
|
||||
Smudge Tool Ferramenta Borrar
|
||||
Soft Suave
|
||||
Soften Suavizar
|
||||
@@ -4232,8 +4231,6 @@ layerLabel No Color Sem cor
|
||||
The fastest and the softest, with colour fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos.
|
||||
“{name}” already exists. Replace it? “{name}” já existe. Substituir?
|
||||
The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”.
|
||||
Move Up Mover para cima
|
||||
Move Down Mover para baixo
|
||||
Previous Brush Pincel anterior
|
||||
Next Brush Próximo pincel
|
||||
First Brush Primeiro pincel
|
||||
@@ -4457,3 +4454,4 @@ layerLabel No Color Sem cor
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Quantidade de formas importadas de {file}: {n}|Quantidade de formas importadas de {file}: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Quantidade de formas importadas de {group}: {n}|Quantidade de formas importadas de {group}: {n}
|
||||
Scrollbar Visibility Visibilidade das barras de rolagem
|
||||
Drag to move the selected path Arraste para mover o demarcador selecionado
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1356,7 +1356,7 @@
|
||||
Current Path Текущий контур
|
||||
Vector Mask from Current Path Векторная маска из текущего контура
|
||||
Create new shape from the current path Создать новую фигуру из текущего контура
|
||||
Drag to move the active shape's path or the Work Path Перетащите, чтобы переместить контур активной фигуры или рабочий контур
|
||||
Drag to move the selected path Перетащите, чтобы переместить выбранный контур
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Щелчок: выбрать узел · Перетаскивание: переместить узел, маркер или сегмент · {shift}-щелчок: добавить · {alt}-щелчок: весь подконтур
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Нарисуйте пером (P) или создайте рабочий контур из выделения.
|
||||
Source: not set ({key}-click with Clone Stamp) Источник: не задан ({key}-клик штампом)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -151,6 +151,8 @@
|
||||
|
||||
# Pen draft after a refused commit (#7648)
|
||||
# Art History Brush (#213).
|
||||
# Smart Filter row actions (#1265).
|
||||
|
||||
Rows Redovi
|
||||
Number Broj
|
||||
Margin Margina
|
||||
@@ -1906,11 +1908,9 @@
|
||||
Slice Select Tool Alatka za izbor isečka
|
||||
Slice Tool Alatka za isecanje na isečke
|
||||
Smart Filters Pametni filteri
|
||||
# Smart Filter row actions (#1265).
|
||||
Move Up Pomeri nagore
|
||||
Move Down Pomeri nadole
|
||||
Delete Smart Filter Obriši pametni filter
|
||||
|
||||
Smudge Tool Alatka za razmazivanje
|
||||
Soft Meko
|
||||
Soften Omekšaj
|
||||
@@ -4492,8 +4492,6 @@ shape Appearance Izgled
|
||||
The fastest and the softest, with colour fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima.
|
||||
“{name}” already exists. Replace it? „{name}“ već postoji. Zameniti?
|
||||
The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“.
|
||||
Move Up Pomeri nagore
|
||||
Move Down Pomeri nadole
|
||||
Previous Brush Prethodna četkica
|
||||
Next Brush Sledeća četkica
|
||||
First Brush Prva četkica
|
||||
@@ -4599,3 +4597,4 @@ shape Appearance Izgled
|
||||
Choose a custom shape file (.csh). Izaberite datoteku prilagođenih oblika (.csh).
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}
|
||||
Drag to move the selected path Prevucite da pomerite izabranu putanju
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4256,3 +4256,4 @@ cameraData Lens Lens
|
||||
@plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} kaynağından içe aktarılan şekil sayısı: {n}
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} kaynağından içe aktarılan şekil sayısı: {n}
|
||||
Scrollbar Visibility Kaydırma çubuklarının görünürlüğü
|
||||
Drag to move the selected path Seçili yolu taşımak için sürükleyin
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1323,7 +1323,7 @@
|
||||
Current Path Поточний контур
|
||||
Vector Mask from Current Path Векторна маска з поточного контуру
|
||||
Create new shape from the current path Створити нову фігуру з поточного контуру
|
||||
Drag to move the active shape's path or the Work Path Перетягніть, щоб перемістити контур активної фігури або робочий контур
|
||||
Drag to move the selected path Перетягніть, щоб перемістити вибраний контур
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Клацання: вибрати вузол · Перетягування: перемістити вузол, ручку або сегмент · {shift}+клацання: додати · {alt}+клацання: увесь підконтур
|
||||
Draw with the Pen tool (P) or make a work path from a selection. Малюйте пером (P) або створіть робочий контур із виділення.
|
||||
Source: not set ({key}-click with Clone Stamp) Джерело: не задано ({key}+клацання клонувальним штампом)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -1039,6 +1039,7 @@
|
||||
Align horizontal centers Căn tâm ngang
|
||||
Align right edges Căn cạnh phải
|
||||
Drag to move the active shape's path or the Work Path Kéo để di chuyển đường dẫn hình đang chọn hoặc Đường dẫn làm việc
|
||||
Drag to move the selected path Kéo để di chuyển đường dẫn đã chọn
|
||||
Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Bấm: góc · Kéo: mượt · Bấm điểm đầu: đóng · {key} xong · Esc hủy
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Bấm: chọn điểm neo · Kéo: di chuyển điểm neo, tay nắm hoặc đoạn · {shift}-bấm: thêm · {alt}-bấm: cả nhánh con
|
||||
Pixel Layer Lớp pixel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -116,6 +116,8 @@
|
||||
# Art History Brush (#213).
|
||||
|
||||
# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic)
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… 蒙版选项…
|
||||
Layer Mask Display Options… 图层蒙版显示选项…
|
||||
Channel Color 通道颜色
|
||||
@@ -1227,7 +1229,6 @@
|
||||
Dots 点
|
||||
Drag a rectangle around the object 围绕对象拖出矩形
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击约束可更改或删除。
|
||||
Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 单击:选择锚点 · 拖动:移动锚点、手柄或线段 · {shift}-单击:添加 · {alt}-单击:整个子路径
|
||||
Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。
|
||||
Drop an image or PSD anywhere to open it. 将图像或 PSD 拖放到任意位置即可打开。
|
||||
@@ -1968,11 +1969,9 @@
|
||||
Slice Select Tool 切片选择工具
|
||||
Slice Tool 切片工具
|
||||
Smart Filters 智能滤镜
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up 上移
|
||||
Move Down 下移
|
||||
Delete Smart Filter 删除智能滤镜
|
||||
|
||||
Smudge Tool 涂抹工具
|
||||
Soft 软
|
||||
Soften 柔化
|
||||
@@ -3099,8 +3098,6 @@ curves Smooth 平滑
|
||||
The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,细节处有色边。
|
||||
“{name}” already exists. Replace it? “{name}”已存在。要替换它吗?
|
||||
The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。
|
||||
Move Up 上移
|
||||
Move Down 下移
|
||||
Previous Brush 上一个画笔
|
||||
Next Brush 下一个画笔
|
||||
First Brush 第一个画笔
|
||||
@@ -3214,3 +3211,4 @@ curves Smooth 平滑
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 从 {group} 导入的形状数量:{n}
|
||||
Scrollbar Visibility 滚动条可见性
|
||||
Hidden 隐藏
|
||||
Drag to move the selected path 拖动以移动选中的路径
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -118,6 +118,8 @@
|
||||
# Art History Brush (#213).
|
||||
|
||||
# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic)
|
||||
# Smart Filter row actions (#1265)
|
||||
|
||||
Mask Options… 遮色片選項…
|
||||
Layer Mask Display Options… 圖層遮色片顯示選項…
|
||||
Channel Color 色版顏色
|
||||
@@ -1210,7 +1212,6 @@
|
||||
Dots 點
|
||||
Drag a rectangle around the object 在物件周圍拖曳出矩形
|
||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;在限制線上按右鍵可變更或刪除。
|
||||
Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑
|
||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 按一下:選取錨點 · 拖曳:移動錨點、把手或線段 · {shift}-按一下:增加 · {alt}-按一下:整個子路徑
|
||||
Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。
|
||||
Drop an image or PSD anywhere to open it. 將影像或 PSD 拖放到任何位置即可開啟。
|
||||
@@ -1938,11 +1939,9 @@
|
||||
Slice Select Tool 切片選取工具
|
||||
Slice Tool 切片工具
|
||||
Smart Filters 智慧型濾鏡
|
||||
# Smart Filter row actions (#1265)
|
||||
Move Up 上移
|
||||
Move Down 下移
|
||||
Delete Smart Filter 刪除智慧型濾鏡
|
||||
|
||||
Smudge Tool 指尖工具
|
||||
Soft 柔邊
|
||||
Soften 柔化
|
||||
@@ -3060,8 +3059,6 @@ curves Smooth 平滑
|
||||
The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,細節處有色邊。
|
||||
“{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎?
|
||||
The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。
|
||||
Move Up 上移
|
||||
Move Down 下移
|
||||
Previous Brush 上一個筆刷
|
||||
Next Brush 下一個筆刷
|
||||
First Brush 第一個筆刷
|
||||
@@ -3170,3 +3167,4 @@ curves Smooth 平滑
|
||||
@plural Imported {n} shape from {group}|Imported {n} shapes from {group} 從 {group} 匯入的形狀數量:{n}
|
||||
Scrollbar Visibility 捲軸可見性
|
||||
Hidden 隱藏
|
||||
Drag to move the selected path 拖曳以移動選取的路徑
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1128,7 +1128,9 @@ fn hand_pointer_over_the_canvas_under_a_dialog() {
|
||||
fn esc_ends_a_pen_path_and_keeps_it() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = crate::state::Tool::Pen;
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(h.state());
|
||||
h.state_mut().ui.pen =
|
||||
Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], owner, ..Default::default() });
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.pen.is_none(), "the Pen leaves drawing state");
|
||||
@@ -1147,7 +1149,8 @@ fn esc_ends_a_pen_path_and_keeps_it() {
|
||||
fn pen_shows_the_close_path_pointer_over_the_first_anchor() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = crate::state::Tool::Pen;
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(h.state());
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], owner, ..Default::default() });
|
||||
let screen = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| {
|
||||
let v = &h.state().ui.views[0];
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
|
||||
@@ -335,6 +335,9 @@ pub struct PhotocraftApp {
|
||||
monitors: monitor_status::State,
|
||||
checker: Option<egui::TextureHandle>,
|
||||
drag: Option<canvas::Drag>,
|
||||
/// An owned vector drag was cancelled (`canvas::cancel_stale_vector_drag`): the rest of its
|
||||
/// pointer events go nowhere.
|
||||
pub(crate) cancelled_vector_drag: bool,
|
||||
/// A Move-tool press landed on a locked layer: the first pointer move shows Photoshop's
|
||||
/// message (`move_lock`), a plain click shows nothing.
|
||||
pub(crate) move_blocked: bool,
|
||||
@@ -456,6 +459,9 @@ pub struct PhotocraftApp {
|
||||
pub(crate) drop_places: std::collections::VecDeque<file_open::DropPlace>,
|
||||
/// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`].
|
||||
view_docs: Vec<DocId>,
|
||||
/// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths
|
||||
/// panel selection never carries over to a same-named path in another tab.
|
||||
path_document: Option<(DocId, u64, std::sync::Weak<Document>)>,
|
||||
pub fps: f32,
|
||||
pub(crate) last_frame_time: f64,
|
||||
thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>,
|
||||
@@ -855,3 +861,6 @@ mod select_and_mask_button_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_path_edit_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pen_path_ownership_tests;
|
||||
|
||||
@@ -611,7 +611,9 @@ fn clicking_the_vector_mask_targets_it_for_the_path_tools() {
|
||||
let k = layer(&h, masked).vector_mask.unwrap().path.subpaths[0].knots[0].anchor;
|
||||
assert_eq!((k.x, k.y), (25.0, 12.0));
|
||||
// The Pen adds a subpath to it.
|
||||
h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(h.state());
|
||||
h.state_mut().ui.pen =
|
||||
Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], owner, ..Default::default() });
|
||||
crate::vector_ui::pen_commit(h.state_mut(), true);
|
||||
assert_eq!(layer(&h, masked).vector_mask.unwrap().path.subpaths.len(), 2);
|
||||
assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "not a work path");
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use egui::Modifiers;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use crate::state::Tool;
|
||||
|
||||
fn app(tool: Tool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.tool = tool;
|
||||
app.ui.extras.snap = false;
|
||||
app
|
||||
}
|
||||
|
||||
fn down(app: &mut PhotocraftApp) {
|
||||
tool_event(app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(app, ToolEvent::Move { x: 50.0, y: 35.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(app, ToolEvent::Move { x: 90.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert!(app.drag.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_and_path_selection_releases_cannot_edit_a_different_document() {
|
||||
for tool in [Tool::FreeformPen, Tool::PathSelection] {
|
||||
let mut app = app(tool);
|
||||
app.run("path.set", json!({"path": {"subpaths": [{"knots": [[20, 20], [50, 35], [90, 80]]}]}})).unwrap();
|
||||
let original = app.session.active().unwrap().doc.work_path.clone();
|
||||
down(&mut app);
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.sync_views();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE);
|
||||
assert!(app.drag.is_none());
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert_eq!(app.session.documents()[0].doc.work_path, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_release_cancels_after_revision_or_target_change() {
|
||||
for change in ["revision", "selected", "mode"] {
|
||||
let mut app = app(Tool::FreeformPen);
|
||||
down(&mut app);
|
||||
match change {
|
||||
"revision" => {
|
||||
app.run("path.set", json!({"path": {"subpaths": [{"knots": [[10, 10], [15, 15]]}]}})).unwrap();
|
||||
}
|
||||
"selected" => app.ui.selected_path = Some("Another path".into()),
|
||||
_ => app.ui.tool_options.vector_mode = "shape".into(),
|
||||
}
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE);
|
||||
assert!(app.drag.is_none());
|
||||
assert_eq!(app.session.active().unwrap().doc.work_path, before.work_path);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers, before.layers);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_valid_release_is_one_undoable_path_edit() {
|
||||
let mut app = app(Tool::FreeformPen);
|
||||
let history = app.session.active().unwrap().history.entries().len();
|
||||
down(&mut app);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE);
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), history + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_cancellation_consumes_old_events_after_switching_tools() {
|
||||
let mut app = app(Tool::FreeformPen);
|
||||
down(&mut app);
|
||||
app.ui.tool = Tool::PolygonLasso;
|
||||
app.ui.polygon = vec![[10.0, 10.0], [20.0, 20.0]];
|
||||
let polygon = app.ui.polygon.clone();
|
||||
app.sync_views();
|
||||
assert!(app.drag.is_none());
|
||||
let modifiers = Modifiers { alt: true, ..Modifiers::NONE };
|
||||
tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 100.0, pressure: 1.0 }, modifiers);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, modifiers);
|
||||
assert_eq!(app.ui.polygon, polygon, "old pointer events must not become a Polygonal Lasso gesture");
|
||||
assert!(!app.cancelled_vector_drag);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Pen drafts and explicit document paths retain their destination until a single commit.
|
||||
|
||||
use egui::Modifiers;
|
||||
use photocraft_doc::{LayerContent, Path};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use crate::state::Tool;
|
||||
use crate::vector_ui;
|
||||
|
||||
fn app(tool: Tool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.ui.tool = tool;
|
||||
app
|
||||
}
|
||||
|
||||
fn steps(app: &PhotocraftApp) -> usize {
|
||||
app.session.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn seed(app: &mut PhotocraftApp, name: &str, y: f64) {
|
||||
app.run("path.set", json!({"name": name, "path": {"subpaths": [{"closed": false, "knots": [[10.0, y], [90.0, y]]}]}})).unwrap();
|
||||
}
|
||||
|
||||
fn path(app: &PhotocraftApp, name: &str) -> Path {
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() }
|
||||
}
|
||||
|
||||
fn click(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE);
|
||||
}
|
||||
|
||||
fn draft(app: &mut PhotocraftApp) {
|
||||
click(app, 30.0, 60.0);
|
||||
click(app, 130.0, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_work_and_saved_paths_append_pen_and_curvature_components_once() {
|
||||
for tool in [Tool::Pen, Tool::CurvaturePen] {
|
||||
for name in ["work", "Saved drawing"] {
|
||||
let mut app = app(tool);
|
||||
seed(&mut app, "work", 20.0);
|
||||
seed(&mut app, "Saved drawing", 40.0);
|
||||
app.ui.selected_path = Some(name.into());
|
||||
let (original, other) = (path(&app, name), path(&app, if name == "work" { "Saved drawing" } else { "work" }));
|
||||
let n = steps(&app);
|
||||
draft(&mut app);
|
||||
assert_eq!(steps(&app), n, "placing anchors is not a history step");
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
let after = path(&app, name);
|
||||
assert_eq!(after.subpaths.len(), 2, "{tool:?}: {name}");
|
||||
assert_eq!(after.subpaths[0], original.subpaths[0]);
|
||||
let anchors: Vec<_> = after.subpaths[1].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||||
assert_eq!(anchors, [[30.0, 60.0], [130.0, 60.0]]);
|
||||
assert_eq!(path(&app, if name == "work" { "Saved drawing" } else { "work" }), other);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some(name));
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, name), original);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, name), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselected_work_path_is_replaced_without_touching_saved_paths() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "work", 20.0);
|
||||
seed(&mut app, "Saved drawing", 40.0);
|
||||
app.ui.selected_path = None;
|
||||
let (before, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app));
|
||||
draft(&mut app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
let after = path(&app, "work");
|
||||
assert_eq!(after.subpaths.len(), 1);
|
||||
assert_eq!(after.subpaths[0].knots[0].anchor.y, 60.0);
|
||||
assert_eq!(path(&app, "Saved drawing"), saved);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, "work"), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_path_and_shape_mode_do_not_follow_later_ui_selection() {
|
||||
for shape in [false, true] {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "work", 20.0);
|
||||
seed(&mut app, "Saved drawing", 40.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
app.ui.tool_options.vector_mode = if shape { "shape" } else { "path" }.into();
|
||||
let (work, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app));
|
||||
click(&mut app, 30.0, 60.0);
|
||||
app.ui.selected_path = Some("work".into());
|
||||
app.ui.tool_options.vector_mode = if shape { "path" } else { "shape" }.into();
|
||||
app.ui.vector_mask_target = true;
|
||||
click(&mut app, 130.0, 60.0);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert_eq!(path(&app, "work"), work);
|
||||
if shape {
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(matches!(st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Shape(_)));
|
||||
assert_eq!(path(&app, "Saved drawing"), saved);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("layer"));
|
||||
} else {
|
||||
assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing"));
|
||||
}
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_pen_paint_preserves_float_colour_and_alpha_after_toolbar_changes() {
|
||||
for closed in [false, true] {
|
||||
let mut app = app(Tool::Pen);
|
||||
app.ui.tool_options.vector_mode = "shape".into();
|
||||
app.ui.tool_options.shape_fill = true;
|
||||
app.ui.tool_options.stroke_width = 3.0;
|
||||
let foreground = [0.12345, 0.34567, 0.56789, 0.375];
|
||||
let background = [0.76543, 0.54321, 0.32109, 0.625];
|
||||
app.session.tools.foreground = foreground;
|
||||
app.session.tools.background = background;
|
||||
click(&mut app, 30.0, 60.0);
|
||||
app.session.tools.foreground = [1.0; 4];
|
||||
app.session.tools.background = [0.0; 4];
|
||||
click(&mut app, 130.0, 60.0);
|
||||
if closed {
|
||||
click(&mut app, 90.0, 130.0);
|
||||
}
|
||||
vector_ui::pen_commit(&mut app, closed);
|
||||
let st = app.session.active().unwrap();
|
||||
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected Pen shape") };
|
||||
let paint = |c: [f32; 4]| photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3]));
|
||||
assert_eq!(shape.fill, closed.then(|| paint(foreground)));
|
||||
assert_eq!(shape.stroke.as_ref().unwrap().paint, paint(if closed { background } else { foreground }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_drafts_are_cancelled_before_tool_switch_or_pointer_input() {
|
||||
for tool in [Tool::Pen, Tool::CurvaturePen] {
|
||||
for change in ["document", "revision", "reopen"] {
|
||||
let mut app = app(tool);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
draft(&mut app);
|
||||
let source = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
match change {
|
||||
"document" => {
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("path.set", json!({"name":"Saved drawing", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap();
|
||||
}
|
||||
"revision" => {
|
||||
app.session.execute("path.set", json!({"name":"work", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap();
|
||||
}
|
||||
"reopen" => {
|
||||
app.session.close(0).unwrap();
|
||||
app.session.add_document((*source).clone(), None);
|
||||
app.session.active_mut().unwrap().revision = revision;
|
||||
}
|
||||
_ => panic!("unknown document-change fixture: {change}"),
|
||||
}
|
||||
let (current, n) = (app.session.active().unwrap().doc.clone(), steps(&app));
|
||||
app.ui.tool = Tool::Brush;
|
||||
vector_ui::finish_pen_if_left(&mut app);
|
||||
assert!(app.ui.pen.is_none(), "{tool:?}: {change}");
|
||||
vector_ui::pen_move(&mut app, 160.0, 80.0, false);
|
||||
vector_ui::curvature_move(&mut app, 160.0, 80.0);
|
||||
vector_ui::pen_up(&mut app);
|
||||
vector_ui::curvature_up(&mut app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, ¤t));
|
||||
assert_eq!(steps(&app), n);
|
||||
assert_eq!(source.paths[0].path.subpaths.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_views_and_input_apis_discard_unowned_or_closed_document_drafts() {
|
||||
for entry in ["sync", "move", "up", "commit", "undo", "toggle", "down"] {
|
||||
let mut app = app(Tool::CurvaturePen);
|
||||
draft(&mut app);
|
||||
app.session.close(0).unwrap();
|
||||
match entry {
|
||||
"sync" => app.sync_views(),
|
||||
"move" => vector_ui::curvature_move(&mut app, 160.0, 80.0),
|
||||
"up" => vector_ui::curvature_up(&mut app),
|
||||
"commit" => {
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
}
|
||||
"undo" => {
|
||||
assert!(vector_ui::pen_undo_last_point(&mut app));
|
||||
}
|
||||
"toggle" => vector_ui::curvature_toggle(&mut app, 30.0, 60.0),
|
||||
"down" => vector_ui::curvature_down(&mut app, 160.0, 80.0),
|
||||
_ => panic!("unknown stale-draft entry fixture: {entry}"),
|
||||
}
|
||||
assert!(app.ui.pen.is_none(), "{entry}");
|
||||
}
|
||||
let mut app = app(Tool::Pen);
|
||||
draft(&mut app);
|
||||
let restored = serde_json::from_value(serde_json::to_value(app.ui.pen.as_ref().unwrap()).unwrap()).unwrap();
|
||||
app.ui.pen = Some(restored);
|
||||
let n = steps(&app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert!(app.ui.pen.is_none());
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
assert_eq!(steps(&app), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_saved_paths_do_not_share_selection_or_drafts_across_tabs() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
let first = path(&app, "Saved drawing");
|
||||
app.run("file.new", json!({"width":200,"height":200})).unwrap();
|
||||
seed(&mut app, "Saved drawing", 90.0);
|
||||
let second = path(&app, "Saved drawing");
|
||||
assert!(app.session.set_active(0));
|
||||
app.sync_views();
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
draft(&mut app);
|
||||
assert!(app.session.set_active(1));
|
||||
app.sync_views();
|
||||
assert!(app.ui.selected_path.is_none());
|
||||
assert!(app.ui.pen.is_none());
|
||||
let n = steps(&app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert_eq!(path(&app, "Saved drawing"), second);
|
||||
assert_eq!(steps(&app), n);
|
||||
// A fresh drawing without explicitly selecting B's saved path creates a work path.
|
||||
draft(&mut app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert_eq!(path(&app, "Saved drawing"), second);
|
||||
assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0);
|
||||
assert!(app.session.set_active(0));
|
||||
app.sync_views();
|
||||
assert!(app.ui.selected_path.is_none());
|
||||
assert_eq!(path(&app, "Saved drawing"), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_same_id_and_revision_do_not_reuse_the_saved_path_selection() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
let original = path(&app, "Saved drawing");
|
||||
draft(&mut app);
|
||||
let closed = app.session.close(0).unwrap();
|
||||
// Recovery can preserve both the document ID and revision before another frame is drawn.
|
||||
app.session.add_document((*closed.doc).clone(), None);
|
||||
app.session.active_mut().unwrap().revision = closed.revision;
|
||||
assert_eq!(app.session.active().unwrap().doc.id, closed.doc.id);
|
||||
app.sync_views();
|
||||
assert!(app.ui.selected_path.is_none());
|
||||
assert!(app.ui.pen.is_none());
|
||||
draft(&mut app);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert_eq!(path(&app, "Saved drawing"), original);
|
||||
assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_path_edits_undo_and_redo_preserve_the_explicit_saved_path_selection() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
let original = path(&app, "Saved drawing");
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
seed(&mut app, "Saved drawing", 90.0);
|
||||
assert!(app.session.active().unwrap().revision > revision);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing"));
|
||||
let changed = path(&app, "Saved drawing");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, "Saved drawing"), original);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing"));
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, "Saved drawing"), changed);
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing"));
|
||||
app.sync_views();
|
||||
assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pen_and_curvature_commits_keep_the_exact_draft_and_do_not_retry_each_frame() {
|
||||
for tool in [Tool::Pen, Tool::CurvaturePen] {
|
||||
let mut app = app(tool);
|
||||
click(&mut app, 30.0, 60.0);
|
||||
click(&mut app, 1e40, 60.0);
|
||||
let (before, original, n) = (app.session.active().unwrap().doc.clone(), app.ui.pen.clone().unwrap(), steps(&app));
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
let failed = app.ui.pen.as_ref().expect("invalid geometry leaves an editable draft");
|
||||
assert_eq!(failed.knots, original.knots);
|
||||
assert_eq!(failed.unlinked, original.unlinked);
|
||||
assert_eq!(failed.owner, original.owner);
|
||||
assert!(failed.commit_failed && app.ui.status_error);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc));
|
||||
assert_eq!(steps(&app), n);
|
||||
app.ui.tool = Tool::Brush;
|
||||
let journal = app.session.journal.len();
|
||||
vector_ui::finish_pen_if_left(&mut app);
|
||||
assert_eq!(app.session.journal.len(), journal, "tool switch must not continually retry a failing commit");
|
||||
assert!(vector_ui::path_to_selection(&mut app, Value::Null).is_err(), "a failed draft cannot select an unrelated path");
|
||||
app.ui.pen.as_mut().unwrap().knots[1] = [[130.0, 60.0]; 3];
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
assert!(app.ui.pen.is_none());
|
||||
assert_eq!(path(&app, "work").subpaths[0].knots[1].anchor.x, 130.0);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_tool_switch_commits_the_owned_draft_once() {
|
||||
for tool in [Tool::Pen, Tool::CurvaturePen] {
|
||||
let mut app = app(tool);
|
||||
draft(&mut app);
|
||||
let n = steps(&app);
|
||||
app.ui.tool = Tool::Brush;
|
||||
vector_ui::finish_pen_if_left(&mut app);
|
||||
vector_ui::finish_pen_if_left(&mut app);
|
||||
assert_eq!(path(&app, "work").subpaths[0].knots.len(), 2);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
assert!(app.ui.pen.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_saved_paths_move_and_free_transform_without_changing_work_or_shape() {
|
||||
for tool in [Tool::PathSelection, Tool::DirectSelection] {
|
||||
let mut app = app(tool);
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [110, 110, 50, 40]})).unwrap();
|
||||
let shape = app.run("shape.info", json!({})).unwrap();
|
||||
seed(&mut app, "work", 20.0);
|
||||
seed(&mut app, "Saved drawing", 40.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app));
|
||||
let (params, selected) = vector_ui::free_transform_path(&app).unwrap();
|
||||
assert_eq!(params, json!({"name":"Saved drawing"}));
|
||||
assert_eq!(selected, original);
|
||||
vector_ui::path_selection_finish(&mut app, [0.0, 0.0], [10.4, -4.6]);
|
||||
let moved = path(&app, "Saved drawing");
|
||||
for (a, b) in original.subpaths[0].knots.iter().zip(&moved.subpaths[0].knots) {
|
||||
assert_eq!(b.anchor.x, a.anchor.x + 10.0);
|
||||
assert_eq!(b.anchor.y, a.anchor.y - 5.0);
|
||||
assert_eq!(b.in_ctrl.x, a.in_ctrl.x + 10.0);
|
||||
assert_eq!(b.out_ctrl.y, a.out_ctrl.y - 5.0);
|
||||
}
|
||||
assert_eq!(path(&app, "work"), work);
|
||||
assert_eq!(app.run("shape.info", json!({})).unwrap(), shape);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, "Saved drawing"), original);
|
||||
let mut params = params;
|
||||
params["matrix"] = json!([1, 0, 0, 1, 7, 9]);
|
||||
app.run("path.transform", params).unwrap();
|
||||
assert_eq!(path(&app, "Saved drawing").subpaths[0].knots[0].anchor.x, 17.0);
|
||||
assert_eq!(path(&app, "work"), work);
|
||||
assert_eq!(app.run("shape.info", json!({})).unwrap(), shape);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app, "Saved drawing"), original);
|
||||
app.ui.selected_path = None;
|
||||
assert_eq!(vector_ui::free_transform_path(&app).unwrap().0["name"], "layer", "deselection restores the shape fallback");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_work_path_precedes_vector_mask_and_deselection_restores_mask_fallback() {
|
||||
let mut app = app(Tool::PathSelection);
|
||||
seed(&mut app, "work", 20.0);
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap();
|
||||
app.ui.vector_mask_target = true;
|
||||
app.ui.selected_path = Some("work".into());
|
||||
assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"work"}));
|
||||
app.ui.selected_path = None;
|
||||
assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"layer","layer":layer.0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_keeps_its_captured_vector_mask_when_another_path_is_selected() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap();
|
||||
app.ui.selected_path = None;
|
||||
app.ui.vector_mask_target = true;
|
||||
let (saved, original, n) = (path(&app, "Saved drawing"), app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap(), steps(&app));
|
||||
click(&mut app, 30.0, 60.0);
|
||||
app.ui.vector_mask_target = false;
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
click(&mut app, 130.0, 60.0);
|
||||
vector_ui::pen_commit(&mut app, false);
|
||||
let mask = app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap();
|
||||
assert_eq!(mask.path.subpaths.len(), 2);
|
||||
assert_eq!(mask.path.subpaths[0], original.path.subpaths[0]);
|
||||
assert_eq!(mask.path.subpaths[1].knots[0].anchor.y, 60.0);
|
||||
assert_eq!(path(&app, "Saved drawing"), saved);
|
||||
assert!(app.session.active().unwrap().doc.work_path.is_none());
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.as_ref(), Some(&original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_finish_uses_its_captured_saved_path_and_rejects_foreign_documents() {
|
||||
let mut app = app(Tool::FreeformPen);
|
||||
seed(&mut app, "work", 20.0);
|
||||
seed(&mut app, "Saved drawing", 40.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
let owner = vector_ui::capture_pen_owner(&app).unwrap();
|
||||
let points = [[30.0, 60.0, 1.0], [80.0, 60.0, 1.0], [130.0, 60.0, 1.0]];
|
||||
let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app));
|
||||
app.ui.selected_path = Some("work".into());
|
||||
app.ui.tool_options.vector_mode = "shape".into();
|
||||
vector_ui::freeform_finish_owned(&mut app, &points, &[], owner);
|
||||
assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2);
|
||||
assert_eq!(path(&app, "Saved drawing").subpaths[0], original.subpaths[0]);
|
||||
assert_eq!(path(&app, "work"), work);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.ui.tool_options.vector_mode = "path".into();
|
||||
let owner = vector_ui::capture_pen_owner(&app).unwrap();
|
||||
app.session.execute("file.new", json!({"width":200,"height":200})).unwrap();
|
||||
let (source, n) = (app.session.active().unwrap().doc.clone(), steps(&app));
|
||||
vector_ui::freeform_finish_owned(&mut app, &points, &[], owner);
|
||||
assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc));
|
||||
assert_eq!(steps(&app), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switch_commits_only_the_valid_original_captured_saved_path() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
draft(&mut app);
|
||||
let source = app.session.active().unwrap().doc.id;
|
||||
app.session.execute("file.new", json!({"width":200,"height":200})).unwrap();
|
||||
let current = app.session.active().unwrap().doc.clone();
|
||||
assert!(vector_ui::cancel_stale_pen(&mut app));
|
||||
assert!(app.ui.pen.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc));
|
||||
let old = app.session.documents().iter().find(|s| s.doc.id == source).unwrap();
|
||||
assert_eq!(old.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path.subpaths.len(), 2);
|
||||
assert!(old.doc.work_path.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn deleting_the_captured_target_cancels_without_recreating_it() {
|
||||
let mut app = app(Tool::Pen);
|
||||
seed(&mut app, "Saved drawing", 20.0);
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
draft(&mut app);
|
||||
app.session.execute("path.delete", json!({"name":"Saved drawing"})).unwrap();
|
||||
let current = app.session.active().unwrap().doc.clone();
|
||||
let n = steps(&app);
|
||||
vector_ui::cancel_stale_pen(&mut app);
|
||||
assert!(app.ui.pen.is_none());
|
||||
assert!(app.ui.status_error);
|
||||
assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc));
|
||||
assert_eq!(steps(&app), n);
|
||||
assert!(app.session.active().unwrap().doc.paths.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Synthetic offscreen evidence for editing a selected saved path beside the Work Path.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::Path;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ViewXform;
|
||||
use crate::state::Tool;
|
||||
|
||||
fn path(app: &PhotocraftApp, name: &str) -> Path {
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() }
|
||||
}
|
||||
|
||||
fn translated(mut path: Path, dx: f64, dy: f64) -> Path {
|
||||
for knot in path.subpaths.iter_mut().flat_map(|s| &mut s.knots) {
|
||||
for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] {
|
||||
point.x += dx;
|
||||
point.y += dy;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// Run explicitly with a wgpu adapter. PHOTOCRAFT_PEN_VISUAL_DIR chooses the output directory;
|
||||
/// PHOTOCRAFT_PEN_VISUAL_BASELINE=1 characterizes the old Work Path targeting for a before capture.
|
||||
#[test]
|
||||
#[ignore = "manual offscreen visual fixture; writes synthetic screenshots"]
|
||||
fn saved_path_drag_visual_fixture() {
|
||||
let baseline = std::env::var("PHOTOCRAFT_PEN_VISUAL_BASELINE").is_ok_and(|v| v == "1");
|
||||
let output = std::env::var_os("PHOTOCRAFT_PEN_VISUAL_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| "log/pen-path-visual".into());
|
||||
std::fs::create_dir_all(&output).expect("create synthetic screenshot directory");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.edit_prefs(|p| p.interface.language = "en".into());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.run("file.new", json!({"name": "Saved path targeting", "width": 400, "height": 260, "background": "white"})).unwrap();
|
||||
app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": true, "knots": [[40, 80], [140, 80], [90, 180]]}]}})).unwrap();
|
||||
app.run(
|
||||
"path.set",
|
||||
json!({"name": "Saved drawing", "path": {"subpaths": [{"closed": false, "knots": [
|
||||
{"anchor": [220, 90], "in": [220, 90], "out": [260, 40]},
|
||||
{"anchor": [320, 180], "in": [280, 230], "out": [320, 180]}
|
||||
]}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
app.sync_views();
|
||||
app.ui.tool = Tool::PathSelection;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.dock_tabs.layers = 2;
|
||||
app.ui.views[0].fit_pending = false;
|
||||
app.ui.views[0].zoom = 1.5;
|
||||
app.ui.views[0].center = [200.0, 130.0];
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Saved drawing").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.selected_path.as_deref(), Some("Saved drawing"), "the Paths panel row selects the saved path");
|
||||
let (work, saved) = (path(h.state(), "work"), path(h.state(), "Saved drawing"));
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let mode = if baseline { "baseline" } else { "fixed" };
|
||||
let before = output.join(format!("{mode}-before.png"));
|
||||
h.render().expect("render initial scene").save(&before).expect("save initial scene");
|
||||
|
||||
// Screen coordinates come from the real full-app layout, including its dock and rulers.
|
||||
let xf = ViewXform::active(h.state()).expect("active document canvas");
|
||||
let start = xf.to_screen(220.0, 90.0);
|
||||
let end = xf.to_screen(245.0, 70.0);
|
||||
assert!(xf.rect.contains(start) && xf.rect.contains(end));
|
||||
h.event(Event::PointerMoved(start));
|
||||
h.event(button(start, true));
|
||||
h.step();
|
||||
h.event(Event::PointerMoved(end));
|
||||
h.step();
|
||||
assert!(h.state().drag.is_some(), "native movement past the drag threshold starts a path gesture");
|
||||
h.event(button(end, false));
|
||||
h.step();
|
||||
h.run_steps(12);
|
||||
assert!(h.state().drag.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one native drag is one Undo step");
|
||||
if baseline {
|
||||
assert_eq!(path(h.state(), "Saved drawing"), saved, "baseline incorrectly leaves the selected saved path untouched");
|
||||
assert_eq!(path(h.state(), "work"), translated(work, 25.0, -20.0), "baseline incorrectly moves the Work Path");
|
||||
} else {
|
||||
assert_eq!(path(h.state(), "work"), work, "editing a saved path preserves the Work Path");
|
||||
assert_eq!(path(h.state(), "Saved drawing"), translated(saved, 25.0, -20.0), "the selected saved path and its curve handles move together");
|
||||
}
|
||||
let after = output.join(format!("{mode}-after.png"));
|
||||
h.render().expect("render completed drag").save(&after).expect("save completed drag");
|
||||
println!("{mode}: selected Saved drawing; native drag (+25, -20); screenshots: {}, {}", before.display(), after.display());
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fn hidden_scrollbars_keep_wheel_scrolling_available() {
|
||||
ui.label(format!("Row {i}"));
|
||||
}
|
||||
});
|
||||
*offset = area.state.offset.y;
|
||||
*offset = area.state.clamped_offset().y;
|
||||
},
|
||||
0.0,
|
||||
);
|
||||
@@ -42,6 +42,7 @@ fn hidden_scrollbars_keep_wheel_scrolling_available() {
|
||||
h.run();
|
||||
h.hover_at(egui::pos2(30.0, 40.0));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -3.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Add Anchor Point and Delete Anchor Point tools (P group with the Pen, #1044), as in Photoshop.
|
||||
//! They edit the paths Direct Selection edits, front first: the targeted vector mask or the active
|
||||
//! shape layer's path, then the work path.
|
||||
//! They edit the paths Direct Selection edits, front first: the work or saved path selected in the
|
||||
//! Paths panel, the targeted vector mask or the active shape layer's path, then the work path.
|
||||
//!
|
||||
//! * Add Anchor Point: hovering a segment highlights it and shows where the anchor would go; a
|
||||
//! click inserts it there (`path.addAnchor`). A curve is split without changing its shape.
|
||||
@@ -38,9 +38,7 @@ pub fn effective(tool: Tool, mods: Modifiers) -> Option<Tool> {
|
||||
/// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete,
|
||||
/// edits nothing.
|
||||
fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> {
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let (r, path, hit) =
|
||||
crate::direct_select::candidates(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?;
|
||||
let (r, path, hit) = crate::direct_select::hit_target(app, p, false)?;
|
||||
match (tool, hit) {
|
||||
(Tool::AddAnchorPoint, Hit::Segment(..)) | (Tool::DeleteAnchorPoint, Hit::Anchor(_)) => Some((r, path, hit)),
|
||||
_ => None,
|
||||
@@ -140,7 +138,7 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) {
|
||||
}
|
||||
match app.run(id, params) {
|
||||
// The anchors shift: a Direct Selection on this path would point at the wrong ones.
|
||||
Ok(_) if app.ui.direct_selection.target == Some(r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() },
|
||||
Ok(_) if app.ui.direct_selection.target.as_ref() == Some(&r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() },
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
|
||||
@@ -133,7 +133,7 @@ fn shape_layer_paths_are_edited_and_a_direct_selection_is_cleared() {
|
||||
let mut app = app(Tool::DirectSelection);
|
||||
let id = app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#3070c0"})).unwrap()["layer"].as_u64().unwrap();
|
||||
click(&mut app, [80.0, 80.0], Modifiers::NONE);
|
||||
assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Layer(id)), vec![[0, 2]]));
|
||||
assert_eq!((app.ui.direct_selection.target.clone(), app.ui.direct_selection.anchors.clone()), (Some(PathRef::Layer(id)), vec![[0, 2]]));
|
||||
app.ui.tool = Tool::AddAnchorPoint;
|
||||
click(&mut app, [50.0, 20.0], Modifiers::NONE);
|
||||
let info = app.run("shape.info", json!({"layer": id})).unwrap();
|
||||
@@ -231,6 +231,32 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() {
|
||||
assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_delete_anchor_tools_edit_the_selected_saved_path() {
|
||||
let mut app = app(Tool::AddAnchorPoint);
|
||||
with_path(&mut app);
|
||||
let before_work = work(&app);
|
||||
let path = photocraft_engine::vector_cmds::path_json(&before_work);
|
||||
app.run("path.set", json!({"name":"Saved", "path":path})).unwrap();
|
||||
app.ui.selected_path = Some("Saved".into());
|
||||
let n = steps(&app);
|
||||
click(&mut app, [80.0, 120.0], Modifiers::NONE);
|
||||
let saved = || app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone();
|
||||
let added = saved();
|
||||
assert_eq!(added.subpaths[0].knots.len(), 5);
|
||||
assert_eq!(work(&app), before_work);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
app.ui.tool = Tool::DeleteAnchorPoint;
|
||||
let anchor = added.subpaths[0].knots[3].anchor;
|
||||
click(&mut app, [anchor.x, anchor.y], Modifiers::NONE);
|
||||
let deleted = app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone();
|
||||
assert_eq!(deleted.subpaths[0].knots.len(), 4);
|
||||
assert_eq!(work(&app), before_work);
|
||||
assert_eq!(steps(&app), n + 2);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path, added);
|
||||
}
|
||||
|
||||
fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> {
|
||||
app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -81,6 +81,94 @@ pub struct DirectSelection {
|
||||
/// adds the anchor it then drags, and both are one step.
|
||||
#[serde(skip)]
|
||||
pub coalesce: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub(crate) owner: Option<Owner>,
|
||||
#[serde(skip)]
|
||||
pub(crate) cancelled_drag: bool,
|
||||
}
|
||||
|
||||
/// Runtime selection ownership is never restored from settings onto another document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Owner {
|
||||
document: photocraft_doc::DocId,
|
||||
source: std::sync::Weak<photocraft_doc::Document>,
|
||||
revision: u64,
|
||||
layer: Option<photocraft_doc::LayerId>,
|
||||
selected_path: Option<String>,
|
||||
vector_mask: bool,
|
||||
tool: Tool,
|
||||
}
|
||||
|
||||
impl PartialEq for Owner {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.source.ptr_eq(&other.source)
|
||||
&& (self.document, self.revision, self.layer, &self.selected_path, self.vector_mask, self.tool)
|
||||
== (other.document, other.revision, other.layer, &other.selected_path, other.vector_mask, other.tool)
|
||||
}
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
fn current(app: &PhotocraftApp) -> Option<Self> {
|
||||
let st = app.session.active()?;
|
||||
Some(Self {
|
||||
document: st.doc.id,
|
||||
source: std::sync::Arc::downgrade(&st.doc),
|
||||
revision: st.revision,
|
||||
layer: st.active_layer,
|
||||
selected_path: app.ui.selected_path.clone(),
|
||||
vector_mask: app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask,
|
||||
tool: app.ui.tool,
|
||||
})
|
||||
}
|
||||
|
||||
fn same_target(&self, app: &PhotocraftApp) -> bool {
|
||||
self.same_path_target(app) && app.ui.tool == self.tool
|
||||
}
|
||||
|
||||
fn same_path_target(&self, app: &PhotocraftApp) -> bool {
|
||||
app.session.active().is_some_and(|st| {
|
||||
st.doc.id == self.document
|
||||
&& st.active_layer == self.layer
|
||||
&& (app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask) == self.vector_mask
|
||||
}) && app.ui.selected_path == self.selected_path
|
||||
}
|
||||
|
||||
fn valid(&self, app: &PhotocraftApp) -> bool {
|
||||
self.valid_path(app) && app.ui.tool == self.tool
|
||||
}
|
||||
|
||||
fn valid_path(&self, app: &PhotocraftApp) -> bool {
|
||||
self.same_path_target(app)
|
||||
&& app.session.active().is_some_and(|st| st.revision == self.revision && self.source.ptr_eq(&std::sync::Arc::downgrade(&st.doc)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `ds` still about the document, revision and target it was made on? Convert Point works on
|
||||
/// the handles selected with Direct Selection, so an idle selection crosses those two tools; a
|
||||
/// drag keeps its own tool.
|
||||
fn still_valid(app: &PhotocraftApp, ds: &DirectSelection) -> bool {
|
||||
ds.owner.as_ref().is_some_and(|owner| owner.valid(app) || (ds.drag.is_none() && owns(owner.tool) && owns(app.ui.tool) && owner.valid_path(app)))
|
||||
}
|
||||
|
||||
/// Clear stale anchors and gestures, keeping the pointer until the old release arrives. True
|
||||
/// when a drag was cancelled.
|
||||
pub(crate) fn cancel_stale(app: &mut PhotocraftApp) -> bool {
|
||||
let ds = &app.ui.direct_selection;
|
||||
let empty = ds.target.is_none() && ds.anchors.is_empty() && ds.segment.is_none() && ds.drag.is_none();
|
||||
if (ds.owner.is_none() && empty) || still_valid(app, ds) {
|
||||
return false;
|
||||
}
|
||||
let cancelled_drag = ds.cancelled_drag || ds.drag.is_some();
|
||||
app.ui.direct_selection = DirectSelection { cancelled_drag, ..Default::default() };
|
||||
cancelled_drag
|
||||
}
|
||||
|
||||
/// Rebind a selection to the revision its own command made; another target or tool drops it.
|
||||
fn keep_after_command(app: &mut PhotocraftApp, mut selection: DirectSelection) {
|
||||
if selection.owner.as_ref().is_some_and(|owner| owner.same_target(app)) {
|
||||
selection.owner = Owner::current(app);
|
||||
app.ui.direct_selection = selection;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -181,13 +269,15 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
|
||||
.or_else(|| crate::vector_ui::active_shape_path(app))
|
||||
.map(|(id, p)| (PathRef::Layer(id), p))
|
||||
.or_else(|| crate::vector_ui::active_type_path(app).map(|(id, p)| (PathRef::TypePath(id), p)));
|
||||
let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app));
|
||||
let saved = crate::vector_ui::selected_saved_path_index(app);
|
||||
layer.into_iter().chain(saved.map(|(i, p)| (PathRef::Saved(i), p))).chain(work.map(|p| (PathRef::Work, p))).collect()
|
||||
let saved = crate::vector_ui::selected_saved_path_index(app).map(|(i, p)| (PathRef::Saved(i), p));
|
||||
let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app)).map(|p| (PathRef::Work, p));
|
||||
let mut out: Vec<_> = saved.into_iter().chain(layer).chain(work).collect();
|
||||
out.retain(|(target, _)| writable(app, target));
|
||||
out
|
||||
}
|
||||
|
||||
/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then
|
||||
/// every visible shape layer.
|
||||
/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, every
|
||||
/// visible shape layer, then the work path.
|
||||
pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
|
||||
let active = app.session.active().map(|st| st.doc.id);
|
||||
let pen = app
|
||||
@@ -209,10 +299,37 @@ pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
|
||||
out.push((PathRef::Layer(l.id.0), sh.path.clone()));
|
||||
}
|
||||
}
|
||||
if !out.iter().any(|(r, _)| *r == PathRef::Work) {
|
||||
out.extend(st.doc.work_path.clone().map(|p| (PathRef::Work, p)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Can `target` be edited: a layer's path only while the layer is not locked.
|
||||
pub(crate) fn writable(app: &PhotocraftApp, target: &PathRef) -> bool {
|
||||
match target {
|
||||
PathRef::Layer(id) | PathRef::TypePath(id) => app.session.active().is_some_and(|st| {
|
||||
let id = photocraft_doc::LayerId(*id);
|
||||
let locks = st.doc.effective_locks(id);
|
||||
st.doc.layer(id).is_some() && !locks.all && !locks.pixels
|
||||
}),
|
||||
_ => app.session.active().is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frontmost editable path under `p` and what is hit on it: the handles shown on a selection
|
||||
/// still valid count when `handles` is set. The hover and the press share it.
|
||||
pub(crate) fn hit_target(app: &PhotocraftApp, p: [f64; 2], handles: bool) -> Option<(PathRef, Path, Hit)> {
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let ds = &app.ui.direct_selection;
|
||||
let valid = handles && still_valid(app, ds);
|
||||
candidates(app).into_iter().filter(|(r, _)| writable(app, r)).find_map(|(r, path)| {
|
||||
let shown = if valid && ds.target.as_ref() == Some(&r) { shown_handles(ds, &path) } else { Vec::new() };
|
||||
edit::hit(&path, Point::new(p[0], p[1]), tol, &shown).map(|h| (r, path, h))
|
||||
})
|
||||
}
|
||||
|
||||
fn retracted(k: &photocraft_doc::Knot, h: Handle) -> bool {
|
||||
let c = if h == Handle::In { k.in_ctrl } else { k.out_ctrl };
|
||||
(c.x - k.anchor.x).abs() < 1e-9 && (c.y - k.anchor.y).abs() < 1e-9
|
||||
@@ -238,6 +355,18 @@ fn shown_handles(ds: &DirectSelection, path: &Path) -> Vec<([usize; 2], Handle)>
|
||||
/// Canvas pointer events for Direct Selection and the Pen's ⌘ / ⌥ modes; false when the event is
|
||||
/// not theirs.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
|
||||
cancel_stale(app);
|
||||
if app.ui.direct_selection.cancelled_drag {
|
||||
// The rest of a gesture cancelled on another document or target goes nowhere.
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => app.ui.direct_selection.cancelled_drag = false,
|
||||
ToolEvent::Up { .. } => {
|
||||
app.ui.direct_selection.cancelled_drag = false;
|
||||
return true;
|
||||
}
|
||||
ToolEvent::Move { .. } => return true,
|
||||
}
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
@@ -255,12 +384,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
// Photoshop. A press on it edits it in place and the Pen carries on (#4022). A
|
||||
// refused commit keeps the draft and the press does nothing more.
|
||||
if !crate::vector_ui::pen_commit(app, false) {
|
||||
app.ui.direct_selection.cancelled_drag = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if tool == Tool::FreeformPen {
|
||||
// The same for a Freeform Pen path waiting for its next ⌥-click.
|
||||
crate::lasso_ui::commit(app);
|
||||
if app.ui.pen.is_some() {
|
||||
app.ui.direct_selection.cancelled_drag = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
press(app, [x, y], mods, convert)
|
||||
}
|
||||
@@ -294,29 +428,21 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
|
||||
/// Does a press at `p` hit the Pen path being drawn: one of its anchors, shown handles or segments?
|
||||
fn hits_pen(app: &PhotocraftApp, p: [f64; 2]) -> bool {
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let ds = &app.ui.direct_selection;
|
||||
candidates(app).into_iter().filter(|(r, _)| *r == PathRef::Pen).any(|(r, path)| {
|
||||
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
|
||||
edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).is_some()
|
||||
})
|
||||
hit_target(app, p, true).is_some_and(|(r, ..)| r == PathRef::Pen)
|
||||
}
|
||||
|
||||
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
|
||||
/// that hit nothing; the Convert Point tool has nothing else to do, so it keeps the press.
|
||||
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
|
||||
let own_tool = app.ui.tool == Tool::ConvertPoint;
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let ds = &app.ui.direct_selection;
|
||||
let found = candidates(app).into_iter().find_map(|(r, path)| {
|
||||
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
|
||||
edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).map(|h| (r, path, h))
|
||||
});
|
||||
let Some((target, base, hit)) = found else {
|
||||
let Some((target, base, hit)) = hit_target(app, p, true) else {
|
||||
if convert {
|
||||
return own_tool;
|
||||
}
|
||||
app.ui.direct_selection.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift });
|
||||
let owner = Owner::current(app);
|
||||
let ds = &mut app.ui.direct_selection;
|
||||
ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift });
|
||||
ds.owner = owner;
|
||||
return true;
|
||||
};
|
||||
if let PathRef::Layer(id) | PathRef::TypePath(id) = target
|
||||
@@ -324,10 +450,12 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
|
||||
{
|
||||
let _ = app.run("layer.select", json!({"layer": id}));
|
||||
}
|
||||
let owner = Owner::current(app);
|
||||
let ds = &mut app.ui.direct_selection;
|
||||
if ds.target != Some(target) {
|
||||
*ds = DirectSelection { target: Some(target), ..Default::default() };
|
||||
if ds.target.as_ref() != Some(&target) {
|
||||
*ds = DirectSelection { target: Some(target.clone()), ..Default::default() };
|
||||
}
|
||||
ds.owner = owner;
|
||||
let edit = match hit {
|
||||
Hit::Handle(k, h) => {
|
||||
let kn = base.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1]));
|
||||
@@ -482,9 +610,18 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s
|
||||
if let (Some(p), Some(key)) = (params.as_object_mut(), app.ui.direct_selection.coalesce.take()) {
|
||||
p.insert("coalesce".into(), json!(key));
|
||||
}
|
||||
if let Err(e) = app.run(id, params) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
// sync_views checks ownership while the command runs: keep this selection off the live UI
|
||||
// until its own command is done, then bind it to the revision that made.
|
||||
let selection = std::mem::take(&mut app.ui.direct_selection);
|
||||
match app.run(id, params) {
|
||||
Ok(_) => keep_after_command(app, selection),
|
||||
Err(e) => {
|
||||
if selection.owner.as_ref().is_some_and(|owner| owner.valid(app)) {
|
||||
app.ui.direct_selection = selection;
|
||||
}
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,9 +630,10 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s
|
||||
fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) {
|
||||
let rect = [start[0], start[1], end[0], end[1]];
|
||||
let hits = candidates(app).into_iter().map(|(r, path)| (r, edit::anchors_in(&path, rect))).find(|(_, a)| !a.is_empty());
|
||||
let owner = Owner::current(app);
|
||||
let ds = &mut app.ui.direct_selection;
|
||||
match hits {
|
||||
Some((r, anchors)) if add && ds.target == Some(r) => {
|
||||
Some((r, anchors)) if add && ds.target.as_ref() == Some(&r) => {
|
||||
for a in anchors {
|
||||
if !ds.anchors.contains(&a) {
|
||||
ds.anchors.push(a);
|
||||
@@ -510,14 +648,16 @@ fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) {
|
||||
ds.segment = None;
|
||||
}
|
||||
}
|
||||
ds.owner = owner;
|
||||
}
|
||||
|
||||
/// Arrow keys with Direct Selection and selected anchors (#4486), as in Photoshop: nudge them
|
||||
/// 1 px (⇧ 10 px) through `path.moveAnchors`, one history step per press. Not while a drag is
|
||||
/// in progress. Returns true when a key was used.
|
||||
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
cancel_stale(app);
|
||||
let ds = &app.ui.direct_selection;
|
||||
let Some(target) = ds.target.filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else {
|
||||
let Some(target) = ds.target.clone().filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
// The anchors belong to a path the canvas no longer offers (another saved path picked in the
|
||||
@@ -539,9 +679,15 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
if let (Some(p), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) {
|
||||
p.extend(t.clone());
|
||||
}
|
||||
if let Err(e) = app.run("path.moveAnchors", params) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
// The nudge makes a new revision: the selection follows it, as after a drag.
|
||||
let selection = std::mem::take(&mut app.ui.direct_selection);
|
||||
match app.run("path.moveAnchors", params) {
|
||||
Ok(_) => keep_after_command(app, selection),
|
||||
Err(e) => {
|
||||
app.ui.direct_selection = selection;
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -569,7 +715,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F
|
||||
_ => path,
|
||||
};
|
||||
crate::vector_ui::draw_outline(painter, &path, to_scr, accent);
|
||||
let ours = ds.target == Some(r);
|
||||
let ours = ds.target.as_ref() == Some(&r);
|
||||
for &(k, h) in if ours { shown_handles(ds, &path) } else { Vec::new() }.iter() {
|
||||
if let Some(kn) = path.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])) {
|
||||
let c = if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl };
|
||||
@@ -590,3 +736,70 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod freeform_command_tests {
|
||||
use super::*;
|
||||
|
||||
/// Is a Direct Selection drag under way on the document and target it started on?
|
||||
fn captured_drag(app: &PhotocraftApp) -> bool {
|
||||
app.ui.direct_selection.drag.is_some() && app.ui.direct_selection.owner.as_ref().is_some_and(|owner| owner.valid(app))
|
||||
}
|
||||
|
||||
fn app(tool: Tool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
for name in ["work", "Saved drawing"] {
|
||||
app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[120,20]]}]}})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
app.ui.tool = tool;
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_with_freeform_and_curvature_edits_the_selected_saved_path_after_modifier_release() {
|
||||
for tool in [Tool::FreeformPen, Tool::CurvaturePen] {
|
||||
let mut app = app(tool);
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let n = app.session.active().unwrap().history.past_len();
|
||||
let command = Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command));
|
||||
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0)));
|
||||
assert!(captured_drag(&app));
|
||||
assert!(pointer(&mut app, ToolEvent::Move { x: 30.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE));
|
||||
assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, Modifiers::NONE));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.work_path, original.work_path);
|
||||
assert_eq!(st.doc.paths[0].path.subpaths[0].knots[0].anchor, Point::new(30.0, 40.0));
|
||||
assert_eq!(st.history.past_len(), n + 1);
|
||||
assert!(app.ui.direct_selection.owner.as_ref().unwrap().valid(&app));
|
||||
assert!(!captured_drag(&app));
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.paths, original.paths);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_cannot_edit_an_existing_path_when_the_freeform_trace_failed_to_finish() {
|
||||
let mut app = app(Tool::FreeformPen);
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let n = app.session.active().unwrap().history.past_len();
|
||||
for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] {
|
||||
assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::ALT));
|
||||
assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Up { x, y }, Modifiers::ALT));
|
||||
}
|
||||
let command = Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command));
|
||||
let pen = app.ui.pen.as_ref().expect("failed Freeform commit must preserve its fitted draft");
|
||||
assert!(pen.commit_failed && pen.owner.is_some());
|
||||
assert!(app.ui.direct_selection.drag.is_none());
|
||||
assert!(app.ui.direct_selection.cancelled_drag);
|
||||
assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, command));
|
||||
assert!(app.ui.direct_selection.drag.is_none());
|
||||
assert_eq!(app.session.active().unwrap().doc.paths, original.paths);
|
||||
assert_eq!(app.session.active().unwrap().doc.work_path, original.work_path);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ fn dragging_an_anchor_moves_it_in_one_undoable_step() {
|
||||
assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0));
|
||||
assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "only the dragged anchor moved");
|
||||
assert_eq!(steps(&app), n + 1, "one history step for the whole drag");
|
||||
assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Work), vec![[0, 3]]));
|
||||
assert_eq!((app.ui.direct_selection.target.clone(), app.ui.direct_selection.anchors.clone()), (Some(PathRef::Work), vec![[0, 3]]));
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(work(&app), before);
|
||||
}
|
||||
@@ -569,3 +569,250 @@ fn a_targeted_vector_mask_on_type_on_a_path_is_edited_not_the_type_path() {
|
||||
assert!((type_path(&app).subpaths[0].knots[1].anchor.x - 160.0).abs() < 1e-6);
|
||||
assert_eq!(layer(&app).vector_mask.unwrap().path.subpaths[0].knots[0].anchor, Point::new(40.0, 30.0));
|
||||
}
|
||||
|
||||
fn save_path(app: &mut PhotocraftApp) {
|
||||
let path = photocraft_engine::vector_cmds::path_json(&work(app));
|
||||
app.run("path.set", json!({"name": "Saved", "path": path})).unwrap();
|
||||
}
|
||||
|
||||
fn saved(app: &PhotocraftApp) -> Path {
|
||||
app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone()
|
||||
}
|
||||
|
||||
/// The arrow keys nudge the anchors of the selected saved path, and the selection follows each
|
||||
/// nudge's new revision; the work path stays.
|
||||
#[test]
|
||||
fn arrow_keys_nudge_the_selected_saved_path() {
|
||||
let mut app = app(Tool::DirectSelection);
|
||||
with_path(&mut app);
|
||||
save_path(&mut app);
|
||||
app.ui.selected_path = Some("Saved".into());
|
||||
click(&mut app, [40.0, 120.0], Modifiers::NONE);
|
||||
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0)));
|
||||
let (work_before, n) = (work(&app), steps(&app));
|
||||
assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE));
|
||||
assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE));
|
||||
assert_eq!(saved(&app).subpaths[0].knots[3].anchor, Point::new(42.0, 120.0));
|
||||
assert_eq!(work(&app), work_before);
|
||||
assert_eq!(steps(&app), n + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_saved_and_work_paths_win_overlapping_shape_hits() {
|
||||
for name in ["Saved", "work"] {
|
||||
let mut app = app(Tool::DirectSelection);
|
||||
let id = app.run("shape.create", json!({"kind":"rect", "rect":[40,40,80,80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
with_path(&mut app);
|
||||
save_path(&mut app);
|
||||
app.ui.selected_path = Some(name.into());
|
||||
let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app));
|
||||
let before_shape = app.run("shape.info", json!({"layer":id})).unwrap()["path"].clone();
|
||||
drag(&mut app, [40.0, 120.0], [55.0, 130.0], Modifiers::NONE);
|
||||
let (actual, unchanged, target) = if name == "Saved" { (saved(&app), work(&app), PathRef::Saved(0)) } else { (work(&app), saved(&app), PathRef::Work) };
|
||||
assert_eq!(actual.subpaths[0].knots[3].anchor, Point::new(55.0, 130.0));
|
||||
assert_eq!(unchanged, if name == "Saved" { before_work.clone() } else { before_saved.clone() });
|
||||
assert_eq!(app.ui.direct_selection.target.as_ref(), Some(&target));
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
assert_eq!(app.run("shape.info", json!({"layer":id})).unwrap()["path"], before_shape);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(work(&app), before_work);
|
||||
assert_eq!(saved(&app), before_saved);
|
||||
app.sync_views();
|
||||
assert!(app.ui.direct_selection.anchors.is_empty(), "undo invalidates runtime anchor ownership");
|
||||
}
|
||||
}
|
||||
|
||||
fn start_command_drag(app: &mut PhotocraftApp) {
|
||||
assert!(super::pointer(app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::COMMAND));
|
||||
assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Edit { .. })));
|
||||
}
|
||||
|
||||
fn consume_stale_drag(app: &mut PhotocraftApp) {
|
||||
assert!(super::pointer(app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::NONE));
|
||||
assert!(super::pointer(app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::NONE));
|
||||
assert!(app.ui.direct_selection.drag.is_none());
|
||||
assert!(app.ui.direct_selection.anchors.is_empty());
|
||||
assert!(!app.ui.direct_selection.cancelled_drag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_cannot_apply_to_another_document_or_a_reopened_identity() {
|
||||
for reopen in [false, true] {
|
||||
let mut app = app(Tool::Pen);
|
||||
with_path(&mut app);
|
||||
let source = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
start_command_drag(&mut app);
|
||||
if reopen {
|
||||
app.session.close(0).unwrap();
|
||||
}
|
||||
app.session.add_document((*source).clone(), None);
|
||||
app.session.active_mut().unwrap().revision = revision;
|
||||
let (before, n) = (work(&app), steps(&app));
|
||||
if !reopen {
|
||||
assert_ne!(source.id, app.session.active().unwrap().doc.id);
|
||||
} else {
|
||||
assert_eq!(source.id, app.session.active().unwrap().doc.id, "a reopened document may preserve its ID");
|
||||
}
|
||||
// The frame hook may cancel before the old pointer event arrives.
|
||||
app.sync_views();
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!(work(&app), before);
|
||||
assert_eq!(steps(&app), n);
|
||||
assert!(app.ui.pen.is_none(), "an old release must not start a new Pen draft");
|
||||
if !reopen {
|
||||
assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), source.work_path.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_revision_cancels_preview_and_preserves_the_new_path() {
|
||||
let mut app = app(Tool::Pen);
|
||||
with_path(&mut app);
|
||||
start_command_drag(&mut app);
|
||||
assert!(super::pointer(&mut app, ToolEvent::Move { x: 50.0, y: 130.0, pressure: 1.0 }, Modifiers::COMMAND));
|
||||
app.session.execute("path.moveAnchors", json!({"name":"work", "anchors":[[0,0]], "move":[-5,7]})).unwrap();
|
||||
let (before, n) = (work(&app), steps(&app));
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!(work(&app), before);
|
||||
assert_eq!(steps(&app), n);
|
||||
assert!(app.ui.pen.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_path_and_tool_changes_cancel_the_owned_edit() {
|
||||
for change in ["layer", "path", "vectorMask", "tool"] {
|
||||
let mut app = app(Tool::Pen);
|
||||
with_path(&mut app);
|
||||
save_path(&mut app);
|
||||
let first = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
let second = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.run("layer.select", json!({"layer":first.0})).unwrap();
|
||||
start_command_drag(&mut app);
|
||||
match change {
|
||||
"layer" => {
|
||||
app.session.execute("layer.select", json!({"layer":second.0})).unwrap();
|
||||
}
|
||||
"path" => app.ui.selected_path = Some("Saved".into()),
|
||||
"vectorMask" => app.ui.vector_mask_target = true,
|
||||
"tool" => app.ui.tool = Tool::Brush,
|
||||
_ => panic!("unknown target-change fixture: {change}"),
|
||||
}
|
||||
let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app));
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!(work(&app), before_work, "{change}");
|
||||
assert_eq!(saved(&app), before_saved, "{change}");
|
||||
assert_eq!(steps(&app), n, "{change}");
|
||||
assert!(app.ui.pen.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_and_changing_marquee_target_discard_old_events() {
|
||||
let mut app = app(Tool::Pen);
|
||||
with_path(&mut app);
|
||||
start_command_drag(&mut app);
|
||||
let closed = app.session.close(0).unwrap();
|
||||
app.sync_views();
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!(closed.doc.work_path.as_ref().unwrap().subpaths[0].knots[3].anchor, Point::new(40.0, 120.0));
|
||||
|
||||
app.session.add_document((*closed.doc).clone(), None);
|
||||
app.sync_views();
|
||||
save_path(&mut app);
|
||||
app.ui.tool = Tool::DirectSelection;
|
||||
assert!(super::pointer(&mut app, ToolEvent::Down { x: 30.0, y: 110.0, pressure: 1.0 }, Modifiers::NONE));
|
||||
assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Marquee { .. })));
|
||||
app.ui.selected_path = Some("Saved".into());
|
||||
let n = steps(&app);
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!(steps(&app), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_anchor_selection_has_no_runtime_ownership_and_is_not_restored() {
|
||||
let mut app = app(Tool::DirectSelection);
|
||||
with_path(&mut app);
|
||||
click(&mut app, [40.0, 120.0], Modifiers::NONE);
|
||||
let serialized = serde_json::to_value(&app.ui.direct_selection).unwrap();
|
||||
assert!(serialized.get("owner").is_none());
|
||||
assert!(serialized.get("drag").is_none());
|
||||
assert!(serialized.get("cancelled_drag").is_none());
|
||||
app.ui.direct_selection = serde_json::from_value(serialized).unwrap();
|
||||
assert!(app.ui.direct_selection.owner.is_none());
|
||||
super::cancel_stale(&mut app);
|
||||
assert!(app.ui.direct_selection.anchors.is_empty());
|
||||
assert!(app.ui.direct_selection.target.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_drag_after_failed_pen_commit_preserves_draft_and_existing_work_path() {
|
||||
let mut app = app(Tool::Pen);
|
||||
with_path(&mut app);
|
||||
app.ui.extras.snap = false;
|
||||
for [x, y] in [[1e40, 0.0], [40.0, 120.0]] {
|
||||
tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut app, ToolEvent::Up { x, y }, Modifiers::NONE);
|
||||
}
|
||||
let before_draft = app.ui.pen.as_ref().unwrap().knots.clone();
|
||||
let (before_work, n, revision) = (work(&app), steps(&app), app.session.active().unwrap().revision);
|
||||
// ⌘ off the draft ends it (a press on it would edit it in place, #4022); here that fails.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::COMMAND);
|
||||
assert!(app.ui.pen.as_ref().unwrap().commit_failed);
|
||||
assert!(app.ui.direct_selection.drag.is_none(), "the press must not go on to edit the old work path");
|
||||
assert!(app.ui.direct_selection.cancelled_drag);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::COMMAND);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::COMMAND);
|
||||
assert_eq!(app.ui.pen.as_ref().unwrap().knots, before_draft);
|
||||
assert_eq!(work(&app), before_work);
|
||||
assert_eq!(steps(&app), n);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert!(app.ui.status_error);
|
||||
assert!(!app.ui.direct_selection.cancelled_drag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_between_selection_and_convert_tools_cancels_an_in_flight_drag() {
|
||||
for (from, to) in [(Tool::DirectSelection, Tool::ConvertPoint), (Tool::ConvertPoint, Tool::DirectSelection)] {
|
||||
let mut app = app(from);
|
||||
with_path(&mut app);
|
||||
let (before, n) = (work(&app), steps(&app));
|
||||
tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert!(app.ui.direct_selection.drag.is_some());
|
||||
app.ui.tool = to;
|
||||
app.sync_views();
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!((work(&app), steps(&app)), (before, n));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_convert_point_tool_edits_selected_saved_paths_without_changing_the_work_path() {
|
||||
let mut app = app(Tool::ConvertPoint);
|
||||
with_path(&mut app);
|
||||
save_path(&mut app);
|
||||
app.ui.selected_path = Some("Saved".into());
|
||||
let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app));
|
||||
click(&mut app, [120.0, 40.0], Modifiers::NONE);
|
||||
assert_eq!(saved(&app).subpaths[0].knots[1], photocraft_doc::Knot::corner(120.0, 40.0));
|
||||
assert_eq!(work(&app), before_work);
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0)));
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(saved(&app), before_saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_channel_vector_mask_target_cancels_an_owned_edit() {
|
||||
let mut app = app(Tool::DirectSelection);
|
||||
with_path(&mut app);
|
||||
tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert!(app.ui.direct_selection.drag.is_some());
|
||||
let (before, n) = (work(&app), steps(&app));
|
||||
app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::VectorMask;
|
||||
assert!(!app.ui.vector_mask_target, "the channel target can change before the UI syncs its mask flag");
|
||||
consume_stale_drag(&mut app);
|
||||
assert_eq!((work(&app), steps(&app)), (before, n));
|
||||
}
|
||||
|
||||
@@ -161,6 +161,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
return true;
|
||||
}
|
||||
modifiers(app, mods);
|
||||
// A fitted path whose commit failed remains the user's draft. Starting another trace
|
||||
// must neither retry it automatically nor replace it on the next release. Releasing Alt
|
||||
// in this press may itself have finished the previous trace into that failed draft.
|
||||
if tool == Tool::FreeformPen && matches!(ev, ToolEvent::Down { .. }) && !active(app) && app.ui.pen.is_some() {
|
||||
return true;
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => {
|
||||
if active(app) {
|
||||
@@ -205,6 +211,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
|
||||
let polygonal = mods.alt && nothing_selected;
|
||||
let intent = if polygonal { Modifiers { alt: false, ..mods } } else { mods };
|
||||
let mut d = Drag::new(tool, p, vec![[p[0], p[1], 1.0]], intent, false);
|
||||
if tool == Tool::FreeformPen {
|
||||
d.capture_vector_owners(app);
|
||||
}
|
||||
let document = app.session.active().map(|st| st.doc.id);
|
||||
d.lasso = Some(Lasso { cursor: p, down: true, polygonal, document, straight: Vec::new() });
|
||||
app.drag = Some(d);
|
||||
|
||||
@@ -170,6 +170,142 @@ fn mouse(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, kind: &str, x: f
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn freeform_harness() -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut h = harness();
|
||||
let app = h.state_mut();
|
||||
for name in ["work", "Saved drawing"] {
|
||||
app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[220,20]]}]}})).unwrap();
|
||||
}
|
||||
app.ui.tool = Tool::FreeformPen;
|
||||
app.ui.tool_options.vector_mode = "path".into();
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
app.ui.extras.snap = false;
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn freeform_run_and_straight_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>) {
|
||||
mouse(h, "down", 50.0, 50.0, Modifiers::NONE);
|
||||
mouse(h, "move", 90.0, 40.0, Modifiers::NONE);
|
||||
mouse(h, "move", 120.0, 50.0, Modifiers::NONE);
|
||||
mouse(h, "up", 120.0, 50.0, Modifiers::ALT);
|
||||
assert!(active(h.state()), "Alt-held release keeps the fitted path in progress");
|
||||
mouse(h, "down", 180.0, 100.0, Modifiers::ALT);
|
||||
mouse(h, "up", 180.0, 100.0, Modifiers::ALT);
|
||||
assert!(active(h.state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_freeform_freehand_and_alt_straight_segments_append_to_the_owned_saved_path_once() {
|
||||
let mut h = freeform_harness();
|
||||
let original = h.state().session.active().unwrap().doc.clone();
|
||||
let n = h.state().session.active().unwrap().history.past_len();
|
||||
freeform_run_and_straight_click(&mut h);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(!active(app) && app.ui.pen.is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.work_path, original.work_path);
|
||||
let path = &st.doc.paths[0].path;
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
assert_eq!(path.subpaths[0], original.paths[0].path.subpaths[0]);
|
||||
let knots = &path.subpaths[1].knots;
|
||||
assert_eq!(knots.last().unwrap().anchor, photocraft_geom::Point::new(180.0, 100.0));
|
||||
assert_eq!(knots.last().unwrap().in_ctrl, knots.last().unwrap().anchor);
|
||||
assert_eq!(knots[knots.len() - 2].out_ctrl, knots[knots.len() - 2].anchor);
|
||||
assert_eq!(st.history.past_len(), n + 1);
|
||||
assert!(h.state_mut().session.undo());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.paths, original.paths);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_freeform_stale_target_source_revision_and_document_cancel_without_finishing_elsewhere() {
|
||||
for change in ["target", "source", "revision", "document"] {
|
||||
let mut h = freeform_harness();
|
||||
freeform_run_and_straight_click(&mut h);
|
||||
let app = h.state_mut();
|
||||
match change {
|
||||
"target" => app.ui.selected_path = Some("work".into()),
|
||||
"source" => {
|
||||
let st = app.session.active_mut().unwrap();
|
||||
st.doc = std::sync::Arc::new((*st.doc).clone());
|
||||
}
|
||||
"revision" => app.session.active_mut().unwrap().revision += 1,
|
||||
"document" => {
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
}
|
||||
_ => panic!("unexpected stale-gesture test case: {change}"),
|
||||
}
|
||||
let source = h.state().session.active().unwrap().doc.clone();
|
||||
let n = h.state().session.active().unwrap().history.past_len();
|
||||
mouse(&mut h, "move", 200.0, 120.0, Modifiers::ALT);
|
||||
mouse(&mut h, "up", 200.0, 120.0, Modifiers::NONE);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
assert!(!active(app) && app.ui.pen.is_none(), "{change}: no stale trace remains");
|
||||
assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc), "{change}: late input changed document pixels or paths");
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n, "{change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_freeform_trace_cannot_replace_a_failed_owned_draft_or_retry_it_automatically() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::FreeformPen;
|
||||
app.ui.extras.snap = false;
|
||||
for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] {
|
||||
event(&mut app, "down", x, y, Modifiers::ALT);
|
||||
event(&mut app, "up", x, y, Modifiers::ALT);
|
||||
}
|
||||
modifiers(&mut app, Modifiers::NONE);
|
||||
let failed = app.ui.pen.clone().expect("invalid fitted trace must remain available");
|
||||
assert!(failed.commit_failed && failed.owner.is_some());
|
||||
assert!(!active(&app));
|
||||
let source = app.session.active().unwrap().doc.clone();
|
||||
let n = app.session.active().unwrap().history.past_len();
|
||||
let journal = app.session.journal.len();
|
||||
for middle in [[1e40, 90.0], [220.0, 90.0]] {
|
||||
for [x, y] in [[160.0, 120.0], middle, [260.0, 140.0]] {
|
||||
event(&mut app, "down", x, y, Modifiers::ALT);
|
||||
event(&mut app, "move", x + 10.0, y + 10.0, Modifiers::ALT);
|
||||
event(&mut app, "up", x, y, Modifiers::ALT);
|
||||
assert!(!active(&app), "pending fitted draft owns the next drawing attempt");
|
||||
}
|
||||
modifiers(&mut app, Modifiers::NONE);
|
||||
assert_eq!(app.ui.pen.as_ref(), Some(&failed));
|
||||
assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n);
|
||||
assert_eq!(app.session.journal.len(), journal, "drawing must not retry the failed engine command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_alt_release_in_the_next_press_keeps_the_failed_fit() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::FreeformPen;
|
||||
for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] {
|
||||
event(&mut app, "down", x, y, Modifiers::ALT);
|
||||
event(&mut app, "up", x, y, Modifiers::ALT);
|
||||
}
|
||||
assert!(active(&app));
|
||||
let source = app.session.active().unwrap().doc.clone();
|
||||
let n = app.session.active().unwrap().history.past_len();
|
||||
// No separate modifier-change event: this press both releases Alt and tries a new trace.
|
||||
event(&mut app, "down", 200.0, 100.0, Modifiers::NONE);
|
||||
let failed = app.ui.pen.clone().expect("failed fit must survive the press that finished it");
|
||||
assert!(failed.commit_failed && failed.owner.is_some());
|
||||
assert!(!active(&app));
|
||||
let journal = app.session.journal.len();
|
||||
event(&mut app, "move", 240.0, 120.0, Modifiers::NONE);
|
||||
event(&mut app, "up", 240.0, 120.0, Modifiers::NONE);
|
||||
assert_eq!(app.ui.pen.as_ref(), Some(&failed));
|
||||
assert_eq!(app.session.journal.len(), journal);
|
||||
assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_canvas_alt_clicks_enter_escape_and_resume() {
|
||||
for finish in ["enter", "escape", "resume", "close"] {
|
||||
|
||||
@@ -77,7 +77,8 @@ fn escape_dismisses_stroke_child_then_parent_without_deselecting_the_document()
|
||||
#[test]
|
||||
fn enter_selects_a_stroke_style_and_escape_closes_its_popups_without_committing_pen() {
|
||||
let mut app = app(Tool::Pen);
|
||||
app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(&app);
|
||||
app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], owner, ..Default::default() });
|
||||
let pen = app.ui.pen.clone();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
|
||||
@@ -89,6 +89,13 @@ pub struct PenPath {
|
||||
/// Curvature Pen: the anchor being dragged.
|
||||
#[serde(skip)]
|
||||
pub moving: Option<usize>,
|
||||
/// The document and target the first anchor was placed on; a draft without one (restored
|
||||
/// from settings) is never committed.
|
||||
#[serde(skip)]
|
||||
pub(crate) owner: Option<PenOwner>,
|
||||
/// The last commit failed: the draft stays as it was and isn't retried on its own.
|
||||
#[serde(skip)]
|
||||
pub(crate) commit_failed: bool,
|
||||
/// Drawing carries on from an endpoint of this open subpath of a document path (#6593): the
|
||||
/// commit replaces that subpath instead of adding one.
|
||||
#[serde(default)]
|
||||
@@ -133,3 +140,131 @@ mod pen_path_ops_tests;
|
||||
mod paint_tests;
|
||||
|
||||
pub(crate) use pen::freeform_locked_shape;
|
||||
|
||||
/// Where a Pen draft goes, captured at its first anchor.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PenOwner {
|
||||
document: DocId,
|
||||
revision: u64,
|
||||
source: Weak<Document>,
|
||||
target: PenTarget,
|
||||
options: ToolOptions,
|
||||
layer: Option<photocraft_doc::LayerId>,
|
||||
vector_mask: bool,
|
||||
foreground: [f32; 4],
|
||||
background: [f32; 4],
|
||||
}
|
||||
|
||||
impl PartialEq for PenOwner {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.document == other.document && self.revision == other.revision && self.source.ptr_eq(&other.source) && self.target == other.target
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum PenTarget {
|
||||
/// A new shape layer, with the paint chosen when drawing began.
|
||||
Shape {
|
||||
fill: Value,
|
||||
stroke: Value,
|
||||
open_stroke: Value,
|
||||
},
|
||||
VectorMask(u64),
|
||||
/// The work path or a saved path; `append` adds the draft as a subpath (#4721).
|
||||
Path {
|
||||
name: String,
|
||||
append: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where the Pen draws now: a new shape layer (Shape mode), else the work or saved path selected
|
||||
/// in the Paths panel (the new path joins it, #4721), else the targeted vector mask, else a new
|
||||
/// Work Path that replaces the old one.
|
||||
pub(crate) fn capture_pen_owner(app: &PhotocraftApp) -> Option<PenOwner> {
|
||||
let st = app.session.active()?;
|
||||
let target = if app.ui.tool_options.vector_mode == "shape" {
|
||||
let mut open_stroke = app.ui.tool_options.shape_stroke.params();
|
||||
open_stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0));
|
||||
open_stroke["color"] = json!(app.session.tools.foreground);
|
||||
PenTarget::Shape { fill: fill_param(app), stroke: stroke_param(app), open_stroke }
|
||||
} else if let Some((name, _)) = selected_document_geometry(app) {
|
||||
PenTarget::Path { name, append: true }
|
||||
} else if let Some(id) = targeted_vector_mask_id(app) {
|
||||
PenTarget::VectorMask(id)
|
||||
} else {
|
||||
PenTarget::Path { name: "work".into(), append: false }
|
||||
};
|
||||
Some(PenOwner {
|
||||
document: st.doc.id,
|
||||
revision: st.revision,
|
||||
source: Arc::downgrade(&st.doc),
|
||||
target,
|
||||
options: app.ui.tool_options.clone(),
|
||||
layer: st.active_layer,
|
||||
vector_mask: app.ui.vector_mask_target,
|
||||
foreground: app.session.tools.foreground,
|
||||
background: app.session.tools.background,
|
||||
})
|
||||
}
|
||||
|
||||
impl PenOwner {
|
||||
fn valid(&self, app: &PhotocraftApp) -> bool {
|
||||
app.session.active().is_some_and(|st| st.doc.id == self.document && st.revision == self.revision && self.source.ptr_eq(&Arc::downgrade(&st.doc)))
|
||||
}
|
||||
}
|
||||
|
||||
use photocraft_doc::DocId;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use path_ops::{selected_document_geometry, targeted_vector_mask_id};
|
||||
pub(crate) use pen::{finish_pen_if_left, freeform_finish_owned};
|
||||
use shapes::{fill_param, stroke_param};
|
||||
|
||||
/// Commit a valid source-owned draft on a tab switch; otherwise cancel with an actionable diagnostic.
|
||||
pub(crate) fn cancel_stale_pen(app: &mut PhotocraftApp) -> bool {
|
||||
let Some(pen) = app.ui.pen.as_ref() else { return false };
|
||||
if pen.owner.as_ref().is_some_and(|o| o.valid(app)) {
|
||||
return false;
|
||||
}
|
||||
if pen.commit_failed {
|
||||
return false;
|
||||
}
|
||||
let owner = pen.owner.clone();
|
||||
if let Some(owner) = owner.as_ref() {
|
||||
let index = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.position(|st| st.doc.id == owner.document && st.revision == owner.revision && owner.source.ptr_eq(&Arc::downgrade(&st.doc)));
|
||||
if let Some(index) = index {
|
||||
let active = app.session.active_index();
|
||||
let saved_ui = app.ui.clone();
|
||||
let draft = app.ui.pen.take();
|
||||
app.session.set_active(index);
|
||||
app.ui.pen = draft;
|
||||
// This source matches the immutable owner; normal commit validation still applies.
|
||||
let result = pen::try_pen_commit(app, false);
|
||||
if let Some(active) = active {
|
||||
app.session.set_active(active);
|
||||
}
|
||||
let failed = app.ui.pen.take();
|
||||
let message = app.ui.status.clone();
|
||||
app.ui = saved_ui;
|
||||
app.ui.pen = if result.is_ok() { None } else { failed };
|
||||
if result.is_err() {
|
||||
app.ui.status = message;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
app.ui.pen_doc = app.ui.pen.as_ref().and_then(|p| p.owner.as_ref().map(|o| o.document));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
app.ui.pen = None;
|
||||
app.ui.pen_doc = None;
|
||||
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
|
||||
app.ui.direct_selection = Default::default();
|
||||
}
|
||||
app.ui.status = "Pen draft cancelled because its source document or path changed; newer work was preserved".into();
|
||||
app.ui.status_error = true;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
if app.ui.pen.is_none() {
|
||||
app.ui.pen_doc = app.session.active().map(|st| st.doc.id);
|
||||
}
|
||||
let owner = capture_pen_owner(app);
|
||||
let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() });
|
||||
if pen.owner.is_none() {
|
||||
pen.owner = owner;
|
||||
}
|
||||
pen.commit_failed = false;
|
||||
if hit.is_none() {
|
||||
pen.knots.push([[x, y]; 3]);
|
||||
}
|
||||
@@ -53,6 +58,7 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
}
|
||||
|
||||
pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
cancel_stale_pen(app);
|
||||
if let Some(pen) = app.ui.pen.as_mut().filter(|p| p.curvature)
|
||||
&& let Some(k) = pen.moving.and_then(|i| pen.knots.get_mut(i))
|
||||
{
|
||||
@@ -62,6 +68,7 @@ pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
}
|
||||
|
||||
pub fn curvature_up(app: &mut PhotocraftApp) {
|
||||
cancel_stale_pen(app);
|
||||
if let Some(pen) = app.ui.pen.as_mut() {
|
||||
pen.moving = None;
|
||||
}
|
||||
@@ -76,6 +83,7 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
crate::direct_select::curvature_toggle_active(app, [x, y]);
|
||||
return;
|
||||
}
|
||||
cancel_stale_pen(app);
|
||||
let Some(i) = curvature_hit(app, x, y) else { return };
|
||||
let Some(pen) = app.ui.pen.as_mut() else { return };
|
||||
if let Some(at) = pen.unlinked.iter().position(|&c| c == i) {
|
||||
@@ -85,3 +93,5 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
}
|
||||
curvature_refresh(pen, false);
|
||||
}
|
||||
|
||||
use super::{cancel_stale_pen, capture_pen_owner};
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
}
|
||||
let o = &mut app.ui.tool_options;
|
||||
if tool == Tool::PathSelection && target.is_none() {
|
||||
lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the active shape's path or the Work Path") });
|
||||
lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the selected path") });
|
||||
return true;
|
||||
}
|
||||
if tool == Tool::DirectSelection && target.is_none() {
|
||||
|
||||
@@ -60,11 +60,9 @@ pub fn with_active_path(app: &PhotocraftApp, id: &str, params: Value) -> Value {
|
||||
pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result<Value, String> {
|
||||
if app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2) {
|
||||
// The finished path becomes the work path, or a shape layer's path or a vector mask.
|
||||
let on_layer = app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some();
|
||||
if !pen_commit(app, false) {
|
||||
return Err(app.ui.status.clone());
|
||||
}
|
||||
app.ui.selected_path = Some(if on_layer { "layer" } else { "work" }.into());
|
||||
}
|
||||
let name = active_path_name(app)
|
||||
.ok_or_else(|| tl!("No path to make a selection from: draw one with the Pen tool or select one in the Paths panel.").to_string())?;
|
||||
@@ -123,13 +121,30 @@ pub(super) enum PathTarget {
|
||||
Work,
|
||||
}
|
||||
|
||||
/// The work or saved path explicitly selected in the Paths panel, by name, and its geometry. Only
|
||||
/// such a selection overrides the active layer's path.
|
||||
pub(super) fn selected_document_geometry(app: &PhotocraftApp) -> Option<(String, &Path)> {
|
||||
let st = app.session.active()?;
|
||||
match app.ui.selected_path.as_deref()? {
|
||||
"work" => st.doc.work_path.as_ref().map(|p| ("work".into(), p)),
|
||||
"layer" => None,
|
||||
name => st.doc.paths.iter().find(|p| p.name == name).map(|p| (p.name.clone(), &p.path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The targeted vector mask of the active layer, if the Layers panel targets it.
|
||||
pub(crate) fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> {
|
||||
let id = targeted_vector_mask_id(app)?;
|
||||
let mask = app.session.active()?.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?;
|
||||
Some((id, mask.path.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn targeted_vector_mask_id(app: &PhotocraftApp) -> Option<u64> {
|
||||
let st = app.session.active()?;
|
||||
let l = st.active_layer.and_then(|id| st.doc.layer(id))?;
|
||||
let targeted = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask;
|
||||
(targeted && !matches!(l.content, LayerContent::Shape(_))).then_some(())?;
|
||||
l.vector_mask.as_ref().map(|m| (l.id.0, m.path.clone()))
|
||||
l.vector_mask.as_ref().map(|_| l.id.0)
|
||||
}
|
||||
|
||||
/// The active type layer's path in document space, if it is type on a path (#9114).
|
||||
@@ -168,6 +183,9 @@ pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool {
|
||||
/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, the
|
||||
/// saved path selected in the Paths panel (#8478), else the work path.
|
||||
pub(super) fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
|
||||
if let Some((name, p)) = selected_document_geometry(app) {
|
||||
return Some((if name == "work" { PathTarget::Work } else { PathTarget::Saved(name) }, p.clone()));
|
||||
}
|
||||
if let Some((id, p)) = targeted_vector_mask(app) {
|
||||
return Some((PathTarget::VectorMask(id), p));
|
||||
}
|
||||
@@ -308,13 +326,13 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64
|
||||
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
|
||||
app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
|
||||
}
|
||||
PathTarget::Saved(name) => {
|
||||
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
|
||||
app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
|
||||
}
|
||||
PathTarget::Work => {
|
||||
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
|
||||
app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)}))
|
||||
}
|
||||
PathTarget::Saved(name) => {
|
||||
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
|
||||
app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]];
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(h.state());
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() });
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::Enter, command());
|
||||
let app = h.state();
|
||||
@@ -80,7 +81,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() {
|
||||
// Plain ↩ still only finishes a path.
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() });
|
||||
let owner = crate::vector_ui::capture_pen_owner(h.state());
|
||||
h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() });
|
||||
h.run_steps(2);
|
||||
press(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().session.active().unwrap().doc.work_path.is_some());
|
||||
|
||||
@@ -52,7 +52,12 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
|
||||
{
|
||||
app.ui.pen = Some(resumed);
|
||||
}
|
||||
let owner = capture_pen_owner(app);
|
||||
let pen = app.ui.pen.get_or_insert_with(PenPath::default);
|
||||
if pen.owner.is_none() {
|
||||
pen.owner = owner;
|
||||
}
|
||||
pen.commit_failed = false;
|
||||
pen.adjusting_in = false;
|
||||
pen.last_pointer = Some([x, y]);
|
||||
pen.reposition = false;
|
||||
@@ -92,6 +97,7 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
|
||||
/// Pen drag: pull out the handles of the anchor just placed. ⇧ (read live, as it is held)
|
||||
/// turns the handle to the nearest 45° direction from its anchor.
|
||||
pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) {
|
||||
cancel_stale_pen(app);
|
||||
if let Some(pen) = app.ui.pen.as_mut()
|
||||
&& pen.dragging
|
||||
{
|
||||
@@ -132,6 +138,9 @@ pub(super) fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] {
|
||||
/// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one
|
||||
/// knot rather than undoing an unrelated, already committed document operation.
|
||||
pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool {
|
||||
if cancel_stale_pen(app) {
|
||||
return true;
|
||||
}
|
||||
if !cancel_stale(app) {
|
||||
return false;
|
||||
}
|
||||
@@ -169,6 +178,7 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool {
|
||||
}
|
||||
|
||||
pub fn pen_up(app: &mut PhotocraftApp) {
|
||||
cancel_stale_pen(app);
|
||||
let jitter = 2.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let Some(pen) = app.ui.pen.as_mut() else { return };
|
||||
pen.dragging = false;
|
||||
@@ -191,6 +201,7 @@ pub fn pen_up(app: &mut PhotocraftApp) {
|
||||
/// ⌥/Alt during a Pen drag breaks the pair of handles being pulled out: from then on only the
|
||||
/// outgoing handle follows the pointer and the incoming curve stays as it is (#3306).
|
||||
pub fn pen_break_handle(app: &mut PhotocraftApp) {
|
||||
cancel_stale_pen(app);
|
||||
let Some(pen) = app.ui.pen.as_mut().filter(|p| p.dragging && !p.adjusting_last) else { return };
|
||||
let Some(i) = pen.knots.len().checked_sub(1) else { return };
|
||||
if !pen.unlinked.contains(&i) {
|
||||
@@ -202,6 +213,7 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) {
|
||||
/// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the
|
||||
/// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both.
|
||||
pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool {
|
||||
cancel_stale_pen(app);
|
||||
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
|
||||
let Some(pen) = app.ui.pen.as_mut() else { return false };
|
||||
let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false };
|
||||
@@ -299,66 +311,49 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve a foreign document's draft as its Work Path before another document can edit it.
|
||||
/// A failed commit retains the draft and blocks the caller until it can be retried.
|
||||
pub fn cancel_stale(app: &mut PhotocraftApp) -> bool {
|
||||
let active = app.session.active().map(|st| st.doc.id);
|
||||
if app.ui.pen.is_none() {
|
||||
app.ui.pen_doc = None;
|
||||
return true;
|
||||
}
|
||||
let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true };
|
||||
if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) {
|
||||
let Some(mut pen) = app.ui.pen.clone() else { return true };
|
||||
if pen.curvature {
|
||||
curvature_refresh(&mut pen, false);
|
||||
}
|
||||
let active = app.session.active_index();
|
||||
app.session.set_active(index);
|
||||
// Bypass shell targets and sync_views: both belong to the newly active document.
|
||||
let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)}));
|
||||
if let Some(index) = active {
|
||||
app.session.set_active(index);
|
||||
}
|
||||
if let Err(e) = result {
|
||||
app.ui.status = e.to_string();
|
||||
app.ui.status_error = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
app.ui.pen = None;
|
||||
app.ui.pen_doc = None;
|
||||
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
|
||||
app.ui.direct_selection = Default::default();
|
||||
}
|
||||
true
|
||||
cancel_stale_pen(app);
|
||||
!app.ui.pen.as_ref().is_some_and(|p| p.commit_failed && p.owner.as_ref().is_some_and(|o| !o.valid(app)))
|
||||
}
|
||||
|
||||
/// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). A commit the
|
||||
/// document refuses (a locked shape layer to add to…) keeps the draft, so nothing drawn is lost
|
||||
/// and it can be committed again; `false` then.
|
||||
pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) -> bool {
|
||||
// The control channel is drained after `sync_views`, so a document switch in this frame has
|
||||
// not been seen yet: never commit the path into the document it was not drawn on.
|
||||
if !cancel_stale(app) {
|
||||
return false;
|
||||
}
|
||||
let Some(draft) = app.ui.pen.take() else { return true };
|
||||
let doc = app.ui.pen_doc.take();
|
||||
let mut pen = draft.clone();
|
||||
if pen.curvature {
|
||||
curvature_refresh(&mut pen, closed);
|
||||
}
|
||||
if !commit_path(app, &pen, closed) {
|
||||
app.ui.pen = Some(draft);
|
||||
app.ui.pen_doc = doc;
|
||||
return false;
|
||||
}
|
||||
try_pen_commit(app, closed).is_ok()
|
||||
}
|
||||
|
||||
/// Finish the draft as [`pen_commit`] does. On failure the draft stays as it was, marked
|
||||
/// `commit_failed`, and the error is shown and returned.
|
||||
pub(super) fn try_pen_commit(app: &mut PhotocraftApp, closed: bool) -> Result<(), String> {
|
||||
cancel_stale_pen(app);
|
||||
let Some(mut pen) = app.ui.pen.take() else { return Ok(()) };
|
||||
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
|
||||
// Its anchors are no longer the Pen's: the committed path is another path.
|
||||
app.ui.direct_selection = Default::default();
|
||||
}
|
||||
true
|
||||
let original = fold_close(&pen)
|
||||
.map(|mut folded| {
|
||||
folded.dragging = false;
|
||||
folded.adjusting_last = false;
|
||||
folded
|
||||
})
|
||||
.unwrap_or_else(|| pen.clone());
|
||||
if pen.curvature {
|
||||
curvature_refresh(&mut pen, closed);
|
||||
}
|
||||
if let Err(e) = commit_path(app, &pen, closed) {
|
||||
app.ui.pen = Some(PenPath { commit_failed: true, ..original });
|
||||
app.ui.status = e.clone();
|
||||
app.ui.status_error = true;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Another tool finishes the Pen's draft, once: a draft whose commit failed waits for the user.
|
||||
pub(crate) fn finish_pen_if_left(app: &mut PhotocraftApp) {
|
||||
cancel_stale_pen(app);
|
||||
if app.ui.pen.as_ref().is_some_and(|p| !p.commit_failed && !p.drawn_with(app.ui.tool)) {
|
||||
pen_commit(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// End the Pen path for good (Esc, or switching to another tool): commit it, and when the
|
||||
@@ -397,7 +392,7 @@ pub fn pen_end(app: &mut PhotocraftApp) {
|
||||
/// layer; in Path mode a subpath of the targeted vector mask, else of the selected path or a new
|
||||
/// work path. An added subpath takes the options bar's Path Operation (#1397). `false` when the
|
||||
/// document refused it, with the reason in the status bar.
|
||||
fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> bool {
|
||||
fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> bool {
|
||||
if pen.knots.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
@@ -684,6 +679,16 @@ pub(crate) fn freeform_locked_shape(app: &PhotocraftApp) -> bool {
|
||||
/// as the Pen does. Releasing on the start point closes the path; a click draws nothing. The
|
||||
/// points at indices `straight` were ⌥-clicked: the segment into each one stays straight.
|
||||
pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize]) {
|
||||
let Some(owner) = capture_pen_owner(app) else { return };
|
||||
freeform_finish_owned(app, points, straight, owner);
|
||||
}
|
||||
|
||||
/// [`freeform_finish`] to the destination captured when the trace began. A trace of another
|
||||
/// document commits nothing; a failed commit keeps the fitted path as a draft.
|
||||
pub(crate) fn freeform_finish_owned(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize], owner: PenOwner) {
|
||||
if !owner.valid(app) {
|
||||
return;
|
||||
}
|
||||
let trace: Vec<[f64; 2]> = points.iter().map(|p| [p[0], p[1]]).collect();
|
||||
let (Some(first), Some(last)) = (trace.first(), trace.last()) else { return };
|
||||
let tol = 6.0 / app.point_zoom().max(0.01) as f64;
|
||||
@@ -698,7 +703,13 @@ pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &
|
||||
} else {
|
||||
fit_with_straight_segments(&trace, straight, fit, closed.then_some(tol))
|
||||
};
|
||||
commit_path(app, &pen.0, pen.1);
|
||||
let (mut pen, closed) = pen;
|
||||
pen.owner = Some(owner);
|
||||
if let Err(e) = commit_path(app, &pen, closed) {
|
||||
app.ui.pen = Some(PenPath { commit_failed: true, ..pen });
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn knot_points(k: &photocraft_doc::Knot) -> [[f64; 2]; 3] {
|
||||
@@ -756,3 +767,37 @@ fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64,
|
||||
corners.dedup();
|
||||
(PenPath { knots, unlinked: corners, ..Default::default() }, closed)
|
||||
}
|
||||
|
||||
use super::{PenOwner, cancel_stale_pen, capture_pen_owner};
|
||||
|
||||
fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(), String> {
|
||||
let Some(owner) = pen.owner.as_ref().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) };
|
||||
let options = std::mem::replace(&mut app.ui.tool_options, owner.options.clone());
|
||||
let vector_mask = std::mem::replace(&mut app.ui.vector_mask_target, owner.vector_mask);
|
||||
app.ui.selected_path = match &owner.target {
|
||||
super::PenTarget::Path { name, append } => append.then(|| name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.active_layer = owner.layer;
|
||||
}
|
||||
let foreground = std::mem::replace(&mut app.session.tools.foreground, owner.foreground);
|
||||
let background = std::mem::replace(&mut app.session.tools.background, owner.background);
|
||||
let channel = app.session.active().map(|st| st.channel_view.target);
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.channel_view.target = if matches!(owner.target, super::PenTarget::VectorMask(_)) {
|
||||
photocraft_engine::channel_cmds::ChannelTarget::VectorMask
|
||||
} else {
|
||||
photocraft_engine::channel_cmds::ChannelTarget::Composite
|
||||
};
|
||||
}
|
||||
let result = commit_path_current(app, pen, closed);
|
||||
if let (Some(st), Some(channel)) = (app.session.active_mut(), channel) {
|
||||
st.channel_view.target = channel;
|
||||
}
|
||||
app.session.tools.foreground = foreground;
|
||||
app.session.tools.background = background;
|
||||
app.ui.tool_options = options;
|
||||
app.ui.vector_mask_target = vector_mask;
|
||||
if result { Ok(()) } else { Err(app.ui.status.clone()) }
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() {
|
||||
a.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(a.run("shape.info", json!({})).unwrap(), info);
|
||||
a.ui.tool_options.vector_mode = "shape".into();
|
||||
a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], ..Default::default() });
|
||||
a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], owner: capture_pen_owner(&a), ..Default::default() });
|
||||
pen_commit(&mut a, false);
|
||||
let open = a.run("shape.info", json!({})).unwrap();
|
||||
assert_eq!(open["stroke"]["dashes"], info["stroke"]["dashes"]);
|
||||
@@ -86,6 +86,44 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_straight_segments_keep_the_captured_saved_path_and_reject_a_foreign_document() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::FreeformPen;
|
||||
let seed = json!({"subpaths":[{"knots":[[20,20],[120,20]]}]});
|
||||
for name in ["work", "Saved drawing"] {
|
||||
app.run("path.set", json!({"name": name, "path": seed})).unwrap();
|
||||
}
|
||||
app.ui.selected_path = Some("Saved drawing".into());
|
||||
let owner = capture_pen_owner(&app).unwrap();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let n = app.session.active().unwrap().history.past_len();
|
||||
let points = [[20.0, 60.0, 1.0], [60.0, 60.0, 1.0], [100.0, 80.0, 1.0], [140.0, 80.0, 1.0]];
|
||||
app.ui.selected_path = Some("work".into());
|
||||
app.ui.tool_options.vector_mode = "shape".into();
|
||||
freeform_finish_owned(&mut app, &points, &[1, 3], owner);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.work_path, original.work_path);
|
||||
let saved = &st.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path;
|
||||
assert_eq!(saved.subpaths[0], original.paths[0].path.subpaths[0]);
|
||||
assert_eq!(saved.subpaths.len(), 2);
|
||||
let knots = &saved.subpaths[1].knots;
|
||||
assert_eq!(knots.len(), 4);
|
||||
for (a, b) in [(0, 1), (2, 3)] {
|
||||
assert_eq!(knots[a].out_ctrl, knots[a].anchor, "straight segment starts at its anchor");
|
||||
assert_eq!(knots[b].in_ctrl, knots[b].anchor, "straight segment ends at its anchor");
|
||||
}
|
||||
assert_eq!(st.history.past_len(), n + 1);
|
||||
assert!(app.ui.pen.is_none());
|
||||
app.ui.tool_options.vector_mode = "path".into();
|
||||
let owner = capture_pen_owner(&app).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len());
|
||||
freeform_finish_owned(&mut app, &points, &[1, 3], owner);
|
||||
assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n);
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
@@ -121,6 +159,24 @@ fn paths_context_menu_uses_the_clicked_row_and_dispatches_commands() {
|
||||
assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_freeform_straight_segment_commit_keeps_its_owned_fitted_draft() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::FreeformPen;
|
||||
let owner = capture_pen_owner(&app).unwrap();
|
||||
let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len());
|
||||
let points = [[20.0, 60.0, 1.0], [1e40, 60.0, 1.0], [140.0, 80.0, 1.0]];
|
||||
freeform_finish_owned(&mut app, &points, &[1, 2], owner.clone());
|
||||
let pen = app.ui.pen.as_ref().expect("invalid fitted geometry must remain available after its failed commit");
|
||||
assert!(pen.commit_failed);
|
||||
assert_eq!(pen.owner.as_ref(), Some(&owner));
|
||||
assert!(owner.valid(&app));
|
||||
assert_eq!(pen.knots.len(), 3);
|
||||
assert_eq!(pen.knots[1], [[1e40, 60.0]; 3]);
|
||||
assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_path_context_save_and_layer_path_deletion_rules() {
|
||||
let mut app = app();
|
||||
@@ -329,7 +385,7 @@ fn show_target_path_and_extras_hide_the_path_outlines() {
|
||||
}
|
||||
// The Pen's path in progress is a gesture, not the target path: it stays visible.
|
||||
app.ui.tool = Tool::Pen;
|
||||
app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() });
|
||||
app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() });
|
||||
toggle(&mut app, "view.show.targetPath");
|
||||
assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn");
|
||||
}
|
||||
@@ -1118,7 +1174,8 @@ fn pen_last_anchor_hit_tolerance_tracks_zoom_and_draft_round_trips() {
|
||||
assert_eq!(pen.knots[1][2], pen.knots[1][0]);
|
||||
let saved = serde_json::to_value(pen).unwrap();
|
||||
let restored: PenPath = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored, *pen);
|
||||
// The runtime owner isn't saved: a restored draft belongs to no document.
|
||||
assert_eq!(restored, PenPath { owner: None, ..pen.clone() });
|
||||
assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false);
|
||||
pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0, false);
|
||||
assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3);
|
||||
|
||||
@@ -73,28 +73,51 @@ fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) {
|
||||
/// Validate before interactive rendering: finite coordinates and a bounded tessellation and
|
||||
/// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels.
|
||||
pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> {
|
||||
validate_workload(path, stroke, 1_000_000.0, "shape coordinates must be finite and within ±1000000 px")
|
||||
}
|
||||
|
||||
fn validate_workload(
|
||||
path: &Path,
|
||||
stroke: Option<&photocraft_doc::ShapeStroke>,
|
||||
coordinate_limit: f64,
|
||||
coordinate_error: &'static str,
|
||||
) -> Result<(), &'static str> {
|
||||
let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?;
|
||||
if knots > 4096 || path.subpaths.len() > 4096 {
|
||||
return Err("shape path exceeds 4096 knots/subpaths");
|
||||
return Err("path exceeds 4096 knots/subpaths");
|
||||
}
|
||||
let mut points = 0usize;
|
||||
let mut length = 0.0;
|
||||
for sub in &path.subpaths {
|
||||
for k in &sub.knots {
|
||||
for p in [k.anchor, k.in_ctrl, k.out_ctrl] {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 {
|
||||
return Err("shape coordinates must be finite and within ±1000000 px");
|
||||
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > coordinate_limit || p.y.abs() > coordinate_limit {
|
||||
return Err(coordinate_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
points = points.saturating_add(1);
|
||||
for seg in sub.segments() {
|
||||
let mut segment_length = 0.0;
|
||||
for pair in seg.windows(2) {
|
||||
let (dx, dy) = (pair[1].x - pair[0].x, pair[1].y - pair[0].y);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err("path geometry arithmetic must be finite");
|
||||
}
|
||||
segment_length += dx.hypot(dy);
|
||||
if !segment_length.is_finite() {
|
||||
return Err("path geometry arithmetic must be finite");
|
||||
}
|
||||
}
|
||||
length += segment_length;
|
||||
if !length.is_finite() {
|
||||
return Err("path geometry arithmetic must be finite");
|
||||
}
|
||||
points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) });
|
||||
length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::<f64>();
|
||||
}
|
||||
}
|
||||
if points > 100_000 {
|
||||
return Err("shape tessellation exceeds 100000 points");
|
||||
return Err("path tessellation exceeds 100000 points");
|
||||
}
|
||||
if let Some(s) = stroke {
|
||||
s.validate()?;
|
||||
@@ -109,6 +132,12 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Work and saved paths may extend far beyond the canvas. Keep their coordinates usable by the
|
||||
/// f32 UI while bounding the complete path's flattening workload before publishing an edit.
|
||||
pub fn validate_path(path: &Path) -> Result<(), &'static str> {
|
||||
validate_workload(path, None, f64::from(f32::MAX), "path coordinates must be finite and representable as f32")
|
||||
}
|
||||
|
||||
/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks
|
||||
/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile
|
||||
/// curves cannot exhaust memory.
|
||||
@@ -203,6 +232,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_paths_bound_total_knots_subpaths_and_combined_tessellation() {
|
||||
let line = Subpath::polygon(&[(0.0, 0.0)]);
|
||||
let mut paths = Path { subpaths: vec![line.clone(); 4096], ..Default::default() };
|
||||
assert!(validate_path(&paths).is_ok());
|
||||
paths.subpaths.push(line);
|
||||
assert_eq!(validate_path(&paths), Err("path exceeds 4096 knots/subpaths"));
|
||||
let knots = Path { subpaths: vec![Subpath::polygon(&vec![(0.0, 0.0); 4097])], ..Default::default() };
|
||||
assert_eq!(validate_path(&knots), Err("path exceeds 4096 knots/subpaths"));
|
||||
|
||||
let curve = shapes::ellipse(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0);
|
||||
let points = 1 + curve.segments().iter().map(|s| if is_line(s) { 1 } else { pieces(s, crate::DEFAULT_TOLERANCE) }).sum::<usize>();
|
||||
let half = 100_000 / points / 2 + 1;
|
||||
let mut combined = Path { subpaths: vec![curve.clone(); half], ..Default::default() };
|
||||
assert!(validate_path(&combined).is_ok());
|
||||
assert!(validate_shape(&combined, None).is_ok());
|
||||
combined.subpaths.extend(vec![curve; half]);
|
||||
assert!(combined.subpaths.len() < 4096 && combined.subpaths.iter().map(|s| s.knots.len()).sum::<usize>() < 4096);
|
||||
assert_eq!(validate_path(&combined), Err("path tessellation exceeds 100000 points"));
|
||||
assert_eq!(validate_shape(&combined, None), Err("path tessellation exceeds 100000 points"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_paths_reject_coordinates_that_overflow_the_ui_or_segment_arithmetic() {
|
||||
for extent in [f64::from(f32::MAX) * 2.0, 1.0e308, f64::INFINITY, f64::NAN] {
|
||||
let path = Path { subpaths: vec![Subpath::polygon(&[(-extent, 0.0), (extent, 0.0)])], ..Default::default() };
|
||||
assert_eq!(validate_path(&path), Err("path coordinates must be finite and representable as f32"));
|
||||
}
|
||||
// The shared arithmetic guard also rejects overflow independently of coordinate policy.
|
||||
let overflowing_difference = Path { subpaths: vec![Subpath::polygon(&[(-1.0e308, 0.0), (1.0e308, 0.0)])], ..Default::default() };
|
||||
assert_eq!(validate_workload(&overflowing_difference, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite"));
|
||||
let overflowing_length = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (f64::MAX, f64::MAX)])], ..Default::default() };
|
||||
assert_eq!(validate_workload(&overflowing_length, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_paths_keep_large_straight_coordinates_and_shape_limits_stay_put() {
|
||||
let at_shape_limit = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_000.0, 0.0)])], ..Default::default() };
|
||||
assert!(validate_shape(&at_shape_limit, None).is_ok());
|
||||
let outside_shape = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_001.0, 0.0)])], ..Default::default() };
|
||||
assert_eq!(validate_shape(&outside_shape, None), Err("shape coordinates must be finite and within ±1000000 px"));
|
||||
assert!(validate_path(&outside_shape).is_ok());
|
||||
for extent in [3.0e9, f64::from(f32::MAX)] {
|
||||
let path = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (extent, 0.0), (extent, extent)])], ..Default::default() };
|
||||
assert!(validate_path(&path).is_ok());
|
||||
let line = flatten_subpath(&path.subpaths[0], crate::DEFAULT_TOLERANCE);
|
||||
assert_eq!(line.pts, [(0.0, 0.0), (extent, 0.0), (extent, extent)]);
|
||||
assert!(line.pts.iter().all(|(x, y)| (*x as f32).is_finite() && (*y as f32).is_finite()));
|
||||
assert!(validate_shape(&path, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_curves_flatten_within_the_point_budget() {
|
||||
// Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far
|
||||
|
||||
Reference in New Issue
Block a user