Crop tool: New / Delete Crop Preset saves and removes user presets (#1919) (#2723)

* 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:
David Mann 2026-10-11 11:31:19 +02:00 committed by GitHub
parent 8a9f4722d2
commit 468b8f2792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 831 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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