Layer Style dialog previews effects live on the canvas (#30)

The canvas only showed effects after OK. While the dialog is open, the
canvas now renders the document with the dialog's style applied: the same
commands OK runs, replayed on a scratch session (no history), cached by
document revision + style fields so switching pages doesn't re-render.
Cancel drops the preview. Works on both GPU and CPU canvas paths
(display_doc). Tests cover the preview, re-render on edit, and cancel.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sommerperandClaude Opus 5.5 authored and GitHub committed 2026-10-04 10:13:36 -04:00
1 parent 8d21ca349d
commit 159c11feb4
3 files changed
+82 -4

No files matched your search

+37 -1
View File
@@ -144,7 +144,7 @@ fn downsample(buf: &photocraft_compose::Buffer, factor: u32) -> photocraft_compo
}
/// The document to render: the committed one, or a clone with the live adjustment preview applied.
fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
let st = &app.session.documents()[idx];
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
@@ -156,6 +156,23 @@ fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u6
{
return (pv.doc.clone(), (1 << 40) + pv.session);
}
// Layer Style dialog: show its effects live (Cancel just drops the preview).
let style = app.ui.dialogs.iter().find(|d| d.kind == crate::state::DialogKind::LayerStyle);
if style.is_none() {
app.style_preview = None;
}
if let Some(d) = style
&& app.session.active_index() == Some(idx)
{
let key = (crate::layer_style::preview_hash(&d.fields) ^ st.revision.wrapping_mul(0x9e37_79b9_7f4a_7c15)) | 1 << 63;
if app.style_preview.as_ref().map(|p| p.0) != Some(key) {
let shown = crate::layer_style::preview_document(&st.doc, &app.session.patterns, &d.fields).map(std::sync::Arc::new);
app.style_preview = Some((key, shown));
}
if let Some((_, Some(doc))) = &app.style_preview {
return (doc.clone(), key);
}
}
if let Some((layer, params)) = &app.live_adjust
&& let Some(l) = st.doc.layer(*layer)
&& let LayerContent::Adjustment(a) = &l.content
@@ -1508,6 +1525,25 @@ mod tests {
}
}
#[test]
fn layer_style_dialog_previews_live_and_cancel_restores() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
let fx = |d: &Document| d.layers.iter().map(|l| l.effects.items.len()).sum::<usize>();
let id = crate::layer_style::open(&mut app, Some("colorOverlay")).unwrap();
let (shown, key) = display_doc(&mut app, 0);
assert_eq!((fx(&shown), fx(&app.session.documents()[0].doc)), (1, 0), "previewed, not committed");
app.ui.dialog_mut(id).unwrap().fields.insert("on:stroke".into(), json!(true));
let (shown, key2) = display_doc(&mut app, 0);
assert_eq!(fx(&shown), 2);
assert_ne!(key, key2, "an edit re-renders the canvas");
app.ui.close_dialog(id);
let (shown, key) = display_doc(&mut app, 0);
assert_eq!((fx(&shown), key), (0, 0));
assert!(app.style_preview.is_none());
}
#[test]
fn zoom_steps_monotone() {
assert_eq!(zoom_step(1.0, 1), 2.0);
+42 -3
View File
@@ -215,23 +215,45 @@ pub fn pattern_list(app: &PhotocraftApp) -> Value {
/// Apply the dialog: replace the layer's effects with the enabled ones.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
apply(f, |id, p| app.run(id, p))
}
/// Runs the dialog's commands through `run`: blending options, clear, then each enabled effect.
fn apply(f: &Map<String, Value>, mut run: impl FnMut(&str, Value) -> Result<Value, String>) -> Result<Value, String> {
let layer = f.get("layer").cloned().unwrap_or(Value::Null);
if let Some(Value::Object(bo)) = f.get(&format!("p:{BLENDING}")) {
let mut p = Value::Object(bo.clone());
p["layer"] = layer.clone();
app.run("layer.layerStyle.blendingOptions", p)?;
run("layer.layerStyle.blendingOptions", p)?;
}
let _ = app.run("layer.layerStyle.clear", json!({"layer": layer}));
let _ = run("layer.layerStyle.clear", json!({"layer": layer}));
for &(kind, _) in KINDS {
if f.get(&format!("on:{kind}")).and_then(Value::as_bool) == Some(true) {
let mut p = f.get(&format!("p:{kind}")).cloned().unwrap_or_else(|| json!({}));
p["layer"] = layer.clone();
app.run(&format!("layer.layerStyle.{kind}"), p)?;
run(&format!("layer.layerStyle.{kind}"), p)?;
}
}
Ok(Value::Null)
}
/// Hash of the fields that change the rendered style (not the selected page).
pub fn preview_hash(f: &Map<String, Value>) -> u64 {
f.iter()
.filter(|(k, _)| k.as_str() == "layer" || k.starts_with("on:") || k.starts_with("p:"))
.flat_map(|(k, v)| k.bytes().chain(v.to_string().into_bytes()))
.fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3))
}
/// `doc` with the dialog's style applied, run on a scratch session (no history) for the live preview.
pub fn preview_document(doc: &photocraft_doc::Document, patterns: &photocraft_engine::pattern_cmds::PatternLibrary, f: &Map<String, Value>) -> Option<photocraft_doc::Document> {
let mut s = photocraft_engine::Session::new();
s.patterns = patterns.clone();
s.add_document(doc.clone(), None);
apply(f, |id, p| s.execute(id, p).map_err(|e| e.to_string())).ok()?;
s.active().map(|d| (*d.doc).clone())
}
/// Dialog body (left list, right parameters).
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
@@ -394,6 +416,23 @@ mod tests {
assert_eq!(f["on:dropShadow"], false);
}
#[test]
fn preview_applies_the_style_without_touching_the_document() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let st = s.active().unwrap();
let mut f = initial_fields(st.doc.layer(st.active_layer.unwrap()).unwrap(), Some("colorOverlay"));
let h = preview_hash(&f);
f.insert("selected".into(), json!("stroke"));
assert_eq!(preview_hash(&f), h, "switching pages doesn't re-render");
f.insert("on:stroke".into(), json!(true));
assert_ne!(preview_hash(&f), h);
let shown = preview_document(&st.doc, &s.patterns, &f).unwrap();
let fx = |d: &photocraft_doc::Document| d.layer(st.active_layer.unwrap()).unwrap().effects.items.len();
assert_eq!((fx(&shown), fx(&st.doc)), (2, 0));
}
#[test]
fn percent_fields_round_trip_through_the_engine() {
let mut s = photocraft_engine::Session::new();
+3
View File
@@ -191,6 +191,8 @@ pub struct PhotocraftApp {
pub(crate) doc_hist: Option<(DocId, u64, f64, std::sync::Arc<tone::Histograms>)>,
/// Free Transform preview (document without the moving pixels + their texture).
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
/// Live Layer Style dialog preview: (key over revision + style fields, document with the style applied).
pub(crate) style_preview: Option<(u64, Option<std::sync::Arc<Document>>)>,
/// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui).
pub(crate) distort: distort_ui::Distort,
/// Filter › Camera Raw Filter dialog (camera_raw_ui).
@@ -259,6 +261,7 @@ impl PhotocraftApp {
info_sample: None,
os_clip_sig: None,
transform_preview: None,
style_preview: None,
distort: Default::default(),
camera_raw: None,
wide_angle: None,