* 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:
parent
a81eaa4187
commit
e398cc80b8
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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(¤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();
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue