From 03bb64a6817a57a02d77a8d5a1b37d5247edd1d0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 10 Oct 2026 01:46:07 +0200 Subject: [PATCH] Brushes: keep the folder hierarchy of imported .abr files (#1851) (#2001) * Brushes: keep the folder hierarchy of imported .abr files (#1851) Photoshop CC brush files keep their Brushes panel folders in a `phry` (preset hierarchy) section; the importer skipped it, so every preset of an .abr landed in one flat group named after the file. - photocraft-psd: `abr::parse` reads `phry` (a token stream of `Grup` {Nm, zuid}, `preset` and `groupEnd` objects) into `AbrFile::folders`, one folder path per preset. A damaged hierarchy never fails the parse: a stray groupEnd, more or fewer preset tokens than presets, folders nested deeper than MAX_FOLDER_DEPTH (32) or an unreadable section become warnings and the affected presets land at the top level. `write_v6_folders` writes `phry` back (`write_v6` is unchanged). - photocraft-paint: `BrushPreset::folder`, the nested folder path inside its group (serde default, so older JSON loads unchanged). - photocraft-io: the file's folders nest inside the group named after the file, as Photoshop's Import Brushes does. - Preset store: each stored preset records its `folder`; group files written before this change load with every preset directly in its group (no rewrite). Paths from disk and from imported .pcpresets files are bounded. - Commands: brush.presets.move, moveGroup, renameGroup and deleteGroup take an optional `folder` path to move a preset into a folder, or to move a folder among its siblings, rename or delete it. importAbr reports the number of folders kept. - Brushes panel and Brush Preset picker: nested folders are drawn indented under their group (list and grid), open and close like groups, take dropped presets, reorder among their siblings by drag and drop, and have Rename/Delete in their context menu. Checked against the 4-folder ABR Photoshop 25.1 exports of its default brushes (8/6/6/8 presets): all 28 land in their folders, and a write/parse round trip keeps them. Co-Authored-By: Claude Opus 5.5 (1M context) * Brushes: bound phry folder allocation; reword the move folder param Review on #2001: - read_phry now runs after every section is read, so it builds folder paths only for the presets in desc (a phry may precede desc), and the paths it holds are capped at 16 MB; presets past the cap import at the top level with a warning. - brush.presets.move documents `folder` as folder names, so the automation filesystem-path classifier no longer flags it. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds.rs | 4 +- crates/engine/src/brush_preset_cmds.rs | 286 ++++++++++++++++++-- crates/engine/src/edit_menu_cmds.rs | 6 +- crates/engine/src/preset_import_cmds.rs | 11 +- crates/engine/src/preset_store.rs | 18 +- crates/engine/src/preset_store/tests.rs | 57 +++- crates/io/src/abr_map.rs | 15 +- crates/io/src/abr_map/tests.rs | 22 +- crates/paint/src/brush.rs | 4 + crates/paint/src/presets.rs | 2 +- crates/psd/src/abr.rs | 343 +++++++++++++++++++++++- crates/ui-egui/src/brush_panel.rs | 5 +- crates/ui-egui/src/brush_panel_tests.rs | 128 ++++++++- crates/ui-egui/src/brush_picker.rs | 4 +- crates/ui-egui/src/brushes_tab.rs | 289 ++++++++++++++++---- crates/ui-egui/src/paint_mouse.rs | 3 +- 16 files changed, 1081 insertions(+), 116 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index e50be669..8d1269be 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -610,7 +610,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result { Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?, None => s.tools.brush.clone(), }; - upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new() }); + upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new() }); Ok(json!({ "name": name, "count": s.tools.presets.len() })) } @@ -686,7 +686,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { } let tip = GrayTile::from_f32(tw, th, &data); let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() }; - upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new() }); + upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new() }); s.tools.brush = brush; Ok(json!({ "name": name, "width": tw, "height": th })) } diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 7f423c33..6f4960eb 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -1,7 +1,13 @@ //! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a -//! preset within or between groups, move, rename and delete whole groups. +//! preset within or between groups and folders, move, rename and delete whole groups and folders. //! -//! The library order is the panel order: groups appear in order of their first preset. Every +//! Groups are the panel's top-level folders ([`BrushPreset::group`]); a group can hold nested +//! folders ([`BrushPreset::folder`], a path inside the group, e.g. the folders of an imported +//! `.abr` file). The group commands take an optional `folder` path to act on a nested folder +//! instead of the whole group. A folder exists while a preset is in it (or below it). +//! +//! The library order is the panel order: groups appear in order of their first preset, and a +//! group's folders and presets in order of their first preset too. Every //! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order //! and the order of every preset, built-ins included. A built-in that is renamed or moved to //! another group becomes the user's preset (built-ins are regenerated with their own group). @@ -21,6 +27,8 @@ fn always(_: &Session) -> std::result::Result<(), String> { /// Longest preset or group name accepted. pub const MAX_NAME: usize = 255; +/// Deepest folder path accepted inside a group (matches the `.abr` reader's limit). +pub const MAX_FOLDER_DEPTH: usize = photocraft_psd::abr::MAX_FOLDER_DEPTH; fn text(p: &Value, k: &str, cmd: &str) -> Result { let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?; @@ -39,6 +47,38 @@ fn group_param(p: &Value, k: &str, cmd: &str) -> Result> { } } +/// A folder path param (`["Outer", "Inner"]`, inside the group): `None` when absent or null. +fn folder_param(p: &Value, cmd: &str) -> Result>> { + let err = || bad(cmd, format!("`folder` must be a list of at most {MAX_FOLDER_DEPTH} folder names (non-empty strings of at most {MAX_NAME} characters)")); + match p.get("folder") { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) if a.len() <= MAX_FOLDER_DEPTH => a + .iter() + .map(|v| v.as_str().map(str::trim).filter(|n| !n.is_empty() && n.chars().count() <= MAX_NAME).map(str::to_string).ok_or_else(err)) + .collect::>>() + .map(Some), + Some(_) => Err(err()), + } +} + +/// Is `x` in folder `f` of group `g`, directly or below it? +fn in_folder(x: &BrushPreset, g: &str, f: &[String]) -> bool { + x.group == g && x.folder.starts_with(f) +} + +/// The subfolders directly inside folder `parent` of group `g`, in panel order. +pub fn subfolder_order(presets: &[BrushPreset], g: &str, parent: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + for x in presets.iter().filter(|x| in_folder(x, g, parent)) { + if let Some(n) = x.folder.get(parent.len()) + && !out.contains(n) + { + out.push(n.clone()); + } + } + out +} + fn index_of(presets: &[BrushPreset], name: &str) -> Option { presets.iter().position(|x| x.name.eq_ignore_ascii_case(name)) } @@ -96,49 +136,119 @@ fn move_preset(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?), }; + let folder = folder_param(p, cmd)?; + let group = group_param(p, "group", cmd)?; let lib = &mut s.tools.presets; - let target = match (group_param(p, "group", cmd)?, before) { - (Some(g), _) => g, - (None, Some(b)) => lib[b].group.clone(), - (None, None) => lib[i].group.clone(), + let (Some(moving), before_pr) = (lib.get(i), before.and_then(|b| lib.get(b))) else { + return Err(bad(cmd, "no such preset")); }; - if let Some(b) = before - && lib[b].group != target - { - return Err(bad(cmd, format!("`before` is not in group `{target}`"))); + let target = match (&group, before_pr) { + (Some(g), _) => g.clone(), + (None, Some(b)) => b.group.clone(), + (None, None) => moving.group.clone(), + }; + // The folder: as given, else `before`'s, else its own when it stays in its group. + let target_folder = match (folder, before_pr) { + (Some(f), _) => f, + (None, Some(b)) => b.folder.clone(), + (None, None) if moving.group == target => moving.folder.clone(), + (None, None) => Vec::new(), + }; + if let Some(b) = before_pr { + if b.group != target { + return Err(bad(cmd, format!("`before` is not in group `{target}`"))); + } + if b.folder != target_folder { + return Err(bad(cmd, "`before` is not in that folder")); + } } - let before_name = before.map(|b| lib[b].name.clone()); + let before_name = before_pr.map(|b| b.name.clone()); let mut pr = lib.remove(i); - if pr.group != target { + if pr.group != target || pr.folder != target_folder { pr.group = target.clone(); + pr.folder = target_folder.clone(); // A built-in moved elsewhere is the user's now (built-ins regenerate in their own group). pr.builtin = false; } let members: Vec = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect(); + // Appended: after the folder's last preset (or the group's, for a new folder). + let end = lib.iter().rposition(|x| in_folder(x, &target, &target_folder)).or(members.last().copied()).map_or(lib.len(), |k| k + 1); let pos = match (before_name, index) { (Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()), (None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) { Some(&k) => k, None => members.last().map_or(lib.len(), |k| k + 1), }, - (None, None) => members.last().map_or(lib.len(), |k| k + 1), + (None, None) => end, }; let pos = pos.min(lib.len()); let name = pr.name.clone(); lib.insert(pos, pr); let at = position_in_group(lib, pos); s.brush_presets_changed(); - Ok(json!({ "name": name, "group": target, "index": at })) + Ok(json!({ "name": name, "group": target, "folder": target_folder, "index": at })) } fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> { if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) } } -fn move_group(s: &mut Session, p: &Value) -> Result { - let cmd = "brush.presets.moveGroup"; +/// The group and (possibly empty) folder path a group command acts on; both must exist. +fn target_folder(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec)> { let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?; group_block(s, &g, cmd)?; + let f = folder_param(p, cmd)?.unwrap_or_default(); + if !s.tools.presets.iter().any(|x| in_folder(x, &g, &f)) { + return Err(bad(cmd, format!("no folder `{}` in brush preset group `{g}`", f.join("/")))); + } + Ok((g, f)) +} + +/// Move folder `f` (non-empty) of group `g` among its sibling folders, as a block. +fn move_folder(s: &mut Session, p: &Value, g: String, f: Vec, cmd: &str) -> Result { + let Some((leaf, parent)) = f.split_last() else { return Err(bad(cmd, "missing `folder`")) }; + let siblings = subfolder_order(&s.tools.presets, &g, parent); + let before = match p.get("before") { + None | Some(Value::Null) => None, + Some(Value::String(b)) if b == leaf => return Err(bad(cmd, "`before` names the folder being moved")), + Some(Value::String(b)) if siblings.contains(b) => Some(b.clone()), + Some(_) => return Err(bad(cmd, "`before` must name a folder next to the one being moved")), + }; + let index = match p.get("index") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?), + }; + let lib = std::mem::take(&mut s.tools.presets); + // Where the block sits among the other presets (its place when nothing else is in the parent). + let start = lib.iter().position(|x| in_folder(x, &g, &f)).unwrap_or(0); + let (block, mut rest): (Vec, Vec) = lib.into_iter().partition(|x| in_folder(x, &g, &f)); + let sibling = |name: &str| -> Vec { parent.iter().cloned().chain(std::iter::once(name.to_string())).collect() }; + let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| in_folder(x, &g, &sibling(name))).unwrap_or(rest.len()); + let others: Vec<&String> = siblings.iter().filter(|n| *n != leaf).collect(); + let parent_end = |rest: &[BrushPreset]| rest.iter().rposition(|x| in_folder(x, &g, parent)).map_or(start, |k| k + 1); + let pos = match (&before, index) { + (Some(b), _) => first(b, &rest), + (None, Some(n)) => match others.get(n.min(usize::MAX as u64) as usize) { + Some(name) => first(name, &rest), + None => parent_end(&rest), + }, + (None, None) => parent_end(&rest), + }; + let tail = rest.split_off(pos.min(rest.len())); + rest.extend(block); + rest.extend(tail); + s.tools.presets = rest; + s.brush_presets_changed(); + let at = subfolder_order(&s.tools.presets, &g, parent).iter().position(|x| x == leaf).unwrap_or(0); + Ok(json!({ "group": g, "folder": f, "index": at })) +} + +fn move_group(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.presets.moveGroup"; + let (g, f) = target_folder(s, p, cmd)?; + if !f.is_empty() { + return move_folder(s, p, g, f, cmd); + } let before = group_param(p, "before", cmd)?; if let Some(b) = &before { group_block(s, b, cmd)?; @@ -170,9 +280,26 @@ fn move_group(s: &mut Session, p: &Value) -> Result { fn rename_group(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.renameGroup"; - let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?; - group_block(s, &g, cmd)?; + let (g, f) = target_folder(s, p, cmd)?; let new = text(p, "newName", cmd)?; + if let Some((leaf, parent)) = f.split_last() { + if new != *leaf { + if subfolder_order(&s.tools.presets, &g, parent).contains(&new) { + return Err(bad(cmd, format!("a folder named `{new}` already exists there"))); + } + let depth = parent.len(); + for x in s.tools.presets.iter_mut().filter(|x| in_folder(x, &g, &f)) { + if let Some(n) = x.folder.get_mut(depth) { + n.clone_from(&new); + } + x.builtin = false; + } + s.brush_presets_changed(); + } + let mut renamed = parent.to_vec(); + renamed.push(new); + return Ok(json!({ "group": g, "folder": renamed })); + } if new != g && s.tools.presets.iter().any(|x| x.group == new) { return Err(bad(cmd, format!("a group named `{new}` already exists"))); } @@ -188,10 +315,9 @@ fn rename_group(s: &mut Session, p: &Value) -> Result { fn delete_group(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.deleteGroup"; - let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?; - group_block(s, &g, cmd)?; + let (g, f) = target_folder(s, p, cmd)?; let before = s.tools.presets.len(); - s.tools.presets.retain(|x| x.group != g); + s.tools.presets.retain(|x| !in_folder(x, &g, &f)); s.brush_presets_changed(); Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() })) } @@ -209,17 +335,27 @@ pub fn specs() -> Vec { spec!( "brush.presets.move", "Move Brush", - r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##, + r##"{"name":string,"group":string?=its group ("" = ungrouped),"folder":[name]? (nested folder names in the group, outermost first; [] = the group itself)=before's folder, else its own if it stays in its group, else [],"before":name? (preset to land before, in that folder) | "index":n? (position in the group)=end of the folder} → {name, group, folder, index}"##, move_preset ), spec!( "brush.presets.moveGroup", "Move Brush Group", - r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##, + r##"{"group":string,"folder":[name]? (move this nested folder among its sibling folders instead),"before":group? (or sibling folder name) | "index":n? (group or sibling folder position)=end} → {group, folder?, index}"##, move_group ), - spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group), - spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group), + spec!( + "brush.presets.renameGroup", + "Rename Brush Group", + r##"{"group":string,"folder":[name]? (rename this nested folder instead),"newName":string} → {group, folder?}"##, + rename_group + ), + spec!( + "brush.presets.deleteGroup", + "Delete Brush Group", + r##"{"group":string,"folder":[name]? (delete this nested folder instead)} → {deleted, count}"##, + delete_group + ), ] } @@ -234,7 +370,7 @@ mod tests { fn with_groups() -> Session { let mut s = Session::new(); for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] { - s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() }); + s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into(), folder: Vec::new() }); } s } @@ -243,7 +379,7 @@ mod tests { fn move_within_and_between_groups() { let mut s = with_groups(); let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap(); - assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0})); + assert_eq!(r, json!({"name": "A3", "group": "Alpha", "folder": [], "index": 0})); assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]); s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap(); assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]); @@ -333,4 +469,100 @@ mod tests { s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap(); s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap(); } + + /// Group "Set": S0 at its top level, folders Ink (I1, I2; subfolder Fine: F1) and Dry (D1). + fn with_folders() -> Session { + let mut s = Session::new(); + for (n, f) in [("S0", &[][..]), ("I1", &["Ink"][..]), ("F1", &["Ink", "Fine"][..]), ("I2", &["Ink"][..]), ("D1", &["Dry"][..])] { + let folder = f.iter().map(|x| x.to_string()).collect(); + s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Set".into(), folder }); + } + s + } + + fn folder_of(s: &Session, n: &str) -> Vec { + photocraft_paint::presets::find(&s.tools.presets, n).unwrap().folder.clone() + } + + #[test] + fn presets_move_between_folders() { + let mut s = with_folders(); + // `before` brings its folder. + let r = s.execute("brush.presets.move", json!({"name": "D1", "before": "I2"})).unwrap(); + assert_eq!(r["folder"], json!(["Ink"])); + assert_eq!(folder_of(&s, "D1"), ["Ink"]); + // An explicit folder, appended at the end of it. + s.execute("brush.presets.move", json!({"name": "S0", "folder": ["Ink", "Fine"]})).unwrap(); + assert_eq!(folder_of(&s, "S0"), ["Ink", "Fine"]); + let pos = |n: &str| s.tools.presets.iter().position(|x| x.name == n).unwrap(); + assert_eq!(pos("S0"), pos("F1") + 1); + // Within its group without a folder keeps its folder; to another group without one lands + // at that group's top level; `folder: []` is the group's top level. + s.execute("brush.presets.move", json!({"name": "S0", "index": 0})).unwrap(); + assert_eq!(folder_of(&s, "S0"), ["Ink", "Fine"]); + s.execute("brush.presets.move", json!({"name": "S0", "group": "Other"})).unwrap(); + assert!(folder_of(&s, "S0").is_empty()); + s.execute("brush.presets.move", json!({"name": "I1", "folder": []})).unwrap(); + assert!(folder_of(&s, "I1").is_empty()); + // `before` in another folder than the one named. + assert!(s.execute("brush.presets.move", json!({"name": "I1", "folder": ["Dry"], "before": "I2"})).is_err()); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Ink"]); + } + + #[test] + fn folders_move_rename_and_delete() { + let mut s = with_folders(); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Ink", "Dry"]); + // Move Dry before Ink: the whole folder moves as a block. + let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"], "before": "Ink"})).unwrap(); + assert_eq!(r, json!({"group": "Set", "folder": ["Dry"], "index": 0})); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Dry", "Ink"]); + s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"]})).unwrap(); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Ink", "Dry"]); + s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"], "index": 0})).unwrap(); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Dry", "Ink"]); + let ink: Vec<&str> = s.tools.presets.iter().filter(|x| x.folder.first().is_some_and(|f| f == "Ink")).map(|x| x.name.as_str()).collect(); + assert_eq!(ink, ["I1", "F1", "I2"], "presets keep their order inside a moved folder"); + // Rename a nested folder: every preset below it follows. + let r = s.execute("brush.presets.renameGroup", json!({"group": "Set", "folder": ["Ink"], "newName": "Inks"})).unwrap(); + assert_eq!(r["folder"], json!(["Inks"])); + assert_eq!(folder_of(&s, "F1"), ["Inks", "Fine"]); + assert!(s.execute("brush.presets.renameGroup", json!({"group": "Set", "folder": ["Inks"], "newName": "Dry"})).is_err()); + // Renaming the group keeps the folders. + s.execute("brush.presets.renameGroup", json!({"group": "Set", "newName": "Kit"})).unwrap(); + assert_eq!(folder_of(&s, "F1"), ["Inks", "Fine"]); + // Delete a nested folder: only the presets in it (and below) go. + let r = s.execute("brush.presets.deleteGroup", json!({"group": "Kit", "folder": ["Inks"]})).unwrap(); + assert_eq!(r["deleted"], 3); + assert!(photocraft_paint::presets::find(&s.tools.presets, "S0").is_some()); + assert!(photocraft_paint::presets::find(&s.tools.presets, "D1").is_some()); + } + + #[test] + fn bad_folder_params_fail_without_changes() { + let mut s = with_folders(); + let before = s.tools.presets.clone(); + let long = "x".repeat(MAX_NAME + 1); + let deep: Vec<&str> = vec!["a"; MAX_FOLDER_DEPTH + 1]; + for (id, p) in [ + ("brush.presets.move", json!({"name": "S0", "folder": "Ink"})), + ("brush.presets.move", json!({"name": "S0", "folder": [""]})), + ("brush.presets.move", json!({"name": "S0", "folder": [7]})), + ("brush.presets.move", json!({"name": "S0", "folder": [long]})), + ("brush.presets.move", json!({"name": "S0", "folder": deep})), + ("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Nope"]})), + ("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "before": "Ink"})), + ("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "before": "Nope"})), + ("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "before": 3})), + ("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "index": -2})), + ("brush.presets.renameGroup", json!({"group": "Set", "folder": ["Ink"], "newName": ""})), + ("brush.presets.renameGroup", json!({"group": "Set", "folder": ["Ink", "Nope"], "newName": "x"})), + ("brush.presets.deleteGroup", json!({"group": "Set", "folder": ["Dry", "Nope"]})), + ("brush.presets.deleteGroup", json!({"group": "Nope", "folder": ["Dry"]})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + assert_eq!(s.tools.presets, before); + s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "index": u64::MAX})).unwrap(); + } } diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index a0b60fdc..a2dece02 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -919,7 +919,11 @@ fn export_import(s: &mut Session, p: &Value) -> Result { } let (mut nb, mut ns) = (0, 0); if want("brushes") { - for b in file.brushes { + for mut b in file.brushes { + // Nested folders as the commands bound them (a hand-edited file can't build an + // absurd tree). + b.folder.retain(|f| !f.trim().is_empty()); + b.folder.truncate(crate::brush_preset_cmds::MAX_FOLDER_DEPTH); match s.tools.presets.iter_mut().find(|x| x.name == b.name) { Some(x) => *x = b, None => s.tools.presets.push(b), diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8f369188..37b375ce 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -1,7 +1,8 @@ //! Importing Photoshop preset files. //! //! - `brush.presets.importAbr` adds the presets of an `.abr` file (v1, v2, v6+) to the brush -//! library as one group (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in +//! library as one group named after the file, with the file's own folders nested inside it +//! as Photoshop does (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in //! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`). //! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the //! Gradients panel as one group. @@ -106,7 +107,11 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; s.tools.brush = b; } - Ok(json!({ "group": group, "imported": names, "count": names.len(), "version": imp.version, "warnings": imp.warnings })) + // Distinct folder paths the file brought (its Photoshop folders, nested inside `group`). + let folders: std::collections::BTreeSet<&[String]> = + s.tools.presets.iter().filter(|x| !x.builtin && x.group == group).flat_map(|x| (1..=x.folder.len()).filter_map(|d| x.folder.get(..d))).collect(); + let folders = folders.len(); + Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings })) } /// Brush-file import command specs. @@ -117,7 +122,7 @@ pub fn specs() -> Vec { label: "Import Brushes…", menu: &[], shortcut: None, - params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name),"select":bool=false (make the first imported preset current)}"##, + params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name),"select":bool=false (make the first imported preset current)} → {group, imported, count, folders (nested folders kept from the file), version, warnings}"##, enabled: always, run: import_abr, journal: true, diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index d4360f16..389d4131 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -5,7 +5,8 @@ //! //! - `-.pcbrushes`: one JSON file per preset group (folder) holding the presets' //! settings with every bitmap (sampled tip, Dual Brush tip, texture tile) replaced by a -//! reference to a tip file, plus each tip's size and the tip file of its small preview; +//! reference to a tip file, plus each tip's size and the tip file of its small preview. A +//! preset in a nested folder of its group records the folder path (`folder`, #1851); //! - `tips/.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when //! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only //! rewrites a small JSON file; a tip no group references any more is deleted; @@ -328,6 +329,10 @@ struct StoredPreset { brush: BrushSettings, #[serde(default)] tips: TipRefs, + /// Nested folders inside the group ([`BrushPreset::folder`]); missing in stores written + /// before #1851, which load with every preset directly in its group. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + folder: Vec, } /// What a group file records about each tip it references, so loading needs only the preview. @@ -445,6 +450,13 @@ fn join_tips(mut b: BrushSettings, refs: &TipRefs, tiles: &HashMap) -> Vec { + folder.into_iter().map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).take(crate::brush_preset_cmds::MAX_FOLDER_DEPTH).collect() +} + fn tip_file(hash: &str) -> String { format!("{TIPS_DIR}/{hash}.{TIP_EXT}") } @@ -647,7 +659,7 @@ pub fn open(backend: Box) -> Opened { hashes.push(i.preview.clone()); } } - items.push(BrushPreset { name: sp.name, brush, builtin: false, group: g.group.clone() }); + items.push(BrushPreset { name: sp.name, brush, builtin: false, group: g.group.clone(), folder: sanitize_folder(sp.folder) }); } None => missing += 1, } @@ -974,7 +986,7 @@ impl PresetStore { } let key = |r: &Option| r.as_ref().map(|t| t.key.clone()); let tips = TipRefs { tip: key(&refs[0]), dual_tip: key(&refs[1]), texture: key(&refs[2]) }; - stored.push(StoredPreset { name: p.name.clone(), brush: settings_only(&mut p.brush), tips }); + stored.push(StoredPreset { name: p.name.clone(), brush: settings_only(&mut p.brush), tips, folder: p.folder.clone() }); tiles.push(refs); } let gf = GroupFile { format: GROUP_FORMAT.into(), version: 1, group: group.to_string(), presets: stored, tips: infos }; diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 42e33ad3..b6017781 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -45,7 +45,7 @@ fn tip16(w: u32, h: u32, seed: u32) -> GrayTile { fn sampled(name: &str, group: &str, tip: GrayTile) -> BrushPreset { let brush = BrushSettings { size: 42.0, spacing: 0.1, tip: TipShape::Sampled(tip), ..Default::default() }; - BrushPreset { name: name.into(), brush, builtin: false, group: group.into() } + BrushPreset { name: name.into(), brush, builtin: false, group: group.into(), folder: Vec::new() } } fn user(s: &Session) -> Vec { @@ -568,3 +568,58 @@ fn a_store_without_previews_makes_them_once_and_then_loads_lazily() { assert_eq!(a.brush.tip, TipShape::Sampled(tips[0].clone())); assert_eq!(a.brush.texture.pattern, Pattern::Tile(tips[1].clone())); } + +/// A v6 `.abr` whose presets sit in Photoshop folders (`phry`). +fn abr_with_folders(presets: &[(&str, &[&str])]) -> Vec { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; + let descs: Vec = presets.iter().map(|(n, _)| Descriptor::new("brushPreset").with("Nm ", Value::Text(UnicodeString::new_nul(n)))).collect(); + let folders: Vec> = presets.iter().map(|(_, f)| f.iter().map(|x| x.to_string()).collect()).collect(); + photocraft_psd::abr::write_v6_folders(2, &[], &[], &descs, &folders, true).unwrap() +} + +#[test] +fn imported_abr_folders_persist_and_old_stores_load_flat() { + let dir = TempDir::new("abr-folders"); + let (mut s, _) = session(&dir); + let abr = abr_with_folders(&[("Loose", &[]), ("Pen", &["Inks"]), ("Nib", &["Inks", "Fine"]), ("Kit Chalk", &["Dry"])]); + let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "group": "Kit"})).unwrap(); + assert_eq!((r["count"].as_u64(), r["folders"].as_u64()), (Some(4), Some(3))); + let folder = |s: &Session, n: &str| photocraft_paint::presets::find(&s.tools.presets, n).unwrap().folder.join("/"); + assert_eq!((folder(&s, "Loose"), folder(&s, "Nib"), folder(&s, "Kit Chalk")), ("".into(), "Inks/Fine".into(), "Dry".into())); + // One group file still holds the whole imported set, folders included. + let groups: Vec = dir.files().into_iter().filter(|f| f.ends_with(".pcbrushes")).collect(); + assert_eq!(groups, [group_file_name("Kit")]); + let (t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(user(&t), user(&s)); + // Renaming a nested folder rewrites the group; the next start sees the new name. + s.execute("brush.presets.renameGroup", json!({"group": "Kit", "folder": ["Inks"], "newName": "Ink"})).unwrap(); + let (t, _) = session(&dir); + assert_eq!(folder(&t, "Nib"), "Ink/Fine"); + // Exported preset files keep the folders. + let out = s.execute("edit.presets.exportImportPresets", json!({"action": "export"})).unwrap(); + assert_eq!(out["data"]["brushes"].as_array().unwrap().iter().find(|b| b["name"] == "Nib").unwrap()["folder"], json!(["Ink", "Fine"])); + + // A group file written before #1851 has no `folder`: every preset loads directly in its group, + // without a warning and without rewriting the file. + let mem = MemBackend::default(); + let old = json!({"format": GROUP_FORMAT, "version": 1, "group": "Old", "presets": [{"name": "Plain", "brush": {"size": 5}}]}); + let file = group_file_name("Old"); + mem.write(&file, &serde_json::to_vec(&old).unwrap()).unwrap(); + let before = mem.read(&file, MAX_GROUP_BYTES).unwrap(); + let mut o = Session::new(); + assert!(o.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); + let p = photocraft_paint::presets::find(&o.tools.presets, "Plain").unwrap(); + assert!(p.group == "Old" && p.folder.is_empty()); + assert_eq!(mem.read(&file, MAX_GROUP_BYTES).unwrap(), before); + // A hand-edited folder path is bounded. + let deep = json!({"format": GROUP_FORMAT, "version": 1, "group": "Deep", "presets": [ + {"name": "Deep", "brush": {}, "folder": vec!["x"; 1000]}, + {"name": "Blank", "brush": {}, "folder": ["", " "]}, + ]}); + mem.write(&group_file_name("Deep"), &serde_json::to_vec(&deep).unwrap()).unwrap(); + let mut d = Session::new(); + d.attach_preset_store(open(Box::new(mem.clone()))); + assert_eq!(photocraft_paint::presets::find(&d.tools.presets, "Deep").unwrap().folder.len(), crate::brush_preset_cmds::MAX_FOLDER_DEPTH); + assert!(photocraft_paint::presets::find(&d.tools.presets, "Blank").unwrap().folder.is_empty()); +} diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 2edbcb9c..225869ea 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -30,7 +30,9 @@ pub struct AbrImport { pub version: u16, } -/// Parse an `.abr` file into presets in group `group` (usually the file name). +/// Parse an `.abr` file into presets in group `group` (usually the file name). The file's own +/// folders (Photoshop CC's preset hierarchy) become each preset's [`BrushPreset::folder`] inside +/// that group, the way Photoshop's Import Brushes puts them in a folder named after the file. pub fn read_abr(bytes: &[u8], group: &str) -> Result { read_abr_with(bytes, group, &photocraft_raster::Interrupt::NONE) } @@ -84,7 +86,7 @@ pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrup } }; let name = if b.name.trim().is_empty() { default_name } else { b.name.trim().to_string() }; - out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string() }); + out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder: Vec::new() }); } } else if f.presets.is_empty() { // Tips without a settings section (early v6 files): one preset per tip. @@ -92,14 +94,19 @@ pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrup step(i)?; let (tip, size) = m.tip(s); let brush = BrushSettings { pressure_size: false, spacing: 0.25, size, tip, ..Default::default() }; - out.presets.push(BrushPreset { name: format!("Sampled Brush {}", i + 1), brush, builtin: false, group: group.to_string() }); + out.presets.push(BrushPreset { name: format!("Sampled Brush {}", i + 1), brush, builtin: false, group: group.to_string(), folder: Vec::new() }); } } else { for (i, d) in f.presets.iter().enumerate() { step(i)?; let name = text(d, "Nm ").filter(|n| !n.trim().is_empty()).map(|n| n.trim().to_string()).unwrap_or_else(|| format!("Brush {}", i + 1)); match m.preset(d) { - Some(brush) => out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string() }), + // The file's folders nest inside `group`, as Photoshop nests them inside a folder + // named after the imported file. + Some(brush) => { + let folder = f.folders.get(i).cloned().unwrap_or_default(); + out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string(), folder }); + } None => { m.warnings.insert(format!("\"{name}\": its sampled tip is missing from the file; skipped")); } diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 69513349..9837f4ea 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_psd::abr::{LegacyBrush, write_v6, write_v12}; +use photocraft_psd::abr::{LegacyBrush, write_v6, write_v6_folders, write_v12}; use photocraft_psd::descriptor::{Id, UnicodeString}; fn sample(id: &str, w: u32, h: u32, depth: u16) -> AbrSample { @@ -188,6 +188,26 @@ fn missing_pattern_and_tip_are_reported() { assert!(imp.warnings.iter().any(|w| w.contains("Orphan")), "{:?}", imp.warnings); } +#[test] +fn file_folders_nest_inside_the_import_group() { + let named = |n: &str| Descriptor::new("brushPreset").with("Nm ", t(n)); + let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere"))); + let presets = [named("Top"), named("Pen"), orphan, named("Nib"), named("Chalk")]; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let folders = [path(&[]), path(&["Inks"]), path(&["Inks"]), path(&["Inks", "Fine"]), path(&["Dry"])]; + let bytes = write_v6_folders(2, &[sample("$a", 4, 4, 8), sample("$b", 4, 4, 8)], &[], &presets, &folders, false).unwrap(); + let imp = read_abr(&bytes, "My Set").unwrap(); + // The skipped preset (missing tip) doesn't shift the others' folders. + let got: Vec<(&str, &str, String)> = imp.presets.iter().map(|p| (p.name.as_str(), p.group.as_str(), p.folder.join("/"))).collect(); + assert_eq!( + got, + [("Top", "My Set", String::new()), ("Pen", "My Set", "Inks".into()), ("Nib", "My Set", "Inks/Fine".into()), ("Chalk", "My Set", "Dry".into())] + ); + // Without a hierarchy every preset is directly in the group. + let flat = read_abr(&write_v6(2, &[], &[], &[named("A")], false).unwrap(), "Flat").unwrap(); + assert!(flat.presets.iter().all(|p| p.group == "Flat" && p.folder.is_empty())); +} + #[test] fn v1_v2_brushes_become_presets() { let brushes = vec![ diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 4c262a59..7e19d36d 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -642,4 +642,8 @@ pub struct BrushPreset { /// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name. #[serde(default, skip_serializing_if = "String::is_empty")] pub group: String, + /// Folders inside `group`, outermost first (empty = directly in the group), like the nested + /// folders of Photoshop's Brushes panel; e.g. an imported `.abr` file's own folders. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub folder: Vec, } diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index c5d9143e..62156518 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -11,7 +11,7 @@ use crate::procedural::{bristle_tip, chalk_tip, charcoal_tip, grass_tip, leaf_ti pub const GROUPS: [&str; 4] = ["General", "Dry Media", "Wet Media", "Special Effects"]; fn preset(group: &str, name: &str, brush: BrushSettings) -> BrushPreset { - BrushPreset { name: name.to_string(), brush, builtin: true, group: group.to_string() } + BrushPreset { name: name.to_string(), brush, builtin: true, group: group.to_string(), folder: Vec::new() } } fn pressure() -> Dynamic { diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index fcd9a3a7..0bb51476 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -18,8 +18,16 @@ //! 264 if 2), bounds 4 × i32, depth i16, compression u8, data //! "patt" := patterns as in the `Patt` global block //! "desc" := version u32 (16) + ActionDescriptor; key `Brsh` lists `brushPreset` objects +//! "phry" := version u32 (16) + ActionDescriptor; key `hierarchy` lists objects read as a +//! token stream: `Grup` { `Nm ` name, `zuid` uuid } opens a folder, `preset` {} is +//! the next preset of `desc` (in order), `groupEnd` {} closes the current folder //! ``` //! +//! The `phry` (preset hierarchy) section keeps the Brushes panel folders of Photoshop CC (2018+) +//! files: [`AbrFile::folders`] gives each preset's folder path. Folders nest (a `Grup` inside a +//! `Grup`); presets outside any folder are at the top level. A damaged hierarchy never fails the +//! parse: mismatches become warnings and the presets they affect land at the top level. +//! //! [`parse`] never panics: every length is bounds-checked, sizes are capped ([`MAX_EDGE`], //! [`MAX_TOTAL_BYTES`]) and a damaged brush is skipped with a warning when the rest of the file //! is still readable. [`write_v12`] and [`write_v6`] produce files in the same layouts (used by @@ -110,6 +118,10 @@ pub struct AbrFile { pub patterns: Vec, /// v6+ brush presets: the `brushPreset` objects of the `desc` section, in file order. pub presets: Vec, + /// v6+ folder of each preset, parallel to [`AbrFile::presets`]: the folder names from the + /// outermost in, empty for a preset at the top level. Empty when the file has no readable + /// preset hierarchy (`phry`), i.e. every preset is at the top level. + pub folders: Vec>, /// Problems that did not stop the parse (skipped brushes, unknown sections…). pub warnings: Vec, } @@ -308,12 +320,113 @@ fn read_desc(data: &[u8], out: &mut AbrFile) { } } +/// Deepest folder nesting read from or written to a preset hierarchy; deeper folders are merged +/// into their ancestor at this depth. +pub const MAX_FOLDER_DEPTH: usize = 32; +/// Longest folder name kept (characters). +const MAX_FOLDER_NAME: usize = 255; + +/// Most bytes of folder names held across all presets' folder paths (each name also counts its +/// `String`). Presets past it are imported at the top level. 20,000 presets three folders deep +/// with 20-character names take about 3 MB. +const MAX_FOLDER_BYTES: usize = 16 << 20; + +/// Reads the `phry` token stream into one folder path per preset (`presets` of them, the count +/// read from `desc`), in order. Paths are built only for those presets, within +/// [`MAX_FOLDER_BYTES`]: a small section can't make the reader allocate more than that. `None` +/// (with a warning) when the section is unreadable. +fn read_phry(data: &[u8], presets: usize, warnings: &mut Vec) -> Option>> { + let vd = match VersionedDescriptor::parse_prefix(data) { + Ok((vd, _)) => vd, + Err(e) => { + warnings.push(format!("brush folders (phry) unreadable: {e}; brushes imported without folders")); + return None; + } + }; + let Some(Value::List(items)) = vd.descriptor.get("hierarchy") else { + warnings.push("brush folders (phry) hold no hierarchy; brushes imported without folders".into()); + return None; + }; + let mut stack: Vec = Vec::new(); + // Folders opened past MAX_FOLDER_DEPTH: their `groupEnd`s close nothing on `stack`. + let mut overflow = 0usize; + let mut out = Vec::with_capacity(presets.min(MAX_BRUSHES)); + // Preset tokens seen (only counted past `presets`), and the bytes held by `out`'s paths. + let (mut listed, mut bytes) = (0usize, 0usize); + let (mut unbalanced, mut too_deep, mut too_big) = (false, false, false); + for v in items { + let Value::Descriptor(d) = v else { continue }; + if d.class_id.is("Grup") { + if stack.len() >= MAX_FOLDER_DEPTH { + overflow = overflow.saturating_add(1); + too_deep = true; + continue; + } + let name = match d.get("Nm ") { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + }; + let name: String = name.trim().chars().take(MAX_FOLDER_NAME).collect(); + stack.push(if name.is_empty() { "Group".to_string() } else { name }); + } else if d.class_id.is("groupEnd") { + if overflow > 0 { + overflow -= 1; + } else if stack.pop().is_none() { + unbalanced = true; + } + } else if d.class_id.is("preset") { + listed = listed.saturating_add(1); + if out.len() >= presets { + continue; + } + let size = stack.iter().map(|n| n.len().saturating_add(size_of::())).fold(0usize, usize::saturating_add); + match bytes.checked_add(size).filter(|&b| b <= MAX_FOLDER_BYTES) { + Some(b) if !too_big => { + bytes = b; + out.push(stack.clone()); + } + _ => { + too_big = true; + out.push(Vec::new()); + } + } + } + } + if unbalanced { + warnings.push("brush folders (phry): a folder end without a folder was ignored".into()); + } + if too_deep { + warnings.push(format!("brush folders nested deeper than {MAX_FOLDER_DEPTH} levels were merged into their parent")); + } + if too_big { + warnings.push("brush folders (phry) too large; the remaining brushes were imported at the top level".into()); + } + if listed != presets { + warnings.push(format!( + "brush folders (phry) list {listed} brushes but the file holds {presets}; {}", + if listed > presets { "the extra entries were ignored" } else { "the rest were imported at the top level" } + )); + out.resize(presets, Vec::new()); + } + Some(out) +} + +/// Pairs the `phry` section's preset tokens with the presets read from `desc`, in order. +fn apply_hierarchy(out: &mut AbrFile, phry: Option<&[u8]>) { + let Some(data) = phry else { return }; + let Some(folders) = read_phry(data, out.presets.len(), &mut out.warnings) else { return }; + if folders.iter().any(|f| !f.is_empty()) { + out.folders = folders; + } +} + fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> { out.subversion = r.u16()?; if !matches!(out.subversion, 1 | 2) { return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion))); } let mut budget = Budget(MAX_TOTAL_BYTES); + let mut hierarchy = None; while r.left() >= 12 { let sig = r.arr::<4>()?; if &sig != b"8BIM" { @@ -341,11 +454,14 @@ fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> { Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), }, b"desc" => read_desc(data, out), - // Preset hierarchy (groups) and tool presets: not needed for the tips. - b"phry" | b"lPdc" => {} + // Read once `desc` gives the preset count; `phry` may come first. + b"phry" => hierarchy = Some(data), + // Tool presets: not needed for the brushes. + b"lPdc" => {} k => out.warnings.push(format!("section {} ignored", String::from_utf8_lossy(k))), } } + apply_hierarchy(out, hierarchy); Ok(()) } @@ -441,8 +557,68 @@ fn section(o: &mut Vec, key: &[u8; 4], data: &[u8]) { } /// Writes a v6 (`subversion` 1 or 2) file: sampled tips, embedded patterns and the preset -/// descriptors (each a `brushPreset` object), listed under `Brsh`. +/// descriptors (each a `brushPreset` object), listed under `Brsh`. Every preset is at the top +/// level ([`write_v6_folders`] keeps folders). pub fn write_v6(subversion: u16, samples: &[AbrSample], patterns: &[PsdPattern], presets: &[Descriptor], rle: bool) -> Result> { + write_v6_folders(subversion, samples, patterns, presets, &[], rle) +} + +/// A stable uuid-shaped folder id (`zuid`) derived from the folder's path and position. +fn folder_uuid(path: &[&str], seq: usize) -> String { + // FNV-1a over the path, with two seeds, for 128 bits. + let hash = |seed: u64| { + let mut h = 0xcbf2_9ce4_8422_2325u64 ^ seed; + for b in path.iter().flat_map(|s| s.bytes().chain(std::iter::once(0))).chain(seq.to_le_bytes()) { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h + }; + let (a, b) = (hash(1), hash(2)); + format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", a >> 32, (a >> 16) & 0xffff, a & 0xffff, b >> 48, b & 0xffff_ffff_ffff) +} + +/// The `phry` descriptor for presets in `folders` (one path per preset, in preset order). +/// Presets of one folder that are not adjacent reopen it (a reader merges them by path). +fn hierarchy_descriptor(folders: &[Vec]) -> Descriptor { + let text = |s: &str| Value::Text(crate::descriptor::UnicodeString::new_nul(s)); + let mut items = Vec::new(); + let mut open: Vec<&str> = Vec::new(); + let mut seq = 0usize; + for path in folders { + let path: Vec<&str> = path.iter().take(MAX_FOLDER_DEPTH).map(String::as_str).collect(); + let common = open.iter().zip(&path).take_while(|(a, b)| a == b).count(); + while open.len() > common { + open.pop(); + items.push(Value::Descriptor(Descriptor::new("groupEnd"))); + } + for name in path.iter().skip(common) { + open.push(name); + seq += 1; + items.push(Value::Descriptor(Descriptor::new("Grup").with("Nm ", text(name)).with("zuid", text(&folder_uuid(&open, seq))))); + } + items.push(Value::Descriptor(Descriptor::new("preset"))); + } + for _ in open { + items.push(Value::Descriptor(Descriptor::new("groupEnd"))); + } + Descriptor::new("null").with("hierarchy", Value::List(items)) +} + +/// [`write_v6`] that also writes the preset hierarchy (`phry`): `folders` holds each preset's +/// folder path (outermost first; empty = top level), parallel to `presets`. With no folders (an +/// empty slice, or only empty paths) no hierarchy is written. +pub fn write_v6_folders( + subversion: u16, + samples: &[AbrSample], + patterns: &[PsdPattern], + presets: &[Descriptor], + folders: &[Vec], + rle: bool, +) -> Result> { + if !folders.is_empty() && folders.len() != presets.len() { + return Err(PsdError::invalid(format!("{} folder paths for {} brush presets", folders.len(), presets.len()))); + } if !matches!(subversion, 1 | 2) { return Err(PsdError::Unsupported(format!("brush file subversion {subversion}"))); } @@ -470,6 +646,9 @@ pub fn write_v6(subversion: u16, samples: &[AbrSample], patterns: &[PsdPattern], let list = Value::List(presets.iter().cloned().map(Value::Descriptor).collect()); let desc = VersionedDescriptor::new(Descriptor::new("null").with("Brsh", list)); section(&mut o, b"desc", &desc.to_bytes()); + if folders.iter().any(|f| !f.is_empty()) { + section(&mut o, b"phry", &VersionedDescriptor::new(hierarchy_descriptor(folders)).to_bytes()); + } Ok(o) } @@ -557,4 +736,162 @@ mod tests { // A huge declared count with no data is an error, not an allocation. assert!(parse(&[0, 2, 0xff, 0xff]).is_err()); } + + fn named(n: &str) -> Descriptor { + Descriptor::new("brushPreset").with("Nm ", Value::Text(UnicodeString::new_nul(n))) + } + + /// Hierarchy tokens: `G(name)` opens, `P` is a preset, `E` closes. + enum Tok<'a> { + G(&'a str), + P, + E, + } + + /// A v6 file with `n` presets and a hand-made `phry` token stream. + fn with_phry(n: usize, toks: &[Tok]) -> Vec { + let presets: Vec = (0..n).map(|i| named(&format!("B{i}"))).collect(); + let mut bytes = write_v6(2, &[], &[], &presets, false).unwrap(); + section(&mut bytes, b"phry", &phry_bytes(toks)); + bytes + } + + /// A `phry` section body holding `toks`. + fn phry_bytes(toks: &[Tok]) -> Vec { + let items = toks + .iter() + .map(|t| { + Value::Descriptor(match t { + Tok::G(name) => Descriptor::new("Grup") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("zuid", Value::Text(UnicodeString::new_nul("c9ee8fd5-eb7d-924c-9e95-ddc98e40bb6c"))), + Tok::P => Descriptor::new("preset"), + Tok::E => Descriptor::new("groupEnd"), + }) + }) + .collect(); + VersionedDescriptor::new(Descriptor::new("null").with("hierarchy", Value::List(items))).to_bytes() + } + + fn path(p: &[&str]) -> Vec { + p.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn phry_flat_folders_like_photoshop_25() { + // The layout of Photoshop 25.1's default brushes: four folders, every preset in one. + use Tok::*; + let f = parse(&with_phry(5, &[G("General"), P, P, E, G("Dry Media"), P, E, G("Wet Media"), P, E, G("Special Effects"), P, E])).unwrap(); + assert_eq!(f.folders, vec![path(&["General"]), path(&["General"]), path(&["Dry Media"]), path(&["Wet Media"]), path(&["Special Effects"])]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + + #[test] + fn phry_nested_folders_and_top_level_presets() { + use Tok::*; + let f = parse(&with_phry(5, &[P, G("Animals"), G("Birds"), P, E, P, G("Fish"), P, E, E, P])).unwrap(); + assert_eq!(f.folders, vec![path(&[]), path(&["Animals", "Birds"]), path(&["Animals"]), path(&["Animals", "Fish"]), path(&[])]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + // No folder at all: no hierarchy. + let f = parse(&with_phry(2, &[P, P])).unwrap(); + assert!(f.folders.is_empty()); + } + + #[test] + fn phry_mismatches_become_warnings() { + use Tok::*; + // A stray groupEnd is ignored; an unclosed folder still holds its presets. + let f = parse(&with_phry(2, &[E, G("A"), P, E, E, G("B"), P])).unwrap(); + assert_eq!(f.folders, vec![path(&["A"]), path(&["B"])]); + assert!(f.warnings.iter().any(|w| w.contains("folder end")), "{:?}", f.warnings); + // Fewer preset tokens than presets: the rest are at the top level. + let f = parse(&with_phry(3, &[G("A"), P, E])).unwrap(); + assert_eq!(f.folders, vec![path(&["A"]), path(&[]), path(&[])]); + assert!(f.warnings.iter().any(|w| w.contains("list 1 brushes but the file holds 3")), "{:?}", f.warnings); + // More preset tokens than presets: extras ignored. + let f = parse(&with_phry(1, &[G("A"), P, P, P, E])).unwrap(); + assert_eq!(f.folders, vec![path(&["A"])]); + assert_eq!(f.presets.len(), 1); + assert!(!f.warnings.is_empty()); + // A depth bomb: folders deeper than the limit merge into their ancestor at the limit. + let mut toks: Vec = (0..10_000).map(|_| G("deep")).collect(); + toks.push(P); + toks.extend((0..10_000).map(|_| E)); + toks.extend([G("next"), P, E]); + let f = parse(&with_phry(2, &toks)).unwrap(); + assert_eq!(f.folders[0].len(), MAX_FOLDER_DEPTH); + assert_eq!(f.folders[1], path(&["next"])); + assert!(f.warnings.iter().any(|w| w.contains("deeper")), "{:?}", f.warnings); + // An unreadable hierarchy: imported flat, with a warning. + let mut bytes = write_v6(2, &[], &[], &[named("x")], false).unwrap(); + section(&mut bytes, b"phry", &[0, 0, 0, 16, 0xff, 0xff]); + let f = parse(&bytes).unwrap(); + assert!(f.folders.is_empty() && f.presets.len() == 1); + assert!(f.warnings.iter().any(|w| w.contains("phry")), "{:?}", f.warnings); + // Truncations and bit flips of a file with a hierarchy never panic. + let good = with_phry(3, &[G("A"), P, G("B"), P, E, E, P]); + for cut in 0..good.len() { + let _ = parse(&good[..cut]); + } + for i in 0..good.len() { + let mut b = good.clone(); + b[i] ^= 0x5a; + let _ = parse(&b); + } + } + + #[test] + fn phry_builds_paths_only_for_the_files_presets() { + use Tok::*; + // A few-KB section listing 100,000 deep presets for a file with 2: two paths are built. + let mut toks: Vec = (0..MAX_FOLDER_DEPTH).map(|_| G("folder")).collect(); + toks.extend((0..100_000).map(|_| P)); + let mut warnings = Vec::new(); + let folders = read_phry(&phry_bytes(&toks), 2, &mut warnings).unwrap(); + assert_eq!(folders.len(), 2); + assert_eq!(folders.capacity(), 2); + assert_eq!(folders[1].len(), MAX_FOLDER_DEPTH); + assert!(warnings.iter().any(|w| w.contains("list 100000 brushes but the file holds 2")), "{warnings:?}"); + // `phry` before `desc` still pairs with the file's presets. + let mut bytes = write_v6(2, &[], &[], &[named("a"), named("b")], false).unwrap(); + let at = bytes.windows(8).position(|w| w == b"8BIMdesc").unwrap(); + let mut phry = Vec::new(); + section(&mut phry, b"phry", &phry_bytes(&[G("A"), P, E, P])); + bytes.splice(at..at, phry); + let f = parse(&bytes).unwrap(); + assert_eq!(f.folders, vec![path(&["A"]), path(&[])]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + + #[test] + fn phry_folder_paths_stay_within_their_byte_budget() { + use Tok::*; + // Thousands of presets under 32 folders of 255-character names would need ~27 MB of paths. + let long = "x".repeat(MAX_FOLDER_NAME); + let mut toks: Vec = (0..MAX_FOLDER_DEPTH).map(|_| G(&long)).collect(); + toks.extend((0..3_000).map(|_| P)); + let mut warnings = Vec::new(); + let folders = read_phry(&phry_bytes(&toks), 3_000, &mut warnings).unwrap(); + assert_eq!(folders.len(), 3_000); + let held: usize = folders.iter().flatten().map(|n| n.len() + size_of::()).sum(); + assert!(held <= MAX_FOLDER_BYTES, "{held}"); + assert_eq!(folders[0].len(), MAX_FOLDER_DEPTH); + assert!(folders[2_999].is_empty(), "past the budget: top level"); + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); + } + + #[test] + fn phry_round_trips_through_the_writer() { + let presets: Vec = (0..6).map(|i| named(&format!("B{i}"))).collect(); + let folders = vec![path(&[]), path(&["Set", "Ink"]), path(&["Set", "Ink"]), path(&["Set"]), path(&["Other"]), path(&["Set", "Ink"])]; + let bytes = write_v6_folders(2, &[], &[], &presets, &folders, true).unwrap(); + let f = parse(&bytes).unwrap(); + assert_eq!(f.presets, presets); + assert_eq!(f.folders, folders); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + // No folders: no phry section, same bytes as write_v6. + assert_eq!(write_v6_folders(2, &[], &[], &presets, &vec![Vec::new(); 6], true).unwrap(), write_v6(2, &[], &[], &presets, true).unwrap()); + // Mismatched lengths are an error. + assert!(write_v6_folders(2, &[], &[], &presets, &folders[..2], true).is_err()); + } } diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index a9c729fa..f2c0b995 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -67,6 +67,8 @@ pub struct Renaming { pub group: bool, /// The preset (or group) being renamed. pub name: String, + /// With `group`: the nested folder of group `name` being renamed (empty = the group itself). + pub folder: Vec, /// The text typed so far. pub text: String, } @@ -75,7 +77,8 @@ pub struct Renaming { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct BrushesPanelState { - /// Names of collapsed groups. + /// Collapsed groups and folders: a group's label, or for a nested folder the group's label and + /// the folder path joined with `/` ([`crate::brushes_tab::folder_view_key`]). pub collapsed: Vec, /// Case-insensitive name filter. pub filter: String, diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 0bebf859..097e1b3a 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -365,32 +365,32 @@ fn drop_targets_and_actions_reorder_presets() { use crate::brushes_tab::{Action, apply, drop_target, group_key}; let mut app = app(); for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] { - app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() }); + app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into(), folder: Vec::new() }); } let p = &app.session.tools.presets; // Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it. - assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), 2))); - assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), 1))); - assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), 0))); - assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), 1))); + assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), vec![], 2))); + assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), vec![], 1))); + assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), vec![], 0))); + assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), vec![], 1))); assert_eq!(drop_target(p, "A2", "Nope", true), None); assert_eq!(group_key(p, UNGROUPED), ""); - let (group, index) = drop_target(p, "A1", "A3", true).unwrap(); - apply(&mut app, vec![Action::Move { name: "A1".into(), group, index: Some(index) }]); + let (group, folder, index) = drop_target(p, "A1", "A3", true).unwrap(); + apply(&mut app, vec![Action::Move { name: "A1".into(), group, folder: Some(folder), index: Some(index) }]); let names = |app: &PhotocraftApp, g: &str| app.session.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect::>(); assert_eq!(names(&app, "Alpha"), ["A2", "A3", "A1"]); - apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), index: None }]); + apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), folder: None, index: None }]); assert_eq!(names(&app, "Beta"), ["B1", "A2"]); - apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), before: Some("Alpha".into()) }]); + apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), folder: vec![], before: Some("Alpha".into()) }]); let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets); assert!(order.iter().position(|g| g == "Beta") < order.iter().position(|g| g == "Alpha")); // Rename and delete from the context menu. - apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), text: "Third".into() })]); + apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), folder: vec![], text: "Third".into() })]); assert!(paint::presets::find(&app.session.tools.presets, "Third").is_some()); assert!(app.ui.brushes_panel.renaming.is_none()); - apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), text: "Bees".into() })]); + apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), folder: vec![], text: "Bees".into() })]); assert_eq!(names(&app, "Bees"), ["B1", "A2"]); - apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]); + apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup { group: "Bees".into(), folder: vec![] }]); assert_eq!(names(&app, "Alpha"), ["A1"]); assert!(names(&app, "Bees").is_empty()); // Every action went through a journaled command. @@ -493,6 +493,110 @@ fn brushes_panel_groups_collapse_and_filter() { assert!(brush_preview::render_count(&ctx) >= n); } +fn kit(name: &str, folder: &[&str]) -> paint::BrushPreset { + paint::BrushPreset { + name: name.into(), + brush: Default::default(), + builtin: false, + group: "Kit".into(), + folder: folder.iter().map(|s| s.to_string()).collect(), + } +} + +#[test] +fn folder_tree_nests_presets_in_panel_order() { + use crate::brushes_tab::{MAX_VIEW_DEPTH, Node, folder_tree}; + let deep: Vec<&str> = vec!["d"; MAX_VIEW_DEPTH + 5]; + let presets = [kit("Top", &[]), kit("Pen", &["Inks"]), kit("Chalk", &["Dry"]), kit("Nib", &["Inks", "Fine"]), kit("Quill", &["Inks"]), kit("Deep", &deep)]; + let items: Vec<&paint::BrushPreset> = presets.iter().collect(); + let tree = folder_tree(&items); + fn show(nodes: &[Node], out: &mut Vec) { + for n in nodes { + match n { + Node::Preset(p) => out.push(p.name.clone()), + Node::Folder(f) => { + out.push(format!("[{} {}]", f.path.join("/"), f.count)); + show(&f.children, out); + out.push("[end]".into()); + } + } + } + } + let mut out = Vec::new(); + show(&tree, &mut out); + let deep_path = vec!["d"; MAX_VIEW_DEPTH].join("/"); + assert_eq!(out[..12], ["Top", "[Inks 3]", "Pen", "[Inks/Fine 1]", "Nib", "[end]", "Quill", "[end]", "[Dry 1]", "Chalk", "[end]", "[d 1]"]); + assert!(out.contains(&format!("[{deep_path} 1]")), "paths are cut at the view depth"); + assert_eq!(out.iter().filter(|s| s.starts_with("[d")).count(), MAX_VIEW_DEPTH); +} + +/// Nested folders show under their group, open and close, and their context menu renames and +/// deletes them through the group commands (#1851). +#[test] +fn brushes_panel_shows_nested_folders() { + use egui_kittest::kittest::Queryable; + let mut h = harness(1, 0); + let builtin_groups: Vec = grouped_presets(&h.state().session.tools.presets).into_iter().map(|(g, _)| g).collect(); + h.state_mut().ui.brushes_panel.collapsed = builtin_groups; + for p in [kit("Kit Top", &[]), kit("Kit Pen", &["Test Inks"]), kit("Kit Nib", &["Test Inks", "Test Fine"]), kit("Kit Chalk", &["Test Dry"])] { + h.state_mut().session.tools.presets.push(p); + } + h.run_steps(3); + for label in ["Kit", "Test Inks", "Test Fine", "Test Dry", "Kit Nib", "Kit Chalk"] { + assert!(h.query_by_label(label).is_some(), "{label} is shown"); + } + // Closing a folder hides what it holds, and only that. + h.get_by_label("Test Inks").click(); + h.run_steps(3); + assert_eq!(h.state().ui.brushes_panel.collapsed.last().map(String::as_str), Some("Kit/Test Inks")); + assert!(h.query_by_label("Kit Nib").is_none() && h.query_by_label("Test Fine").is_none()); + assert!(h.query_by_label("Kit Chalk").is_some()); + // Rename a nested folder from its context menu. + h.get_by_label("Test Dry").click_secondary(); + h.run_steps(2); + h.get_by_label("Rename Group…").click(); + h.run_steps(2); + assert_eq!(h.state().ui.brushes_panel.renaming.as_ref().map(|r| r.folder.clone()), Some(vec!["Test Dry".to_string()])); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Test Wet".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!( + last_journal(h.state()).unwrap(), + ("brush.presets.renameGroup".to_string(), json!({ "group": "Kit", "folder": ["Test Dry"], "newName": "Test Wet" })) + ); + assert_eq!(paint::presets::find(&h.state().session.tools.presets, "Kit Chalk").unwrap().folder, ["Test Wet"]); + // Delete it: only its presets go. + h.get_by_label("Test Wet").click_secondary(); + h.run_steps(2); + h.get_by_label("Delete Group").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).unwrap(), ("brush.presets.deleteGroup".to_string(), json!({ "group": "Kit", "folder": ["Test Wet"] }))); + let kit_left: Vec = h.state().session.tools.presets.iter().filter(|p| p.group == "Kit").map(|p| p.name.clone()).collect(); + assert_eq!(kit_left, ["Kit Top", "Kit Pen", "Kit Nib"]); +} + +#[test] +fn folder_actions_become_folder_commands() { + use crate::brushes_tab::{Action, apply, drop_target}; + let mut app = app(); + for p in [kit("Kit Top", &[]), kit("Kit Pen", &["Inks"]), kit("Kit Nib", &["Inks"]), kit("Kit Chalk", &["Dry"])] { + app.session.tools.presets.push(p); + } + // Dropping a preset on one inside a folder moves it into that folder. + let (group, folder, index) = drop_target(&app.session.tools.presets, "Kit Top", "Kit Nib", false).unwrap(); + assert_eq!((group.as_str(), folder.clone(), index), ("Kit", vec!["Inks".to_string()], 1)); + apply(&mut app, vec![Action::Move { name: "Kit Top".into(), group, folder: Some(folder), index: Some(index) }]); + assert_eq!(paint::presets::find(&app.session.tools.presets, "Kit Top").unwrap().folder, ["Inks"]); + // Dropping on a folder header appends to the folder; a folder header dropped on a sibling's moves it. + apply(&mut app, vec![Action::Move { name: "Kit Pen".into(), group: "Kit".into(), folder: Some(vec!["Dry".into()]), index: None }]); + assert_eq!(paint::presets::find(&app.session.tools.presets, "Kit Pen").unwrap().folder, ["Dry"]); + apply(&mut app, vec![Action::MoveGroup { group: "Kit".into(), folder: vec!["Dry".into()], before: Some("Inks".into()) }]); + assert_eq!(photocraft_engine::brush_preset_cmds::subfolder_order(&app.session.tools.presets, "Kit", &[]), ["Dry", "Inks"]); + assert_eq!(last_journal(&app).unwrap(), ("brush.presets.moveGroup".to_string(), json!({ "group": "Kit", "folder": ["Dry"], "before": "Inks" }))); +} + /// A fresh session with the Brush tool: shortcuts, the options bar and the Brush Settings window, /// in the default theme (#258). fn app_harness() -> Harness<'static, PhotocraftApp> { diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 613c6be2..370e2e2e 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -82,7 +82,7 @@ pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, picks: Vec) { run_or_status(app, "brush.presets.save", json!({ "name": name })); // Photoshop asks for the new preset's name: the picker's rename bar does. if app.session.tools.presets.iter().any(|p| p.name == name) { - app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, text: String::new() }); + app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); } } Pick::Import => { @@ -184,7 +184,7 @@ fn gear_menu(ui: &mut egui::Ui, b: &BrushSettings, presets: &[BrushPreset], st: if ui.button(tl!("Rename Brush…")).clicked() && let Some(name) = current.clone() { - st.renaming = Some(Renaming { group: false, name, text: String::new() }); + st.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); ui.close(); } if ui.button(tl!("Delete Brush")).clicked() diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 23f923d0..6e6863cf 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -2,9 +2,12 @@ //! preview, name) or a grid of tips, with a size slider and a search field. //! //! Presets and groups are organised like Photoshop's panel: drag a preset within its group or -//! into another group (onto a preset, or onto a group's header to append), drag a group's header -//! to reorder groups, and right-click for Rename and Delete. Every change is a `brush.presets.*` -//! command, so it is journaled, drivable, and persisted by the preset store. +//! into another group or folder (onto a preset, or onto a header to append), drag a group's +//! header to reorder groups (a nested folder's header to reorder it among its sibling folders), +//! and right-click for Rename and Delete. Groups hold nested folders ([`BrushPreset::folder`], +//! e.g. an imported `.abr` file's own folders), drawn indented under their group and opened or +//! closed like groups. Every change is a `brush.presets.*` command, so it is journaled, drivable, +//! and persisted by the preset store. //! //! The list itself ([`preset_list`]) is shared with the Brush Preset picker (`brush_picker`), so //! both show, pick and organise presets the same way; each keeps its own view state. @@ -25,6 +28,11 @@ use crate::{PhotocraftApp, icons, widgets}; pub enum BrushDrag { Preset(String), Group(String), + /// A nested folder of a group. + Folder { + group: String, + folder: Vec, + }, } /// What a preset list does after drawing (the presets are borrowed while it draws). @@ -34,15 +42,21 @@ pub enum Action { Select(String), /// A double-click: make the preset current and dismiss the picker showing it (Photoshop). Choose(String), - /// View only: open or close a group ([`preset_list`] applies it to its view state). + /// View only: open or close a group or folder (by its [`folder_view_key`]; [`preset_list`] + /// applies it to its view state). ToggleGroup(String), Move { name: String, group: String, + /// The nested folder in `group` (`None`: the command's default). + folder: Option>, index: Option, }, + /// Move a group (`folder` empty) among the groups, or a nested folder among its siblings; + /// `before` is a group, or a sibling folder's name. MoveGroup { group: String, + folder: Vec, before: Option, }, /// View only: show the rename bar for a preset or group ([`preset_list`] applies it). @@ -50,7 +64,11 @@ pub enum Action { /// Commit a rename typed in the rename bar. Rename(Renaming), Delete(String), - DeleteGroup(String), + /// Delete a group (`folder` empty) or one of its nested folders, with their presets. + DeleteGroup { + group: String, + folder: Vec, + }, } /// How a preset list is laid out: the Brushes tab's, or the Brush Preset picker's (narrower, @@ -74,9 +92,86 @@ pub fn group_key(presets: &[BrushPreset], label: &str) -> String { if label == UNGROUPED && !presets.iter().any(|p| p.group == UNGROUPED) { String::new() } else { label.to_string() } } -/// Where a preset dropped onto `target` lands: (group, index in that group after the dragged -/// preset is taken out). `after` = dropped on the target's lower (list) or right (grid) half. -pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, usize)> { +/// Deepest nested folder the panel draws; deeper folders show at this depth. +pub const MAX_VIEW_DEPTH: usize = photocraft_engine::brush_preset_cmds::MAX_FOLDER_DEPTH; + +/// The collapse key of a nested folder of the group shown as `label` (`label` itself for the +/// group). +pub fn folder_view_key(label: &str, folder: &[String]) -> String { + if folder.is_empty() { label.to_string() } else { format!("{label}/{}", folder.join("/")) } +} + +/// A group's content in panel order: its presets and nested folders. +#[derive(Debug)] +pub enum Node<'a> { + Preset(&'a BrushPreset), + Folder(FolderNode<'a>), +} + +/// A nested folder and what it holds. +#[derive(Debug)] +pub struct FolderNode<'a> { + /// The folder's path in its group. + pub path: Vec, + pub children: Vec>, + /// Presets in it and below it. + pub count: usize, +} + +/// The tree of a group's presets (`items`, in library order): each folder appears where its first +/// preset is, presets keep their order. Paths deeper than [`MAX_VIEW_DEPTH`] are cut there. +pub fn folder_tree<'a>(items: &[&'a BrushPreset]) -> Vec> { + level(items.iter().map(|p| (p.folder.get(..p.folder.len().min(MAX_VIEW_DEPTH)).unwrap_or_default(), *p)).collect(), &[]) +} + +/// A preset with its folder path below the level being built. +type Entry<'a> = (&'a [String], &'a BrushPreset); + +/// One level of [`folder_tree`]: `items` pairs each preset with its path below `prefix`. The +/// recursion is as deep as the longest path (at most [`MAX_VIEW_DEPTH`]). +fn level<'a>(items: Vec>, prefix: &[String]) -> Vec> { + enum Slot { + Preset(usize), + Folder(usize), + } + let mut slots = Vec::new(); + let mut folders: Vec<(&String, Vec>)> = Vec::new(); + for (i, (path, p)) in items.iter().enumerate() { + match path.split_first() { + None => slots.push(Slot::Preset(i)), + Some((name, rest)) => match folders.iter_mut().position(|(n, _)| *n == name) { + Some(k) => { + if let Some((_, v)) = folders.get_mut(k) { + v.push((rest, *p)); + } + } + None => { + slots.push(Slot::Folder(folders.len())); + folders.push((name, vec![(rest, *p)])); + } + }, + } + } + let mut folders: Vec>)>> = folders.into_iter().map(Some).collect(); + slots + .into_iter() + .filter_map(|s| match s { + Slot::Preset(i) => items.get(i).map(|(_, p)| Node::Preset(p)), + Slot::Folder(k) => { + let (name, sub) = folders.get_mut(k)?.take()?; + let path: Vec = prefix.iter().cloned().chain(std::iter::once(name.clone())).collect(); + let count = sub.len(); + let children = level(sub, &path); + Some(Node::Folder(FolderNode { path, children, count })) + } + }) + .collect() +} + +/// Where a preset dropped onto `target` lands: (group, folder, index in that group after the +/// dragged preset is taken out). `after` = dropped on the target's lower (list) or right (grid) +/// half. +pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, Vec, usize)> { let t = presets.iter().find(|p| p.name == target)?; let members: Vec<&str> = presets.iter().filter(|p| p.group == t.group).map(|p| p.name.as_str()).collect(); let tp = members.iter().position(|n| *n == target)?; @@ -86,7 +181,7 @@ pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: { idx -= 1; } - Some((t.group.clone(), idx)) + Some((t.group.clone(), t.folder.clone(), idx)) } /// Apply the view-only actions to `st`; returns the rest, the commands for [`apply`]. @@ -124,26 +219,46 @@ pub fn apply(app: &mut PhotocraftApp, acts: Vec) { } } Action::ToggleGroup(_) | Action::BeginRename(_) => {} - Action::Move { name, group, index } => { + Action::Move { name, group, folder, index } => { let mut p = json!({ "name": name, "group": group }); + if let Some(f) = folder { + p["folder"] = json!(f); + } if let Some(i) = index { p["index"] = json!(i); } run_or_status(app, "brush.presets.move", p); } - Action::MoveGroup { group, before } => run_or_status(app, "brush.presets.moveGroup", json!({ "group": group, "before": before })), + Action::MoveGroup { group, folder, before } => { + let mut p = json!({ "group": group, "before": before }); + if !folder.is_empty() { + p["folder"] = json!(folder); + } + run_or_status(app, "brush.presets.moveGroup", p); + } Action::Rename(r) => { let text = r.text.trim().to_string(); - if !text.is_empty() && text != r.name { + let old = r.folder.last().unwrap_or(&r.name); + if !text.is_empty() && text != *old { if r.group { - run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text })); + let mut p = json!({ "group": r.name, "newName": text }); + if !r.folder.is_empty() { + p["folder"] = json!(r.folder); + } + run_or_status(app, "brush.presets.renameGroup", p); } else { run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text })); } } } Action::Delete(name) => run_or_status(app, "brush.presets.delete", json!({ "name": name })), - Action::DeleteGroup(g) => run_or_status(app, "brush.presets.deleteGroup", json!({ "group": g })), + Action::DeleteGroup { group, folder } => { + let mut p = json!({ "group": group }); + if !folder.is_empty() { + p["folder"] = json!(folder); + } + run_or_status(app, "brush.presets.deleteGroup", p); + } } } } @@ -165,9 +280,9 @@ fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: & if let Some(d) = resp.dnd_release_payload::() && let BrushDrag::Preset(n) = &*d && *n != p.name - && let Some((group, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid)) + && let Some((group, folder, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid)) { - acts.push(Action::Move { name: n.clone(), group, index: Some(index) }); + acts.push(Action::Move { name: n.clone(), group, folder: Some(folder), index: Some(index) }); } if resp.double_clicked() { acts.push(Action::Choose(p.name.clone())); @@ -176,7 +291,7 @@ fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: & } resp.context_menu(|ui| { if ui.button(tl!("Rename Brush…")).clicked() { - acts.push(Action::BeginRename(Renaming { group: false, name: p.name.clone(), text: String::new() })); + acts.push(Action::BeginRename(Renaming { group: false, name: p.name.clone(), folder: Vec::new(), text: String::new() })); ui.close(); } if ui.button(tl!("Delete Brush")).clicked() { @@ -189,9 +304,11 @@ fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: & /// Width a list row keeps for the gaps around its stroke preview and the preset name. const STROKE_NAME_ROOM: f32 = 140.0; -fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec) { +fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], indent: f32, acts: &mut Vec) { let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag()); + let (full, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag()); + // Rows inside nested folders are indented; the whole width stays the drop target. + let r = egui::Rect::from_min_max(full.min + vec2(indent.min(full.width() / 2.0), 0.0), full.max); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name)); if !ui.is_rect_visible(r) { return; @@ -268,15 +385,21 @@ fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[Brush preset_interactions(ui, &resp, r, p, presets, true, acts); } -/// A group's header: chevron, folder, name, count. Click toggles; drag reorders groups; a -/// preset dropped here moves to the end of the group. -fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: usize, acts: &mut Vec) { +/// Indent of one nested folder level. +const FOLDER_INDENT: f32 = 16.0; + +/// A group's or nested folder's header: chevron, folder, name, count. Click toggles; drag +/// reorders groups (a folder among its sibling folders); a preset dropped here moves to the end of +/// the group or folder. `key` is the group key, `folder` the nested folder's path (empty for the +/// group), `label` the group's panel label. +fn group_header(ui: &mut egui::Ui, label: &str, key: &str, folder: &[String], open: bool, count: usize, acts: &mut Vec) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); if resp.hovered() { ui.painter().rect_filled(r, t.radius_sm, t.hover); } - let x = r.left() + 4.0; + let shown = folder.last().map_or(label, String::as_str); + let x = r.left() + 4.0 + FOLDER_INDENT * folder.len().min(MAX_VIEW_DEPTH) as f32; icons::paint( ui, egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), @@ -291,13 +414,23 @@ fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: us 14.0, t.icon, ); - ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text); + let font = if folder.is_empty() { theme::semibold(12.0) } else { theme::medium(12.0) }; + ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text); ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::CollapsingHeader, true, label)); - resp.dnd_set_drag_payload(BrushDrag::Group(key.to_string())); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::CollapsingHeader, true, shown)); + let me = match folder { + [] => BrushDrag::Group(key.to_string()), + f => BrushDrag::Folder { group: key.to_string(), folder: f.to_vec() }, + }; + // A folder moves among its siblings: same group, same parent, not itself. + let sibling = |g: &str, f: &[String]| { + g == key && !folder.is_empty() && f.len() == folder.len() && f != folder && f.split_last().map(|x| x.1) == folder.split_last().map(|x| x.1) + }; + resp.dnd_set_drag_payload(me); if let Some(d) = resp.dnd_hover_payload::() { match &*d { - BrushDrag::Group(g) if g != key => widgets::drop_line(ui, r, false, false, &t), + BrushDrag::Group(g) if folder.is_empty() && g != key => widgets::drop_line(ui, r, false, false, &t), + BrushDrag::Folder { group, folder: f } if sibling(group, f) => widgets::drop_line(ui, r, false, false, &t), BrushDrag::Preset(_) => { ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } @@ -306,33 +439,98 @@ fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: us } if let Some(d) = resp.dnd_release_payload::() { match &*d { - BrushDrag::Group(g) if g != key => acts.push(Action::MoveGroup { group: g.clone(), before: Some(key.to_string()) }), - BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), index: None }), + BrushDrag::Group(g) if folder.is_empty() && g != key => { + acts.push(Action::MoveGroup { group: g.clone(), folder: Vec::new(), before: Some(key.to_string()) }) + } + BrushDrag::Folder { group, folder: f } if sibling(group, f) => { + acts.push(Action::MoveGroup { group: group.clone(), folder: f.clone(), before: Some(shown.to_string()) }) + } + BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), folder: Some(folder.to_vec()), index: None }), _ => {} } } if resp.clicked() { - acts.push(Action::ToggleGroup(label.to_string())); + acts.push(Action::ToggleGroup(folder_view_key(label, folder))); } resp.context_menu(|ui| { if ui.button(tl!("Rename Group…")).clicked() { - acts.push(Action::BeginRename(Renaming { group: true, name: key.to_string(), text: String::new() })); + acts.push(Action::BeginRename(Renaming { group: true, name: key.to_string(), folder: folder.to_vec(), text: String::new() })); ui.close(); } if ui.button(tl!("Delete Group")).clicked() { - acts.push(Action::DeleteGroup(key.to_string())); + acts.push(Action::DeleteGroup { group: key.to_string(), folder: folder.to_vec() }); ui.close(); } }); } +/// How a group's content is drawn. +struct Draw<'a> { + label: &'a str, + key: &'a str, + presets: &'a [BrushPreset], + brush: &'a photocraft_engine::BrushSettings, + collapsed: &'a [String], + filtering: bool, + grid: bool, + layout: ListLayout, +} + +/// Draw `nodes` (a group's or folder's content) at nesting `depth`: runs of presets as rows or a +/// grid, nested folders as headers with their content below when open. `depth` is bounded by +/// [`folder_tree`] (paths are cut at [`MAX_VIEW_DEPTH`]). +fn draw_nodes(ui: &mut egui::Ui, d: &Draw, nodes: &[Node], depth: usize, acts: &mut Vec) { + let indent = FOLDER_INDENT * depth as f32; + let mut run: Vec<&BrushPreset> = Vec::new(); + let flush = |ui: &mut egui::Ui, run: &mut Vec<&BrushPreset>, acts: &mut Vec| { + if run.is_empty() { + return; + } + if d.grid { + // Rows of whole columns, each with the same margin: a wrapped row indented only + // its first line and left the spare width on the right. + let (left, cols) = grid_columns(ui.available_width(), d.layout.cell, d.layout.indent + indent); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(GRID_GAP, GRID_GAP); + for row in run.chunks(cols) { + ui.horizontal(|ui| { + ui.add_space(left); + for p in row { + grid_cell(ui, p, is_current(&p.brush, d.brush), d.presets, d.layout.cell, acts); + } + }); + } + }); + } else { + for p in run.iter() { + list_row(ui, p, is_current(&p.brush, d.brush), d.presets, indent, acts); + } + } + run.clear(); + }; + for n in nodes { + match n { + Node::Preset(p) => run.push(p), + Node::Folder(f) => { + flush(ui, &mut run, acts); + let open = d.filtering || !d.collapsed.contains(&folder_view_key(d.label, &f.path)); + group_header(ui, d.label, d.key, &f.path, open, f.count, acts); + if open && depth < MAX_VIEW_DEPTH { + draw_nodes(ui, d, &f.children, depth + 1, acts); + } + } + } + } + flush(ui, &mut run, acts); +} + /// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or /// Cancel stops. fn rename_bar(ui: &mut egui::Ui, st: &mut BrushesPanelState, acts: &mut Vec) { let Some(r) = st.renaming.as_mut() else { return }; let t = Tokens::get(ui.ctx()); if r.text.is_empty() { - r.text = r.name.clone(); + r.text = r.folder.last().unwrap_or(&r.name).clone(); } let mut done = false; ui.horizontal(|ui| { @@ -388,30 +586,12 @@ pub fn preset_list( } let key = group_key(presets, &label); let open = !filter.is_empty() || !collapsed.contains(&label); - group_header(ui, &label, &key, open, items.len(), &mut acts); + group_header(ui, &label, &key, &[], open, items.len(), &mut acts); if !open { continue; } - if grid { - // Rows of whole columns, each with the same margin: a wrapped row indented only - // its first line and left the spare width on the right. - let (left, cols) = grid_columns(ui.available_width(), layout.cell, layout.indent); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing = vec2(GRID_GAP, GRID_GAP); - for row in items.chunks(cols) { - ui.horizontal(|ui| { - ui.add_space(left); - for p in row { - grid_cell(ui, p, is_current(&p.brush, brush), presets, layout.cell, &mut acts); - } - }); - } - }); - } else { - for p in items { - list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts); - } - } + let d = Draw { label: &label, key: &key, presets, brush, collapsed, filtering: !filter.is_empty(), grid, layout }; + draw_nodes(ui, &d, &folder_tree(&items), 0, &mut acts); ui.add_space(2.0); } if presets.is_empty() { @@ -432,6 +612,7 @@ pub fn preset_list( g.clone() } } + BrushDrag::Folder { folder, .. } => folder.last().cloned().unwrap_or_default(), }; egui::Area::new(egui::Id::new(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( ui.ctx(), diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 9bdd54eb..8d82f4d2 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -426,7 +426,8 @@ mod tests { assert_eq!(h.state().ui.brush_picker_list.view, crate::brush_panel::BrushesView::List); assert!(h.state().ui.brush_picker.is_some()); // A rename left open goes with the picker. - h.state_mut().ui.brush_picker_list.renaming = Some(crate::brush_panel::Renaming { group: false, name: "Inky".into(), text: String::new() }); + h.state_mut().ui.brush_picker_list.renaming = + Some(crate::brush_panel::Renaming { group: false, name: "Inky".into(), folder: Vec::new(), text: String::new() }); h.run_steps(2); let far = c - vec2(300.0, 200.0); h.event(egui::Event::PointerMoved(far));