From e398cc80b8c909fb5183fb6b879191a258410997 Mon Sep 17 00:00:00 2001 From: Shayan Horbank <63400439+shorbank@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:03:51 +0200 Subject: [PATCH] Use shared Color Picker for Layer Style and text styles (#2015) (#2049) * Use shared Color Picker for Layer Style colors (#2015) * Use shared Color Picker for text style colors (#2015) --------- Co-authored-by: Brandon Thomas --- crates/engine/src/type_styles_cmds.rs | 58 ++-- crates/engine/src/type_styles_cmds/tests.rs | 54 ++++ crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/color_picker_ui.rs | 29 +- crates/ui-egui/src/control.rs | 121 +++++++- crates/ui-egui/src/dialogs.rs | 41 ++- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/layer_style.rs | 13 +- .../ui-egui/src/layer_style/color_picker.rs | 147 ++++++++++ .../src/layer_style/color_picker/tests.rs | 261 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/type_panels_ui.rs | 21 +- .../src/type_panels_ui/color_picker.rs | 155 +++++++++++ .../src/type_panels_ui/color_picker/tests.rs | 202 ++++++++++++++ crates/ui-egui/src/widgets.rs | 2 +- docs/control-protocol.md | 2 + 31 files changed, 1108 insertions(+), 62 deletions(-) create mode 100644 crates/ui-egui/src/layer_style/color_picker.rs create mode 100644 crates/ui-egui/src/layer_style/color_picker/tests.rs create mode 100644 crates/ui-egui/src/type_panels_ui/color_picker.rs create mode 100644 crates/ui-egui/src/type_panels_ui/color_picker/tests.rs diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 363bccf4..797dfe7f 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -284,26 +284,29 @@ fn restyle(t: &mut TextLayer, old: &TextStyles, new: &TextStyles, range: Option< /// Replaces the document's styles with `new` and re-resolves every type layer (as one history /// step), applying `op` to `target` (layer + optional byte range). fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op: Op) -> Result { - s.edit(label, |doc, _| { - let old = doc.text_styles.clone(); - doc.text_styles = new.clone(); - let snapshot = doc.clone(); - let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); - let mut changed = 0; - for id in ids { - let tgt = target.iter().find(|(t, _)| *t == id); - let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) else { continue }; - let did = match tgt { - Some((_, r)) => restyle(t, &old, &new, *r, op), - None => restyle(t, &old, &new, Some((0, 0)), Op::default()), - }; - if did { - refresh(&snapshot, t); - changed += 1; - } + s.edit(label, |doc, _| Ok(apply_styles(doc, &new, target, op))) +} + +// Shared by history-backed commands and isolated preview documents. +fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize { + let old = doc.text_styles.clone(); + doc.text_styles = new.clone(); + let snapshot = doc.clone(); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + let mut changed = 0; + for id in ids { + let tgt = target.iter().find(|(t, _)| *t == id); + let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) else { continue }; + let did = match tgt { + Some((_, r)) => restyle(t, &old, new, *r, op), + None => restyle(t, &old, new, Some((0, 0)), Op::default()), + }; + if did { + refresh(&snapshot, t); + changed += 1; } - Ok(changed) - }) + } + changed } /// A type layer and an optional byte range in it. @@ -559,9 +562,8 @@ fn rename(s: &mut Session, p: &Value, paragraph: bool) -> Result { Ok(json!({ "id": id, "name": name })) } -fn set_options(s: &mut Session, p: &Value, paragraph: bool) -> Result { +fn prepare_options(mut st: TextStyles, p: &Value, paragraph: bool) -> Result<(TextStyles, u32)> { let cmd = if paragraph { "type.paragraphStyle.set" } else { "type.characterStyle.set" }; - let mut st = styles(s)?; let id = lookup_id(cmd, &st, p, paragraph, paragraph)?; let (ca, pa) = parse_attrs(cmd, p.get("attrs").unwrap_or(&Value::Null))?; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); @@ -600,6 +602,20 @@ fn set_options(s: &mut Session, p: &Value, paragraph: bool) -> Result { d.name = n; } } + Ok((st, id)) +} + +/// Apply Style Options to an isolated document, using exactly the command's resolution and +/// rendering logic. The source document and the caller's session/history remain untouched. +pub fn preview_options(doc: &Document, p: &Value, paragraph: bool) -> Result { + let (new, _) = prepare_options(doc.text_styles.clone(), p, paragraph)?; + let mut shown = doc.clone(); + apply_styles(&mut shown, &new, &[], Op::default()); + Ok(shown) +} + +fn set_options(s: &mut Session, p: &Value, paragraph: bool) -> Result { + let (st, id) = prepare_options(styles(s)?, p, paragraph)?; let layers = commit(s, "Style Options", st, &[], Op::default())?; let mut v = list(s, &json!({}), paragraph)?; v["layers"] = json!(layers); diff --git a/crates/engine/src/type_styles_cmds/tests.rs b/crates/engine/src/type_styles_cmds/tests.rs index cbb2ddbe..0a5761d5 100644 --- a/crates/engine/src/type_styles_cmds/tests.rs +++ b/crates/engine/src/type_styles_cmds/tests.rs @@ -189,3 +189,57 @@ fn exhausted_style_ids_return_errors_without_history_changes() { assert_eq!(s.active().unwrap().doc.text_styles.next_char_id(), None); assert_eq!(s.active().unwrap().doc.text_styles.next_para_id(), None); } + +#[test] +fn isolated_options_preview_matches_commit_with_multiple_layers_and_local_overrides() { + for paragraph in [false, true] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":400,"height":200,"depth":depth,"background":"transparent"})).unwrap(); + let a = s.execute("type.create", json!({"x":10,"y":50,"text":"Hello world","size":24})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("type.create", json!({"x":10,"y":100,"text":"Second layer","size":24})).unwrap()["layer"].as_u64().unwrap(); + let prefix = if paragraph { "type.paragraphStyle" } else { "type.characterStyle" }; + let id = s.execute(&format!("{prefix}.new"), json!({"fromSelection":false,"attrs":{"color":"#ff0000"}})).unwrap()["id"].as_u64().unwrap(); + for layer in [a, b] { + s.execute(&format!("{prefix}.apply"), json!({"id":id,"layer":layer})).unwrap(); + } + s.execute("type.setStyle", json!({"layer":a,"range":[6,11],"color":"#0000ff"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let p = json!({"id":id,"attrs":{"color":"#00ff00"}}); + let shown = preview_options(&doc, &p, paragraph).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(text_of(&shown, LayerId(a)).unwrap().char_runs().last().unwrap().style.color.to_rgb(), [0.0, 0.0, 1.0]); + assert_eq!(text_of(&shown, LayerId(b)).unwrap().color.to_rgb(), [0.0, 1.0, 0.0]); + s.execute(&format!("{prefix}.set"), p).unwrap(); + let committed = &s.active().unwrap().doc; + assert_eq!(shown.text_styles, committed.text_styles); + for layer in [a, b] { + let expected = text_of(&shown, LayerId(layer)).unwrap(); + let actual = text_of(committed, LayerId(layer)).unwrap(); + assert_eq!(expected.runs, actual.runs); + assert_eq!(expected.paragraphs, actual.paragraphs); + assert_eq!(expected.psd_raw, actual.psd_raw); + } + assert_eq!(photocraft_compose::flatten(&shown).get(20, 40), photocraft_compose::flatten(committed).get(20, 40)); + } + } +} + +#[test] +fn preview_options_validates_without_mutating_source() { + let (s, _) = session("Text"); + let doc = s.active().unwrap().doc.clone(); + for paragraph in [false, true] { + for p in [json!({}), json!({"id":"bad"}), json!({"id":u64::MAX}), json!({"id":999}), json!({"id":0,"attrs":3})] { + assert!(preview_options(&doc, &p, paragraph).is_err()); + } + } + let before = doc.text_styles.clone(); + let p = json!({"id":0,"attrs":{"color":"#112233"}}); + assert!(preview_options(&doc, &p, false).is_err()); + let shown = preview_options(&doc, &p, true).unwrap(); + assert_ne!(shown.text_styles, before); + assert_eq!(doc.text_styles, before); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 44bfef03..960e7461 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -761,6 +761,9 @@ pub(crate) const GPU_OUTPUT: u32 = u32::MAX; /// The document to render: the committed one, or a clone with the live adjustment preview applied. pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc, u64) { + if let Some(shown) = crate::type_panels_ui::color_picker::display_doc(app, idx) { + return shown; + } if let Some(shown) = crate::solid_fill_ui::display_doc(app, idx) { return shown; } @@ -813,9 +816,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(d) = style.filter(|_| preview_on) && 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; + let fields = crate::layer_style::color_picker::preview_fields(app, d.id, &d.fields); + let key = (crate::layer_style::preview_hash(&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); + let shown = crate::layer_style::preview_document(&st.doc, &app.session.patterns, &fields).map(std::sync::Arc::new); if let Err(error) = &shown { log::warn!("Layer Style preview: {error}"); } diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index cd5f4a45..04d303f4 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -170,32 +170,29 @@ fn c32(c: [f32; 3]) -> Color32 { pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 { let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground }; let rgb = [c[0], c[1], c[2]]; - let hsv = rgb_to_hsv(rgb); + let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" }; + app.ui.open_dialog(DialogKind::Command, initial_fields(target, label, rgb)) +} + +/// Shared initial state for every target; the picker layout is independent of its destination. +pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map { let mut f = Map::new(); f.insert("__colorPicker".into(), json!(target)); - f.insert("__label".into(), json!(if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" })); + f.insert("__label".into(), json!(label)); f.insert("color".into(), json!(hex(rgb))); f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); + f.insert("__hsv".into(), json!(rgb_to_hsv(rgb))); f.insert("__mode".into(), json!("h")); f.insert("__webOnly".into(), json!(false)); - app.ui.open_dialog(DialogKind::Command, f) + f } /// Open the picker on `rgb` for something other than the tool colours: OK runs `command` with /// `params` plus `"color": "#rrggbb"` (e.g. a gradient stop's colour). pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], command: &str, params: Value) -> u64 { - let hsv = rgb_to_hsv(rgb); - let mut f = Map::new(); - f.insert("__colorPicker".into(), json!("command")); - f.insert("__label".into(), json!(label)); + let mut f = initial_fields("command", label, rgb); f.insert("__command".into(), json!(command)); f.insert("__params".into(), params); - f.insert("color".into(), json!(hex(rgb))); - f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); - f.insert("__mode".into(), json!("h")); - f.insert("__webOnly".into(), json!(false)); app.ui.open_dialog(DialogKind::Command, f) } @@ -641,6 +638,12 @@ pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { /// OK: set the foreground or background colour. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + if crate::type_panels_ui::color_picker::owns(f) { + return crate::type_panels_ui::color_picker::confirm(app, f); + } + if crate::layer_style::color_picker::owns(f) { + return crate::layer_style::color_picker::confirm(app, f); + } if crate::solid_fill_ui::owns(f) { return crate::solid_fill_ui::confirm(app, f); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c8d4a751..5f01971f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -557,6 +557,37 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) }; } "colorPicker" | "ColorPicker" => { + if let Some(style) = p.get("textStyle") { + if ["parent", "effect", "field", "target"].iter().any(|key| p.get(*key).is_some()) { + return err("textStyle cannot be combined with another picker target"); + } + let Some(style) = style.as_object().filter(|s| s.keys().all(|k| k == "kind" || k == "id")) else { + return err("textStyle needs kind and id only"); + }; + let Some(kind) = style.get("kind").cloned().and_then(|v| serde_json::from_value::(v).ok()) + else { + return err("textStyle kind must be character or paragraph"); + }; + let Some(id) = style.get("id").and_then(Value::as_u64).and_then(|id| u32::try_from(id).ok()) else { + return err("textStyle id must be a u32 integer"); + }; + return match crate::type_panels_ui::color_picker::open(app, kind, id) { + Ok(id) => ok(json!({"dialog": id})), + Err(error) => err(error), + }; + } + if p.get("parent").is_some() { + let (Some(parent), Some(effect), Some(field)) = (u("parent"), s("effect"), s("field")) else { + return err("need integer `parent` and string `effect` and `field`"); + }; + return match crate::layer_style::color_picker::open(app, parent, effect, field) { + Ok(id) => ok(json!({"dialog": id})), + Err(error) => err(error), + }; + } + if p.get("effect").is_some() || p.get("field").is_some() { + return err("Layer Style color pickers need `parent`"); + } let target = if s("target") == Some("background") { "background" } else { "foreground" }; return ok(json!({"dialog": crate::color_picker_ui::open(app, target)})); } @@ -632,9 +663,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } None => err("missing `dialog`"), }, - "ui.dialog.cancel" => match u("dialog").and_then(|id| app.ui.close_dialog(id)) { - Some(_) => ok(Value::Null), - None => err("no such dialog"), + "ui.dialog.cancel" => match u("dialog") { + Some(id) => match crate::dialogs::cancel(app, id) { + Ok(value) => ok(value), + Err(error) => err(error), + }, + None => err("missing `dialog`"), }, "ui.window.open" => { if let Some(d) = u("document") @@ -951,6 +985,87 @@ mod tests { } } + #[test] + fn layer_style_picker_control_binds_previews_confirms_and_cancels() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + let parent = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"layerStyle","effect":"gradientOverlay"}))["result"]["dialog"].as_u64(); + let parent = parent.unwrap(); + let before = app.ui.dialogs[0].fields.clone(); + for field in ["from", "to"] { + let reply = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":field})); + assert_eq!(reply["ok"], true, "{reply}"); + let picker = app.ui.dialogs.last().unwrap().id; + let inspected = call(&mut app, &ctx, "ui.inspect", json!({})); + assert!(inspected.to_string().contains("__layerStyleColor")); + assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":parent}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.dialog.set", json!({"dialog":picker,"field":"color","value":"#123456"}))["ok"], true); + assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":picker}))["ok"], true); + assert_eq!(app.ui.dialogs[0].fields["effects"][0]["params"][field], "#123456"); + } + assert_ne!(app.ui.dialogs[0].fields, before); + call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"to"})); + assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog":parent}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); + } + + #[test] + fn text_style_picker_control_uses_bound_target_and_rejects_invalid_requests_atomically() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width":32,"height":32})).unwrap(); + let id = app.run("type.characterStyle.new", json!({"fromSelection":false,"attrs":{"color":"#123456"}})).unwrap()["id"].as_u64().unwrap(); + for style in [ + Value::Null, + json!({"kind":"wrong","id":id}), + json!({"kind":"character","id":"bad"}), + json!({"kind":"paragraph","id":u64::MAX}), + json!({"kind":"character","id":0}), + json!({"kind":"character","id":id,"extra":1}), + ] { + let panels = app.ui.type_panels.clone(); + assert_eq!(call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","textStyle":style}))["ok"], false); + assert_eq!(app.ui.type_panels, panels); + assert!(app.ui.dialogs.is_empty()); + } + assert_eq!(call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","textStyle":{"kind":"character","id":id},"parent":1}))["ok"], false); + let doc = app.session.active().unwrap().doc.clone(); + let r = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","textStyle":{"kind":"character","id":id}})); + assert_eq!(r["ok"], true); + let picker = r["result"]["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialogs[0].fields["__textStyleColor"]["id"], id); + assert_eq!(call(&mut app, &ctx, "ui.dialog.set", json!({"dialog":picker,"field":"color","value":"#00ff00"}))["ok"], true); + assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog":picker}))["ok"], true); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + let r = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","textStyle":{"kind":"character","id":id}})); + let picker = r["result"]["dialog"].as_u64().unwrap(); + call(&mut app, &ctx, "ui.dialog.set", json!({"dialog":picker,"field":"color","value":"#00ff00"})); + assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":picker}))["ok"], true); + assert_ne!(app.session.active().unwrap().doc.text_styles, doc.text_styles); + } + + #[test] + fn layer_style_picker_control_rejects_bad_targets_without_opening_tool_picker() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + let parent = crate::layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + for params in [ + json!({"kind":"colorPicker","parent":null,"effect":"fx1","field":"color"}), + json!({"kind":"colorPicker","parent":"bad","effect":"fx1","field":"color"}), + json!({"kind":"colorPicker","parent":parent,"effect":3,"field":"color"}), + json!({"kind":"colorPicker","parent":parent,"effect":"missing","field":"color"}), + json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"opacity"}), + json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"to"}), + json!({"kind":"colorPicker","effect":"fx1","field":"color"}), + ] { + let before = app.ui.dialogs.clone(); + assert_eq!(call(&mut app, &ctx, "ui.dialog.open", params)["ok"], false); + assert_eq!(app.ui.dialogs, before); + } + } + #[test] fn shell_dialogs_are_listed_and_driven_through_ui_dialog() { // #1004: Window › Workspace and View dialogs live in `ui.shell.dialog`, and ui.inspect and diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index be9b0c68..bc69fe65 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,7 +77,10 @@ fn place_id(d: &Dialog) -> egui::Id { } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + crate::layer_style::color_picker::prune(app); + crate::type_panels_ui::color_picker::prune(app); let dialogs = app.ui.dialogs.clone(); + let top = dialogs.last().map(|d| d.id); let mut shown = Vec::new(); for d in dialogs { let lang = if crate::prefs_ui::is_preferences(&d.fields) { @@ -86,6 +89,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::i18n::current() }; let _language = crate::i18n::language_scope(lang); + let interactive = top == Some(d.id); let mut fields = d.fields.clone(); let mut outcome: Option = None; // Some(true)=OK, Some(false)=Cancel let mut apply_requested = false; @@ -111,6 +115,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let modal = modal.show(ctx, |ui| { + if !interactive { + ui.disable(); + } sizing = ui.is_sizing_pass(); ui.set_min_width(380.0); let wide = crate::prefs_ui::width(&d.fields); @@ -222,7 +229,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } if crate::color_picker_ui::owns(&fields) { - if outcome.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if interactive && outcome.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { outcome = Some(true); } } else { @@ -265,7 +272,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(ButtonRole::Cancel) => outcome = Some(false), Some(ButtonRole::Apply) => apply_requested = true, Some(_) => outcome = Some(true), - None if ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true), + None if interactive && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true), None => {} } } @@ -290,7 +297,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } // Esc cancels (topmost dialog, no popup open). A click outside does nothing: Photoshop keeps // the dialog, and the pointer may be panning or zooming the canvas under it. - if outcome.is_none() + if interactive + && outcome.is_none() && (modal.response.should_close() || (modal.is_top_modal && !modal.any_popup_open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))) { @@ -308,14 +316,18 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } match outcome { Some(true) => { - let _ = confirm(app, d.id); + if let Err(error) = confirm(app, d.id) { + app.ui.status = error; + } } Some(false) => { - app.ui.close_dialog(d.id); - app.filter_preview = None; - app.color_range = None; + let _ = cancel(app, d.id); + } + None => { + if let Err(error) = crate::layer_style::color_picker::take_request(app, d.id) { + app.ui.status = error; + } } - None => {} } } ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), shown)); @@ -377,6 +389,9 @@ pub fn title(d: &Dialog) -> String { /// Confirm a dialog: run its action and close it. Used by the OK button and by automation. pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { + if crate::layer_style::color_picker::has_child(app, id) { + return Err("confirm or cancel the Layer Style color picker first".into()); + } let d = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; match d.kind { DialogKind::NewDocument => { @@ -429,6 +444,16 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { } } +/// Cancel a dialog and its dependent Layer Style picker, without applying edits. +pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { + app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; + app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); + app.filter_preview = None; + app.color_range = None; + crate::type_panels_ui::color_picker::prune(app); + Ok(Value::Null) +} + /// Open the parameter dialog of `command`: the adjustment editor for `image.adjustments.*`, the /// schema dialog for filters, the Color Range dialog for `select.colorRange`, otherwise a bare /// confirm dialog. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2e7c51ce..af4f96c4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2315,6 +2315,9 @@ fontWeight Italic Kurzíva Cycle Orientation Přepnout orientaci Set the overlay options for the Crop tool Nastavit volby překryvu pro nástroj Oříznutí Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+klik převzalo pracovní prostředí, které pomocí Alt+tažení přesouvá okna. Nastavte jeho klávesu pro přesun oken na Super, nebo zamkněte Alt v nabídce Okno › Modifikační klávesy. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Výběr barvy (barva stylu vrstvy) Auto Hide Notifications Automaticky skrývat oznámení Notification Duration (seconds) Trvání oznámení (sekundy) Appearance Mode Režim vzhledu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 56587d75..ad4ecc0d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2354,6 +2354,9 @@ fontWeight Italic Kursiv Cycle Orientation Ausrichtung wechseln Set the overlay options for the Crop tool Überlagerungsoptionen für das Freistellungswerkzeug festlegen Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Der Alt-Klick ging an die Arbeitsumgebung, die Fenster mit Alt-Ziehen verschiebt. Stelle dort die Taste zum Verschieben von Fenstern auf Super oder raste Alt unter Fenster › Modifikatortasten ein. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Farbwähler (Ebenenstilfarbe) Auto Hide Notifications Benachrichtigungen automatisch ausblenden Notification Duration (seconds) Dauer der Benachrichtigung (Sekunden) Appearance Mode Erscheinungsmodus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5a8e0e03..3c18d63b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3425,6 +3425,12 @@ fontWeight Thin Πολύ λεπτά Cycle Orientation Εναλλαγή προσανατολισμού Set the overlay options for the Crop tool Ορισμός επιλογών επικάλυψης για το εργαλείο Περικοπή Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Το Alt-κλικ πήγε στο περιβάλλον εργασίας, που μετακινεί παράθυρα με Alt-σύρσιμο. Ορίστε το πλήκτρο μετακίνησης παραθύρων του σε Super ή κλειδώστε το Alt στο Παράθυρο › Πλήκτρα τροποποίησης. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Επιλογέας χρώματος (χρώμα στυλ επιπέδου) + +# Shared text style Color Picker (#2015) + Color Picker (Text Color) Επιλογέας χρώματος (χρώμα κειμένου) Auto Hide Notifications Αυτόματη απόκρυψη ειδοποιήσεων Notification Duration (seconds) Διάρκεια ειδοποίησης (δευτερόλεπτα) Appearance Mode Λειτουργία εμφάνισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b389e641..26ca6c75 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2383,6 +2383,9 @@ fontWeight Italic Cursiva Cycle Orientation Alternar orientación Set the overlay options for the Crop tool Definir las opciones de superposición de la herramienta Recortar Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. El Alt+clic lo recibió el escritorio, que mueve ventanas con Alt+arrastrar. Cambia su tecla para mover ventanas a Super o bloquea Alt en Ventana › Teclas modificadoras. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Selector de color (color del estilo de capa) Auto Hide Notifications Ocultar notificaciones automáticamente Notification Duration (seconds) Duración de la notificación (segundos) Appearance Mode Modo de apariencia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fac228bb..c9049078 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2736,6 +2736,9 @@ fontWeight Italic Italique Cycle Orientation Changer d’orientation Set the overlay options for the Crop tool Définir les options de superposition de l’outil Recadrage Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-clic a été intercepté par le bureau, qui déplace les fenêtres avec Alt-glisser. Réglez sa touche de déplacement des fenêtres sur Super, ou verrouillez Alt dans Fenêtre › Touches de modification. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Sélecteur de couleurs (couleur du style de calque) Auto Hide Notifications Masquer automatiquement les notifications Notification Duration (seconds) Durée de la notification (secondes) Appearance Mode Mode d’apparence diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5b26a435..ff2c14ab 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2723,6 +2723,9 @@ fontWeight Italic Miring Cycle Orientation Ganti Orientasi Set the overlay options for the Crop tool Atur opsi overlay untuk alat Crop Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik diambil oleh desktop, yang memindahkan jendela dengan Alt-seret. Atur tombol pemindah jendelanya ke Super, atau kunci Alt di Jendela › Tombol Pengubah. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Pemilih Warna (Warna Gaya Lapisan) Auto Hide Notifications Sembunyikan notifikasi secara otomatis Notification Duration (seconds) Durasi notifikasi (detik) Appearance Mode Mode tampilan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b0d3279b..381e6f1a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2385,6 +2385,9 @@ fontWeight Italic Corsivo Cycle Orientation Cambia orientamento Set the overlay options for the Crop tool Imposta le opzioni di sovrapposizione dello strumento Taglierina Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-click è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Selettore colore (colore dello stile livello) Auto Hide Notifications Nascondi automaticamente le notifiche Notification Duration (seconds) Durata della notifica (secondi) Appearance Mode Modalità aspetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f201274..bc512484 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3339,6 +3339,9 @@ fontWeight Italic 斜体 Cycle Orientation 方向を切り替え Set the overlay options for the Crop tool 切り抜きツールのオーバーレイオプションを設定 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+クリックはデスクトップに取られました(Alt+ドラッグでウィンドウを移動する設定です)。デスクトップのウィンドウ移動キーを Super に変更するか、ウィンドウ › 修飾キーで Alt を固定してください。 + +# Shared Layer Style color picker + Color Picker (Layer Style Color) カラーピッカー(レイヤースタイルの色) Auto Hide Notifications 通知を自動的に隠す Notification Duration (seconds) 通知の表示時間(秒) Appearance Mode 外観モード diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4c489b4..9db595fb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2894,6 +2894,9 @@ fontWeight Italic 기울임 Cycle Orientation 방향 순환 Set the overlay options for the Crop tool 자르기 도구의 오버레이 옵션 설정 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 클릭이 데스크톱으로 전달되었습니다. 데스크톱이 Alt 드래그로 창을 이동합니다. 창 이동 키를 Super로 바꾸거나 창 › 보조 키에서 Alt를 고정하세요. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) 색상 피커 (레이어 스타일 색상) Auto Hide Notifications 알림 자동 숨기기 Notification Duration (seconds) 알림 지속 시간(초) Appearance Mode 화면 모드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 071f517e..b80dfa54 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3500,9 +3500,11 @@ cameraRaw Whites Witten Cycle Orientation Oriëntatie wisselen Set the overlay options for the Crop tool Overlay-opties voor het gereedschap Uitsnijden instellen Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. De Alt-klik ging naar het bureaublad, dat vensters verplaatst met Alt-slepen. Stel daar de toets voor het verplaatsen van vensters in op Super, of vergrendel Alt via Venster › Wijzigingstoetsen. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Kleurkiezer (kleur van laagstijl) Auto Hide Notifications Meldingen automatisch verbergen Notification Duration (seconds) Duur van melding (seconden) - Appearance Mode Weergavemodus Next Appearance Mode Volgende weergavemodus Sync with system Synchroniseren met systeem diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5a8e5240..bad10852 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3338,6 +3338,9 @@ cameraRaw Roughness Chropowatość Cycle Orientation Przełącz orientację Set the overlay options for the Crop tool Ustaw opcje nakładki narzędzia Kadrowanie Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+kliknięcie przejęło środowisko pulpitu, które przesuwa okna przez Alt+przeciąganie. Ustaw w nim klawisz przesuwania okien na Super lub zablokuj Alt w Okno › Klawisze modyfikujące. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Próbnik kolorów (kolor stylu warstwy) Auto Hide Notifications Automatycznie ukrywaj powiadomienia Notification Duration (seconds) Czas trwania powiadomienia (sekundy) Appearance Mode Tryb wyglądu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c60e0c06..c2d52fe5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2744,6 +2744,9 @@ fontWeight Italic Itálico Cycle Orientation Alternar orientação Set the overlay options for the Crop tool Definir as opções de sobreposição da ferramenta Corte demarcado Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. O Alt-clique foi para o desktop, que move janelas com Alt-arrastar. Mude a tecla de mover janelas dele para Super ou trave o Alt em Janela › Teclas modificadoras. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Seletor de cores (cor do estilo de camada) Auto Hide Notifications Ocultar notificações automaticamente Notification Duration (seconds) Duração da notificação (segundos) Appearance Mode Modo de aparência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6785912..fff70ccc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2590,6 +2590,9 @@ fontWeight Italic Курсив Cycle Orientation Сменить ориентацию Set the overlay options for the Crop tool Задать параметры перекрытия для инструмента «Рамка» Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+щелчок перехватило окружение рабочего стола: оно перемещает окна через Alt+перетаскивание. Назначьте для перемещения окон клавишу Super или зафиксируйте Alt в меню Окно › Клавиши-модификаторы. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Палитра цветов (цвет стиля слоя) Auto Hide Notifications Автоскрытие уведомлений Notification Duration (seconds) Длительность уведомления (секунды) Appearance Mode Режим оформления diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b368aba9..14d6a81a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3425,9 +3425,11 @@ cameraRaw Masking Маскування Cycle Orientation Змінити орієнтацію Set the overlay options for the Crop tool Задати параметри накладання для інструмента «Рамка» Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-клік перехопило робоче середовище, яке переміщує вікна через Alt-перетягування. Призначте для переміщення вікон клавішу Super або зафіксуйте Alt у меню Вікно › Клавіші-модифікатори. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Палітра кольорів (колір стилю шару) Auto Hide Notifications Автоматично приховувати сповіщення Notification Duration (seconds) Тривалість сповіщення (секунди) - Appearance Mode Режим вигляду Dark Theme Темна тема Light Theme Світла тема diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2fda66d..f37b952e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2323,6 +2323,9 @@ fontWeight Italic 斜体 Cycle Orientation 循环切换取向 Set the overlay options for the Crop tool 设置裁剪工具的叠加选项 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 单击被桌面环境截获了,它用 Alt 拖动来移动窗口。请把桌面的窗口移动键改为 Super,或在 窗口 › 修饰键 中锁定 Alt。 + +# Shared Layer Style color picker + Color Picker (Layer Style Color) 拾色器(图层样式颜色) Auto Hide Notifications 自动隐藏通知 Notification Duration (seconds) 通知持续时间(秒) Appearance Mode 外观模式 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 78e3b621..07fda8d8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2292,6 +2292,9 @@ fontWeight Italic 斜體 Cycle Orientation 循環切換方向 Set the overlay options for the Crop tool 設定裁切工具的覆蓋選項 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 按一下被桌面環境攔截了,它用 Alt 拖曳來移動視窗。請把桌面的視窗移動鍵改為 Super,或在 視窗 › 輔助鍵 中鎖定 Alt。 + +# Shared Layer Style color picker + Color Picker (Layer Style Color) 檢色器(圖層樣式顏色) Auto Hide Notifications 自動隱藏通知 Notification Duration (seconds) 通知持續時間(秒) Appearance Mode 外觀模式 diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 7ba7a7f7..269e3117 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -21,6 +21,8 @@ use serde_json::{Map, Value, json}; use crate::theme::Tokens; use crate::{PhotocraftApp, widgets}; +pub(crate) mod color_picker; + #[derive(Clone, Copy)] enum P { Slider(f32, f32, &'static str), @@ -494,6 +496,7 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { let mut f = initial_fields(&layer, select, light); // Channels and Blend If list the document's own colour channels. set_blending_fields(&mut f, &layer, mode); + f.insert("__document".into(), json!(st.doc.id)); f.insert("patternList".into(), pattern_list(app)); Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f)) } @@ -910,12 +913,10 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { ui.horizontal(|ui| { ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let hexs = disp.get(key).and_then(Value::as_str).unwrap_or("#000000").to_string(); - let mut c = parse_hex(&hexs); - if crate::widgets::color_edit_button_srgba(ui, &mut c).changed() { - let value = json!(format!("#{r:02x}{g:02x}{b:02x}", r = c.r(), g = c.g(), b = c.b())); - disp[key] = value.clone(); - set_param(f, &selected, key, value); + let color = disp.get(key).and_then(Value::as_str).unwrap_or("#000000"); + let response = widgets::color_swatch(ui, parse_hex(color)).on_hover_text(tl!("Color Picker (Layer Style Color)")); + if response.clicked() { + f.insert(color_picker::REQUEST.into(), json!({"effect": selected, "field": key})); } }); } diff --git a/crates/ui-egui/src/layer_style/color_picker.rs b/crates/ui-egui/src/layer_style/color_picker.rs new file mode 100644 index 00000000..b0edb163 --- /dev/null +++ b/crates/ui-egui/src/layer_style/color_picker.rs @@ -0,0 +1,147 @@ +//! A Color Picker transaction over one pending Layer Style parameter. +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::{P, defaults, effects_of, set_param, spec}; +use crate::{PhotocraftApp, color_picker_ui, state::DialogKind}; + +const TARGET: &str = "__layerStyleColor"; +pub(super) const REQUEST: &str = "__openColorPicker"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Target { + parent: u64, + document: photocraft_doc::DocId, + layer: photocraft_doc::LayerId, + effect: String, + field: String, + color: String, + // A reused effect id after loading another style must not redirect the transaction. + source: Value, +} + +fn target(fields: &Map) -> Result { + serde_json::from_value(fields.get(TARGET).cloned().ok_or("picker has no Layer Style target")?).map_err(|_| "invalid Layer Style color target".into()) +} + +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(TARGET) +} + +pub(crate) fn child_of(fields: &Map, parent: u64) -> bool { + fields.get(TARGET).and_then(|v| v.get("parent")).and_then(Value::as_u64) == Some(parent) +} + +pub(crate) fn has_child(app: &PhotocraftApp, parent: u64) -> bool { + app.ui.dialogs.iter().any(|d| child_of(&d.fields, parent)) +} + +struct ColorField { + source: Value, + color: String, +} + +fn color_value(fields: &Map, effect: &str, field: &str) -> Result { + let mut matches = effects_of(fields).iter().filter(|e| e.get("id").and_then(Value::as_str) == Some(effect)); + let e = matches.next().ok_or("Layer Style effect no longer exists")?; + if matches.next().is_some() { + return Err("ambiguous Layer Style effect id".into()); + } + let kind = e.get("kind").and_then(Value::as_str).ok_or("invalid effect kind")?; + if !spec(kind).iter().any(|(key, _, p)| *key == field && matches!(p, P::Color)) { + return Err("not a Layer Style color parameter".into()); + } + let params = e.get("params").and_then(Value::as_object).ok_or("invalid effect parameters")?; + let original = params.get(field).cloned(); + let base = defaults(kind); + let color = original.as_ref().or_else(|| base.get(field)).and_then(Value::as_str).ok_or("invalid effect color")?; + let rgb = color_picker_ui::parse_hex(color).ok_or("invalid effect color")?; + Ok(ColorField { source: e.clone(), color: color_picker_ui::hex(rgb) }) +} + +fn validate<'a>(app: &'a PhotocraftApp, t: &Target) -> Result<&'a Map, String> { + let st = app.session.active().ok_or("no active document")?; + if st.doc.id != t.document || st.doc.layer(t.layer).is_none() { + return Err("Layer Style color target document or layer changed".into()); + } + let parent = app.ui.dialogs.iter().find(|d| d.id == t.parent && d.kind == DialogKind::LayerStyle).ok_or("Layer Style dialog closed")?; + if parent.fields.get("__document").and_then(Value::as_u64) != Some(t.document.0) { + return Err("Layer Style dialog belongs to another document".into()); + } + if parent.fields.get("layer").and_then(Value::as_u64) != Some(t.layer.0) { + return Err("Layer Style target layer changed".into()); + } + let ColorField { source, .. } = color_value(&parent.fields, &t.effect, &t.field)?; + if source != t.source { + return Err("Layer Style color target changed while the picker was open".into()); + } + Ok(&parent.fields) +} + +/// Both swatch clicks and the control protocol use the same bound target. +pub(crate) fn open(app: &mut PhotocraftApp, parent: u64, effect: &str, field: &str) -> Result { + if app.ui.dialogs.last().map(|d| d.id) != Some(parent) || has_child(app, parent) { + return Err("open the picker from the top Layer Style dialog".into()); + } + let d = app.ui.dialogs.iter().find(|d| d.id == parent && d.kind == DialogKind::LayerStyle).ok_or("no Layer Style dialog")?; + let st = app.session.active().ok_or("no active document")?; + let layer = photocraft_doc::LayerId(d.fields.get("layer").and_then(Value::as_u64).ok_or("invalid layer id")?); + let ColorField { source, color } = color_value(&d.fields, effect, field)?; + let t = Target { parent, document: st.doc.id, layer, effect: effect.into(), field: field.into(), color: color.clone(), source }; + validate(app, &t)?; + let rgb = color_picker_ui::parse_hex(&color).ok_or("invalid effect color")?; + let mut fields = color_picker_ui::initial_fields("dialog", "Color Picker (Layer Style Color)", rgb); + fields.insert(TARGET.into(), serde_json::to_value(t).map_err(|e| e.to_string())?); + Ok(app.ui.open_dialog(DialogKind::Command, fields)) +} + +pub(crate) fn take_request(app: &mut PhotocraftApp, parent: u64) -> Result<(), String> { + let request = app.ui.dialog_mut(parent).and_then(|d| d.fields.remove(REQUEST)); + if let Some(r) = request { + let effect = r.get("effect").and_then(Value::as_str).ok_or("invalid effect request")?; + let field = r.get("field").and_then(Value::as_str).ok_or("invalid color request")?; + open(app, parent, effect, field)?; + } + Ok(()) +} + +/// Overlay only for rendering. Cancel therefore needs no rollback of parent fields. +pub(crate) fn preview_fields<'a>(app: &PhotocraftApp, parent: u64, fields: &'a Map) -> Cow<'a, Map> { + let Some(d) = app.ui.dialogs.last().filter(|d| child_of(&d.fields, parent)) else { return Cow::Borrowed(fields) }; + let Ok(t) = target(&d.fields) else { return Cow::Borrowed(fields) }; + if validate(app, &t).is_err() { + return Cow::Borrowed(fields); + } + let Some(color) = d.fields.get("color").and_then(Value::as_str).and_then(color_picker_ui::parse_hex).map(color_picker_ui::hex) else { + return Cow::Borrowed(fields); + }; + if t.color == color { + return Cow::Borrowed(fields); + } + let mut shown = fields.clone(); + set_param(&mut shown, &t.effect, &t.field, json!(color)); + Cow::Owned(shown) +} + +pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let t = target(fields)?; + validate(app, &t)?; + let color = fields.get("color").and_then(Value::as_str).and_then(color_picker_ui::parse_hex).map(color_picker_ui::hex).ok_or("invalid picker color")?; + if t.color != color { + let parent = app.ui.dialog_mut(t.parent).ok_or("Layer Style dialog closed")?; + set_param(&mut parent.fields, &t.effect, &t.field, json!(color)); + } + Ok(Value::Null) +} + +/// Handles document switches and direct automation edits without stale previews. +pub(crate) fn prune(app: &mut PhotocraftApp) { + let invalid: Vec<_> = + app.ui.dialogs.iter().filter(|d| owns(&d.fields) && target(&d.fields).and_then(|t| validate(app, &t)).is_err()).map(|d| d.id).collect(); + app.ui.dialogs.retain(|d| !invalid.contains(&d.id)); +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/layer_style/color_picker/tests.rs b/crates/ui-egui/src/layer_style/color_picker/tests.rs new file mode 100644 index 00000000..9bfe4845 --- /dev/null +++ b/crates/ui-egui/src/layer_style/color_picker/tests.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::{canvas, dialogs, layer_style, theme::ThemeKind}; +use egui::{Key, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use std::sync::Arc; + +fn app(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path":"interface.language","value":"en"})).unwrap(); + app.run("file.new", json!({"width":32,"height":24,"depth":depth,"background":"#406080"})).unwrap(); + app +} +fn fields(app: &PhotocraftApp, id: u64) -> Map { + app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() +} +fn set_color(app: &mut PhotocraftApp, id: u64, color: &str) { + app.ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!(color)); +} +fn selected(app: &PhotocraftApp, parent: u64) -> String { + fields(app, parent)["selected"].as_str().unwrap().into() +} + +#[test] +fn every_exposed_color_field_uses_the_shared_picker_and_only_updates_its_target() { + for (kind, _) in layer_style::KINDS { + for (field, _, p) in spec(kind) { + if !matches!(p, P::Color) { + continue; + } + let mut app = app(8); + let parent = layer_style::open(&mut app, Some(kind)).unwrap(); + let effect = selected(&app, parent); + let original = fields(&app, parent); + let tools = app.session.tools.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + let picker = open(&mut app, parent, &effect, field).unwrap(); + assert!(color_picker_ui::owns(&fields(&app, picker))); + assert_eq!(fields(&app, picker)["__label"], "Color Picker (Layer Style Color)"); + set_color(&mut app, picker, "#23a567"); + let shown = preview_fields(&app, parent, &original); + assert_eq!(super::super::entry(&shown, &effect).unwrap()["params"][field], "#23a567"); + assert_eq!(fields(&app, parent), original); + dialogs::confirm(&mut app, picker).unwrap(); + let mut expected = original; + set_param(&mut expected, &effect, field, json!("#23a567")); + assert_eq!(fields(&app, parent), expected, "{kind}.{field}"); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(app.session.tools.foreground, tools.foreground); + assert_eq!(app.session.tools.background, tools.background); + } + } +} + +#[test] +fn preview_cancel_ok_parent_cancel_and_apply_are_transactional_at_every_depth() { + for depth in [8, 16, 32] { + let mut app = app(depth); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let effect = selected(&app, parent); + layer_style::add_instance(&mut app.ui.dialog_mut(parent).unwrap().fields, "stroke"); + let original = fields(&app, parent); + let st = app.session.active().unwrap(); + let (doc, history, revision, dirty) = (st.doc.clone(), st.history.past_len(), st.revision, st.is_dirty()); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "#00ff00"); + let (shown, key) = canvas::display_doc(&mut app, 0); + assert!(!Arc::ptr_eq(&shown, &doc)); + let again = canvas::display_doc(&mut app, 0); + assert_eq!(key, again.1); + assert!(Arc::ptr_eq(&shown, &again.0), "unchanged preview is cached"); + let pixel = photocraft_compose::flatten(&shown).get(3, 3); + assert!(pixel[1] > pixel[0] && pixel[1] > pixel[2], "live preview is green at {depth} bits"); + let st = app.session.active().unwrap(); + assert_eq!((st.history.past_len(), st.revision, st.is_dirty()), (history, revision, dirty)); + assert!(Arc::ptr_eq(&st.doc, &doc)); + dialogs::cancel(&mut app, picker).unwrap(); + assert_eq!(fields(&app, parent), original); + assert_ne!(canvas::display_doc(&mut app, 0).1, key); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "#00ff00"); + dialogs::confirm(&mut app, picker).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + dialogs::cancel(&mut app, parent).unwrap(); + assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &doc)); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let effect = selected(&app, parent); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "#00ff00"); + dialogs::confirm(&mut app, picker).unwrap(); + dialogs::confirm(&mut app, parent).unwrap(); + let applied = app.session.active().unwrap().doc.clone(); + let pixel = photocraft_compose::flatten(&applied).get(3, 3); + assert!(pixel[1] > pixel[0]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layer(doc.layers[0].id).unwrap().effects.items.len(), 0); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layer(doc.layers[0].id).unwrap().effects, applied.layer(doc.layers[0].id).unwrap().effects); + } +} + +#[test] +fn unchanged_ok_preserves_missing_parameters_and_imported_effect_snapshot() { + let mut app = app(8); + app.run("layer.layerStyle.stroke", json!({"size":7,"color":"#123456","add":true})).unwrap(); + app.run("layer.layerStyle.stroke", json!({"size":9,"color":"#abcdef","add":true})).unwrap(); + let parent = layer_style::open(&mut app, Some("stroke")).unwrap(); + let original = fields(&app, parent); + for effect in ["fx1", "fx2"] { + let picker = open(&mut app, parent, effect, "color").unwrap(); + dialogs::confirm(&mut app, picker).unwrap(); + assert_eq!(fields(&app, parent), original); + } + let picker = open(&mut app, parent, "fx2", "color").unwrap(); + set_color(&mut app, picker, "#223344"); + dialogs::confirm(&mut app, picker).unwrap(); + let current = fields(&app, parent); + assert_eq!(super::super::entry(¤t, "fx1"), super::super::entry(&original, "fx1")); + assert_eq!(super::super::entry(¤t, "fx2").unwrap()["fx"], super::super::entry(&original, "fx2").unwrap()["fx"]); + dialogs::cancel(&mut app, parent).unwrap(); + let parent = layer_style::open(&mut app, Some("gradientOverlay")).unwrap(); + let effect = selected(&app, parent); + app.ui.dialog_mut(parent).unwrap().fields.get_mut("effects").unwrap().as_array_mut().unwrap()[0]["params"].as_object_mut().unwrap().remove("from"); + let original = fields(&app, parent); + let picker = open(&mut app, parent, &effect, "from").unwrap(); + dialogs::confirm(&mut app, picker).unwrap(); + assert_eq!(fields(&app, parent), original); +} + +#[test] +fn stale_targets_and_invalid_colors_never_apply_or_leave_preview() { + for change in ["document", "parent", "effect", "kind", "color", "source", "malformed", "layer", "replaced"] { + let mut app = app(8); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let effect = selected(&app, parent); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "#00ff00"); + match change { + "document" => { + app.run("file.new", json!({"width":3,"height":3})).unwrap(); + } + "parent" => { + app.ui.close_dialog(parent); + } + "effect" => { + app.ui.dialog_mut(parent).unwrap().fields.insert("effects".into(), json!([])); + } + "kind" => { + app.ui.dialog_mut(parent).unwrap().fields["effects"][0]["kind"] = json!("stroke"); + } + "color" => { + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, "color", json!("#ff00ff")); + } + "replaced" => { + app.ui.dialog_mut(parent).unwrap().fields["effects"][0] = + json!({"id":effect,"kind":"colorOverlay","on":true,"params":{"color":"#ff0000","opacity":12}}); + } + "source" => { + app.ui.dialog_mut(parent).unwrap().fields["effects"][0]["fx"] = json!({"replacement":true}); + } + "malformed" => { + app.ui.dialog_mut(picker).unwrap().fields.insert(TARGET.into(), json!(null)); + } + _ => { + app.ui.dialog_mut(parent).unwrap().fields.insert("layer".into(), json!(999999)); + } + } + let current_parent = app.ui.dialogs.iter().find(|d| d.id == parent).cloned(); + assert!(dialogs::confirm(&mut app, picker).is_err(), "{change}"); + assert_eq!(app.ui.dialogs.iter().find(|d| d.id == parent).cloned(), current_parent); + } + let mut app = app(8); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let effect = selected(&app, parent); + let original = fields(&app, parent); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "bad"); + assert!(dialogs::confirm(&mut app, picker).is_err()); + assert_eq!(fields(&app, parent), original); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + app.run("file.new", json!({"width":3,"height":3})).unwrap(); + prune(&mut app); + assert!(!app.ui.dialogs.iter().any(|d| d.id == picker)); +} + +#[test] +fn preview_off_and_parent_confirmation_and_cancellation_keep_their_contract() { + let mut app = app(8); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + app.ui.dialog_mut(parent).unwrap().fields.insert("preview".into(), json!(false)); + let effect = selected(&app, parent); + let original = fields(&app, parent); + let doc = app.session.active().unwrap().doc.clone(); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + set_color(&mut app, picker, "#00ff00"); + assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &doc)); + assert!(dialogs::confirm(&mut app, parent).is_err()); + assert_eq!(fields(&app, parent), original); + assert_eq!(app.ui.dialogs.len(), 2); + dialogs::cancel(&mut app, parent).unwrap(); + assert!(app.ui.dialogs.is_empty()); +} + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = app(8); + layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let mut h = Harness::builder().with_size(vec2(1500.0, 1000.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium); + h.run_steps(4); + h +} + +#[test] +fn real_swatch_click_opens_shared_dialog_and_enter_escape_only_affect_the_child() { + for action in [Key::Enter, Key::Escape] { + let mut h = harness(); + let parent = h.state().ui.dialogs[0].id; + let before = fields(h.state(), parent); + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run(); + assert_eq!(h.state().ui.dialogs.len(), 2); + let picker = h.state().ui.dialogs[1].id; + set_color(h.state_mut(), picker, "#00ff00"); + h.key_press(action); + h.run(); + assert_eq!(h.state().ui.dialogs.len(), 1); + assert_eq!(h.state().ui.dialogs[0].id, parent); + if action == Key::Escape { + assert_eq!(fields(h.state(), parent), before); + } else { + assert_eq!(super::super::entry(&fields(h.state(), parent), "fx1").unwrap()["params"]["color"], "#00ff00"); + } + } +} + +#[test] +fn eyedropper_edits_only_the_picker_and_invalid_samples_leave_it_unchanged() { + let mut app = app(8); + let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); + let effect = selected(&app, parent); + let original = fields(&app, parent); + let doc = app.session.active().unwrap().doc.clone(); + let tools = app.session.tools.clone(); + let picker = open(&mut app, parent, &effect, "color").unwrap(); + color_picker_ui::sample_at(&mut app, 4.0, 4.0); + assert_eq!(fields(&app, picker)["color"], "#406080"); + color_picker_ui::sample_at(&mut app, -100.0, -100.0); + assert_eq!(fields(&app, picker)["color"], "#406080"); + assert_eq!(fields(&app, parent), original); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert_eq!(app.session.tools.foreground, tools.foreground); + dialogs::cancel(&mut app, picker).unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7509895..bad237b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -481,6 +481,8 @@ pub struct PhotocraftApp { pub(crate) transform_preview: Option, /// Move-tool ⇧/⌥ drag state (move_mods). pub(crate) move_mods: move_mods::MoveDrag, + /// Cached document for a modal text Style Options color preview. + pub(crate) text_style_preview: Option, /// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error). pub(crate) style_preview: Option<(u64, Result, String>)>, pub(crate) solid_fill_preview: Option, @@ -627,6 +629,7 @@ impl PhotocraftApp { transform_preview: None, move_mods: Default::default(), style_preview: None, + text_style_preview: None, solid_fill_preview: None, distort: Default::default(), gradient: Default::default(), diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 200a8214..d7217a1c 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -208,6 +208,8 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +pub(crate) mod color_picker; + // ------------------------------------------------------------------ styles pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) { @@ -345,6 +347,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s let resolved_p = s["resolved"]["paragraph"].clone(); let mut set: Option = None; let mut clear: Vec<&str> = Vec::new(); + let mut open_color = false; ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new(tl!("Style Options")).color(t.text).size(11.5).strong()); @@ -408,12 +411,8 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s } "color" => { let c = serde_json::from_value::(v.clone()).map(|c| c.to_rgb()).unwrap_or([0.0; 3]); - // Style colours are stored encoded (like the Color panel's hex), not linear. - let h = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u8; - let mut rgb = c.map(h); - if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() { - set = Some(json!({ "color": format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]) })); - } + let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + open_color = crate::widgets::color_swatch(ui, Color32::from_rgb(r, g, b)).on_hover_text(tl!("Color Picker (Text Color)")).clicked(); } _ => { let mut b = v.as_bool().unwrap_or(false); @@ -491,6 +490,16 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s } let _ = run(app, &format!("{prefix}.set"), json!({ "id": id, "clear": c })); } + if open_color { + let kind = if paragraph { color_picker::Kind::Paragraph } else { color_picker::Kind::Character }; + match u32::try_from(id).map_err(|_| "invalid text style id".to_string()).and_then(|id| color_picker::open(app, kind, id)) { + Ok(_) => {} + Err(error) => { + app.ui.status = error; + app.ui.status_error = true; + } + } + } } /// Searchable family combo box. diff --git a/crates/ui-egui/src/type_panels_ui/color_picker.rs b/crates/ui-egui/src/type_panels_ui/color_picker.rs new file mode 100644 index 00000000..3c68f72e --- /dev/null +++ b/crates/ui-egui/src/type_panels_ui/color_picker.rs @@ -0,0 +1,155 @@ +//! A modal color transaction for a named text style; the canvas preview never edits the session. +use std::sync::Arc; + +use photocraft_doc::{Color, DocId, Document}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, color_picker_ui, state::DialogKind}; + +const TARGET: &str = "__textStyleColor"; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Kind { + Character, + Paragraph, +} + +impl Kind { + fn paragraph(self) -> bool { + self == Self::Paragraph + } + + fn command(self) -> &'static str { + if self.paragraph() { "type.paragraphStyle.set" } else { "type.characterStyle.set" } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Target { + document: DocId, + revision: u64, + kind: Kind, + id: u32, + color: String, +} + +pub(crate) struct Preview { + key: u64, + shown: Result, String>, +} + +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(TARGET) +} + +fn target(fields: &Map) -> Result { + serde_json::from_value(fields.get(TARGET).cloned().ok_or("picker has no text style target")?).map_err(|_| "invalid text style color target".into()) +} + +fn color(doc: &Document, kind: Kind, id: u32) -> Result { + let attrs = if kind.paragraph() { + &doc.text_styles.para_style(id).ok_or("no such paragraph style")?.char_attrs + } else { + &doc.text_styles.char_style(id).ok_or("no such character style")?.attrs + }; + serde_json::from_value(attrs.get("color").cloned().ok_or("enable the style's Color attribute first")?).map_err(|_| "invalid text style color".into()) +} + +fn validate(app: &PhotocraftApp, t: &Target) -> Result<(), String> { + let st = app.session.active().ok_or("no active document")?; + if st.doc.id != t.document || st.revision != t.revision { + return Err("text style color document changed while the picker was open".into()); + } + let panels = &app.ui.type_panels; + if !panels.styles || panels.styles_tab != usize::from(t.kind.paragraph()) || panels.options != Some((t.kind.paragraph(), t.id)) { + return Err("text Style Options closed or changed".into()); + } + color(&st.doc, t.kind, t.id)?; + Ok(()) +} + +/// Validate before changing either the panel state or the dialog stack. Also used by automation. +pub(crate) fn open(app: &mut PhotocraftApp, kind: Kind, id: u32) -> Result { + if !app.ui.dialogs.is_empty() { + return Err("close the current dialog before opening a text style color picker".into()); + } + let st = app.session.active().ok_or("no active document")?; + let rgb = color(&st.doc, kind, id)?.to_rgb(); + if !rgb.iter().all(|v| v.is_finite()) { + return Err("invalid text style color".into()); + } + // Style colors use encoded RGB, just like the existing compact style picker. + let t = Target { document: st.doc.id, revision: st.revision, kind, id, color: color_picker_ui::hex(rgb) }; + let mut fields = color_picker_ui::initial_fields("dialog", "Color Picker (Text Color)", rgb); + fields.insert(TARGET.into(), serde_json::to_value(t).map_err(|e| e.to_string())?); + let panels = &mut app.ui.type_panels; + panels.styles = true; + panels.styles_tab = usize::from(kind.paragraph()); + panels.options = Some((kind.paragraph(), id)); + Ok(app.ui.open_dialog(DialogKind::Command, fields)) +} + +fn chosen(fields: &Map) -> Result { + fields.get("color").and_then(Value::as_str).and_then(color_picker_ui::parse_hex).map(color_picker_ui::hex).ok_or("invalid picker color".into()) +} + +pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let t = target(fields)?; + validate(app, &t)?; + let color = chosen(fields)?; + app.text_style_preview = None; + if color == t.color { + return Ok(Value::Null); + } + app.run(t.kind.command(), json!({"id": t.id, "attrs": {"color": color}})) +} + +pub(crate) fn prune(app: &mut PhotocraftApp) { + let invalid: Vec<_> = + app.ui.dialogs.iter().filter(|d| owns(&d.fields) && target(&d.fields).and_then(|t| validate(app, &t)).is_err()).map(|d| d.id).collect(); + app.ui.dialogs.retain(|d| !invalid.contains(&d.id)); + if !app.ui.dialogs.iter().any(|d| owns(&d.fields)) { + app.text_style_preview = None; + } +} + +/// CPU and GPU canvases use the same isolated document; unchanged frames reuse its Arc. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + prune(app); + let d = app.ui.dialogs.last().filter(|d| owns(&d.fields))?; + let t = target(&d.fields).ok()?; + validate(app, &t).ok()?; + let color = match chosen(&d.fields) { + Ok(color) => color, + Err(error) => { + app.ui.status = error; + app.ui.status_error = true; + return None; + } + }; + let st = app.session.documents().get(idx)?; + if st.doc.id != t.document { + return None; + } + if t.color == color { + app.text_style_preview = None; + return None; + } + let key = (egui::Id::new((t.document.0, t.revision, d.id, t.kind.paragraph(), t.id, &color)).value() & ((1 << 60) - 1)) | (1 << 60); + if app.text_style_preview.as_ref().map(|p| p.key) != Some(key) { + let shown = photocraft_engine::type_styles_cmds::preview_options(&st.doc, &json!({"id": t.id, "attrs": {"color": color}}), t.kind.paragraph()) + .map(Arc::new) + .map_err(|e| e.to_string()); + if let Err(error) = &shown { + app.ui.status = error.clone(); + app.ui.status_error = true; + } + app.text_style_preview = Some(Preview { key, shown }); + } + app.text_style_preview.as_ref()?.shown.as_ref().ok().map(|doc| (doc.clone(), key)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/type_panels_ui/color_picker/tests.rs b/crates/ui-egui/src/type_panels_ui/color_picker/tests.rs new file mode 100644 index 00000000..6d7882fe --- /dev/null +++ b/crates/ui-egui/src/type_panels_ui/color_picker/tests.rs @@ -0,0 +1,202 @@ +use super::*; +use crate::{canvas, dialogs, theme::ThemeKind}; +use egui::{Key, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn fixture(kind: Kind, depth: u32) -> (PhotocraftApp, u32) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path":"interface.language","value":"en"})).unwrap(); + app.run("file.new", json!({"width":320,"height":160,"depth":depth,"background":"transparent"})).unwrap(); + let layer = app.run("type.create", json!({"x":10,"y":55,"text":"Shared color","size":30})).unwrap()["layer"].as_u64().unwrap(); + let prefix = if kind.paragraph() { "type.paragraphStyle" } else { "type.characterStyle" }; + let id = app.run(&format!("{prefix}.new"), json!({"fromSelection":false,"attrs":{"color":"#ff0000"}})).unwrap()["id"].as_u64().unwrap() as u32; + app.run(&format!("{prefix}.apply"), json!({"id":id,"layer":layer})).unwrap(); + (app, id) +} +fn fields(app: &PhotocraftApp, id: u64) -> Map { + app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() +} +fn set(app: &mut PhotocraftApp, id: u64, color: &str) { + app.ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!(color)); +} + +#[test] +fn preview_is_cached_cancel_is_clean_and_ok_is_one_undo_step_at_all_depths() { + for kind in [Kind::Character, Kind::Paragraph] { + for depth in [8, 16, 32] { + let (mut app, id) = fixture(kind, depth); + let st = app.session.active().unwrap(); + let (doc, rev, dirty, history) = (st.doc.clone(), st.revision, st.is_dirty(), st.history.past_len()); + let original_pixels = photocraft_compose::flatten(&doc); + let (x, y) = + (0..160).flat_map(|y| (0..320).map(move |x| (x, y))).find(|&(x, y)| original_pixels.get(x, y)[3] > 0.1).expect("fixture renders visible text"); + let picker = open(&mut app, kind, id).unwrap(); + set(&mut app, picker, "#00ff00"); + let (shown, key) = canvas::display_doc(&mut app, 0); + let again = canvas::display_doc(&mut app, 0); + assert!(Arc::ptr_eq(&shown, &again.0)); + assert_eq!(key, again.1); + assert_ne!(photocraft_compose::flatten(&shown).get(x, y), photocraft_compose::flatten(&doc).get(x, y)); + let st = app.session.active().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &doc)); + assert_eq!((st.revision, st.is_dirty(), st.history.past_len()), (rev, dirty, history)); + set(&mut app, picker, "#0000ff"); + assert_ne!(canvas::display_doc(&mut app, 0).1, key); + dialogs::cancel(&mut app, picker).unwrap(); + assert!(app.text_style_preview.is_none()); + assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &doc)); + let picker = open(&mut app, kind, id).unwrap(); + set(&mut app, picker, "#00ff00"); + let shown = canvas::display_doc(&mut app, 0).0; + dialogs::confirm(&mut app, picker).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), history + 1); + assert_eq!(serde_json::to_value(&st.doc.text_styles).unwrap(), serde_json::to_value(&shown.text_styles).unwrap()); + assert_eq!(photocraft_compose::flatten(&st.doc).get(x, y), photocraft_compose::flatten(&shown).get(x, y)); + assert!(app.text_style_preview.is_none()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc.text_styles).unwrap(), serde_json::to_value(&doc.text_styles).unwrap()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(color(&app.session.active().unwrap().doc, kind, id).unwrap().to_rgb(), [0.0, 1.0, 0.0]); + } + } +} + +#[test] +fn no_op_keeps_precise_imported_color_and_returning_to_original_removes_preview() { + let (mut app, id) = fixture(Kind::Character, 32); + app.run("type.characterStyle.set", json!({"id":id,"attrs":{"color":photocraft_doc::Color::rgba(0.12345,0.67891,0.33333,0.45678)}})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + let picker = open(&mut app, Kind::Character, id).unwrap(); + let original = fields(&app, picker)["color"].as_str().unwrap().to_string(); + set(&mut app, picker, "#ffffff"); + canvas::display_doc(&mut app, 0); + set(&mut app, picker, &original); + assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &before)); + assert!(app.text_style_preview.is_none()); + dialogs::confirm(&mut app, picker).unwrap(); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &before)); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +#[test] +fn invalidated_targets_never_redirect_and_bad_color_never_commits() { + for change in 0..6 { + let (mut app, id) = fixture(Kind::Character, 8); + let picker = open(&mut app, Kind::Character, id).unwrap(); + set(&mut app, picker, "#00ff00"); + canvas::display_doc(&mut app, 0); + let pending = fields(&app, picker); + match change { + 0 => { + app.run("file.new", json!({"width":10,"height":10})).unwrap(); + } + 1 => { + app.run("type.characterStyle.delete", json!({"id":id})).unwrap(); + } + 2 => { + app.run("type.characterStyle.set", json!({"id":id,"attrs":{"size":42}})).unwrap(); + } + 3 => app.ui.type_panels.options = None, + 4 => app.ui.type_panels.styles = false, + _ => app.ui.type_panels.styles_tab = 1, + } + let doc = app.session.active().unwrap().doc.clone(); + assert!(confirm(&mut app, &pending).is_err()); + prune(&mut app); + assert!(app.ui.dialogs.is_empty()); + assert!(app.text_style_preview.is_none()); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + } + let (mut app, id) = fixture(Kind::Character, 8); + let picker = open(&mut app, Kind::Character, id).unwrap(); + set(&mut app, picker, "invalid"); + let doc = app.session.active().unwrap().doc.clone(); + assert!(dialogs::confirm(&mut app, picker).is_err()); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); +} + +#[test] +fn inherited_colors_and_invalid_open_requests_are_atomic() { + let (mut app, _) = fixture(Kind::Character, 8); + let inherited = app.run("type.characterStyle.new", json!({"fromSelection":false})).unwrap()["id"].as_u64().unwrap() as u32; + let panels = app.ui.type_panels.clone(); + for (kind, id) in [(Kind::Character, 0), (Kind::Character, u32::MAX), (Kind::Character, inherited), (Kind::Paragraph, 0)] { + assert!(open(&mut app, kind, id).is_err()); + assert_eq!(app.ui.type_panels, panels); + assert!(app.ui.dialogs.is_empty()); + } + app.run("type.paragraphStyle.set", json!({"id":0,"attrs":{"color":"#112233"}})).unwrap(); + let picker = open(&mut app, Kind::Paragraph, 0).unwrap(); + set(&mut app, picker, "#abcdef"); + dialogs::confirm(&mut app, picker).unwrap(); + assert_eq!(color_picker_ui::hex(color(&app.session.active().unwrap().doc, Kind::Paragraph, 0).unwrap().to_rgb()), "#abcdef"); +} + +fn harness(kind: Kind) -> Harness<'static, PhotocraftApp> { + let (mut app, id) = fixture(kind, 8); + app.ui.type_panels.styles = true; + app.ui.type_panels.styles_tab = usize::from(kind.paragraph()); + app.ui.type_panels.options = Some((kind.paragraph(), id)); + let mut h = Harness::builder().with_size(vec2(1500.0, 1000.0)).build_ui_state( + move |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + super::super::styles_panel(app, ui, kind.paragraph()); + dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium); + h.run_steps(4); + h +} + +#[test] +fn actual_style_swatch_opens_shared_picker_enter_commits_escape_cancels() { + for kind in [Kind::Character, Kind::Paragraph] { + for key in [Key::Enter, Key::Escape] { + let mut h = harness(kind); + let doc = h.state().session.active().unwrap().doc.clone(); + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run(); + assert_eq!(h.state().ui.dialogs.len(), 1); + let picker = h.state().ui.dialogs[0].id; + assert_eq!(fields(h.state(), picker)["__label"], "Color Picker (Text Color)"); + set(h.state_mut(), picker, "#00ff00"); + h.key_press(key); + h.run(); + assert!(h.state().ui.dialogs.is_empty()); + if key == Key::Escape { + assert!(Arc::ptr_eq(&h.state().session.active().unwrap().doc, &doc)); + } else { + assert!(!Arc::ptr_eq(&h.state().session.active().unwrap().doc, &doc)); + } + assert!(h.state().ui.type_panels.options.is_some()); + } + } +} + +#[test] +#[ignore = "manual preview performance comparison"] +fn preview_timings() { + let (mut app, id) = fixture(Kind::Character, 8); + let t = std::time::Instant::now(); + for _ in 0..100 { + canvas::display_doc(&mut app, 0); + } + let plain = t.elapsed(); + let picker = open(&mut app, Kind::Character, id).unwrap(); + let t = std::time::Instant::now(); + for i in 0..20 { + set(&mut app, picker, &format!("#{:06x}", 0x008000 + i)); + canvas::display_doc(&mut app, 0); + } + let changes = t.elapsed(); + let t = std::time::Instant::now(); + for _ in 0..100 { + canvas::display_doc(&mut app, 0); + } + println!("plain 100 frames: {plain:?}; 20 color changes: {changes:?}; cached 100 frames: {:?}", t.elapsed()); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index dc9e38d5..82ce33a8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1491,7 +1491,7 @@ mod tests { pub fn color_edit_button_srgba(ui: &mut Ui, color: &mut Color32) -> Response { color_edit_button(ui, color, egui::color_picker::Alpha::BlendOrAdditive) } -fn color_swatch(ui: &mut Ui, color: Color32) -> Response { +pub(crate) fn color_swatch(ui: &mut Ui, color: Color32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, response) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click()); response.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ColorButton)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c247c91..068d6c9f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -47,6 +47,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75) and `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged). `ui.inspect` reports it as `toolOptions.crop_shield`. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once + - A Layer Style color field opens the shared picker with `ui.dialog.open {"kind":"colorPicker","parent":12,"effect":"fx2","field":"color"}`. `parent` is the Layer Style dialog id; `effect` is its effect instance id. `field` must be one of that instance's color parameters (`color`, or `from`/`to` for Gradient Overlay). The picker previews over the pending style without changing the document. Picker OK changes only the parent parameter; Cancel discards the preview. Confirm the parent afterwards to apply the style. Parent confirmation is refused while its picker is open; parent cancellation closes both. The binding is exposed in `ui.inspect` as `__layerStyleColor`; changed/closed targets or a document switch invalidate it. + - `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`. - The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.