mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 18:52:15 +01:00
Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2
This commit is contained in:
+102
-8
@@ -348,19 +348,83 @@ fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f3
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn motion(src: &Image, out: Rect, ctx: &Ctx, angle: f32, distance: f32) -> Vec<f32> {
|
||||
/// Sample offsets of a motion blur of `distance` at `angle`, relative to the pixel centre.
|
||||
fn motion_offsets(angle: f32, distance: f32) -> Vec<(f32, f32)> {
|
||||
let d = distance.abs();
|
||||
if d < 0.5 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
let steps = d.ceil() as i32;
|
||||
average_samples(src, out, ctx, |x, y, pts| {
|
||||
for i in 0..=steps {
|
||||
(0..=steps)
|
||||
.map(|i| {
|
||||
let t = i as f32 / steps as f32 - 0.5;
|
||||
pts.push((x + c * d * t, y - s * d * t));
|
||||
(c * d * t, -s * d * t)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Motion blur: the mean of bilinear samples along a line centred on the pixel. The offsets are the
|
||||
/// same for every pixel, so their bilinear taps are merged once into a fixed kernel, which is
|
||||
/// applied tap by tap to whole rows of a premultiplied window (the same premultiplied mean the
|
||||
/// per-sample loop takes, summed in another order).
|
||||
pub(crate) fn motion(src: &Image, out: Rect, ctx: &Ctx, angle: f32, distance: f32) -> Vec<f32> {
|
||||
if distance.abs() < 0.5 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let offs = motion_offsets(angle, distance);
|
||||
let k = 1.0 / offs.len() as f32;
|
||||
// Merged taps (dx, dy, weight), in the order first seen.
|
||||
let mut taps: Vec<(i32, i32, f32)> = Vec::new();
|
||||
let mut slot = std::collections::HashMap::new();
|
||||
for &(ox, oy) in &offs {
|
||||
let (x0, y0) = (ox.floor(), oy.floor());
|
||||
let (ax, ay) = (ox - x0, oy - y0);
|
||||
let (x0, y0) = (x0 as i32, y0 as i32);
|
||||
for (dx, dy, w) in [(0, 0, (1.0 - ax) * (1.0 - ay)), (1, 0, ax * (1.0 - ay)), (0, 1, (1.0 - ax) * ay), (1, 1, ax * ay)] {
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let i = *slot.entry((x0 + dx, y0 + dy)).or_insert_with(|| {
|
||||
taps.push((x0 + dx, y0 + dy, 0.0));
|
||||
taps.len() - 1
|
||||
});
|
||||
taps[i].2 += w * k;
|
||||
}
|
||||
})
|
||||
}
|
||||
let reach = taps.iter().map(|t| t.0.abs().max(t.1.abs())).max().unwrap_or(0);
|
||||
let n = src.ch;
|
||||
let win = out.inflate(reach);
|
||||
let ww = win.width() as usize;
|
||||
// Premultiplied window; outside the source reads as transparent, as the sampler does.
|
||||
let mut pw = vec![0.0f32; ww * win.height() as usize * n];
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let i = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
|
||||
let a = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 };
|
||||
for c in 0..n {
|
||||
let v = src.get(x, y, c);
|
||||
pw[i + c] = if ctx.alpha && c < n - 1 { v * a } else { v };
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
let r = reach as usize;
|
||||
for (oy, row) in res.chunks_exact_mut(ow * n).enumerate() {
|
||||
for &(dx, dy, w) in &taps {
|
||||
let start = ((oy + r).wrapping_add_signed(dy as isize) * ww + r.wrapping_add_signed(dx as isize)) * n;
|
||||
for (o, v) in row.iter_mut().zip(&pw[start..start + ow * n]) {
|
||||
*o += w * v;
|
||||
}
|
||||
}
|
||||
if ctx.alpha {
|
||||
for px in row.chunks_exact_mut(n) {
|
||||
let a = px[n - 1];
|
||||
for v in px.iter_mut().take(n - 1) {
|
||||
*v = if a > 1e-7 { *v / a } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: RadialMethod, center: (f32, f32)) -> Vec<f32> {
|
||||
@@ -702,4 +766,34 @@ mod tests {
|
||||
img.data[i] = 0.5 / 255.0;
|
||||
assert!(surface_8bit(&img, out, &ctx, 4, 0.1, 26).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_kernel_matches_the_per_pixel_samples() {
|
||||
// Noisy RGBA with some transparent pixels; the output reaches the source edge.
|
||||
let src_rect = Rect::new(-30, -30, 50, 40);
|
||||
let mut img = Image::new(src_rect, 4);
|
||||
let mut s = 0x1b87_3593u32;
|
||||
for px in img.data.as_chunks_mut::<4>().0 {
|
||||
for v in px.iter_mut() {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
*v = (s % 1000) as f32 / 999.0;
|
||||
}
|
||||
if s.is_multiple_of(5) {
|
||||
px[3] = 0.0;
|
||||
}
|
||||
}
|
||||
let out = Rect::new(-12, -6, 30, 22);
|
||||
for alpha in [true, false] {
|
||||
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha };
|
||||
for (angle, distance) in [(0.0, 1.0), (0.0, 9.0), (30.0, 7.0), (90.0, 12.0), (-45.0, 20.0), (137.0, 33.5), (200.0, 40.0)] {
|
||||
let fast = motion(&img, out, &ctx, angle, distance);
|
||||
let offs = motion_offsets(angle, distance);
|
||||
let slow = average_samples(&img, out, &ctx, |x, y, pts| pts.extend(offs.iter().map(|&(dx, dy)| (x + dx, y + dy))));
|
||||
let err = slow.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(err < 1e-5, "alpha {alpha} angle {angle} distance {distance}: max error {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,13 +164,16 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<(
|
||||
}
|
||||
}
|
||||
(VarKind::TextReplacement, VarValue::Text(text)) => {
|
||||
let snapshot = doc.clone();
|
||||
if let Some(l) = doc.layer_mut(def.layer)
|
||||
&& let LayerContent::Text(t) = &mut l.content
|
||||
{
|
||||
t.text = text.clone();
|
||||
t.cache = None; // force re-render
|
||||
t.runs.clear(); // re-flow as one run from the summary style
|
||||
t.paragraphs.clear();
|
||||
// The compositor draws a type layer from its cache: clearing it left the
|
||||
// layer blank until some other edit re-rendered it (#990).
|
||||
crate::type_cmds::refresh(&snapshot, t);
|
||||
}
|
||||
}
|
||||
(VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => {
|
||||
|
||||
@@ -190,3 +190,25 @@ fn pixel_replacement_changes_the_layer() {
|
||||
assert!(b > r, "replacement is blue-ish: {rgba:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_text_is_rendered_in_the_document_and_in_exports() {
|
||||
// Applying a text value cleared the type layer's render cache and nothing rebuilt it, so the
|
||||
// text vanished from the canvas and from every exported file (#990).
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap();
|
||||
let id = s.execute("type.create", json!({"x": 4, "y": 40, "text": "HELLO", "size": 32, "color": "#000000"})).unwrap()["layer"].clone();
|
||||
let dark = |d: &Document| photocraft_compose::render(d, d.bounds()).px.iter().filter(|p| p[0] < 0.5).count();
|
||||
assert!(dark(doc(&s)) > 0);
|
||||
s.execute("image.variables.define", json!({"defs": [{"name": "headline", "layer": id, "type": "textReplacement"}]})).unwrap();
|
||||
s.execute("image.variables.dataSets", json!({"dataSets": [{"name": "row1", "values": [{"variable": "headline", "kind": "text", "value": "BYE"}]}]}))
|
||||
.unwrap();
|
||||
let dir = tmp("text-export");
|
||||
let out = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "format": "png"})).unwrap();
|
||||
let path = out["files"][0].as_str().unwrap();
|
||||
let exported = photocraft_io::import(path, &std::fs::read(path).unwrap()).unwrap().document;
|
||||
assert!(dark(&exported) > 0, "the exported row keeps its text");
|
||||
s.execute("image.applyDataSet", json!({"name": "row1"})).unwrap();
|
||||
assert!(dark(doc(&s)) > 0, "the applied text is drawn");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,11 @@ impl Ex {
|
||||
|
||||
fn encode(&self, id: i16, plane: &[u8], w: usize, h: usize) -> ChannelData {
|
||||
let depth = psd_depth(self.fmt.sample);
|
||||
ChannelData::encode(id, self.compression(), plane, w, h, depth, self.version)
|
||||
// Photoshop writes empty layer/mask channels as just the raw compression code.
|
||||
// A ZIP stream for a zero-sized channel makes readers try to decompress an
|
||||
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
|
||||
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
|
||||
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
|
||||
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
|
||||
.unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
mod common;
|
||||
use common::Features;
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_io::{ExportOptions, export};
|
||||
use photocraft_psd::{Compression, PsdFile};
|
||||
|
||||
#[test]
|
||||
fn empty_layer_and_mask_channels_use_raw_without_a_payload() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for ext in ["out.psd", "out.psb"] {
|
||||
let mut doc = common::gen_doc(mode, depth, Features::ALL);
|
||||
let mut masked = common::raster("Uniform mask", doc.pixel_format(), photocraft_geom::Rect::new(0, 0, 3, 3), 1, true);
|
||||
masked.mask = Some(photocraft_doc::LayerMask {
|
||||
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, depth, false)),
|
||||
enabled: true,
|
||||
linked: true,
|
||||
density: 1.0,
|
||||
feather: 0.0,
|
||||
});
|
||||
doc.layers.push(masked);
|
||||
let bytes = export(&doc, ext, &ExportOptions::default()).unwrap().bytes;
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let mut empty = 0;
|
||||
let mut empty_masks = 0;
|
||||
for layer in file.layers() {
|
||||
for channel in &layer.channels {
|
||||
let rect = layer.channel_rect(channel.id);
|
||||
if rect.width() == 0 || rect.height() == 0 {
|
||||
empty += 1;
|
||||
if channel.id == -2 {
|
||||
empty_masks += 1;
|
||||
}
|
||||
assert_eq!(channel.compression, Some(Compression::Raw), "{mode:?} {depth:?} {ext}: {} channel {}", layer.name(), channel.id);
|
||||
assert!(channel.data.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(empty > 0);
|
||||
assert!(empty_masks > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-2
@@ -214,11 +214,31 @@ impl LayerMask {
|
||||
|
||||
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
|
||||
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share.
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share. When
|
||||
/// both orders fit the record's length, Photoshop's is taken unless its reading is
|
||||
/// implausible (a real-mask background that is neither 0 nor 255, or parameter flag bits
|
||||
/// the spec does not define) while the spec's reading with a real mask is sound.
|
||||
pub(crate) fn real_before_parameters(d: &[u8]) -> bool {
|
||||
// Parameters at `pf_at`, then 0..=3 bytes of padding, and nothing else.
|
||||
let fits =
|
||||
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
|
||||
!fits(18) && fits(36)
|
||||
if fits(18) {
|
||||
return false;
|
||||
}
|
||||
let photoshop = fits(36);
|
||||
if !photoshop {
|
||||
return false;
|
||||
}
|
||||
let known_flags = |pf: u8| pf & 0xF0 == 0;
|
||||
let real_background_ok = |at: usize| d.get(at + 1).is_some_and(|&bg| bg == 0 || bg == 255);
|
||||
// Photoshop's reading: real-mask fields at 18, parameters at 36.
|
||||
let photoshop_plausible = real_background_ok(18) && d.get(36).is_some_and(|&pf| known_flags(pf));
|
||||
// The spec's reading: parameters at 18, then the 18 real-mask bytes, then padding.
|
||||
let spec_plausible = d.get(18).is_some_and(|&pf| {
|
||||
let real_at = 19 + mask_parameter_bytes(pf);
|
||||
known_flags(pf) && (real_at + 18..=real_at + 21).contains(&d.len()) && real_background_ok(real_at)
|
||||
});
|
||||
photoshop_plausible || !spec_plausible
|
||||
}
|
||||
|
||||
fn parse(d: &[u8]) -> Result<Self> {
|
||||
@@ -845,6 +865,45 @@ mod tests {
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
|
||||
/// A density-only record in the spec's order (#949): the parameter flags byte at 18 says one
|
||||
/// value byte, so the spec's reading excludes the 38-byte length only if the real mask is
|
||||
/// ignored, while byte 36 (inside the real rectangle's `right`) is 0 and makes Photoshop's
|
||||
/// reading fit too. Photoshop's reading puts the density (64) where a 0-or-255 background
|
||||
/// belongs, so the spec's order wins and the density survives.
|
||||
#[test]
|
||||
fn density_only_spec_layout_is_not_read_as_photoshop_order() {
|
||||
for (density, right) in [(64u8, 1u32), (200, 1), (64, 300), (1, 7)] {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(0, 0, 1, 1).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS]);
|
||||
d.extend([0b0001, density]);
|
||||
d.extend([0, 0]);
|
||||
Rect::from_xywh(0, 0, right, 1).write(&mut d);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert!(!m.real_first, "density {density}, right {right}: {m:?}");
|
||||
assert_eq!(m.parameters.unwrap().user_density, Some(density));
|
||||
assert_eq!(m.real, Some(RealMask { flags: 0, background: 0, rect: Rect::from_xywh(0, 0, right, 1) }));
|
||||
assert!(m.trailing.is_empty());
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
// Photoshop's order with the same lengths still reads as Photoshop's order.
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(0, 0, 1, 1).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS]);
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 1, 1).write(&mut d);
|
||||
d.extend([0b0001, 64]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert!(m.real_first, "{m:?}");
|
||||
assert_eq!(m.parameters.unwrap().user_density, Some(64));
|
||||
assert_eq!(m.real.map(|r| r.background), Some(255));
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
|
||||
/// The spec's order (parameters, then real fields) still parses and stays byte-stable.
|
||||
#[test]
|
||||
fn real_mask_after_parameters_spec_layout() {
|
||||
|
||||
@@ -285,3 +285,30 @@ fn save_and_open() {
|
||||
assert_eq!(g, f);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A density-only mask record written in the spec's order (parameters, then the real-mask
|
||||
/// fields) used to be read back in Photoshop's order, which moved the density into the real
|
||||
/// mask's background and dropped it from the parameters (#949). The model must survive the
|
||||
/// round trip, not only the bytes.
|
||||
#[test]
|
||||
fn spec_order_density_only_mask_round_trips_as_a_model() {
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
let layer = &mut f.layers_mut()[0];
|
||||
layer.mask = MaskData::Mask(LayerMask {
|
||||
rect: Rect::from_xywh(0, 0, 1, 1),
|
||||
default_color: 255,
|
||||
flags: LayerMask::FLAG_PARAMETERS,
|
||||
parameters: Some(MaskParameters { flags: 1, user_density: Some(64), ..Default::default() }),
|
||||
real: Some(RealMask { flags: 0, background: 0, rect: Rect::from_xywh(0, 0, 1, 1) }),
|
||||
trailing: vec![],
|
||||
real_first: false,
|
||||
});
|
||||
// The generated 2 x 2 mask channel is replaced by one that matches the 1 x 1 rectangle.
|
||||
let mask_channel = layer.channels.iter_mut().find(|c| c.id == -2).expect("the generated layer has a mask channel");
|
||||
*mask_channel = ChannelData::encode(-2, Compression::Raw, &[64], 1, 1, 8, Version::Psd).unwrap();
|
||||
let b = f.to_bytes().unwrap();
|
||||
let g = PsdFile::from_bytes(&b).unwrap();
|
||||
assert_eq!(g, f, "model round trip");
|
||||
assert_eq!(g.layers()[0].decode_channel(-2, 8, Version::Psd).unwrap(), vec![64]);
|
||||
assert_eq!(g.to_bytes().unwrap(), b, "byte stability");
|
||||
}
|
||||
|
||||
@@ -1817,12 +1817,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
{
|
||||
crate::canvas_tool_menu::open_transform(app, [p.x, p.y]);
|
||||
}
|
||||
let lasso_retracted = tool == Tool::Lasso && response.secondary_clicked() && crate::lasso_ui::undo_last_vertex(app);
|
||||
if tool == Tool::Lasso {
|
||||
crate::lasso_ui::canvas_input(app, &ctx, &xf, &response);
|
||||
(buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false);
|
||||
}
|
||||
// Right-click with the Move tool, or ⌘/Ctrl+right-click: the layers under the pointer.
|
||||
if response.secondary_clicked()
|
||||
&& !lasso_retracted
|
||||
&& !transforming
|
||||
&& crate::layer_pick_ui::is_gesture(tool, mods)
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
@@ -1832,6 +1834,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::layer_pick_ui::open(app, [p.x, p.y], d[0], d[1]);
|
||||
}
|
||||
if response.secondary_clicked()
|
||||
&& !lasso_retracted
|
||||
&& !transforming
|
||||
&& !crate::layer_pick_ui::is_gesture(tool, mods)
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
|
||||
@@ -397,8 +397,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
None => err("no such dialog"),
|
||||
},
|
||||
"ui.window.open" => {
|
||||
if let Some(d) = u("document") {
|
||||
app.session.set_active(d as usize);
|
||||
if let Some(d) = u("document")
|
||||
&& !app.session.set_active(d as usize)
|
||||
{
|
||||
return err(format!("no document {d}"));
|
||||
}
|
||||
wrap(crate::menus::invoke(app, ctx, "window.newWindowForDocument", json!({})))
|
||||
}
|
||||
@@ -805,6 +807,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_window_open_rejects_an_unknown_document_index() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
// An out-of-range index must error instead of silently switching the active document
|
||||
// and opening the window for whatever is active now.
|
||||
let before = app.session.active_index();
|
||||
let r = call(&mut app, &ctx, "ui.window.open", json!({"document": 99}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().unwrap().contains("no document 99"), "{r}");
|
||||
assert_eq!(app.session.active_index(), before, "the active document is untouched");
|
||||
// A valid index still opens the window.
|
||||
assert_eq!(call(&mut app, &ctx, "ui.window.open", json!({"document": 0}))["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_brush_size_dispatches_a_journaled_brush_command() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -324,7 +324,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
app.filter_preview = None;
|
||||
let cmd = d.fields.get("__command").and_then(|v| v.as_str().map(str::to_string)).ok_or("dialog has no command")?;
|
||||
let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields));
|
||||
app.run(&cmd, params)
|
||||
let result = app.run(&cmd, params.clone());
|
||||
if result.is_ok() && cmd == "view.newGuideLayout" {
|
||||
app.ui.view.guide_layout = params;
|
||||
}
|
||||
result
|
||||
}
|
||||
DialogKind::LayerStyle => crate::layer_style::confirm(app, &d.fields),
|
||||
DialogKind::About | DialogKind::Error => Ok(Value::Null),
|
||||
|
||||
@@ -20,6 +20,29 @@ pub fn active(app: &PhotocraftApp) -> bool {
|
||||
app.drag.as_ref().is_some_and(|d| d.tool == Tool::Lasso && d.lasso.is_some())
|
||||
}
|
||||
|
||||
/// Between polygonal clicks, Backspace/right-click retracts the last fixed vertex.
|
||||
/// Do not affect a freehand stroke whose primary button is still held.
|
||||
pub fn waiting_for_vertex(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool == Tool::Lasso
|
||||
&& app
|
||||
.drag
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.tool == Tool::Lasso && d.lasso.as_ref().is_some_and(|l| !l.down && l.document == app.session.active().map(|st| st.doc.id)))
|
||||
}
|
||||
|
||||
pub fn undo_last_vertex(app: &mut PhotocraftApp) -> bool {
|
||||
cancel_stale(app);
|
||||
if !waiting_for_vertex(app) {
|
||||
return false;
|
||||
}
|
||||
if app.drag.as_ref().is_some_and(|d| d.points.len() <= 1) {
|
||||
app.drag = None;
|
||||
} else if let Some(d) = app.drag.as_mut() {
|
||||
d.points.pop();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A lasso drag that moves the selection (started inside it, `canvas::selection_drag_kind`):
|
||||
/// the canvas's own selection-move path handles it, this module only feeds it the events.
|
||||
fn moving_selection(app: &PhotocraftApp) -> bool {
|
||||
|
||||
@@ -199,3 +199,54 @@ fn a_drag_inside_the_selection_moves_it() {
|
||||
assert!(h.state().drag.is_none());
|
||||
assert_eq!(bounds(h.state()), Some(photocraft_geom::Rect::new(110, 105, 160, 145)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retracting_polygonal_vertices_keeps_the_document_unchanged() {
|
||||
let mut app = app();
|
||||
begin(&mut app, Modifiers::NONE);
|
||||
event(&mut app, "up", 120.0, 50.0, Modifiers::ALT);
|
||||
assert!(waiting_for_vertex(&app));
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
assert_eq!(app.drag.as_ref().unwrap().points.len(), 3);
|
||||
assert!(undo_last_vertex(&mut app));
|
||||
assert_eq!(app.drag.as_ref().unwrap().points.len(), 2);
|
||||
assert!(undo_last_vertex(&mut app));
|
||||
assert_eq!(app.drag.as_ref().unwrap().points.len(), 1);
|
||||
assert!(undo_last_vertex(&mut app));
|
||||
assert!(!active(&app));
|
||||
assert!(!undo_last_vertex(&mut app));
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.doc.layer(layer).is_some());
|
||||
assert!(st.doc.selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_canvas_backspace_and_right_click_retract_polygonal_points() {
|
||||
let mut h = harness();
|
||||
mouse(&mut h, "down", 50.0, 50.0, Modifiers::NONE);
|
||||
mouse(&mut h, "move", 120.0, 50.0, Modifiers::NONE);
|
||||
mouse(&mut h, "up", 120.0, 50.0, Modifiers::ALT);
|
||||
mouse(&mut h, "down", 180.0, 100.0, Modifiers::ALT);
|
||||
mouse(&mut h, "up", 180.0, 100.0, Modifiers::ALT);
|
||||
assert_eq!(h.state().drag.as_ref().unwrap().points.len(), 3);
|
||||
let layer = h.state().session.active().unwrap().active_layer.unwrap();
|
||||
|
||||
h.event(Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().drag.as_ref().unwrap().points.len(), 2);
|
||||
assert!(h.state().session.active().unwrap().doc.layer(layer).is_some());
|
||||
|
||||
let state = h.state();
|
||||
let view = &state.ui.views[0];
|
||||
let xf = ViewXform { rect: crate::rulers::content_rect(state, state.last_canvas_rect), zoom: view.zoom, center: view.center, flip: false };
|
||||
let pos = xf.to_screen(155.0, 100.0);
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::ALT });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().drag.as_ref().unwrap().points.len(), 1);
|
||||
assert!(h.state().ui.canvas_tool_menu.is_none());
|
||||
assert!(h.state().session.active().unwrap().doc.layer(layer).is_some());
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_none());
|
||||
}
|
||||
|
||||
@@ -296,6 +296,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::magnetic_lasso_ui::keys(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// While placing polygonal lasso vertices, Backspace/Delete retracts the last point;
|
||||
// it must win over the regular Edit › Clear shortcut, which edits document pixels.
|
||||
if crate::lasso_ui::waiting_for_vertex(app) {
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
if !mods.command && !mods.ctrl && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) {
|
||||
crate::lasso_ui::undo_last_vertex(app);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Inline type editing eats text and navigation keys; ⌘-shortcuts still reach the menus.
|
||||
let editing = crate::type_tool::handle_keys(app, ctx);
|
||||
// Registry, UI and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]).
|
||||
|
||||
@@ -94,6 +94,12 @@ pub struct ViewOptions {
|
||||
/// and the Middle Eastern & South Asian composer.
|
||||
pub language_features: String,
|
||||
pub middle_eastern_composer: bool,
|
||||
/// Parameters from the last successfully applied New Guide Layout dialog.
|
||||
pub guide_layout: Value,
|
||||
}
|
||||
|
||||
fn default_guide_layout() -> Value {
|
||||
json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false})
|
||||
}
|
||||
|
||||
impl Default for ViewOptions {
|
||||
@@ -112,6 +118,7 @@ impl Default for ViewOptions {
|
||||
font_preview_size: "medium".into(),
|
||||
language_features: "defaultFeatures".into(),
|
||||
middle_eastern_composer: false,
|
||||
guide_layout: default_guide_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,7 +853,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
|
||||
json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]}),
|
||||
),
|
||||
"view.newGuideLayout" => {
|
||||
dialog(app, json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}), json!({}))
|
||||
let fields = app.ui.view.guide_layout.clone();
|
||||
dialog(app, fields, json!({}))
|
||||
}
|
||||
"type.warpText" => {
|
||||
let styles: Vec<&str> = std::iter::once("none").chain(photocraft_text::warp::STYLES.iter().map(|(_, s)| *s)).collect();
|
||||
|
||||
@@ -172,3 +172,43 @@ fn engine_commands_open_their_dialogs() {
|
||||
crate::menus::invoke_unguarded(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
|
||||
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guide_layout_remembers_successful_values_not_cancelled_edits() {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
let first = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["columns"], 8);
|
||||
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["gutter"], 20);
|
||||
for (key, value) in [("columns", json!(2)), ("gutter", json!(0)), ("rows", json!(2)), ("rowGutter", json!(0))] {
|
||||
app.ui.dialog_mut(first).unwrap().fields.insert(key.into(), value);
|
||||
}
|
||||
crate::dialogs::confirm(&mut app, first).unwrap();
|
||||
|
||||
let second = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
let fields = &app.ui.dialog_mut(second).unwrap().fields;
|
||||
assert_eq!(fields["columns"], 2);
|
||||
assert_eq!(fields["gutter"], 0);
|
||||
assert_eq!(fields["rows"], 2);
|
||||
assert_eq!(fields["rowGutter"], 0);
|
||||
assert_eq!(fields["clearExisting"], false);
|
||||
// A cancelled edit must not replace the last successfully used values.
|
||||
app.ui.dialog_mut(second).unwrap().fields.insert("columns".into(), json!(9));
|
||||
app.ui.close_dialog(second);
|
||||
let third = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(third).unwrap().fields["columns"], 2);
|
||||
|
||||
// The remembered state is part of the serializable UI state.
|
||||
let json_state = serde_json::to_value(&app.ui).unwrap();
|
||||
assert_eq!(json_state["view"]["guide_layout"]["columns"], 2);
|
||||
let restored: crate::state::UiState = serde_json::from_value(json_state).unwrap();
|
||||
assert_eq!(restored.view.guide_layout["rows"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guide_layout_does_not_remember_a_failed_command() {
|
||||
let (mut app, _) = app_with(0);
|
||||
let id = front(&mut app, "view.newGuideLayout", &json!({})).unwrap().unwrap()["dialog"].as_u64().unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(3));
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(app.ui.view.guide_layout["columns"], 8);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user