File › New › Recent was a hard-coded pair of presets plus Clipboard. Each document created with the dialog now records its settings (size, resolution, mode, depth, background, display units) through the new document.recent.add command: newest first, equal entries collapsed, at most 20, persisted with the presets (a damaged entry is dropped on load, the rest kept). The Recent tab lists Clipboard, those documents, then the defaults they don't cover; a click restores every setting, a double-click creates, and the grid scrolls past three rows. document.recent.list exposes the list to agents; creation stays in file.new. Closes scorecard row UI-217-12. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
48c1721470
commit
538c5020b2
|
|
@ -15,6 +15,8 @@ const _: () =
|
|||
assert!(photocraft_format::MAX_CANVAS_SIDE >= MAX_DIMENSION && photocraft_format::MAX_CANVAS_PIXELS >= MAX_DIMENSION as u64 * MAX_DIMENSION as u64);
|
||||
pub(crate) const MAX_RESOLUTION: f64 = 30_000.0;
|
||||
const MAX_PRESETS: usize = 256;
|
||||
/// File › New › Recent keeps this many settings, like Photoshop's Recent tab.
|
||||
pub const MAX_RECENT: usize = 20;
|
||||
|
||||
/// The formats accepted by `file.new`, also used when validating saved configurations.
|
||||
pub(crate) fn color_mode(mode: &str) -> Option<ColorMode> {
|
||||
|
|
@ -205,6 +207,39 @@ pub(crate) fn load(value: Value) -> Vec<DocumentPreset> {
|
|||
presets
|
||||
}
|
||||
|
||||
/// Read the Recent list like [`load`]: a damaged entry is dropped, the rest kept, newest first.
|
||||
pub(crate) fn load_recent(value: Value) -> Vec<DocumentSettings> {
|
||||
let Some(entries) = value.as_array() else { return Vec::new() };
|
||||
let mut recent: Vec<DocumentSettings> = Vec::new();
|
||||
for entry in entries {
|
||||
let Ok(mut settings) = serde_json::from_value::<DocumentSettings>(entry.clone()) else { continue };
|
||||
if settings.mode == "grayscale" {
|
||||
settings.mode = "gray".into();
|
||||
}
|
||||
if settings.validate().is_err() || recent.iter().any(|r| r.command_params() == settings.command_params()) {
|
||||
continue;
|
||||
}
|
||||
recent.push(settings);
|
||||
if recent.len() >= MAX_RECENT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
recent
|
||||
}
|
||||
|
||||
/// Record settings a document was just created with: first in Recent, an equal older entry
|
||||
/// (the same document, perhaps shown in other units) removed, the oldest dropped past the cap.
|
||||
fn add_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "document.recent.add";
|
||||
let settings = DocumentSettings::from_params(s, p.get("settings").unwrap_or(&Value::Null), CMD)?;
|
||||
let recent = &mut s.presets.recent_documents;
|
||||
recent.retain(|r| r.command_params() != settings.command_params());
|
||||
recent.insert(0, settings);
|
||||
recent.truncate(MAX_RECENT);
|
||||
s.presets_changed();
|
||||
Ok(json!({"recent":s.presets.recent_documents}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "document.presets.save";
|
||||
let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?;
|
||||
|
|
@ -280,6 +315,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
run: delete,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "document.recent.list",
|
||||
label: "List Recent Document Settings",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} -> {recent:[settings]} (newest first; each settings.command params minus unit/resolutionUnit go to file.new)",
|
||||
enabled: always,
|
||||
run: |s, _| Ok(json!({"recent":s.presets.recent_documents})),
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "document.recent.add",
|
||||
label: "Add Recent Document Settings",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"settings":{same fields as document.presets.save}} (File › New records each document it creates; newest first, max 20, duplicates collapsed)"#,
|
||||
enabled: always,
|
||||
run: add_recent,
|
||||
// Bookkeeping for File › New, not an editing step an action should replay.
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -451,6 +507,36 @@ mod tests {
|
|||
assert_eq!(s.prefs.rev(), rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_lists_created_settings_newest_first_capped_and_survives_reload() {
|
||||
let mut s = Session::new();
|
||||
let add = |s: &mut Session, w: u32, unit: &str| s.execute("document.recent.add", json!({"settings":{"width":w,"height":10,"unit":unit}}));
|
||||
for w in 1..=MAX_RECENT as u32 + 5 {
|
||||
add(&mut s, w, "px").unwrap();
|
||||
}
|
||||
// The same document again (shown in other units) moves to the front instead of repeating.
|
||||
add(&mut s, 10, "mm").unwrap();
|
||||
let mut s = reload(&s);
|
||||
let recent = s.execute("document.recent.list", json!({})).unwrap()["recent"].clone();
|
||||
let widths: Vec<u64> = recent.as_array().unwrap().iter().map(|r| r["width"].as_u64().unwrap()).collect();
|
||||
assert_eq!(widths.len(), MAX_RECENT);
|
||||
assert_eq!(&widths[..3], &[10, 25, 24]);
|
||||
assert_eq!(widths.iter().filter(|w| **w == 10).count(), 1);
|
||||
assert_eq!(recent[0]["unit"], "mm");
|
||||
// Bad settings change nothing; damaged stored entries are dropped, the rest kept.
|
||||
let rev = s.prefs.rev();
|
||||
for bad in [json!({}), json!({"settings":{"width":0,"height":1}}), json!({"settings":"x"}), Value::Null] {
|
||||
assert!(s.execute("document.recent.add", bad).is_err());
|
||||
}
|
||||
assert_eq!(s.prefs.rev(), rev);
|
||||
let mut value = s.prefs_value();
|
||||
value["presets"]["recent_documents"] = json!([{"width":-1}, recent[1], "junk", recent[1], recent[2]]);
|
||||
let mut next = Session::new();
|
||||
next.load_prefs_json(&value.to_string()).unwrap();
|
||||
assert_eq!(next.presets.recent_documents.iter().map(|r| r.width).collect::<Vec<_>>(), [25, 24]);
|
||||
assert!(next.documents().is_empty(), "recording never creates a document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_explicit_background_colour_does_not_create_a_document() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ pub struct PresetState {
|
|||
pub tool_presets: Vec<tools::ToolPreset>,
|
||||
/// File › New: named snapshots, separate from the name of the document being created.
|
||||
pub documents: Vec<crate::document_preset_cmds::DocumentPreset>,
|
||||
/// File › New › Recent: the settings of documents recently created there, newest first.
|
||||
pub recent_documents: Vec<crate::document_preset_cmds::DocumentSettings>,
|
||||
pub clone: clone_source::CloneSources,
|
||||
/// The user defaults "Make Default" in the Layer Style dialog saves
|
||||
/// (effect kind → param set); "Reset to Default" restores them.
|
||||
|
|
@ -102,6 +104,7 @@ impl Default for PresetState {
|
|||
pattern: None,
|
||||
tool_presets: tools::builtin(),
|
||||
documents: Vec::new(),
|
||||
recent_documents: Vec::new(),
|
||||
clone: Default::default(),
|
||||
layer_defaults: BTreeMap::new(),
|
||||
swatches: swatches::builtin(),
|
||||
|
|
@ -130,6 +133,8 @@ struct Persisted {
|
|||
#[serde(default)]
|
||||
documents: Option<Value>,
|
||||
#[serde(default)]
|
||||
recent_documents: Option<Value>,
|
||||
#[serde(default)]
|
||||
custom_shapes: Option<Vec<crate::edit_menu_cmds::CustomShape>>,
|
||||
#[serde(default)]
|
||||
layer_defaults: Option<BTreeMap<String, Value>>,
|
||||
|
|
@ -147,6 +152,7 @@ impl PresetState {
|
|||
pattern_groups: Some(self.pattern_groups.clone()),
|
||||
tool_presets: Some(json!(self.tool_presets)),
|
||||
documents: Some(json!(self.documents)),
|
||||
recent_documents: Some(json!(self.recent_documents)),
|
||||
custom_shapes: Some(s.edit_state.custom_shapes.clone()),
|
||||
layer_defaults: Some(self.layer_defaults.clone()),
|
||||
swatches: Some(self.swatches.clone()),
|
||||
|
|
@ -182,6 +188,9 @@ impl Session {
|
|||
if let Some(d) = p.documents {
|
||||
st.documents = crate::document_preset_cmds::load(d);
|
||||
}
|
||||
if let Some(r) = p.recent_documents {
|
||||
st.recent_documents = crate::document_preset_cmds::load_recent(r);
|
||||
}
|
||||
if let Some(c) = p.custom_shapes {
|
||||
self.edit_state.custom_shapes = c;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,6 +470,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
|||
}
|
||||
if r.is_ok() {
|
||||
crate::remember_new_document(app, &d.fields);
|
||||
// File › New › Recent. Best effort: the document exists either way.
|
||||
let _ = app.run("document.recent.add", json!({"settings": crate::new_doc_ui::document_settings(&d.fields)}));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,3 +76,27 @@ fn a_corrupt_remembered_value_is_ignored() {
|
|||
let _ = crate::dialogs::confirm(&mut app, id);
|
||||
assert_eq!(app.session.prefs().dialogs["file.new"]["height"], json!(600), "unchanged");
|
||||
}
|
||||
|
||||
/// #3131: File › New › Recent lists the documents created with the dialog, newest first, with
|
||||
/// every setting restored by a click, and survives a restart.
|
||||
#[test]
|
||||
fn created_documents_fill_the_recent_category() {
|
||||
let mut app = app();
|
||||
create(&mut app, json!({"width": 640, "height": 480, "resolution": 300.0, "mode": "cmyk", "depth": 16, "background": "transparent", "__unit": "in"}));
|
||||
create(&mut app, json!({"width": 1920, "height": 1080, "resolution": 72.0, "mode": "rgb", "depth": 8, "background": "white", "__unit": "px"}));
|
||||
let recent = app.session.presets.recent_documents.clone();
|
||||
assert_eq!(recent.iter().map(|r| (r.width, r.unit.as_str())).collect::<Vec<_>>(), [(1920, "px"), (640, "in")]);
|
||||
// The settings are kept with the preferences.
|
||||
let mut restarted = photocraft_engine::Session::new();
|
||||
restarted.load_prefs_json(&app.session.prefs_to_json()).unwrap();
|
||||
assert_eq!(restarted.presets.recent_documents, recent);
|
||||
// A recent card restores all of its settings but keeps the new document's name.
|
||||
let mut f = app.new_document_fields();
|
||||
f.insert("name".into(), json!("Next"));
|
||||
crate::new_doc_ui::apply_settings(&mut f, &recent[1]);
|
||||
let p = crate::new_doc_ui::command_params(&f);
|
||||
assert_eq!(
|
||||
(&p["width"], &p["height"], &p["mode"], &p["depth"], &p["background"], &p["name"], &f["__unit"]),
|
||||
(&json!(640), &json!(480), &json!("cmyk"), &json!(16), &json!("transparent"), &json!("Next"), &json!("in"))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use photocraft_engine::prefs::UnitsAndRulers;
|
|||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{icons, widgets};
|
||||
use photocraft_engine::document_preset_cmds::DocumentSettings;
|
||||
|
||||
/// A blank-document preset: (name, width px, height px, ppi).
|
||||
pub type Preset = (&'static str, u32, u32, f32);
|
||||
|
|
@ -256,19 +257,103 @@ fn page_icon(ui: &egui::Ui, r: Rect, w: u32, h: u32, t: &Tokens) {
|
|||
|
||||
/// Restore a snapshot without replacing the new document's name or its clipboard offer.
|
||||
pub fn apply_saved_preset(f: &mut Map<String, Value>, preset: &photocraft_engine::document_preset_cmds::DocumentPreset) {
|
||||
apply_settings(f, &preset.settings);
|
||||
f.insert("__savedPreset".into(), json!(preset.name));
|
||||
}
|
||||
|
||||
/// Restore every setting of a saved or recent document (all but its name).
|
||||
pub fn apply_settings(f: &mut Map<String, Value>, settings: &DocumentSettings) {
|
||||
f.remove("backgroundColor");
|
||||
if let Some(params) = preset.settings.command_params().as_object() {
|
||||
if let Some(params) = settings.command_params().as_object() {
|
||||
f.extend(params.clone());
|
||||
}
|
||||
f.insert("__unit".into(), json!(preset.settings.unit));
|
||||
f.insert("__resUnit".into(), json!(preset.settings.resolution_unit));
|
||||
f.insert("__savedPreset".into(), json!(preset.name));
|
||||
f.insert("__unit".into(), json!(settings.unit));
|
||||
f.insert("__resUnit".into(), json!(settings.resolution_unit));
|
||||
f.remove("__savedPreset");
|
||||
f.remove("__preset");
|
||||
// Typed Width/Height belong to the previous values (`shown_size`).
|
||||
f.remove(&typed_key("width"));
|
||||
f.remove(&typed_key("height"));
|
||||
}
|
||||
|
||||
/// The dialog's settings as `document.presets.save` / `document.recent.add` take them: the
|
||||
/// `file.new` params plus the display units.
|
||||
pub fn document_settings(f: &Map<String, Value>) -> Value {
|
||||
let mut settings = command_params(f);
|
||||
let unit = get_s(f, "__unit", "px");
|
||||
settings["unit"] = json!(if UNITS.iter().any(|u| u.0 == unit) { unit.as_str() } else { "px" });
|
||||
settings["resolutionUnit"] = json!(if get_s(f, "__resUnit", "in") == "cm" { "cm" } else { "in" });
|
||||
settings
|
||||
}
|
||||
|
||||
/// Whether the dialog currently holds `settings` (so its Recent card shows selected).
|
||||
fn holds(f: &Map<String, Value>, settings: &DocumentSettings) -> bool {
|
||||
// Unset fields hold the dialog's defaults, as the details pane shows them.
|
||||
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
|
||||
(w, h, get_f(f, "resolution", 72.0)) == (settings.width as f32, settings.height as f32, settings.resolution)
|
||||
&& f.get("depth").and_then(Value::as_u64).unwrap_or(8) == u64::from(settings.depth)
|
||||
&& get_s(f, "mode", "rgb") == settings.mode
|
||||
&& get_s(f, "background", "white") == settings.background
|
||||
}
|
||||
|
||||
/// A card in the preset grid: a built-in preset or a recently created document.
|
||||
enum Card {
|
||||
Builtin(Preset),
|
||||
Recent(DocumentSettings),
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Pixel size and resolution; a built-in preset at the Units & Rulers preset resolutions.
|
||||
fn size(&self, units: &UnitsAndRulers) -> (u32, u32, f32) {
|
||||
match self {
|
||||
Card::Builtin(listed) => {
|
||||
let p = at_pref_resolutions(listed, units);
|
||||
(p.1, p.2, p.3)
|
||||
}
|
||||
Card::Recent(s) => (s.width, s.height, s.resolution),
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset's name; a recent document takes the name of the built-in preset it matches.
|
||||
fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Card::Builtin(p) => p.0,
|
||||
Card::Recent(s) => {
|
||||
CATEGORIES.iter().flat_map(|c| c.1.iter()).find(|p| (p.1, p.2, p.3) == (s.width, s.height, s.resolution)).map_or("Custom", |p| p.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The size line, in the unit the preset is specified in (a recent one: the unit it was made in).
|
||||
fn size_line(&self, units: &UnitsAndRulers) -> String {
|
||||
let (w, h, ppi) = self.size(units);
|
||||
let unit = match self {
|
||||
Card::Builtin(p) if is_print(p) => "in",
|
||||
Card::Builtin(_) => "px",
|
||||
Card::Recent(s) => s.unit.as_str(),
|
||||
};
|
||||
if unit == "px" {
|
||||
format!("{w} x {h} px @ {} ppi", widgets::fmt_num(f64::from(ppi)))
|
||||
} else {
|
||||
let v = |n: u32| widgets::fmt_num(f64::from(to_unit(n as f32, unit, ppi)));
|
||||
format!("{} x {} {unit} @ {} ppi", v(w), v(h), widgets::fmt_num(f64::from(ppi)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Recent category: the Clipboard preset, the documents recently created here (newest
|
||||
/// first), then the built-in defaults they don't already cover.
|
||||
fn recent_cards(f: &Map<String, Value>, recent: &[DocumentSettings], units: &UnitsAndRulers) -> Vec<Card> {
|
||||
let mut cards: Vec<Card> = clipboard_preset(f).map(Card::Builtin).into_iter().collect();
|
||||
cards.extend(recent.iter().cloned().map(Card::Recent));
|
||||
let defaults = CATEGORIES[0].1.iter().filter(|p| {
|
||||
let (_, w, h, ppi) = at_pref_resolutions(p, units);
|
||||
!recent.iter().any(|s| (s.width, s.height, s.resolution) == (w, h, ppi))
|
||||
});
|
||||
cards.extend(defaults.copied().map(Card::Builtin));
|
||||
cards
|
||||
}
|
||||
|
||||
fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) -> bool {
|
||||
let presets = app.session.presets.documents.clone();
|
||||
if presets.is_empty() {
|
||||
|
|
@ -326,10 +411,7 @@ fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<St
|
|||
f.insert("__presetName".into(), json!(name));
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Save"), 110.0).clicked() || enter {
|
||||
let mut settings = command_params(f);
|
||||
settings["unit"] = json!(get_s(f, "__unit", "px"));
|
||||
settings["resolutionUnit"] = json!(get_s(f, "__resUnit", "in"));
|
||||
match app.run("document.presets.save", json!({"name":name,"settings":settings})) {
|
||||
match app.run("document.presets.save", json!({"name":name,"settings":document_settings(f)})) {
|
||||
Ok(_) => {
|
||||
if let Some(preset) = app.session.presets.documents.last() {
|
||||
apply_saved_preset(f, preset);
|
||||
|
|
@ -380,11 +462,12 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
|
|||
ui.add_space(6.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(8.0);
|
||||
let presets = CATEGORIES.iter().find(|c| c.0 == cat).map_or(CATEGORIES[0].1, |c| c.1);
|
||||
// The clipboard image's size comes first among the Recent presets.
|
||||
let presets: Vec<Preset> = clipboard_preset(f).filter(|_| cat == CATEGORIES[0].0).into_iter().chain(presets.iter().copied()).collect();
|
||||
let chosen = get_s(f, "__preset", "");
|
||||
let units = app.session.prefs().units_and_rulers.clone();
|
||||
let presets: Vec<Card> = match CATEGORIES.iter().find(|c| c.0 == cat) {
|
||||
Some(c) if c.0 != CATEGORIES[0].0 => c.1.iter().copied().map(Card::Builtin).collect(),
|
||||
_ => recent_cards(f, &app.session.presets.recent_documents, &units),
|
||||
};
|
||||
let chosen = get_s(f, "__preset", "");
|
||||
ui.horizontal_top(|ui| {
|
||||
// Left: preset grid.
|
||||
ui.vertical(|ui| {
|
||||
|
|
@ -396,51 +479,65 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
|
|||
ui.label(RichText::new(crate::i18n::fmt(tl!("BLANK DOCUMENT PRESETS ({n})"), &[("n", &presets.len().to_string())])).size(11.0).color(t.text_faint));
|
||||
ui.add_space(6.0);
|
||||
let card = vec2(164.0, 112.0);
|
||||
for row in presets.chunks(3) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
for listed in row {
|
||||
let p = &at_pref_resolutions(listed, &units);
|
||||
let (r, resp) = ui.allocate_exact_size(card, Sense::click());
|
||||
let on = chosen == p.0;
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
t.radius,
|
||||
// Up to 20 recent documents: scroll past three rows rather than grow the dialog.
|
||||
egui::ScrollArea::vertical().id_salt("new-document-presets").max_height(3.0 * (card.y + 8.0)).show(ui, |ui| {
|
||||
for row in presets.chunks(3) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
for c in row {
|
||||
let (r, resp) = ui.allocate_exact_size(card, Sense::click());
|
||||
let (w, h, _) = c.size(&units);
|
||||
let on = match c {
|
||||
Card::Builtin(p) => chosen == p.0,
|
||||
Card::Recent(settings) => holds(f, settings),
|
||||
};
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
t.radius,
|
||||
if on {
|
||||
t.row_selected
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field
|
||||
},
|
||||
);
|
||||
if on {
|
||||
t.row_selected
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field
|
||||
},
|
||||
);
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
page_icon(ui, Rect::from_center_size(pos2(r.center().x, r.top() + 34.0), vec2(40.0, 40.0)), w, h, &t);
|
||||
let title = card_title(ui.painter(), tl!(c.title()), card.x - 12.0, t.text);
|
||||
let elided = title.elided;
|
||||
ui.painter().galley(pos2(r.center().x, r.top() + 70.0 - title.size().y / 2.0), title, t.text);
|
||||
let size = c.size_line(&units);
|
||||
ui.painter().text(pos2(r.center().x, r.top() + 97.0), Align2::CENTER_CENTER, &size, egui::FontId::proportional(10.5), t.text_faint);
|
||||
let resp = match c {
|
||||
// A recent document's tooltip names everything it restores.
|
||||
Card::Recent(s) => {
|
||||
let background = match s.background.as_str() {
|
||||
"white" => tl!("White"),
|
||||
"black" => tl!("Black"),
|
||||
"transparent" => tl!("Transparent"),
|
||||
"backgroundColor" => tl!("Background Color"),
|
||||
hex => hex,
|
||||
};
|
||||
resp.on_hover_text(format!("{} · {size} · {}/{} · {background}", tl!(c.title()), s.mode.to_uppercase(), s.depth))
|
||||
}
|
||||
Card::Builtin(_) if elided => resp.on_hover_text(tl!(c.title())),
|
||||
Card::Builtin(_) => resp,
|
||||
};
|
||||
if resp.clicked() {
|
||||
match c {
|
||||
Card::Builtin(p) => apply_preset(f, p, &units),
|
||||
Card::Recent(settings) => apply_settings(f, settings),
|
||||
}
|
||||
create |= resp.double_clicked() || resp.triple_clicked();
|
||||
}
|
||||
}
|
||||
page_icon(ui, Rect::from_center_size(pos2(r.center().x, r.top() + 34.0), vec2(40.0, 40.0)), p.1, p.2, &t);
|
||||
let title = card_title(ui.painter(), tl!(p.0), card.x - 12.0, t.text);
|
||||
let elided = title.elided;
|
||||
ui.painter().galley(pos2(r.center().x, r.top() + 70.0 - title.size().y / 2.0), title, t.text);
|
||||
let size = if is_print(listed) {
|
||||
format!(
|
||||
"{} x {} in @ {} ppi",
|
||||
widgets::fmt_num(to_unit(p.1 as f32, "in", p.3) as f64),
|
||||
widgets::fmt_num(to_unit(p.2 as f32, "in", p.3) as f64),
|
||||
p.3
|
||||
)
|
||||
} else {
|
||||
format!("{} x {} px @ {} ppi", p.1, p.2, p.3)
|
||||
};
|
||||
ui.painter().text(pos2(r.center().x, r.top() + 97.0), Align2::CENTER_CENTER, size, egui::FontId::proportional(10.5), t.text_faint);
|
||||
let resp = if elided { resp.on_hover_text(tl!(p.0)) } else { resp };
|
||||
if resp.clicked() {
|
||||
apply_preset(f, listed, &units);
|
||||
create |= resp.double_clicked() || resp.triple_clicked();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
// Right: Preset Details.
|
||||
|
|
@ -681,6 +778,35 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() {
|
||||
let settings = |w, h, ppi| DocumentSettings {
|
||||
width: w,
|
||||
height: h,
|
||||
resolution: ppi,
|
||||
mode: "rgb".into(),
|
||||
depth: 8,
|
||||
background: "white".into(),
|
||||
background_color: None,
|
||||
unit: "px".into(),
|
||||
resolution_unit: "in".into(),
|
||||
};
|
||||
let mut f = Map::new();
|
||||
set_clipboard(&mut f, 20, 30);
|
||||
let cards = recent_cards(&f, &[settings(800, 600, 72.0), settings(1920, 1080, 72.0)], &UnitsAndRulers::default());
|
||||
let titles: Vec<(&str, String)> = cards.iter().map(|c| (c.title(), c.size_line(&UnitsAndRulers::default()))).collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
[
|
||||
(CLIPBOARD, "20 x 30 px @ 72 ppi".to_string()),
|
||||
("Custom", "800 x 600 px @ 72 ppi".to_string()),
|
||||
("HDTV 1080p", "1920 x 1080 px @ 72 ppi".to_string()),
|
||||
("Default PhotoCraft Size", "7 x 5 in @ 300 ppi".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(holds(&f, &settings(20, 30, 72.0)) && !holds(&f, &settings(800, 600, 72.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_preset_comes_first_and_is_selected() {
|
||||
// Nothing on the clipboard: the dialog opens as before.
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ store expose and persist the same setting; scripts keep using canonical command
|
|||
| `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected |
|
||||
| `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` |
|
||||
| `document.presets.delete` | `{"name":"Product square"}`: delete the named saved configuration |
|
||||
| `document.recent.list` | `{}` → `{recent:[settings]}`: File › New › Recent, the settings of the documents last created with the New Document dialog, newest first (at most 20) |
|
||||
| `document.recent.add` | `{"settings":{…as document.presets.save}}`: put settings first in Recent (the dialog does this after Create; equal entries collapse); not recorded into actions |
|
||||
| `type.hitTest` | `{"layer":id?,"x":px,"y":px}`: character under a document point. No `layer` picks the topmost visible type layer there. Result `{"layer","index","line","inside"}` |
|
||||
| `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) |
|
||||
| `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` |
|
||||
|
|
|
|||
|
|
@ -9,7 +9,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) | 6 | 13 | 8 | 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) | 28 | 14 | 1 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 12 | 6 | 5 | settings that do nothing: 30 of 155 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) |
|
||||
| [Type](#type) | 4 | 2 | 3 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
|
||||
|
|
@ -193,7 +193,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
|
|||
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
|
||||
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents |
|
||||
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes |
|
||||
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
|
||||
| UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category |
|
||||
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
|
||||
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) |
|
||||
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) |
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ note = "theme.rs ThemeKind has twelve themes (the four Pro brightness levels, St
|
|||
[[item]]
|
||||
id = "UI-217-12"
|
||||
target = "Save custom New Document presets; a real Recent category"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 217
|
||||
note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard"
|
||||
note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-13"
|
||||
|
|
|
|||
Loading…
Reference in New Issue