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 <echelon@gmail.com>
This commit is contained in:
Shayan Horbank 2026-10-10 04:03:51 +02:00 committed by GitHub
parent a81eaa4187
commit e398cc80b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1108 additions and 62 deletions

View File

@ -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<usize> {
s.edit(label, |doc, _| {
let old = doc.text_styles.clone();
doc.text_styles = new.clone();
let snapshot = doc.clone();
let ids: Vec<LayerId> = 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<LayerId> = 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<Value> {
Ok(json!({ "id": id, "name": name }))
}
fn set_options(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
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<Value> {
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<Document> {
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<Value> {
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);

View File

@ -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);
}

View File

@ -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<Document>, 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}");
}

View File

@ -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<String, Value> {
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<String, Value>) {
/// OK: set the foreground or background colour.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
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);
}

View File

@ -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::<crate::type_panels_ui::color_picker::Kind>(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

View File

@ -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<bool> = 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<Value, String> {
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<Value, String> {
}
}
/// Cancel a dialog and its dependent Layer Style picker, without applying edits.
pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
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.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 Λειτουργία εμφάνισης

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -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 外観モード

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 화면 모드

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -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

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 Режим оформления

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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 Світла тема

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 外观模式

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -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 外觀模式

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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<u64> {
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<String, Valu
P::Color => {
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}));
}
});
}

View File

@ -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<String, Value>) -> Result<Target, String> {
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<String, Value>) -> bool {
fields.contains_key(TARGET)
}
pub(crate) fn child_of(fields: &Map<String, Value>, 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<String, Value>, effect: &str, field: &str) -> Result<ColorField, String> {
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, Value>, 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<u64, String> {
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<String, Value>) -> Cow<'a, Map<String, Value>> {
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<String, Value>) -> Result<Value, String> {
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;

View File

@ -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<String, Value> {
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(&current, "fx1"), super::super::entry(&original, "fx1"));
assert_eq!(super::super::entry(&current, "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();
}

View File

@ -481,6 +481,8 @@ pub struct PhotocraftApp {
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
/// 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<type_panels_ui::color_picker::Preview>,
/// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error).
pub(crate) style_preview: Option<(u64, Result<std::sync::Arc<Document>, String>)>,
pub(crate) solid_fill_preview: Option<solid_fill_ui::Preview>,
@ -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(),

View File

@ -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<Value> = 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::<photocraft_doc::Color>(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.

View File

@ -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<Arc<Document>, String>,
}
pub(crate) fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(TARGET)
}
fn target(fields: &Map<String, Value>) -> Result<Target, String> {
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<Color, String> {
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<u64, String> {
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<String, Value>) -> Result<String, String> {
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<String, Value>) -> Result<Value, String> {
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<Document>, 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;

View File

@ -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<String, Value> {
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());
}

View File

@ -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));

View File

@ -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.