* Crop tool: New / Delete Crop Preset saves and removes user presets (#1919) The Crop options-bar dropdown ends with New Crop Preset... and Delete Crop Preset..., as in Photoshop. A crop preset is a "crop" tool preset (tool.presets.*, persisted with the preferences), so it also shows in the Tool Presets panel. New saves W x H x Resolution (W, H, resolution and unit) or a ratio under a name made from the values; it is greyed out with nothing to save. Delete lists the saved crop presets for a multi-select; built-in size presets stay. The dropdown shows the preset the fields hold. Engine: tool preset names are checked (1-255 chars) and the store capped; saved tool presets load entry by entry so a damaged one is skipped; a multi-name delete is all or nothing. ui.dialog.open gains newCropPreset / deleteCropPreset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Regenerate the scorecard after rebasing onto main Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
8a9f4722d2
commit
468b8f2792
|
|
@ -123,8 +123,9 @@ struct Persisted {
|
|||
shapes: Option<Vec<Group<shapes::ShapePreset>>>,
|
||||
#[serde(default)]
|
||||
pattern_groups: Option<Vec<Group<String>>>,
|
||||
// Read entry by entry (`tools::load`): a bad entry must not discard the rest.
|
||||
#[serde(default)]
|
||||
tool_presets: Option<Vec<tools::ToolPreset>>,
|
||||
tool_presets: Option<Value>,
|
||||
// Read this set independently: a bad entry must not discard the other preset kinds.
|
||||
#[serde(default)]
|
||||
documents: Option<Value>,
|
||||
|
|
@ -144,7 +145,7 @@ impl PresetState {
|
|||
styles: Some(self.styles.clone()),
|
||||
shapes: Some(self.shapes.clone()),
|
||||
pattern_groups: Some(self.pattern_groups.clone()),
|
||||
tool_presets: Some(self.tool_presets.clone()),
|
||||
tool_presets: Some(json!(self.tool_presets)),
|
||||
documents: Some(json!(self.documents)),
|
||||
custom_shapes: Some(s.edit_state.custom_shapes.clone()),
|
||||
layer_defaults: Some(self.layer_defaults.clone()),
|
||||
|
|
@ -176,7 +177,7 @@ impl Session {
|
|||
st.pattern_groups = g;
|
||||
}
|
||||
if let Some(t) = p.tool_presets {
|
||||
st.tool_presets = t;
|
||||
st.tool_presets = tools::load(t);
|
||||
}
|
||||
if let Some(d) = p.documents {
|
||||
st.documents = crate::document_preset_cmds::load(d);
|
||||
|
|
@ -286,9 +287,20 @@ pub(crate) fn edit_groups<T: Named>(groups: &mut Vec<Group<T>>, action: &str, p:
|
|||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
_ => vec![req_str(p, "preset", cmd)?.to_string()],
|
||||
};
|
||||
// All or nothing: a missing name deletes none of the others.
|
||||
if let Some(name) = names.iter().find(|n| find(groups, n, group).is_none()) {
|
||||
return Err(bad(cmd, format!("no preset \"{name}\"")));
|
||||
}
|
||||
let mut names = names;
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for name in &names {
|
||||
let (gi, ii) = find(groups, name, group).ok_or_else(|| bad(cmd, format!("no preset \"{name}\"")))?;
|
||||
groups[gi].items.remove(ii);
|
||||
if let Some((gi, ii)) = find(groups, name, group)
|
||||
&& let Some(g) = groups.get_mut(gi)
|
||||
&& ii < g.items.len()
|
||||
{
|
||||
g.items.remove(ii);
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": names.len()}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -512,6 +512,92 @@ fn tool_presets_save_filter_select() {
|
|||
assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err());
|
||||
}
|
||||
|
||||
/// Crop presets are `crop` tool presets: saved, listed, persisted and deleted like the others.
|
||||
#[test]
|
||||
fn crop_presets_are_tool_presets_that_persist() {
|
||||
let mut s = Session::new();
|
||||
let opts =
|
||||
json!({"toolOptions": {"crop_ratio": "whr", "crop_width": "4 in", "crop_height": "5 in", "crop_resolution": "300", "crop_resolution_unit": "px/in"}});
|
||||
s.execute("tool.presets.new", json!({"name": "4 x 5 in 300 ppi", "tool": "crop", "options": opts})).unwrap();
|
||||
let r = s.execute("tool.presets.new", json!({"name": "4 x 5 in 300 ppi", "tool": "crop", "options": opts})).unwrap();
|
||||
assert_eq!(r["name"], "4 x 5 in 300 ppi 2", "a taken name gets a number");
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&s.prefs_to_json()).unwrap();
|
||||
let v = t.execute("tool.presets.list", json!({"tool": "crop"})).unwrap();
|
||||
assert_eq!(v["presets"].as_array().unwrap().len(), 2);
|
||||
let r = t.execute("tool.presets.select", json!({"preset": "4 x 5 in 300 ppi"})).unwrap();
|
||||
assert_eq!(r["options"]["toolOptions"]["crop_width"], "4 in");
|
||||
// Deleting several is all or nothing.
|
||||
assert!(t.execute("tool.presets.edit", json!({"action": "delete", "preset": ["4 x 5 in 300 ppi", "missing"]})).is_err());
|
||||
assert_eq!(t.presets.tool_presets.iter().filter(|p| p.tool == "crop").count(), 2);
|
||||
let r = t.execute("tool.presets.edit", json!({"action": "delete", "preset": ["4 x 5 in 300 ppi 2", "4 x 5 in 300 ppi", "4 x 5 in 300 ppi"]})).unwrap();
|
||||
assert_eq!(r["deleted"], 2);
|
||||
assert!(t.presets.tool_presets.iter().all(|p| p.tool != "crop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_preset_names_and_params_are_checked() {
|
||||
let mut s = Session::new();
|
||||
let n = s.presets.tool_presets.len();
|
||||
let long = "x".repeat(crate::brush_preset_cmds::MAX_NAME + 1);
|
||||
for bad in [
|
||||
json!({"name": long, "tool": "crop"}),
|
||||
json!({"name": 7, "tool": "crop"}),
|
||||
json!({"name": ["a"], "tool": "crop"}),
|
||||
json!({"tool": 3}),
|
||||
json!({"tool": "crop", "options": "big"}),
|
||||
json!({"tool": "crop", "options": [1]}),
|
||||
json!(null),
|
||||
json!([]),
|
||||
] {
|
||||
assert!(s.execute("tool.presets.new", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.presets.tool_presets.len(), n);
|
||||
// An empty or blank name falls back to the tool's; the longest name fits.
|
||||
assert_eq!(s.execute("tool.presets.new", json!({"name": " ", "tool": "crop"})).unwrap()["name"], "crop");
|
||||
let max = "é".repeat(crate::brush_preset_cmds::MAX_NAME);
|
||||
assert_eq!(s.execute("tool.presets.new", json!({"name": max, "tool": "crop"})).unwrap()["name"], json!(max));
|
||||
assert!(s.execute("tool.presets.edit", json!({"action": "rename", "preset": "crop", "name": long})).is_err());
|
||||
assert!(s.execute("tool.presets.edit", json!({"action": "rename", "preset": "crop", "name": " "})).is_err());
|
||||
// The store is capped.
|
||||
while s.presets.tool_presets.len() < super::tools::MAX_PRESETS {
|
||||
let i = s.presets.tool_presets.len();
|
||||
s.presets.tool_presets.push(super::tools::ToolPreset { name: format!("p{i}"), tool: "crop".into(), options: json!({}) });
|
||||
}
|
||||
assert!(s.execute("tool.presets.new", json!({"name": "one more", "tool": "crop"})).is_err());
|
||||
}
|
||||
|
||||
/// A damaged entry in the saved tool presets is skipped; the other presets (and preferences) load.
|
||||
#[test]
|
||||
fn damaged_tool_presets_skip_only_the_bad_entries() {
|
||||
let mut s = Session::new();
|
||||
s.execute("gradient.presets.new", json!({"name": "Kept", "stops": [[0, "#ff0000"], [1, "#0000ff"]]})).unwrap();
|
||||
let mut prefs: Value = serde_json::from_str(&s.prefs_to_json()).unwrap();
|
||||
prefs["presets"]["tool_presets"] = json!([
|
||||
{"name": "Good", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "2:3"}}},
|
||||
{"name": 5, "tool": "crop"},
|
||||
{"tool": "crop"},
|
||||
{"name": " ", "tool": "crop"},
|
||||
{"name": "x".repeat(300), "tool": "crop"},
|
||||
{"name": "No tool", "tool": ""},
|
||||
"junk",
|
||||
null,
|
||||
{"name": "Good", "tool": "brush"},
|
||||
{"name": "Odd options", "tool": "crop", "options": 12},
|
||||
]);
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&prefs.to_string()).unwrap();
|
||||
let names: Vec<&str> = t.presets.tool_presets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Good", "Odd options"]);
|
||||
assert!(t.presets.tool_presets[1].options.is_object());
|
||||
assert!(t.presets.gradients.iter().any(|g| g.items.iter().any(|p| p.name == "Kept")), "other presets still load");
|
||||
// Not an array at all: no tool presets, still no crash.
|
||||
prefs["presets"]["tool_presets"] = json!({"a": 1});
|
||||
let mut u = Session::new();
|
||||
u.load_prefs_json(&prefs.to_string()).unwrap();
|
||||
assert!(u.presets.tool_presets.is_empty());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ clone source
|
||||
|
||||
fn clone_doc(depth: u32) -> Session {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
//! JSON that the frontend writes and reads back (the desktop shell stores its `ToolOptions` under
|
||||
//! `"toolOptions"`). Two keys the engine understands itself: `"brush"` (brush settings, applied
|
||||
//! to the session brush like `tools.setBrush`) and `"foreground"` (Include Color).
|
||||
//!
|
||||
//! The Crop tool's options-bar presets (New / Delete Crop Preset…) are tool presets of the
|
||||
//! `"crop"` tool, as in Photoshop, so they also show in the Tool Presets panel.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -30,6 +33,44 @@ impl Named for ToolPreset {
|
|||
}
|
||||
}
|
||||
|
||||
/// The most tool presets kept (saving more is refused; a larger preferences file loads the first).
|
||||
pub const MAX_PRESETS: usize = 1000;
|
||||
|
||||
/// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters.
|
||||
fn checked_name(name: &str, cmd: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "the preset name is empty"));
|
||||
}
|
||||
if name.chars().count() > crate::brush_preset_cmds::MAX_NAME {
|
||||
return Err(bad(cmd, format!("preset names are at most {} characters", crate::brush_preset_cmds::MAX_NAME)));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Read saved tool presets entry by entry, so a damaged entry (wrong type, empty or overlong name,
|
||||
/// a duplicate name) is skipped without discarding the others.
|
||||
pub(crate) fn load(value: Value) -> Vec<ToolPreset> {
|
||||
let Some(entries) = value.as_array() else { return Vec::new() };
|
||||
let mut presets: Vec<ToolPreset> = Vec::new();
|
||||
for entry in entries {
|
||||
if presets.len() >= MAX_PRESETS {
|
||||
break;
|
||||
}
|
||||
let Ok(mut preset) = serde_json::from_value::<ToolPreset>(entry.clone()) else { continue };
|
||||
let Ok(name) = checked_name(&preset.name, "") else { continue };
|
||||
if preset.tool.trim().is_empty() || presets.iter().any(|p| p.name == name) {
|
||||
continue;
|
||||
}
|
||||
if !preset.options.is_object() {
|
||||
preset.options = json!({});
|
||||
}
|
||||
preset.name = name;
|
||||
presets.push(preset);
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
fn norm(t: &str) -> String {
|
||||
t.to_ascii_lowercase().replace([' ', '_', '-'], "").trim_end_matches("tool").to_string()
|
||||
}
|
||||
|
|
@ -90,8 +131,17 @@ fn new_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let f = s.tools.foreground;
|
||||
options["foreground"] = json!(f);
|
||||
}
|
||||
if s.presets.tool_presets.len() >= MAX_PRESETS {
|
||||
return Err(bad(CMD, format!("at most {MAX_PRESETS} tool presets can be saved; delete some first")));
|
||||
}
|
||||
let base = match p.get("name") {
|
||||
None | Some(Value::Null) => tool.clone(),
|
||||
Some(Value::String(n)) if n.trim().is_empty() => tool.clone(),
|
||||
Some(Value::String(n)) => checked_name(n, CMD)?,
|
||||
Some(_) => return Err(bad(CMD, "`name` must be a string")),
|
||||
};
|
||||
let groups = [Group::new("", s.presets.tool_presets.clone())];
|
||||
let name = super::unique_name(&groups, str_param(p, "name").unwrap_or(&tool));
|
||||
let name = super::unique_name(&groups, &base);
|
||||
s.presets.tool_presets.push(ToolPreset { name: name.clone(), tool: tool.clone(), options });
|
||||
s.presets_changed();
|
||||
Ok(json!({"name": name, "tool": tool}))
|
||||
|
|
@ -123,6 +173,9 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !matches!(action.as_str(), "rename" | "delete") {
|
||||
return Err(bad(CMD, "tool presets support rename and delete"));
|
||||
}
|
||||
if action == "rename" {
|
||||
checked_name(req_str(p, "name", CMD)?, CMD)?;
|
||||
}
|
||||
let mut groups = vec![Group::new("", std::mem::take(&mut s.presets.tool_presets))];
|
||||
let r = edit_groups(&mut groups, &action, p, CMD);
|
||||
s.presets.tool_presets = groups.pop().map(|g| g.items).unwrap_or_default();
|
||||
|
|
@ -154,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "New Tool Preset…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"name":str?=tool,"tool":name,"options":{…}? (opaque; "brush" defaults to the current brush for painting tools),"includeColor":bool=false}"##,
|
||||
params: r##"{"name":str?=tool (at most 255 characters; a taken name gets " 2", " 3"…),"tool":name ("crop" for Crop presets),"options":{…}? (opaque; "brush" defaults to the current brush for painting tools),"includeColor":bool=false}"##,
|
||||
enabled: always,
|
||||
run: new_preset,
|
||||
journal: true,
|
||||
|
|
|
|||
|
|
@ -745,6 +745,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
let target = if s("target") == Some("background") { "background" } else { "foreground" };
|
||||
return ok(json!({"dialog": crate::color_picker_ui::open(app, target)}));
|
||||
}
|
||||
// The Crop tool's New / Delete Crop Preset… (#1919).
|
||||
"newCropPreset" => {
|
||||
return match crate::crop_presets::open_new(app) {
|
||||
Some(id) => ok(json!({"dialog": id})),
|
||||
None => err("nothing to save: set W and H (W x H x Resolution) or a ratio first"),
|
||||
};
|
||||
}
|
||||
"deleteCropPreset" => {
|
||||
return match crate::crop_presets::open_delete(app) {
|
||||
Some(id) => ok(json!({"dialog": id})),
|
||||
None => err("no crop presets are saved"),
|
||||
};
|
||||
}
|
||||
"command" | "Command" => {
|
||||
let Some(cmd) = s("command") else { return err("command dialogs need `command`") };
|
||||
let label = photocraft_engine::commands::find(cmd).map(|c| c.label).unwrap_or(cmd);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,550 @@
|
|||
//! The Crop tool's saved presets (#1919): New Crop Preset… and Delete Crop Preset… at the end of
|
||||
//! the options bar's preset dropdown, as in Photoshop.
|
||||
//!
|
||||
//! A crop preset is a tool preset of the `"crop"` tool (`tool.presets.*`, persisted with the
|
||||
//! preferences like every tool preset), the way Photoshop keeps them: they show in this dropdown
|
||||
//! and in Window › Tool Presets alike. A preset's options hold `{"toolOptions": {crop_ratio, …}}`:
|
||||
//! W x H x Resolution saves `crop_ratio` = "whr", W, H, the resolution and its unit; a ratio saves
|
||||
//! its `crop_ratio` ("2:3").
|
||||
//!
|
||||
//! - New Crop Preset… opens "New Crop Preset" with a Name (made from the values, "4 x 5 in 300 ppi"
|
||||
//! or "2 : 3"); OK saves it (`tool.presets.new`). It is greyed out when there is nothing to save
|
||||
//! (W or H empty, no ratio).
|
||||
//! - Delete Crop Preset… lists the saved crop presets; OK deletes the selected ones
|
||||
//! (`tool.presets.edit` delete). Photoshop's built-in size presets ([`crop_size::PRESETS`]) are
|
||||
//! not tool presets here and can't be deleted. Greyed out with no saved crop preset.
|
||||
//! - The dropdown shows the name of the preset whose values the fields hold, so a preset just saved
|
||||
//! or chosen reads as selected, and the mode again once the fields change or it is deleted.
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::crop_size::{self, PX_PER_CM, PX_PER_IN, WHR};
|
||||
use crate::state::{DialogKind, Tool, ToolOptions};
|
||||
|
||||
/// Dialog field marking New Crop Preset.
|
||||
const NEW_MARK: &str = "__cropPresetNew";
|
||||
/// Dialog field marking Delete Crop Preset.
|
||||
const DELETE_MARK: &str = "__cropPresetDelete";
|
||||
/// The tool name crop presets are saved under.
|
||||
pub const TOOL: &str = "crop";
|
||||
/// The longest preset name (the engine's limit for tool preset names).
|
||||
pub const MAX_NAME: usize = 255;
|
||||
/// The `ToolOptions` fields a crop preset saves.
|
||||
const KEYS: [&str; 5] = ["crop_ratio", "crop_width", "crop_height", "crop_resolution", "crop_resolution_unit"];
|
||||
|
||||
/// One entry of the preset dropdown.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Choice {
|
||||
/// A built-in key (`crop_ratio` value, size preset or Front Image), see [`crop_size::chosen`].
|
||||
Builtin(String),
|
||||
/// A saved crop preset, by name.
|
||||
User(String),
|
||||
New,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// The saved crop presets: (name, crop fields of its `toolOptions`), in saved order.
|
||||
pub fn user_presets(app: &PhotocraftApp) -> Vec<(String, Map<String, Value>)> {
|
||||
app.session
|
||||
.presets
|
||||
.tool_presets
|
||||
.iter()
|
||||
.filter(|p| Tool::from_name(&p.tool) == Some(Tool::Crop))
|
||||
.map(|p| (p.name.clone(), crop_fields(p.options.get("toolOptions").unwrap_or(&Value::Null))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The crop fields of a `toolOptions` object (string values only; anything else is dropped).
|
||||
fn crop_fields(v: &Value) -> Map<String, Value> {
|
||||
KEYS.iter().filter_map(|k| v.get(*k).filter(|x| x.is_string()).map(|x| (k.to_string(), x.clone()))).collect()
|
||||
}
|
||||
|
||||
/// The fields of `o` a preset compares against.
|
||||
fn current_fields(o: &ToolOptions) -> Map<String, Value> {
|
||||
let v = json!({"crop_ratio": o.crop_ratio, "crop_width": o.crop_width, "crop_height": o.crop_height,
|
||||
"crop_resolution": o.crop_resolution, "crop_resolution_unit": o.crop_resolution_unit});
|
||||
v.as_object().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the fields hold a preset's values (each crop field it saved, `crop_ratio` required).
|
||||
fn matches(o: &ToolOptions, fields: &Map<String, Value>) -> bool {
|
||||
let cur = current_fields(o);
|
||||
fields.contains_key("crop_ratio") && fields.iter().all(|(k, v)| cur.get(k) == Some(v))
|
||||
}
|
||||
|
||||
/// The number of a length or resolution as the preset name shows it ("8.5", "300").
|
||||
fn number(v: f64, unit: photocraft_engine::prefs::Unit) -> String {
|
||||
let s = crop_size::format_length(v, unit);
|
||||
s.split_once(' ').map_or(s.clone(), |(n, _)| n.to_string())
|
||||
}
|
||||
|
||||
/// What New Crop Preset would save from `o`: the crop fields and the name it suggests. `None` when
|
||||
/// there is nothing to save (W x H x Resolution without both W and H, or no numeric ratio).
|
||||
pub fn savable(o: &ToolOptions) -> Option<(Map<String, Value>, String)> {
|
||||
use photocraft_engine::prefs::Unit;
|
||||
if o.crop_ratio == WHR {
|
||||
let (w, wu) = crop_size::parse_length(&o.crop_width, Unit::Pixels)?;
|
||||
let (h, hu) = crop_size::parse_length(&o.crop_height, Unit::Pixels)?;
|
||||
let mut name = if wu == hu {
|
||||
format!("{} x {} {}", number(w, wu), number(h, hu), wu.suffix())
|
||||
} else {
|
||||
format!("{} x {}", crop_size::format_length(w, wu), crop_size::format_length(h, hu))
|
||||
};
|
||||
if let Some(r) = crate::widgets::parse_num(&o.crop_resolution.trim().replace(',', ".")).filter(|r| r.is_finite() && *r > 0.0) {
|
||||
let unit = if o.crop_resolution_unit == PX_PER_CM { "px/cm" } else { "ppi" };
|
||||
name = format!("{name} {} {unit}", crate::widgets::fmt_num2(r));
|
||||
}
|
||||
let mut fields = current_fields(o);
|
||||
if fields.get("crop_resolution_unit").and_then(Value::as_str).is_none_or(|u| u != PX_PER_CM) {
|
||||
fields.insert("crop_resolution_unit".into(), json!(PX_PER_IN));
|
||||
}
|
||||
return Some((fields, name));
|
||||
}
|
||||
if o.crop_ratio == "original" {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0)?;
|
||||
if !(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let name = format!("{} : {}", crate::widgets::fmt_num(a), crate::widgets::fmt_num(b));
|
||||
let mut fields = Map::new();
|
||||
fields.insert("crop_ratio".into(), json!(o.crop_ratio));
|
||||
Some((fields, name))
|
||||
}
|
||||
|
||||
/// The dropdown's button text: the saved preset (latest first) or built-in size preset the fields
|
||||
/// hold, else the mode or ratio entry, else "Ratio" for a typed ratio.
|
||||
fn button_label(o: &ToolOptions, builtin: &[(String, &'static str)], users: &[(String, Map<String, Value>)]) -> String {
|
||||
if let Some((name, _)) = users.iter().rev().find(|(_, f)| matches(o, f)) {
|
||||
return name.clone();
|
||||
}
|
||||
if o.crop_ratio == WHR
|
||||
&& let Some((_, label, ..)) = crop_size::PRESETS
|
||||
.iter()
|
||||
.find(|(_, _, w, h, r)| o.crop_width == *w && o.crop_height == *h && o.crop_resolution == *r && o.crop_resolution_unit == PX_PER_IN)
|
||||
{
|
||||
return tl!(label).to_string();
|
||||
}
|
||||
let label = builtin.iter().find(|(k, _)| *k == o.crop_ratio).or_else(|| builtin.first()).map_or("", |(_, l)| *l);
|
||||
tl!(label).to_string()
|
||||
}
|
||||
|
||||
/// The options bar's preset dropdown: the built-in entries ([`crop_size::dropdown_options`]), the
|
||||
/// saved crop presets before Front Image, then New Crop Preset… and Delete Crop Preset….
|
||||
pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let builtin = crop_size::dropdown_options();
|
||||
let users = user_presets(app);
|
||||
let o = &app.ui.tool_options;
|
||||
let label = button_label(o, &builtin, &users);
|
||||
let current_user = users.iter().rev().find(|(_, f)| matches(o, f)).map(|(n, _)| n.clone());
|
||||
let can_new = savable(o).is_some();
|
||||
let mut chosen: Option<Choice> = None;
|
||||
egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT);
|
||||
for (k, l) in &before {
|
||||
if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() {
|
||||
chosen = Some(Choice::Builtin(k.clone()));
|
||||
}
|
||||
}
|
||||
for (name, _) in &users {
|
||||
if ui.selectable_label(current_user.as_deref() == Some(name), name.as_str()).clicked() {
|
||||
chosen = Some(Choice::User(name.clone()));
|
||||
}
|
||||
}
|
||||
for (k, l) in &after {
|
||||
if ui.selectable_label(false, tl!(l)).clicked() {
|
||||
chosen = Some(Choice::Builtin(k.clone()));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(can_new, egui::Button::selectable(false, tl!("New Crop Preset…"))).clicked() {
|
||||
chosen = Some(Choice::New);
|
||||
}
|
||||
if ui.add_enabled(!users.is_empty(), egui::Button::selectable(false, tl!("Delete Crop Preset…"))).clicked() {
|
||||
chosen = Some(Choice::Delete);
|
||||
}
|
||||
});
|
||||
match chosen {
|
||||
Some(Choice::Builtin(k)) => {
|
||||
app.ui.tool_options.crop_ratio = k;
|
||||
// A size preset or Front Image fills W x H x Resolution (#2443).
|
||||
crop_size::chosen(app);
|
||||
}
|
||||
Some(Choice::User(name)) => select(app, &name),
|
||||
Some(Choice::New) => {
|
||||
open_new(app);
|
||||
}
|
||||
Some(Choice::Delete) => {
|
||||
open_delete(app);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a saved crop preset: its fields fill the options bar (`tool.presets.select`, the same
|
||||
/// as clicking it in the Tool Presets panel).
|
||||
pub fn select(app: &mut PhotocraftApp, name: &str) {
|
||||
crate::preset_panels::select_tool_preset(app, name);
|
||||
}
|
||||
|
||||
/// Open New Crop Preset with the name made from the current values. `None` (nothing opens) when
|
||||
/// there is nothing to save; an open one is reused.
|
||||
pub fn open_new(app: &mut PhotocraftApp) -> Option<u64> {
|
||||
let (fields, name) = savable(&app.ui.tool_options)?;
|
||||
if let Some(d) = app.ui.dialogs.iter().find(|d| d.fields.contains_key(NEW_MARK)) {
|
||||
return Some(d.id);
|
||||
}
|
||||
let f = json!({NEW_MARK: true, "__label": "New Crop Preset", "name": name, "options": fields});
|
||||
Some(app.ui.open_dialog(DialogKind::Command, f.as_object().cloned().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Open Delete Crop Preset. `None` when no crop preset is saved; an open one is reused.
|
||||
pub fn open_delete(app: &mut PhotocraftApp) -> Option<u64> {
|
||||
if user_presets(app).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(d) = app.ui.dialogs.iter().find(|d| d.fields.contains_key(DELETE_MARK)) {
|
||||
return Some(d.id);
|
||||
}
|
||||
let f = json!({DELETE_MARK: true, "__label": "Delete Crop Preset", "selected": []});
|
||||
Some(app.ui.open_dialog(DialogKind::Command, f.as_object().cloned().unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(NEW_MARK) || fields.contains_key(DELETE_MARK)
|
||||
}
|
||||
|
||||
/// The trimmed name a New Crop Preset dialog would save, if it is one (1–[`MAX_NAME`] characters).
|
||||
fn new_name(fields: &Map<String, Value>) -> Option<String> {
|
||||
let n = fields.get("name").and_then(Value::as_str)?.trim();
|
||||
(!n.is_empty() && n.chars().count() <= MAX_NAME).then(|| n.to_string())
|
||||
}
|
||||
|
||||
/// The names a Delete Crop Preset dialog selected that are saved crop presets.
|
||||
fn delete_names(app: &PhotocraftApp, fields: &Map<String, Value>) -> Vec<String> {
|
||||
let saved = user_presets(app);
|
||||
let picked: Vec<&str> = fields.get("selected").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
saved.into_iter().map(|(n, _)| n).filter(|n| picked.contains(&n.as_str())).collect()
|
||||
}
|
||||
|
||||
/// Whether OK (and Enter) may confirm the dialog: a name to save, a preset to delete. True for any
|
||||
/// other dialog.
|
||||
pub fn can_confirm(app: &PhotocraftApp, fields: &Map<String, Value>) -> bool {
|
||||
if fields.contains_key(NEW_MARK) {
|
||||
new_name(fields).is_some()
|
||||
} else if fields.contains_key(DELETE_MARK) {
|
||||
!delete_names(app, fields).is_empty()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
|
||||
if fields.contains_key(NEW_MARK) {
|
||||
new_body(ui, fields);
|
||||
} else {
|
||||
delete_body(app, ui, fields);
|
||||
}
|
||||
}
|
||||
|
||||
fn new_body(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
|
||||
let mut name = fields.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let id = ui.id().with("crop-preset-name");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).id(id).char_limit(MAX_NAME).desired_width(260.0));
|
||||
// Photoshop focuses the name with all of it selected, so typing replaces it.
|
||||
let once = id.with("focused");
|
||||
if !ui.ctx().data(|d| d.get_temp::<bool>(once).unwrap_or(false)) {
|
||||
r.request_focus();
|
||||
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) {
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ui.ctx(), id);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(once, true));
|
||||
}
|
||||
}
|
||||
});
|
||||
fields.insert("name".into(), json!(name));
|
||||
}
|
||||
|
||||
fn delete_body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
|
||||
let saved = user_presets(app);
|
||||
let mut selected: Vec<String> =
|
||||
fields.get("selected").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
ui.label(tl!("Select the crop presets to delete:"));
|
||||
ui.add_space(4.0);
|
||||
let toggle = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).field_border)).inner_margin(2.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("crop-preset-delete").max_height(220.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
// Full-width rows, names on the left.
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| {
|
||||
for (name, _) in &saved {
|
||||
let on = selected.contains(name);
|
||||
let r = ui.add(egui::Button::selectable(on, name.as_str()).min_size(vec2(0.0, 22.0)));
|
||||
if r.clicked() {
|
||||
// Click picks one; Ctrl/Cmd or Shift adds or removes it.
|
||||
if !toggle {
|
||||
selected.clear();
|
||||
selected.push(name.clone());
|
||||
} else if on {
|
||||
selected.retain(|n| n != name);
|
||||
} else {
|
||||
selected.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
fields.insert("selected".into(), json!(selected));
|
||||
}
|
||||
|
||||
/// OK: save the preset, or delete the selected ones.
|
||||
pub fn confirm(app: &mut PhotocraftApp, fields: &Map<String, Value>) -> Result<Value, String> {
|
||||
if fields.contains_key(NEW_MARK) {
|
||||
let name = new_name(fields).ok_or(tl!("Enter a name for the crop preset"))?;
|
||||
let options = crop_fields(fields.get("options").unwrap_or(&Value::Null));
|
||||
if !options.contains_key("crop_ratio") {
|
||||
return Err(tl!("There is no crop size or ratio to save").into());
|
||||
}
|
||||
return app.run("tool.presets.new", json!({"name": name, "tool": TOOL, "options": {"toolOptions": options}}));
|
||||
}
|
||||
let names = delete_names(app, fields);
|
||||
if names.is_empty() {
|
||||
return Err(tl!("Select the crop presets to delete").into());
|
||||
}
|
||||
app.run("tool.presets.edit", json!({"action": "delete", "preset": names}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
fn app_with(services: crate::Services) -> PhotocraftApp {
|
||||
let mut doc = Document::with_background("crop", Size::new(400, 300), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
doc.resolution_dpi = 72.0;
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc, None);
|
||||
let mut app = PhotocraftApp::new(s, services);
|
||||
app.ui.tool = Tool::Crop;
|
||||
app
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
app_with(crate::Services::default())
|
||||
}
|
||||
|
||||
fn whr(app: &mut PhotocraftApp, w: &str, h: &str, r: &str, unit: &str) {
|
||||
let o = &mut app.ui.tool_options;
|
||||
o.crop_ratio = WHR.into();
|
||||
o.crop_width = w.into();
|
||||
o.crop_height = h.into();
|
||||
o.crop_resolution = r.into();
|
||||
o.crop_resolution_unit = unit.into();
|
||||
}
|
||||
|
||||
fn names(app: &PhotocraftApp) -> Vec<String> {
|
||||
user_presets(app).into_iter().map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggested_names_follow_the_values() {
|
||||
let mut app = app();
|
||||
whr(&mut app, "4 in", "5 in", "300", PX_PER_IN);
|
||||
assert_eq!(savable(&app.ui.tool_options).unwrap().1, "4 x 5 in 300 ppi");
|
||||
whr(&mut app, "1024 px", "768 px", "", PX_PER_IN);
|
||||
assert_eq!(savable(&app.ui.tool_options).unwrap().1, "1024 x 768 px");
|
||||
whr(&mut app, "10 cm", "200 px", "118.11", PX_PER_CM);
|
||||
assert_eq!(savable(&app.ui.tool_options).unwrap().1, "10 cm x 200 px 118.11 px/cm");
|
||||
app.ui.tool_options.crop_ratio = "2:3".into();
|
||||
let (f, n) = savable(&app.ui.tool_options).unwrap();
|
||||
assert_eq!((n.as_str(), f.len()), ("2 : 3", 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_save_greys_out_new_and_opens_nothing() {
|
||||
let mut app = app();
|
||||
for (ratio, w, h) in [(WHR, "", "5 in"), (WHR, "4 in", ""), (WHR, "junk", "5 in"), ("", "", ""), ("original", "", ""), ("0:0", "", ""), ("a:b", "", "")]
|
||||
{
|
||||
whr(&mut app, w, h, "300", PX_PER_IN);
|
||||
app.ui.tool_options.crop_ratio = ratio.into();
|
||||
assert!(savable(&app.ui.tool_options).is_none(), "{ratio} {w} {h}");
|
||||
assert_eq!(open_new(&mut app), None);
|
||||
}
|
||||
// No saved crop preset: Delete is greyed out too (the built-in Brush presets don't count).
|
||||
assert!(names(&app).is_empty() && !app.session.presets.tool_presets.is_empty());
|
||||
assert_eq!(open_delete(&mut app), None);
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
}
|
||||
|
||||
/// New Crop Preset → saved → kept across a restart → listed in the dropdown → choosing it fills
|
||||
/// the fields; Delete Crop Preset removes it, and the dropdown falls back to the mode.
|
||||
#[test]
|
||||
fn save_reload_select_and_delete() {
|
||||
let saved = Rc::new(RefCell::new(String::new()));
|
||||
let copy = saved.clone();
|
||||
let mut app = app_with(crate::Services {
|
||||
save_prefs: Some(Box::new(move |text| {
|
||||
*copy.borrow_mut() = text.to_string();
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
whr(&mut app, "6 in", "9 in", "240", PX_PER_IN);
|
||||
let id = open_new(&mut app).unwrap();
|
||||
assert_eq!(open_new(&mut app), Some(id), "one dialog at a time");
|
||||
assert_eq!(app.ui.dialogs[0].fields["name"], "6 x 9 in 240 ppi");
|
||||
assert_eq!(crate::dialogs::title(&app.ui.dialogs[0]), "New Crop Preset");
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!(" Poster "));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(names(&app), ["Poster"]);
|
||||
let builtin = crop_size::dropdown_options();
|
||||
assert_eq!(button_label(&app.ui.tool_options, &builtin, &user_presets(&app)), "Poster", "the new preset reads as selected");
|
||||
// A ratio preset too.
|
||||
app.ui.tool_options.crop_ratio = "3:2".into();
|
||||
let id = open_new(&mut app).unwrap();
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(names(&app), ["Poster", "3 : 2"]);
|
||||
crate::prefs_ui::save_preferences(&mut app).unwrap();
|
||||
|
||||
// Restart.
|
||||
let text = saved.borrow().clone();
|
||||
assert!(text.contains("Poster"));
|
||||
let mut fresh = app_with(crate::Services { load_prefs: Some(Box::new(move || Some(text.clone()))), ..Default::default() });
|
||||
assert_eq!(names(&fresh), ["Poster", "3 : 2"]);
|
||||
whr(&mut fresh, "", "", "", PX_PER_CM);
|
||||
select(&mut fresh, "Poster");
|
||||
let o = &fresh.ui.tool_options;
|
||||
assert_eq!(
|
||||
(o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str(), o.crop_resolution.as_str(), o.crop_resolution_unit.as_str()),
|
||||
(WHR, "6 in", "9 in", "240", PX_PER_IN)
|
||||
);
|
||||
assert_eq!(fresh.ui.tool, Tool::Crop);
|
||||
select(&mut fresh, "3 : 2");
|
||||
assert_eq!(fresh.ui.tool_options.crop_ratio, "3:2");
|
||||
select(&mut fresh, "Poster");
|
||||
|
||||
// Delete the selected one: the fields stay, the dropdown shows the mode again.
|
||||
let id = open_delete(&mut fresh).unwrap();
|
||||
assert!(!can_confirm(&fresh, &fresh.ui.dialogs[0].fields), "nothing selected yet");
|
||||
fresh.ui.dialog_mut(id).unwrap().fields.insert("selected".into(), json!(["Poster", "not a preset"]));
|
||||
assert!(can_confirm(&fresh, &fresh.ui.dialogs[0].fields));
|
||||
crate::dialogs::confirm(&mut fresh, id).unwrap();
|
||||
assert_eq!(names(&fresh), ["3 : 2"]);
|
||||
assert_eq!(fresh.ui.tool_options.crop_width, "6 in");
|
||||
let label = button_label(&fresh.ui.tool_options, &crop_size::dropdown_options(), &user_presets(&fresh));
|
||||
assert_eq!(label, "W x H x Resolution");
|
||||
// Other tool presets are untouched.
|
||||
assert!(fresh.session.presets.tool_presets.iter().any(|p| p.name == "Soft Round 100 px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dialog_input_is_refused_without_a_crash() {
|
||||
let mut app = app();
|
||||
whr(&mut app, "4 in", "5 in", "", PX_PER_IN);
|
||||
for bad in [json!(""), json!(" "), json!(42), json!(null), json!("x".repeat(MAX_NAME + 1))] {
|
||||
let id = open_new(&mut app).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), bad.clone());
|
||||
assert!(!can_confirm(&app, &app.ui.dialogs[0].fields), "{bad}");
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{bad}");
|
||||
}
|
||||
// Tampered options: not an object, or without a ratio.
|
||||
for bad in [json!(7), json!({"crop_width": "4 in"}), json!({"crop_ratio": 3})] {
|
||||
let id = open_new(&mut app).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("options".into(), bad.clone());
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{bad}");
|
||||
}
|
||||
assert!(names(&app).is_empty());
|
||||
// Duplicate names get a number (the engine keeps names unique); a long name fits.
|
||||
for _ in 0..2 {
|
||||
let id = open_new(&mut app).unwrap();
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
}
|
||||
let id = open_new(&mut app).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!("é".repeat(MAX_NAME)));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(names(&app)[..2], ["4 x 5 in", "4 x 5 in 2"]);
|
||||
// Delete with junk selections.
|
||||
for bad in [json!(null), json!("4 x 5 in"), json!([1, 2]), json!([])] {
|
||||
let id = open_delete(&mut app).unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("selected".into(), bad.clone());
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(names(&app).len(), 3);
|
||||
// A preset from a hand-edited file with odd options is listed and selecting it is harmless.
|
||||
app.session.presets.tool_presets.push(photocraft_engine::presets::tools::ToolPreset {
|
||||
name: "Odd".into(),
|
||||
tool: "crop".into(),
|
||||
options: json!({"toolOptions": {"crop_ratio": 5, "crop_width": []}}),
|
||||
});
|
||||
let before = app.ui.tool_options.clone();
|
||||
select(&mut app, "Odd");
|
||||
assert_eq!(app.ui.tool_options, before);
|
||||
let _ = button_label(&app.ui.tool_options, &crop_size::dropdown_options(), &user_presets(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_channel_opens_the_dialogs() {
|
||||
use crate::control::{ControlRequest, Outcome, handle};
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let call = |app: &mut PhotocraftApp, m: &str, p: Value| {
|
||||
let (r, _rx) = ControlRequest::new(m, p);
|
||||
match handle(app, &ctx, &r) {
|
||||
Outcome::Done(v) => v,
|
||||
_ => Value::Null,
|
||||
}
|
||||
};
|
||||
assert_eq!(call(&mut app, "ui.dialog.open", json!({"kind": "newCropPreset"}))["ok"], false);
|
||||
assert_eq!(call(&mut app, "ui.dialog.open", json!({"kind": "deleteCropPreset"}))["ok"], false);
|
||||
app.ui.tool_options.crop_ratio = "16:10".into();
|
||||
let id = call(&mut app, "ui.dialog.open", json!({"kind": "newCropPreset"}))["result"]["dialog"].as_u64().unwrap();
|
||||
assert_eq!(call(&mut app, "ui.dialog.set", json!({"dialog": id, "field": "name", "value": "Wide"}))["ok"], true);
|
||||
assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true);
|
||||
assert_eq!(names(&app), ["Wide"]);
|
||||
let id = call(&mut app, "ui.dialog.open", json!({"kind": "deleteCropPreset"}))["result"]["dialog"].as_u64().unwrap();
|
||||
call(&mut app, "ui.dialog.set", json!({"dialog": id, "field": "selected", "value": ["Wide"]}));
|
||||
assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true);
|
||||
assert!(names(&app).is_empty());
|
||||
}
|
||||
|
||||
/// The dropdown in the real options bar: New Crop Preset… is greyed out with nothing to save,
|
||||
/// and opens the dialog otherwise.
|
||||
#[test]
|
||||
fn options_bar_dropdown_offers_new_and_delete() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = app();
|
||||
whr(&mut app, "", "", "", PX_PER_IN);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_value("W x H x Resolution").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.get_by_label("New Crop Preset…").accesskit_node().is_disabled(), "nothing to save");
|
||||
assert!(h.get_by_label("Delete Crop Preset…").accesskit_node().is_disabled(), "nothing saved");
|
||||
h.get_by_value("W x H x Resolution").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("New Crop Preset…").is_none(), "the list closed");
|
||||
whr(h.state_mut(), "4 in", "6 in", "300", PX_PER_IN);
|
||||
h.run_steps(4);
|
||||
h.get_by_value("4 x 6 in 300 ppi").click();
|
||||
h.run_steps(4);
|
||||
assert!(!h.get_by_label("New Crop Preset…").accesskit_node().is_disabled());
|
||||
h.get_by_label("New Crop Preset…").click_accesskit();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().ui.dialogs.iter().any(|d| d.fields.contains_key(NEW_MARK)), "{}", h.state().ui.status);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
assert_eq!(names(h.state()), ["4 x 6 in 300 ppi"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -215,6 +215,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields),
|
||||
DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::color_picker_ui::owns(&fields) => {
|
||||
|
|
@ -271,7 +272,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
} else {
|
||||
crate::file_ui::ok_label(&d.fields).unwrap_or(tl!("OK"))
|
||||
};
|
||||
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0);
|
||||
// Dialogs that need an input first (a preset name, a selection) grey OK out.
|
||||
let can_ok = crate::crop_presets::can_confirm(app, &fields);
|
||||
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok);
|
||||
// Photoshop: holding Alt turns Cancel into Reset (the dialog stays open).
|
||||
let reset = ui.input(|i| i.modifiers.alt) && crate::adjust_dialog::resets(&fields);
|
||||
let cancel_label = if reset {
|
||||
|
|
@ -293,7 +296,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 interactive && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true),
|
||||
None if interactive && can_ok && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -430,6 +433,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
|||
DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::crop_presets::owns(&d.fields) => crate::crop_presets::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields),
|
||||
|
|
|
|||
|
|
@ -2478,5 +2478,11 @@ cameraData Lens Objektiv
|
|||
Previous Document Předchozí dokument
|
||||
W x H x Resolution Š x V x Rozlišení
|
||||
Front Image Přední obraz
|
||||
New Crop Preset… Nová předvolba oříznutí…
|
||||
Delete Crop Preset… Odstranit předvolbu oříznutí…
|
||||
Select the crop presets to delete: Vyberte předvolby oříznutí k odstranění:
|
||||
Select the crop presets to delete Vyberte předvolby oříznutí k odstranění
|
||||
Enter a name for the crop preset Zadejte název předvolby oříznutí
|
||||
There is no crop size or ratio to save Není žádná velikost ani poměr oříznutí k uložení
|
||||
Search settings… Hledat nastavení…
|
||||
No matching settings Žádná odpovídající nastavení
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2511,5 +2511,11 @@ cameraData Lens Objektiv
|
|||
Previous Document Vorheriges Dokument
|
||||
W x H x Resolution B x H x Auflösung
|
||||
Front Image Vorderes Bild
|
||||
New Crop Preset… Neue Freistellungsvorgabe…
|
||||
Delete Crop Preset… Freistellungsvorgabe löschen…
|
||||
Select the crop presets to delete: Wählen Sie die zu löschenden Freistellungsvorgaben:
|
||||
Select the crop presets to delete Wählen Sie die zu löschenden Freistellungsvorgaben
|
||||
Enter a name for the crop preset Geben Sie einen Namen für die Freistellungsvorgabe ein
|
||||
There is no crop size or ratio to save Es gibt keine Freistellungsgröße und kein Seitenverhältnis zum Speichern
|
||||
Search settings… Einstellungen durchsuchen…
|
||||
No matching settings Keine passenden Einstellungen
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3575,5 +3575,11 @@ cameraData Lens Φακός
|
|||
Previous Document Προηγούμενο έγγραφο
|
||||
W x H x Resolution Π x Υ x Ανάλυση
|
||||
Front Image Μπροστινή εικόνα
|
||||
New Crop Preset… Νέα προεπιλογή περικοπής…
|
||||
Delete Crop Preset… Διαγραφή προεπιλογής περικοπής…
|
||||
Select the crop presets to delete: Επιλέξτε τις προεπιλογές περικοπής για διαγραφή:
|
||||
Select the crop presets to delete Επιλέξτε τις προεπιλογές περικοπής για διαγραφή
|
||||
Enter a name for the crop preset Εισαγάγετε ένα όνομα για την προεπιλογή περικοπής
|
||||
There is no crop size or ratio to save Δεν υπάρχει μέγεθος ή αναλογία περικοπής για αποθήκευση
|
||||
Search settings… Αναζήτηση ρυθμίσεων…
|
||||
No matching settings Δεν βρέθηκαν ρυθμίσεις
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 5.
|
|
|
@ -2552,5 +2552,11 @@ cameraData Lens Objetivo
|
|||
Previous Document Documento anterior
|
||||
W x H x Resolution An. x Al. x Resolución
|
||||
Front Image Imagen frontal
|
||||
New Crop Preset… Nuevo ajuste preestablecido de recorte…
|
||||
Delete Crop Preset… Eliminar ajuste preestablecido de recorte…
|
||||
Select the crop presets to delete: Seleccione los ajustes preestablecidos de recorte que desea eliminar:
|
||||
Select the crop presets to delete Seleccione los ajustes preestablecidos de recorte que desea eliminar
|
||||
Enter a name for the crop preset Introduzca un nombre para el ajuste preestablecido de recorte
|
||||
There is no crop size or ratio to save No hay ningún tamaño ni proporción de recorte que guardar
|
||||
Search settings… Buscar ajustes…
|
||||
No matching settings No hay ajustes que coincidan
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2889,5 +2889,11 @@ cameraData Lens Objectif
|
|||
Previous Document Document précédent
|
||||
W x H x Resolution L x H x Résolution
|
||||
Front Image Image de premier plan
|
||||
New Crop Preset… Nouveau paramètre prédéfini de recadrage…
|
||||
Delete Crop Preset… Supprimer le paramètre prédéfini de recadrage…
|
||||
Select the crop presets to delete: Sélectionnez les paramètres prédéfinis de recadrage à supprimer :
|
||||
Select the crop presets to delete Sélectionnez les paramètres prédéfinis de recadrage à supprimer
|
||||
Enter a name for the crop preset Saisissez un nom pour le paramètre prédéfini de recadrage
|
||||
There is no crop size or ratio to save Aucune taille ni proportion de recadrage à enregistrer
|
||||
Search settings… Rechercher des réglages…
|
||||
No matching settings Aucun réglage correspondant
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2876,5 +2876,11 @@ cameraData Lens Lensa
|
|||
Previous Document Dokumen Sebelumnya
|
||||
W x H x Resolution L x T x Resolusi
|
||||
Front Image Gambar Depan
|
||||
New Crop Preset… Preset Pangkas Baru…
|
||||
Delete Crop Preset… Hapus Preset Pangkas…
|
||||
Select the crop presets to delete: Pilih preset pangkas yang akan dihapus:
|
||||
Select the crop presets to delete Pilih preset pangkas yang akan dihapus
|
||||
Enter a name for the crop preset Masukkan nama untuk preset pangkas
|
||||
There is no crop size or ratio to save Tidak ada ukuran atau rasio pangkas untuk disimpan
|
||||
Search settings… Cari pengaturan…
|
||||
No matching settings Tidak ada pengaturan yang cocok
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2542,5 +2542,11 @@ cameraData Lens Obiettivo
|
|||
Previous Document Documento precedente
|
||||
W x H x Resolution L x A x Risoluzione
|
||||
Front Image Immagine in primo piano
|
||||
New Crop Preset… Nuovo predefinito di ritaglio…
|
||||
Delete Crop Preset… Elimina predefinito di ritaglio…
|
||||
Select the crop presets to delete: Seleziona i predefiniti di ritaglio da eliminare:
|
||||
Select the crop presets to delete Seleziona i predefiniti di ritaglio da eliminare
|
||||
Enter a name for the crop preset Inserisci un nome per il predefinito di ritaglio
|
||||
There is no crop size or ratio to save Non c'è nessuna dimensione o proporzione di ritaglio da salvare
|
||||
Search settings… Cerca impostazioni…
|
||||
No matching settings Nessuna impostazione corrispondente
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 14.
|
|
|
@ -3490,5 +3490,11 @@ cameraData Lens レンズ
|
|||
Previous Document 前のドキュメント
|
||||
W x H x Resolution 幅 x 高さ x 解像度
|
||||
Front Image 前面の画像
|
||||
New Crop Preset… 新規切り抜きプリセット…
|
||||
Delete Crop Preset… 切り抜きプリセットを削除…
|
||||
Select the crop presets to delete: 削除する切り抜きプリセットを選択してください:
|
||||
Select the crop presets to delete 削除する切り抜きプリセットを選択してください
|
||||
Enter a name for the crop preset 切り抜きプリセットの名前を入力してください
|
||||
There is no crop size or ratio to save 保存する切り抜きのサイズまたは比率がありません
|
||||
Search settings… 設定を検索…
|
||||
No matching settings 一致する設定はありません
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3056,5 +3056,11 @@ cameraData Lens 렌즈
|
|||
Previous Document 이전 문서
|
||||
W x H x Resolution 폭 x 높이 x 해상도
|
||||
Front Image 전면 이미지
|
||||
New Crop Preset… 새 자르기 사전 설정…
|
||||
Delete Crop Preset… 자르기 사전 설정 삭제…
|
||||
Select the crop presets to delete: 삭제할 자르기 사전 설정을 선택하십시오:
|
||||
Select the crop presets to delete 삭제할 자르기 사전 설정을 선택하십시오
|
||||
Enter a name for the crop preset 자르기 사전 설정의 이름을 입력하십시오
|
||||
There is no crop size or ratio to save 저장할 자르기 크기나 비율이 없습니다
|
||||
Search settings… 설정 검색…
|
||||
No matching settings 일치하는 설정 없음
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 10.
|
|
|
@ -3643,6 +3643,12 @@ cameraData Lens Lens
|
|||
Previous Document Vorig document
|
||||
W x H x Resolution B x H x Resolutie
|
||||
Front Image Voorste afbeelding
|
||||
New Crop Preset… Nieuwe uitsnijvoorinstelling…
|
||||
Delete Crop Preset… Uitsnijvoorinstelling verwijderen…
|
||||
Select the crop presets to delete: Selecteer de uitsnijvoorinstellingen die u wilt verwijderen:
|
||||
Select the crop presets to delete Selecteer de uitsnijvoorinstellingen die u wilt verwijderen
|
||||
Enter a name for the crop preset Voer een naam in voor de uitsnijvoorinstelling
|
||||
There is no crop size or ratio to save Er is geen uitsnijformaat of verhouding om op te slaan
|
||||
Search settings… Instellingen zoeken…
|
||||
No matching settings Geen overeenkomende instellingen
|
||||
PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
|
|
@ -3491,5 +3491,11 @@ cameraData Lens Obiektyw
|
|||
Previous Document Poprzedni dokument
|
||||
W x H x Resolution Szer. x Wys. x Rozdzielczość
|
||||
Front Image Obraz na wierzchu
|
||||
New Crop Preset… Nowe ustawienie kadrowania…
|
||||
Delete Crop Preset… Usuń ustawienie kadrowania…
|
||||
Select the crop presets to delete: Wybierz ustawienia kadrowania do usunięcia:
|
||||
Select the crop presets to delete Wybierz ustawienia kadrowania do usunięcia
|
||||
Enter a name for the crop preset Wprowadź nazwę ustawienia kadrowania
|
||||
There is no crop size or ratio to save Brak rozmiaru lub proporcji kadrowania do zapisania
|
||||
Search settings… Szukaj ustawień…
|
||||
No matching settings Brak pasujących ustawień
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2897,5 +2897,11 @@ cameraData Lens Lente
|
|||
Previous Document Documento anterior
|
||||
W x H x Resolution L x A x Resolução
|
||||
Front Image Imagem frontal
|
||||
New Crop Preset… Nova predefinição de corte…
|
||||
Delete Crop Preset… Excluir predefinição de corte…
|
||||
Select the crop presets to delete: Selecione as predefinições de corte a excluir:
|
||||
Select the crop presets to delete Selecione as predefinições de corte a excluir
|
||||
Enter a name for the crop preset Digite um nome para a predefinição de corte
|
||||
There is no crop size or ratio to save Não há tamanho nem proporção de corte para salvar
|
||||
Search settings… Pesquisar configurações…
|
||||
No matching settings Nenhuma configuração encontrada
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2821,3 +2821,9 @@ cameraData Lens Объектив
|
|||
Rasterize Layer Style Растрировать стиль слоя
|
||||
Search settings… Поиск настроек…
|
||||
No matching settings Нет подходящих настроек
|
||||
New Crop Preset… Новый набор параметров кадрирования…
|
||||
Delete Crop Preset… Удалить набор параметров кадрирования…
|
||||
Select the crop presets to delete: Выберите наборы параметров кадрирования для удаления:
|
||||
Select the crop presets to delete Выберите наборы параметров кадрирования для удаления
|
||||
Enter a name for the crop preset Введите имя набора параметров кадрирования
|
||||
There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3579,3 +3579,9 @@ cameraData Lens Об'єктив
|
|||
Search settings… Пошук налаштувань…
|
||||
No matching settings Немає відповідних налаштувань
|
||||
PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана
|
||||
New Crop Preset… Новий набір параметрів кадрування…
|
||||
Delete Crop Preset… Видалити набір параметрів кадрування…
|
||||
Select the crop presets to delete: Виберіть набори параметрів кадрування для видалення:
|
||||
Select the crop presets to delete Виберіть набори параметрів кадрування для видалення
|
||||
Enter a name for the crop preset Введіть назву набору параметрів кадрування
|
||||
There is no crop size or ratio to save Немає розміру чи співвідношення сторін кадрування для збереження
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -2487,3 +2487,9 @@ cameraData Lens 镜头
|
|||
Front Image 前面的图像
|
||||
Search settings… 搜索设置…
|
||||
No matching settings 没有匹配的设置
|
||||
New Crop Preset… 新建裁剪预设…
|
||||
Delete Crop Preset… 删除裁剪预设…
|
||||
Select the crop presets to delete: 选择要删除的裁剪预设:
|
||||
Select the crop presets to delete 选择要删除的裁剪预设
|
||||
Enter a name for the crop preset 输入裁剪预设的名称
|
||||
There is no crop size or ratio to save 没有可保存的裁剪大小或比例
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 14.
|
|
|
@ -2457,3 +2457,9 @@ cameraData Lens 鏡頭
|
|||
Front Image 前面的影像
|
||||
Search settings… 搜尋設定…
|
||||
No matching settings 沒有符合的設定
|
||||
New Crop Preset… 新增裁切預設集…
|
||||
Delete Crop Preset… 刪除裁切預設集…
|
||||
Select the crop presets to delete: 選取要刪除的裁切預設集:
|
||||
Select the crop presets to delete 選取要刪除的裁切預設集
|
||||
Enter a name for the crop preset 輸入裁切預設集的名稱
|
||||
There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -48,6 +48,7 @@ pub mod control;
|
|||
pub mod credits;
|
||||
pub mod crop_mode;
|
||||
pub mod crop_overlay;
|
||||
pub mod crop_presets;
|
||||
pub mod crop_shield;
|
||||
pub mod crop_size;
|
||||
pub mod crop_straighten;
|
||||
|
|
|
|||
|
|
@ -1148,11 +1148,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
crate::gradient_ui::options_changed(app, &before);
|
||||
}
|
||||
Tool::Crop if t.pro => {
|
||||
let presets = crate::crop_size::dropdown_options();
|
||||
if widgets::dropdown(ui, "crop-ratio", &mut app.ui.tool_options.crop_ratio, &presets, 140.0) {
|
||||
// A size preset or Front Image fills W x H x Resolution (#2443).
|
||||
crate::crop_size::chosen(app);
|
||||
}
|
||||
// Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919).
|
||||
crate::crop_presets::dropdown(app, ui);
|
||||
let o = &mut app.ui.tool_options;
|
||||
if o.crop_ratio == crate::crop_size::WHR {
|
||||
crate::crop_size::fields(o, ui);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
|||
- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box.
|
||||
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), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes.
|
||||
- `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
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`, `newCropPreset` / `deleteCropPreset`: the Crop tool's New / Delete Crop Preset… dialogs, fields `name` and `selected: [names]`; crop presets are `crop` tool presets, so `tool.presets.new/list/edit {"tool":"crop"}` drive them too) / `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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
|||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 22 | 17 | 4 | |
|
||||
| [Tools](#tools) | 23 | 16 | 4 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 37 of 152 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
|
||||
|
|
@ -153,7 +153,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
|||
| TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs:136 builds a block-grid mesh |
|
||||
| TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog |
|
||||
| TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | Arbitrary rotation is a generic dialog; ruler only feeds straighten |
|
||||
| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); no New/Delete Crop Preset |
|
||||
| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) |
|
||||
| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); no Shift 45 deg, Backspace or Alt while drawing |
|
||||
| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog |
|
||||
| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only |
|
||||
|
|
|
|||
|
|
@ -169,9 +169,9 @@ note = "Arbitrary rotation is a generic dialog; ruler only feeds straighten"
|
|||
[[item]]
|
||||
id = "TOOL-214-9"
|
||||
target = "Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 214
|
||||
note = "Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); no New/Delete Crop Preset"
|
||||
note = "Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-10"
|
||||
|
|
|
|||
Loading…
Reference in New Issue