diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index a6dbd31098..764e74b9df 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let cmd = "paint.mixerBrush"; let pts = parse_points(p, cmd)?; let brush = resolve_brush(s, p, cmd)?; - let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) }; - let sample_all = flag(p, "sampleAllLayers", false); + // The brush's Mixer Brush options, overridden by the scalar params. + let bm = &brush.mixer; + let m = MixerSettings { + wet: pct(p, "wet", bm.wet * 100.0), + load: pct(p, "load", bm.load * 100.0), + mix: pct(p, "mix", bm.mix * 100.0), + flow: pct(p, "flow", bm.flow * 100.0), + sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), + }; + let sample_all = m.sample_all_layers; let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); let mut state = s.tools.mixer.clone(); if load_after || state.reservoir.is_none() { @@ -543,10 +551,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result { } } b = merge_brush(&b, &patch, cmd)?; - s.tools.brush = b; + let before = std::mem::replace(&mut s.tools.brush, b); + // A coalesced gesture (one slider drag) journals as one call: remember the brush it started from. + let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none()); + let continuing = |s: &Session, k: &str| { + s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k) + && s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k)) + }; + match key { + Some(k) if continuing(s, k) => {} + Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)), + None => s.tools.brush_gesture = None, + } Ok(brush_json(&s.tools.brush)) } +/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by +/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides. +fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value { + let mut c = b.clone(); + if skip.0 { + c.tip = TipShape::Round; + } + if skip.1 { + c.dual_brush.tip = TipShape::Round; + } + if skip.2 { + c.texture.pattern = photocraft_paint::Pattern::default(); + } + serde_json::to_value(&c).unwrap_or(Value::Null) +} + +/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal +/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed). +pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value { + let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern); + fn diff(a: &Value, b: &Value) -> Option { + match (a, b) { + (Value::Object(ao), Value::Object(bo)) => { + let mut out = serde_json::Map::new(); + for (k, bv) in bo { + match ao.get(k) { + Some(av) => { + if let Some(d) = diff(av, bv) { + out.insert(k.clone(), d); + } + } + None => { + out.insert(k.clone(), bv.clone()); + } + } + } + (!out.is_empty()).then_some(Value::Object(out)) + } + // Enums with payloads (tips, patterns) are replaced whole. + _ => (a != b).then(|| b.clone()), + } + } + diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})) +} + +/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same +/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal +/// entry, the patch from the brush before the gesture to the brush now. Returns true when the +/// call was folded into the previous entry. +pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool { + if id != "tools.setBrush" { + return false; + } + let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false }; + let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false }; + if gk != key { + return false; + } + let patch = brush_patch(start, &s.tools.brush); + match s.journal.last_mut() { + Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => { + *lp = json!({ "brush": patch, "coalesce": key }); + true + } + _ => false, + } +} + macro_rules! spec { ($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => { CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal } @@ -567,7 +654,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, has_paintable, mixer_brush, true diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 85afaebd35..0e9ab4034a 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -311,3 +311,93 @@ fn live_stroke_matches_the_committed_stroke() { assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err()); assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err()); } + +#[test] +fn new_brush_controls_round_trip_through_set_brush() { + let mut s = session(40, 40); + let patch = json!({ + "spacingEnabled": false, + "shapeDynamics": {"tiltScale": 1.5, "brushProjection": true}, + "transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}}, + "locks": {"shapeDynamics": true, "texture": true, "noise": true}, + "mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true}, + }); + s.execute("tools.setBrush", json!({ "brush": patch })).unwrap(); + let b = &s.tools.brush; + assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5); + assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure); + assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2)); + assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering); + assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true)); + // brush.get reports them, and a fresh session gets the same brush from the reported JSON. + let got = s.execute("brush.get", json!({})).unwrap(); + for (path, want) in [ + ("/spacingEnabled", json!(false)), + ("/shapeDynamics/tiltScale", json!(1.5)), + ("/shapeDynamics/brushProjection", json!(true)), + ("/transfer/wetness/control", json!("penPressure")), + ("/locks/texture", json!(true)), + ("/mixer/sampleAllLayers", json!(true)), + ("/mixer/load", json!(0.25)), + ] { + assert_eq!(got.pointer(path), Some(&want), "{path}"); + } + let mut t = session(40, 40); + t.execute("tools.setBrush", json!({ "brush": got })).unwrap(); + assert_eq!(t.tools.brush, s.tools.brush); + // Locked sections survive picking a preset through the command. + s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap(); + assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture); + // Wrong types are errors and leave the brush alone. + let before = s.tools.brush.clone(); + for bad in + [json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})] + { + assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err()); + } + assert_eq!(s.tools.brush, before); +} + +#[test] +fn mixer_brush_uses_the_brush_mixer_settings() { + let mut s = session(200, 30); + s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + // No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply. + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap(); + s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap(); + assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out"); + // A param still overrides the brush. + s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap(); + assert!(rgba(&s, 190, 5)[3] > 0.95); +} + +#[test] +fn coalesced_set_brush_calls_journal_once_per_gesture() { + let mut s = session(20, 20); + let n = s.journal.len(); + for k in 1..=10 { + s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap(); + s.execute("brush.get", json!({})).unwrap(); + } + s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap(); + assert_eq!(s.journal.len(), n + 1, "one gesture, one entry"); + let (id, p) = s.journal.last().unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}})); + // A new gesture is a new entry; so is an uncoalesced call. + s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap(); + assert_eq!(s.journal.len(), n + 3); + // Another command in between ends the gesture. + s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap(); + assert_eq!(s.journal.len(), n + 6); + // Replay gives the same brush. + let mut t = session(20, 20); + for (id, p) in s.journal.iter().skip(1) { + t.execute(id, p.clone()).unwrap(); + } + assert_eq!(t.tools.brush, s.tools.brush); +} diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs new file mode 100644 index 0000000000..7f423c330b --- /dev/null +++ b/crates/engine/src/brush_preset_cmds.rs @@ -0,0 +1,336 @@ +//! 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. +//! +//! The library order is the panel order: groups appear in order of their first preset. 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). + +use photocraft_paint::BrushPreset; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +/// Longest preset or group name accepted. +pub const MAX_NAME: usize = 255; + +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}`")))?; + if v.chars().count() > MAX_NAME { + return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters"))); + } + Ok(v.to_string()) +} + +/// A group name param: may be empty (the ungrouped presets), never absurdly long. +fn group_param(p: &Value, k: &str, cmd: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())), + Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))), + } +} + +fn index_of(presets: &[BrushPreset], name: &str) -> Option { + presets.iter().position(|x| x.name.eq_ignore_ascii_case(name)) +} + +fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result { + let name = text(p, k, cmd)?; + index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`"))) +} + +/// Groups in panel order (first appearance). +pub fn group_order(presets: &[BrushPreset]) -> Vec { + let mut out: Vec = Vec::new(); + for p in presets { + if !out.contains(&p.group) { + out.push(p.group.clone()); + } + } + out +} + +fn position_in_group(presets: &[BrushPreset], i: usize) -> usize { + presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count()) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.presets.rename"; + let i = find(s, p, "name", cmd)?; + let new = text(p, "newName", cmd)?; + if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) { + return Err(bad(cmd, format!("a brush preset named `{new}` already exists"))); + } + let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?; + if pr.name != new { + pr.name = new.clone(); + pr.builtin = false; + s.brush_presets_changed(); + } + Ok(json!({ "name": new })) +} + +fn move_preset(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.presets.move"; + let i = find(s, p, "name", cmd)?; + let before = match p.get("before") { + None | Some(Value::Null) => None, + Some(_) => { + let b = find(s, p, "before", cmd)?; + if b == i { + return Err(bad(cmd, "`before` names the preset being moved")); + } + Some(b) + } + }; + 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 = &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(), + }; + if let Some(b) = before + && lib[b].group != target + { + return Err(bad(cmd, format!("`before` is not in group `{target}`"))); + } + let before_name = before.map(|b| lib[b].name.clone()); + let mut pr = lib.remove(i); + if pr.group != target { + pr.group = target.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(); + 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), + }; + 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 })) +} + +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"; + let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?; + group_block(s, &g, cmd)?; + let before = group_param(p, "before", cmd)?; + if let Some(b) = &before { + group_block(s, b, cmd)?; + if *b == g { + return Err(bad(cmd, "`before` names the group 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); + let (block, mut rest): (Vec, Vec) = lib.into_iter().partition(|x| x.group == g); + let order = group_order(&rest); + let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len()); + let pos = match (&before, index) { + (Some(b), _) => first(b, &rest), + (None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)), + (None, None) => rest.len(), + }; + 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 = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0); + Ok(json!({ "group": g, "index": at })) +} + +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 new = text(p, "newName", cmd)?; + if new != g && s.tools.presets.iter().any(|x| x.group == new) { + return Err(bad(cmd, format!("a group named `{new}` already exists"))); + } + if new != g { + for x in s.tools.presets.iter_mut().filter(|x| x.group == g) { + x.group = new.clone(); + x.builtin = false; + } + s.brush_presets_changed(); + } + Ok(json!({ "group": new })) +} + +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 before = s.tools.presets.len(); + s.tools.presets.retain(|x| x.group != g); + s.brush_presets_changed(); + Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() })) +} + +macro_rules! spec { + ($id:literal, $label:literal, $params:literal, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true } + }; +} + +/// Brush preset organisation specs. +pub fn specs() -> Vec { + vec![ + spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename), + 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}"##, + move_preset + ), + spec!( + "brush.presets.moveGroup", + "Move Brush Group", + r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, 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), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(s: &Session, g: &str) -> Vec { + s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect() + } + + 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 + } + + #[test] + 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!(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"]); + s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap(); + assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]); + // Into another group, before a member (the group comes from `before`). + s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap(); + assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()])); + // Into a group by name (appended), and into a new group. + s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap(); + assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]); + s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap(); + assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma")); + assert!(names(&s, "Alpha").is_empty()); + // A built-in moved to another group becomes the user's. + let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone(); + s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap(); + let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap(); + assert!(!moved.builtin && moved.group == "Beta"); + assert_eq!(names(&s, "Beta")[0], b); + } + + #[test] + fn groups_move_rename_and_delete() { + let mut s = with_groups(); + let order = group_order(&s.tools.presets); + let n = order.len(); + assert_eq!(&order[n - 2..], ["Alpha", "Beta"]); + let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap(); + assert_eq!(r["index"], 0); + assert_eq!(group_order(&s.tools.presets)[0], "Beta"); + assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order"); + s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap(); + assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]); + s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap(); + assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha")); + s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap(); + assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]); + assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err()); + let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap(); + assert_eq!(r["deleted"], 3); + assert!(names(&s, "First").is_empty()); + } + + #[test] + fn rename_checks_names() { + let mut s = with_groups(); + s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap(); + assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some()); + assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err()); + // Case-only renames of itself are fine. + s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap(); + let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone(); + s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap(); + assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin); + } + + #[test] + fn bad_params_fail_without_changes() { + let mut s = with_groups(); + let before = s.tools.presets.clone(); + let long = "x".repeat(MAX_NAME + 1); + for (id, p) in [ + ("brush.presets.move", json!({})), + ("brush.presets.move", json!({"name": "Nope"})), + ("brush.presets.move", json!({"name": "A1", "before": "A1"})), + ("brush.presets.move", json!({"name": "A1", "before": "Nope"})), + ("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})), + ("brush.presets.move", json!({"name": "A1", "index": -1})), + ("brush.presets.move", json!({"name": "A1", "index": "first"})), + ("brush.presets.move", json!({"name": "A1", "group": 7})), + ("brush.presets.move", json!({"name": "A1", "group": long})), + ("brush.presets.rename", json!({"name": "A1"})), + ("brush.presets.rename", json!({"name": "A1", "newName": " "})), + ("brush.presets.rename", json!({"name": "A1", "newName": long})), + ("brush.presets.moveGroup", json!({"group": "Nope"})), + ("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})), + ("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})), + ("brush.presets.renameGroup", json!({"group": "Alpha"})), + ("brush.presets.deleteGroup", json!({"group": null})), + ("brush.presets.deleteGroup", json!({"group": "Nope"})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + assert_eq!(s.tools.presets, before); + // Huge indices clamp to the end. + s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap(); + s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap(); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a8d96479a3..715e42f71d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -940,6 +940,7 @@ fn build() -> Vec { v.extend(crate::edit_cmds::specs()); v.extend(crate::color_cmds::specs()); v.extend(crate::brush_cmds::specs()); + v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); v.extend(crate::retouch_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f6a0fd11bb..8536b0b785 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod analysis_cmds; pub mod artboard_cmds; pub mod automate_cmds; pub mod brush_cmds; +pub mod brush_preset_cmds; pub mod build_info; mod canvas_geom; pub mod channel_cmds; @@ -198,6 +199,9 @@ pub struct ToolState { pub presets_rev: u64, /// Mixer Brush paint carried between strokes. pub mixer: photocraft_paint::mixer::MixerState, + /// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the + /// gesture journals as one call ([`brush_cmds::coalesce_journal`]). + pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>, } impl Default for ToolState { @@ -213,6 +217,7 @@ impl Default for ToolState { presets: photocraft_paint::presets::builtin(), presets_rev: 0, mixer: Default::default(), + brush_gesture: None, } } } @@ -336,7 +341,7 @@ impl Session { edit_menu_cmds::after_command(self, id); automate_cmds::after_command(self, id); self.sync_preset_store(); - if spec.journal { + if spec.journal && !brush_cmds::coalesce_journal(self, id, ¶ms) { self.journal.push((id.to_string(), params)); } Ok(r) diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 2b6bdfbc9d..272cb34f2f 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -9,7 +9,8 @@ //! - `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; -//! - `index.json`: group order and the built-in presets the user deleted. +//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop +//! in the Brushes panel persists) and the built-in presets the user deleted. //! //! Built-in presets are never written (they are regenerated on start). The store syncs after //! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups @@ -324,6 +325,8 @@ struct IndexFile { groups: Vec, /// Built-in presets the user deleted (by name). hidden_builtins: Vec, + /// Every preset's name in library (panel) order; empty when it is the default order. + order: Vec, } /// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so @@ -403,6 +406,8 @@ pub struct Opened { pub presets: Vec, /// Built-in presets the user deleted. pub hidden_builtins: Vec, + /// The saved library order (preset names), empty when none was saved. + pub order: Vec, /// Files that were skipped (corrupt, oversized, missing tips). pub warnings: Vec, } @@ -515,8 +520,9 @@ pub fn open(backend: Box) -> Opened { } } let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default(); + let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default(); store.index = index; - Opened { store, presets, hidden_builtins, warnings } + Opened { store, presets, hidden_builtins, order, warnings } } /// Read and decode tips, in parallel on native targets. @@ -615,10 +621,24 @@ impl PresetStore { // Index: group order and deleted built-ins. let hidden: Vec = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect(); let groups: Vec = files.into_iter().filter(|f| self.groups.contains_key(f)).collect(); - let want = IndexFile { version: 1, groups, hidden_builtins: hidden }; - let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins); + // The full order is only saved once it differs from the default (built-ins, then the rest). + let order: Vec = presets.iter().map(|p| p.name.clone()).collect(); + let default_order: Vec = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect(); + let builtins_in_order = { + let names = builtin_names(); + let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name)); + let first = it.next().flatten(); + it.try_fold(first, |prev, cur| match (prev, cur) { + (Some(a), Some(b)) if b > a => Some(Some(b)), + _ => None, + }) + .is_some() + }; + let order = if builtins_in_order && order == default_order { Vec::new() } else { order }; + let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order }; + let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order); if !same { - let empty = want.groups.is_empty() && want.hidden_builtins.is_empty(); + let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty(); let r = if empty && self.index.is_none() { Ok(()) } else { @@ -693,7 +713,7 @@ impl Session { /// built-in of the same name; presets created before the store finished loading win), hide /// deleted built-ins, then sync. Returns the load warnings. pub fn attach_preset_store(&mut self, opened: Opened) -> Vec { - let Opened { store, presets, hidden_builtins, mut warnings } = opened; + let Opened { store, presets, hidden_builtins, order, mut warnings } = opened; let lib = &mut self.tools.presets; lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name)))); for p in presets { @@ -703,6 +723,11 @@ impl Session { None => lib.push(p), } } + // The saved panel order (stable: presets it doesn't name keep their order, at the end). + if !order.is_empty() { + let pos: HashMap = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect(); + lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX)); + } self.preset_store = Some(store); self.brush_presets_changed(); self.sync_preset_store(); diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index e8c24eff9e..1e66a7756e 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() { assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::>(), "group order survives"); } +#[test] +fn reordering_presets_and_groups_persists() { + let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::>(); + let dir = TempDir::new("reorder"); + let (mut s, _) = session(&dir); + for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] { + s.execute("brush.presets.save", json!({"name": n})).unwrap(); + s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into(); + } + s.brush_presets_changed(); + s.sync_preset_store(); + // Untouched order: no order list is written (the default order is implied). + let (t, _) = session(&dir); + assert_eq!(names(&t), names(&s)); + // Reorder a built-in within its group, a user preset across groups, and whole groups. + let builtin_group = s.tools.presets[0].group.clone(); + let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone(); + let first = s.tools.presets[0].name.clone(); + s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap(); + s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap(); + s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap(); + s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap(); + assert_eq!(s.tools.presets[0].name, "Ink 2"); + let (t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(names(&t), names(&s), "the panel order survives a restart"); + assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in"); + // Renaming and deleting a group persist too. + s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap(); + s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap(); + let (t, _) = session(&dir); + assert_eq!(names(&t), names(&s)); + assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks")); +} + #[test] fn corrupt_and_oversized_files_are_skipped_with_a_warning() { let dir = TempDir::new("corrupt"); diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index b694dde08e..487f9856b0 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -10,7 +10,7 @@ use std::collections::BTreeSet; use photocraft_paint::{ - BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer, + BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer, }; use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip}; use photocraft_psd::descriptor::{Descriptor, Value}; @@ -281,6 +281,25 @@ impl Mapper<'_> { } } + /// Mixer Brush tool options captured with a preset (Wet, Load, Mix, Flow, Sample All Layers). + /// Key names are best effort (Photoshop doesn't document them); several spellings are read. + fn mixer_options(&mut self, to: &Descriptor, m: &mut MixerSettings) { + let first = |keys: &[&str]| keys.iter().find_map(|k| pct(to, k)); + let wet = first(&["wetness", "Wtns", "wet"]); + let load = first(&["dryness", "load", "Ld "]); + let mix = first(&["mix", "mixRatio", "Mx "]); + if wet.is_none() && load.is_none() && mix.is_none() { + return; + } + m.wet = wet.unwrap_or(m.wet).clamp(0.0, 1.0); + m.load = load.unwrap_or(m.load).clamp(0.0, 1.0); + m.mix = mix.unwrap_or(m.mix).clamp(0.0, 1.0); + m.flow = pct(to, "flow").unwrap_or(m.flow).clamp(0.0, 1.0); + if let Some(v) = ["sampleMerged", "useAllLayers", "sampleAllLayers", "Mrgd"].iter().find_map(|k| boolean(to, k)) { + m.sample_all_layers = v; + } + } + fn preset(&mut self, d: &Descriptor) -> Option { for (k, _) in &d.items { let k = String::from_utf8_lossy(k.as_bytes()).to_string(); @@ -298,8 +317,9 @@ impl Mapper<'_> { } b.angle = num(t, "Angl").unwrap_or(0.0) as f32; b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0); - // `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight. - b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) }; + // `Intr` off = the Spacing checkbox is off: the pointer's speed sets the spacing. + b.spacing = pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0); + b.spacing_enabled = boolean(t, "Intr") != Some(false); b.flip_x = boolean(t, "flipX").unwrap_or(false); b.flip_y = boolean(t, "flipY").unwrap_or(false); } @@ -316,13 +336,9 @@ impl Mapper<'_> { roundness, flip_x_jitter: boolean(d, "flipX").unwrap_or(false), flip_y_jitter: boolean(d, "flipY").unwrap_or(false), + tilt_scale: pct(d, "tiltScale").unwrap_or(0.0).clamp(0.0, 2.0), + brush_projection: boolean(d, "brushProjection").unwrap_or(false), }; - if pct(d, "tiltScale").is_some_and(|v| v > 0.0) { - self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into()); - } - if boolean(d, "brushProjection") == Some(true) { - self.warnings.insert("Brush Projection is not supported".into()); - } } // Scattering. if boolean(d, "useScatter") == Some(true) { @@ -393,12 +409,13 @@ impl Mapper<'_> { } // Transfer. if boolean(d, "usePaintDynamics") == Some(true) { - b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) }; - for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] { - if self.dynamic(obj(d, k)).is_active() { - self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported")); - } - } + b.transfer = Transfer { + enabled: true, + opacity: self.dynamic(obj(d, "opVr")), + flow: self.dynamic(obj(d, "prVr")), + wetness: self.dynamic(obj(d, "wtVr")), + mix: self.dynamic(obj(d, "mxVr")), + }; } // Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°). if boolean(d, "useBrushPose") == Some(true) { @@ -443,6 +460,7 @@ impl Mapper<'_> { if let Some(v) = boolean(to, "smoothingZoomCompensation") { b.smoothing.adjust_for_zoom = v; } + self.mixer_options(to, &mut b.mixer); if mask_blend_is_set(to) { self.warnings.insert("tool blend modes stored with presets are not applied".into()); } diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 4581962716..5520795ed7 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -226,6 +226,51 @@ fn big_tips_are_downsampled() { assert!(imp.warnings.iter().any(|w| w.contains("downsampled"))); } +#[test] +fn tilt_scale_projection_spacing_and_mixer_fields_map() { + let tip = Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 30.0)).with("Spcn", prc(35.0)).with("Intr", Value::Boolean(false)); + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Wet Pen")) + .with("Brsh", Value::Descriptor(tip)) + .with("useTipDynamics", Value::Boolean(true)) + .with("szVr", var(3, 0.0, 0.0)) + .with("tiltScale", prc(150.0)) + .with("brushProjection", Value::Boolean(true)) + .with("usePaintDynamics", Value::Boolean(true)) + .with("wtVr", var(2, 30.0, 10.0)) + .with("mxVr", var(1, 45.0, 0.0)) + .with( + "toolOptions", + Value::Descriptor( + Descriptor::new("currentToolOptions") + .with("wetness", prc(80.0)) + .with("dryness", prc(20.0)) + .with("mix", prc(65.0)) + .with("flow", prc(70.0)) + .with("sampleMerged", Value::Boolean(true)), + ), + ); + let plain = + Descriptor::new("brushPreset").with("Nm ", t("Plain")).with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 9.0)))); + let bytes = write_v6(2, &[], &[], &[preset, plain], true).unwrap(); + let imp = read_abr(&bytes, "Mixer").unwrap(); + let b = &imp.presets[0].brush; + assert!(!b.spacing_enabled && (b.spacing - 0.35).abs() < 1e-6, "Intr off = Spacing unchecked, the value kept"); + let sd = &b.shape_dynamics; + assert!(sd.brush_projection && (sd.tilt_scale - 1.5).abs() < 1e-6 && sd.size.control == Control::PenTilt); + let tr = &b.transfer; + assert_eq!((tr.wetness.control, tr.wetness.jitter, tr.wetness.minimum), (Control::PenPressure, 0.3, 0.1)); + assert_eq!((tr.mix.control, tr.mix.jitter), (Control::Fade, 0.45)); + let m = &b.mixer; + assert_eq!((m.wet, m.load, m.mix, m.flow, m.sample_all_layers), (0.8, 0.2, 0.65, 0.7, true)); + assert!(!imp.warnings.iter().any(|w| w.contains("Tilt Scale") || w.contains("Projection") || w.contains("Mixer")), "{:?}", imp.warnings); + // Defaults when the file says nothing. + let p = &imp.presets[1].brush; + assert!(p.spacing_enabled && !p.shape_dynamics.brush_projection && p.shape_dynamics.tilt_scale == 0.0); + assert_eq!(p.mixer, photocraft_paint::MixerSettings::default()); + assert_eq!((p.transfer.wetness, p.transfer.mix), (Dynamic::default(), Dynamic::default())); +} + #[test] fn garbage_is_an_error() { assert!(read_abr(b"", "g").is_err()); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index aca8475702..01ffc756b3 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -10,6 +10,7 @@ use photocraft_color::BlendMode; use serde::{Deserialize, Serialize}; +use crate::mixer::MixerSettings; use crate::tile::GrayTile; /// What drives a dynamic parameter (Photoshop's "Control" pop-ups). @@ -80,12 +81,19 @@ pub struct ShapeDynamics { pub enabled: bool, /// Size jitter / control; `minimum` is Photoshop's Minimum Diameter. pub size: Dynamic, + /// Tilt Scale, 0..2 (0–200 %): with the size control on Pen Tilt, how much a tilted pen + /// squashes the tip's height (before the tip angle is applied). At full tilt the height is + /// scaled by `1 − tiltScale / 2`, so 0 leaves the tip alone and 200 % flattens it. + pub tilt_scale: f32, /// Angle jitter (fraction of ±180°) / control. pub angle: Dynamic, /// Roundness jitter / control; `minimum` is Minimum Roundness. pub roundness: Dynamic, pub flip_x_jitter: bool, pub flip_y_jitter: bool, + /// Brush Projection: the pen's tilt and barrel rotation are applied to the tip as a + /// projection (the tip is foreshortened along the tilt direction and turns with the pen). + pub brush_projection: bool, } /// Scattering. @@ -254,13 +262,36 @@ impl Default for ColorDynamics { } } -/// Transfer: per-dab opacity ceiling and flow. +/// Transfer: per-dab opacity ceiling and flow, plus the Mixer Brush's wetness and mix. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Transfer { pub enabled: bool, pub opacity: Dynamic, pub flow: Dynamic, + /// Wetness Jitter / control (Mixer Brush only): scales the Wet setting per dab. + pub wetness: Dynamic, + /// Mix Jitter / control (Mixer Brush only): scales the Mix setting per dab. + pub mix: Dynamic, +} + +/// The sections a lock can hold (the lock icons in Photoshop's Brush Settings list): a locked +/// section keeps its settings when another brush preset is picked. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SectionLocks { + pub shape_dynamics: bool, + pub scattering: bool, + pub texture: bool, + pub dual_brush: bool, + pub color_dynamics: bool, + pub transfer: bool, + pub pose: bool, + pub noise: bool, + pub wet_edges: bool, + pub build_up: bool, + pub smoothing: bool, + pub protect_texture: bool, } /// Brush Pose: stylus values used instead of (override) or for missing device data. @@ -327,6 +358,9 @@ pub struct BrushSettings { pub hardness: f32, /// Spacing between dabs as a fraction of the diameter. pub spacing: f32, + /// Photoshop's Spacing checkbox. Off: the pointer's speed sets the spacing (one dab every + /// [`crate::dynamics::SPEED_SPACING_MS`] of stroke time; without timestamps, one per input point). + pub spacing_enabled: bool, /// Maximum coverage for the whole stroke. pub opacity: f32, /// Per-dab coverage. @@ -374,6 +408,10 @@ pub struct BrushSettings { pub smoothing: Smoothing, /// Keep the current texture (pattern + scale) when switching to another textured preset. pub protect_texture: bool, + /// Sections whose settings stay when another preset is picked (tool state, like Photoshop's locks). + pub locks: SectionLocks, + /// Mixer Brush options (Wet, Load, Mix, Flow, Sample All Layers) used by `paint.mixerBrush`. + pub mixer: MixerSettings, /// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay. pub seed: u64, } @@ -384,6 +422,7 @@ impl Default for BrushSettings { size: 20.0, hardness: 1.0, spacing: 0.1, + spacing_enabled: true, opacity: 1.0, flow: 1.0, pressure_size: true, @@ -411,6 +450,8 @@ impl Default for BrushSettings { build_up_rate: 20.0, smoothing: Smoothing::default(), protect_texture: false, + locks: SectionLocks::default(), + mixer: MixerSettings::default(), seed: 0, } } @@ -418,10 +459,47 @@ impl Default for BrushSettings { impl BrushSettings { /// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool - /// option, so it stays the tool's (Photoshop), and a protected texture stays too. + /// option, so it stays the tool's (Photoshop), a protected texture stays too, and so does every + /// section `current` has locked (the locks themselves are tool state and carry over). pub fn picked_over(self, current: &BrushSettings) -> Self { let mut b = self.with_protected_texture(current); b.smoothing = current.smoothing.clone(); + let l = ¤t.locks; + if l.shape_dynamics { + b.shape_dynamics = current.shape_dynamics.clone(); + } + if l.scattering { + b.scattering = current.scattering.clone(); + } + if l.texture { + b.texture = current.texture.clone(); + } + if l.dual_brush { + b.dual_brush = current.dual_brush.clone(); + } + if l.color_dynamics { + b.color_dynamics = current.color_dynamics.clone(); + } + if l.transfer { + b.transfer = current.transfer.clone(); + } + if l.pose { + b.pose = current.pose.clone(); + } + if l.noise { + b.noise = current.noise; + } + if l.wet_edges { + b.wet_edges = current.wet_edges; + } + if l.build_up { + b.build_up = current.build_up; + b.build_up_rate = current.build_up_rate; + } + if l.protect_texture { + b.protect_texture = current.protect_texture; + } + b.locks = current.locks.clone(); b } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index f79fda87f9..dc35ee5189 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -232,6 +232,7 @@ fn jitter_is_deterministic_per_seed() { roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() }, flip_x_jitter: true, flip_y_jitter: true, + ..Default::default() }, scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) }, ..brush() @@ -638,3 +639,177 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() { r.push(&pts); assert!(r.tail_preview().is_none()); } + +// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ---------- + +/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre. +fn extents(b: &BrushSettings, d: &Dab) -> (usize, usize) { + let r = raster(b, d); + let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32); + let w = (r.0.x0..r.0.x1).filter(|&x| at(&r, x, cy) > 0.5).count(); + let h = (r.0.y0..r.0.y1).filter(|&y| at(&r, cx, y) > 0.5).count(); + (w, h) +} + +fn pen(x: f64, y: f64, tilt_x: f32, tilt_y: f32, rotation: f32) -> StrokePoint { + StrokePoint { tilt_x, tilt_y, rotation, ..StrokePoint::new(x, y, 1.0) } +} + +#[test] +fn tilt_scale_squashes_the_tip_height_with_pen_tilt() { + // Size control on Pen Tilt with a 100 % minimum, so only Tilt Scale changes the shape. + let mk = |scale: f32| BrushSettings { + size: 40.0, + shape_dynamics: ShapeDynamics { + enabled: true, + size: Dynamic { control: Control::PenTilt, minimum: 1.0, ..Default::default() }, + tilt_scale: scale, + ..Default::default() + }, + ..brush() + }; + let upright = [pen(50.0, 50.0, 0.0, 0.0, 0.0)]; + let tilted = [pen(50.0, 50.0, 45.0, 0.0, 0.0)]; + // 0 % (the default) and an upright pen leave the tip round. + let d = dabs_of(&mk(0.0), &tilted)[0]; + assert_eq!(d.roundness, 1.0); + let d = dabs_of(&mk(2.0), &upright)[0]; + assert_eq!(d.roundness, 1.0); + // 100 % at 45° tilt: height × (1 − 0.5 × 0.5) = 0.75; 200 %: 0.5. + let d = dabs_of(&mk(1.0), &tilted)[0]; + assert!((d.roundness - 0.75).abs() < 1e-4, "{}", d.roundness); + let d2 = dabs_of(&mk(2.0), &tilted)[0]; + assert!((d2.roundness - 0.5).abs() < 1e-4, "{}", d2.roundness); + let (w, h) = extents(&mk(2.0), &d2); + assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}"); + // Only with the size control on Pen Tilt (Photoshop greys the slider otherwise). + let mut b = mk(2.0); + b.shape_dynamics.size.control = Control::PenPressure; + assert_eq!(dabs_of(&b, &tilted)[0].roundness, 1.0); + // Rendered strokes differ. + let a = paint(&mk(0.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0); + let c = paint(&mk(2.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0); + let area = Rect::new(0, 0, 160, 80); + assert!(alpha_sum(&c, area) < alpha_sum(&a, area) * 0.75, "{} vs {}", alpha_sum(&c, area), alpha_sum(&a, area)); +} + +#[test] +fn brush_projection_foreshortens_along_tilt_and_turns_with_rotation() { + let mk = |on: bool| BrushSettings { size: 40.0, shape_dynamics: ShapeDynamics { enabled: true, brush_projection: on, ..Default::default() }, ..brush() }; + // Tilted 60° towards +x: the tip is foreshortened to cos 60° = ½ along x. + let pts = [pen(50.0, 50.0, 60.0, 0.0, 0.0)]; + let d = dabs_of(&mk(true), &pts)[0]; + assert!((d.proj_scale - 0.5).abs() < 1e-4 && d.proj_angle.abs() < 1e-5, "{d:?}"); + let (w, h) = extents(&mk(true), &d); + assert!((w as i32 - 20).abs() <= 2 && h >= 38, "{w}×{h}"); + // Tilted along y instead: foreshortened vertically. + let d = dabs_of(&mk(true), &[pen(50.0, 50.0, 0.0, 60.0, 0.0)])[0]; + let (w, h) = extents(&mk(true), &d); + assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}"); + // Off: the tilt doesn't touch the tip. + let d = dabs_of(&mk(false), &pts)[0]; + assert_eq!((d.proj_scale, d.angle), (1.0, 0.0)); + assert_eq!(extents(&mk(false), &d), (40, 40)); + // Barrel rotation turns the tip (an elliptical one shows it). + let mut b = mk(true); + b.roundness = 0.5; + let d = dabs_of(&b, &[pen(50.0, 50.0, 0.0, 0.0, 90.0)])[0]; + assert!((d.angle.to_degrees() - 90.0).abs() < 1e-3); + let (w, h) = extents(&b, &d); + assert!(w < h, "{w}×{h}"); + // Dual-brush dabs are never projected. + let ctx = BrushContext::new(&mk(true)); + let dd = Dab { proj_scale: 0.3, ..Dab::round(Point::new(50.0, 50.0), 20.0, 1.0) }; + let (r, mut a, mut c) = (ctx.dab_rect(&dd, true), Vec::new(), Vec::new()); + ctx.rasterize(&dd, true, r, &mut a); + ctx.rasterize(&Dab { proj_scale: 1.0, ..dd }, true, r, &mut c); + assert_eq!(a, c); +} + +#[test] +fn spacing_off_spaces_dabs_by_pointer_speed() { + let mut b = BrushSettings { size: 20.0, spacing_enabled: false, ..brush() }; + let timed = |ms: f64| vec![StrokePoint { time: 0.0, ..StrokePoint::new(0.0, 10.0, 1.0) }, StrokePoint { time: ms, ..StrokePoint::new(100.0, 10.0, 1.0) }]; + // 100 px in 400 ms: a dab every 8 ms = every 2 px; in 40 ms: every 20 px. + let slow = dabs_of(&b, &timed(400.0)).len(); + let fast = dabs_of(&b, &timed(40.0)).len(); + assert_eq!((slow, fast), (51, 6)); + // Never denser than half a pixel, however slow. + assert!(dabs_of(&b, &timed(1.0e9)).len() <= 201); + // No timestamps: one dab per input point. + let pts: Vec = (0..7).map(|i| StrokePoint::new(f64::from(i) * 15.0, 10.0, 1.0)).collect(); + assert_eq!(dabs_of(&b, &pts).len(), 7); + // Checked: fixed spacing (25 % of 20 px = 5 px) whatever the speed. + b.spacing_enabled = true; + assert_eq!(dabs_of(&b, &timed(400.0)).len(), dabs_of(&b, &timed(40.0)).len()); + // Chunked input gives the same dabs. + b.spacing_enabled = false; + let pts: Vec = (0..20).map(|i| StrokePoint { time: f64::from(i) * 13.0, ..StrokePoint::new(f64::from(i) * 7.0, 10.0, 1.0) }).collect(); + let one = dabs_of(&b, &pts); + let mut g = crate::dynamics::DabGenerator::new(&b, 1.0); + let (mut out, mut dual) = (Vec::new(), Vec::new()); + for c in pts.chunks(3) { + g.push(c, &mut out, &mut dual); + } + g.finish(&mut out, &mut dual); + assert_eq!(one, out); +} + +#[test] +fn locked_sections_survive_picking_a_preset() { + let presets = crate::presets::builtin(); + let chalk = crate::presets::find(&presets, "chalk").unwrap().brush.clone(); + let mut cur = BrushSettings { + scattering: Scattering { enabled: true, count: 5, ..Default::default() }, + transfer: Transfer { enabled: true, wetness: Dynamic::jitter(0.4), ..Default::default() }, + noise: true, + ..brush() + }; + // Unlocked: the preset's sections win. + let next = chalk.clone().picked_over(&cur); + assert_eq!(next.scattering, chalk.scattering); + assert_eq!(next.noise, chalk.noise); + // Locked: the current sections stay, and so do the locks. + cur.locks = SectionLocks { scattering: true, transfer: true, noise: true, ..Default::default() }; + let next = chalk.clone().picked_over(&cur); + assert_eq!(next.scattering, cur.scattering); + assert_eq!(next.transfer, cur.transfer); + assert!(next.noise); + assert_eq!(next.locks, cur.locks); + // Unlocked sections still come from the preset. + assert_eq!(next.texture, chalk.texture); + assert_eq!(next.tip, chalk.tip); +} + +#[test] +fn transfer_wetness_and_mix_vary_per_dab() { + let b = BrushSettings { + transfer: Transfer { + enabled: true, + wetness: Dynamic::controlled(Control::PenPressure), + mix: Dynamic { jitter: 1.0, minimum: 0.25, ..Default::default() }, + ..Default::default() + }, + ..brush() + }; + let pts = [StrokePoint::new(0.0, 0.0, 0.3), StrokePoint::new(60.0, 0.0, 0.3)]; + let d = dabs_of(&b, &pts); + assert!(d.iter().all(|x| (x.wet - 0.3).abs() < 1e-5)); + assert!(d.iter().all(|x| (0.25..=1.0).contains(&x.mix)) && d.iter().any(|x| x.mix < 0.9)); + // Transfer off: as set. + let d = dabs_of(&brush(), &pts); + assert!(d.iter().all(|x| x.wet == 1.0 && x.mix == 1.0)); + // New fields round-trip and old JSON loads with neutral values. + let full = BrushSettings { + spacing_enabled: false, + shape_dynamics: ShapeDynamics { tilt_scale: 1.5, brush_projection: true, ..Default::default() }, + locks: SectionLocks { texture: true, ..Default::default() }, + mixer: crate::mixer::MixerSettings { wet: 0.9, sample_all_layers: true, ..Default::default() }, + ..b + }; + let back: BrushSettings = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap(); + assert_eq!(back, full); + let old: BrushSettings = serde_json::from_str(r#"{"size": 12, "shapeDynamics": {"enabled": true}, "transfer": {"enabled": true}}"#).unwrap(); + assert!(old.spacing_enabled && !old.shape_dynamics.brush_projection && old.shape_dynamics.tilt_scale == 0.0 && old.locks == SectionLocks::default()); + assert_eq!(old.mixer, crate::mixer::MixerSettings::default()); +} diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 68a98bacb5..64caebeafd 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -13,6 +13,13 @@ use crate::{Dab, StrokePoint}; /// Maximum pulled-string length at 100 % smoothing, in screen pixels. pub const MAX_STRING_PX: f64 = 100.0; +/// With Spacing unchecked, one dab per this many milliseconds of stroke time, so faster strokes +/// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing"). +pub const SPEED_SPACING_MS: f64 = 8.0; + +/// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer). +const MIN_SPEED_STEP: f64 = 0.5; + #[inline] fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint { let ff = f as f32; @@ -147,11 +154,32 @@ pub struct PathWalker { /// Airbrush interval in ms (None = off). interval: Option, time_acc: f64, + /// Speed spacing (Spacing unchecked): one step per this many ms of stroke time. + speed: Option, + speed_acc: f64, } impl PathWalker { pub fn new(build_up_interval_ms: Option) -> Self { - Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 } + Self { + last: None, + next_at: 0.0, + pending_first: None, + initial_dir: None, + dir: 0.0, + step: 0, + interval: build_up_interval_ms, + time_acc: 0.0, + speed: None, + speed_acc: 0.0, + } + } + + /// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement + /// spreads them out. Segments without timestamps get one step at their end point. + pub fn speed_spacing(mut self, interval_ms: f64) -> Self { + self.speed = Some(interval_ms.max(0.1)); + self } fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) { @@ -179,12 +207,36 @@ impl PathWalker { self.emit(f, out); } if len > 1e-9 { - while self.next_at <= len + 1e-9 { - let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); - self.emit(q, out); - self.next_at += step_len(&q).max(0.25); + match self.speed { + None => { + while self.next_at <= len + 1e-9 { + let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); + self.emit(q, out); + self.next_at += step_len(&q).max(0.25); + } + self.next_at -= len; + } + Some(iv) => { + let dt = p.time - a.time; + if dt > 0.0 && dt.is_finite() { + self.speed_acc += dt; + let max_n = (len / MIN_SPEED_STEP).ceil().max(1.0) as usize; + let mut n = 0; + while self.speed_acc >= iv && n < max_n { + self.speed_acc -= iv; + let f = ((dt - self.speed_acc) / dt).clamp(0.0, 1.0); + self.emit(lerp_pt(&a, &p, f), out); + n += 1; + } + if n == max_n { + self.speed_acc = self.speed_acc.rem_euclid(iv); + } + } else { + // No timestamps: one dab per input point. + self.emit(p, out); + } + } } - self.next_at -= len; } if let Some(iv) = self.interval { let dt = p.time - a.time; @@ -379,6 +431,22 @@ impl DabBuilder { fy = !fy; } } + // Tilt Scale and Brush Projection (Shape Dynamics). + let (mut proj_angle, mut proj_scale) = (0.0, 1.0); + if sd.enabled { + let tilt = s.point.tilt_x.hypot(s.point.tilt_y).clamp(0.0, 90.0); + if sd.size.control == Control::PenTilt && sd.tilt_scale > 0.0 { + let k = 1.0 - sd.tilt_scale.clamp(0.0, 2.0) * 0.5 * tilt / 90.0; + roundness = (roundness * k).clamp(0.01, 1.0); + } + if sd.brush_projection { + angle += s.point.rotation; + if tilt > 0.0 { + proj_angle = (-s.point.tilt_y).atan2(s.point.tilt_x); + proj_scale = tilt.to_radians().cos().max(0.05); + } + } + } let radius = (diameter / 2.0).max(0.5); // Scatter. let mut c = Point::new(s.point.x, s.point.y); @@ -403,6 +471,7 @@ impl DabBuilder { opacity = scalar(&tr.opacity, s, r(stream::OPACITY)); flow *= scalar(&tr.flow, s, r(stream::FLOW)); } + let (wet, mix) = if tr.enabled { (scalar(&tr.wetness, s, r(stream::WET)), scalar(&tr.mix, s, r(stream::MIX))) } else { (1.0, 1.0) }; if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) { opacity *= s.point.pressure.clamp(0.0, 1.0); } @@ -428,6 +497,10 @@ impl DabBuilder { color, depth, index: i, + proj_angle, + proj_scale, + wet, + mix, }); } } @@ -479,6 +552,10 @@ impl DualBuilder { color: [0.0, 0.0, 0.0, 1.0], depth: 1.0, index: i, + proj_angle: 0.0, + proj_scale: 1.0, + wet: 1.0, + mix: 1.0, }); } } @@ -519,7 +596,7 @@ impl DabGenerator { let dual_on = brush.dual_brush.enabled; Self { smoother: Smoother::new(&brush.smoothing, zoom), - walker: PathWalker::new(interval), + walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) }, dual_walker: dual_on.then(|| PathWalker::new(None)), builder: DabBuilder::new(brush), dual: dual_on.then(|| DualBuilder::new(brush)), diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index b1208240c4..96e98facf8 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -31,9 +31,10 @@ pub mod rng; pub mod tile; pub use brush::{ - BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing, - Texture, TipShape, Transfer, + BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, SectionLocks, ShapeDynamics, + Smoothing, Texture, TipShape, Transfer, }; +pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, render_stroke}; pub use tile::GrayTile; @@ -94,6 +95,13 @@ pub struct Dab { pub depth: f32, /// Position in the stroke (0 = first). pub index: u64, + /// Brush Projection: direction (radians, counter-clockwise, y up) along which the tip is + /// foreshortened, and the factor (1 = no projection). + pub proj_angle: f32, + pub proj_scale: f32, + /// Mixer Brush multipliers of Wet and Mix (Transfer › Wetness/Mix Jitter), 1 = as set. + pub wet: f32, + pub mix: f32, } impl Dab { @@ -111,6 +119,10 @@ impl Dab { color: [0.0, 0.0, 0.0, 1.0], depth: 1.0, index: 0, + proj_angle: 0.0, + proj_scale: 1.0, + wet: 1.0, + mix: 1.0, } } } diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index dcbc1acf52..afb8092cd2 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -24,11 +24,13 @@ pub struct MixerSettings { pub load: f32, pub mix: f32, pub flow: f32, + /// Pick up colour from all visible layers instead of the target layer only. + pub sample_all_layers: bool, } impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0 } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false } } } @@ -87,6 +89,8 @@ pub fn apply_mixer_stroke( let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + // Transfer › Wetness / Mix Jitter scale the settings per dab. + let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. acc.iter_mut().for_each(|v| *v = 0.0); let (mut wsum, mut asum) = (0.0f32, 0.0f32); @@ -165,7 +169,7 @@ mod tests { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); st.load([1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0 }, &mut st, None, false); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false); let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]); assert!(start > 0.95, "{start}"); if expect_dry { @@ -186,7 +190,15 @@ mod tests { s.fill_rect(Rect::new(100, 0, 200, 20), &[1.0, 1.0, 1.0, 1.0]); let mut st = MixerState::default(); st.load([1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0 }, &mut st, None, false); + apply_mixer_stroke( + &mut s, + None, + &stroke(10.0), + &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0, ..Default::default() }, + &mut st, + None, + false, + ); // Blue is dragged into the white half. let c = s.rgba(115, 10); assert!(c[2] > c[1] + 0.05 && c[1] < 0.95, "{c:?}"); @@ -205,4 +217,24 @@ mod tests { assert!(!dmg.is_empty()); assert_eq!(s.rgba(50, 10)[3], 0.0); } + + #[test] + fn transfer_wetness_control_scales_pickup_per_dab() { + // Wetness on Pen Pressure: no pressure means a dry brush (no pickup), full pressure picks up. + let run = |pressure: f32| { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 200, 20), &[0.0, 0.0, 1.0, 1.0]); + let mut st = MixerState::default(); + st.load([1.0, 0.0, 0.0, 1.0]); + let mut k = stroke(10.0); + k.brush.transfer = crate::Transfer { enabled: true, wetness: crate::Dynamic::controlled(crate::Control::PenPressure), ..Default::default() }; + for p in &mut k.points { + p.pressure = pressure; + } + apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + st.pickup + }; + assert!(run(0.0).is_none()); + assert!(run(1.0).is_some_and(|p| p[2] > 0.2)); + } } diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index e987348d53..c5d9143e34 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -27,7 +27,7 @@ fn size_dyn(size: Dynamic) -> ShapeDynamics { } fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer { - Transfer { enabled: true, opacity, flow } + Transfer { enabled: true, opacity, flow, ..Default::default() } } fn paper(seed: u32, depth: f32) -> Texture { diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 3cee715d59..cf33a1ef5f 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -144,6 +144,12 @@ impl BrushContext { let (cx, cy) = (d.center.x as f32, d.center.y as f32); let (sn, cs) = d.angle.sin_cos(); let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 }); + // Brush Projection: stretch the sampling coordinates along the tilt direction, which + // foreshortens the tip there by `proj_scale`. + let proj = (!dual && d.proj_scale < 0.999).then(|| { + let (ps, pc) = d.proj_angle.sin_cos(); + (pc, ps, 1.0 / d.proj_scale.max(0.05) - 1.0) + }); let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5); @@ -163,8 +169,13 @@ impl BrushContext { for xx in 0..w { let x = rect.x0 + xx as i32; let dx = x as f32 + 0.5 - cx; - // To y-up, rotate by -angle, flip. - let (ux, uy) = (dx, -dy); + // To y-up, project, rotate by -angle, flip. + let (mut ux, mut uy) = (dx, -dy); + if let Some((ax, ay, k)) = proj { + let t = (ux * ax + uy * ay) * k; + ux += t * ax; + uy += t * ay; + } let u = (ux * cs + uy * sn) * fx; let v = (-ux * sn + uy * cs) * fy; let (mut val, rn) = match mips { diff --git a/crates/paint/src/rng.rs b/crates/paint/src/rng.rs index 9d053653a1..3dfe5fd809 100644 --- a/crates/paint/src/rng.rs +++ b/crates/paint/src/rng.rs @@ -53,6 +53,8 @@ pub mod stream { pub const DUAL_X: u64 = 16; pub const DUAL_Y: u64 = 17; pub const DUAL_FLIP: u64 = 18; + pub const WET: u64 = 19; + pub const MIX: u64 = 20; } /// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed. diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 8ff7f4923e..8e9768117a 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -8,11 +8,15 @@ //! //! The panel never writes the session brush itself: each frame's edits become one //! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control -//! channel and the MCP server see exactly what the panel did and can do the same. +//! channel and the MCP server see exactly what the panel did and can do the same. A drag's calls +//! share a `coalesce` key, so a whole gesture is one journal entry ([`commit_gesture`]). +//! +//! The lock beside a section name keeps that section's settings when another preset is picked +//! (`locks` in the brush, applied by the engine). The Brushes tab lives in [`crate::brushes_tab`]. use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2}; use photocraft_engine::BrushSettings; -use photocraft_engine::paint::{self, TipShape}; +use photocraft_engine::paint; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -22,6 +26,7 @@ use crate::{PhotocraftApp, icons, widgets}; pub use crate::brush_preview::{preview_pixels, preview_sig}; pub use crate::brush_sections::section_body; +pub use photocraft_engine::brush_cmds::brush_patch; /// Sections in Photoshop's order. The bool says whether the section has an enable box. pub const SECTIONS: [(&str, bool); 13] = [ @@ -41,9 +46,31 @@ pub const SECTIONS: [(&str, bool); 13] = [ ]; /// Panel width and the stroke strip's size. -const WIDTH: f32 = 540.0; +pub(crate) const WIDTH: f32 = 540.0; const STRIP: (u32, u32) = (488, 76); +/// How the Brushes tab lists presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BrushesView { + /// Rows: tip, size, stroke preview, name. + #[default] + List, + /// Tip thumbnails with their sizes. + Grid, +} + +/// A rename in progress in the Brushes tab (a preset, or a group when `group` is set). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Renaming { + pub group: bool, + /// The preset (or group) being renamed. + pub name: String, + /// The text typed so far. + pub text: String, +} + /// Brushes panel view state (serde, so the control channel can read and drive it). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -52,6 +79,8 @@ pub struct BrushesPanelState { pub collapsed: Vec, /// Case-insensitive name filter. pub filter: String, + pub view: BrushesView, + pub renaming: Option, } /// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing). @@ -72,63 +101,74 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> { }) } -/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by -/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides. -fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value { - let mut c = b.clone(); - if skip.0 { - c.tip = TipShape::Round; - } - if skip.1 { - c.dual_brush.tip = TipShape::Round; - } - if skip.2 { - c.texture.pattern = paint::Pattern::default(); - } - serde_json::to_value(&c).unwrap_or(Value::Null) -} - -/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal -/// `tools.setBrush` patch (`{}` when nothing changed). -pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value { - let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern); - fn diff(a: &Value, b: &Value) -> Option { - match (a, b) { - (Value::Object(ao), Value::Object(bo)) => { - let mut out = Map::new(); - for (k, bv) in bo { - match ao.get(k) { - Some(av) => { - if let Some(d) = diff(av, bv) { - out.insert(k.clone(), d); - } - } - None => { - out.insert(k.clone(), bv.clone()); - } - } - } - (!out.is_empty()).then_some(Value::Object(out)) - } - // Enums with payloads (tips, patterns) are replaced whole. - _ => (a != b).then(|| b.clone()), - } - } - diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})) +/// The lock behind section `i` (every section but Brush Tip Shape has one, as in Photoshop). +pub fn section_lock(b: &mut BrushSettings, i: usize) -> Option<&mut bool> { + let l = &mut b.locks; + Some(match i { + 1 => &mut l.shape_dynamics, + 2 => &mut l.scattering, + 3 => &mut l.texture, + 4 => &mut l.dual_brush, + 5 => &mut l.color_dynamics, + 6 => &mut l.transfer, + 7 => &mut l.pose, + 8 => &mut l.noise, + 9 => &mut l.wet_edges, + 10 => &mut l.build_up, + 11 => &mut l.smoothing, + 12 => &mut l.protect_texture, + _ => return None, + }) } /// Send the panel's edits (`before` → `after`) through `tools.setBrush`. pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) { + send(app, before, after, None); +} + +fn send(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings, key: Option) -> bool { if before == after { - return; + return false; } let patch = brush_patch(before, after); if patch.as_object().is_some_and(Map::is_empty) { - return; + return false; } - if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) { + let mut p = json!({ "brush": patch }); + if let Some(k) = key { + p["coalesce"] = json!(k); + } + if let Err(e) = app.run("tools.setBrush", p) { app.ui.status = e; } + true +} + +const GESTURE: &str = "brush-set-gesture"; + +/// The current brush-edit gesture number. A pointer press starts a new gesture (once per frame); +/// [`commit_gesture`] ends one after an edit made with the pointer up (a click, a typed value). +fn gesture(ctx: &egui::Context) -> u64 { + let frame = ctx.cumulative_pass_nr(); + let pressed = ctx.input(|i| i.pointer.any_pressed()); + ctx.data_mut(|d| { + let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)); + if pressed && g.1 != frame { + g.0 += 1; + g.1 = frame; + } + g.0 + }) +} + +/// [`commit`] as part of a gesture: every call of one drag carries the same `coalesce` key, so +/// the engine journals the drag as a single `tools.setBrush` (Rule 1: one command per gesture). +/// Call it every frame (it tracks presses even when nothing changed). +pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) { + let g = gesture(ctx); + if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1); + } } /// Group label for presets saved without a group. @@ -147,189 +187,41 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec bool { preset.hardness == brush.hardness && preset.spacing == brush.spacing && preset.tip == brush.tip - && BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush + && BrushSettings { + color: brush.color, + size: brush.size, + background: brush.background, + smoothing: brush.smoothing.clone(), + locks: brush.locks.clone(), + ..preset.clone() + } == *brush } /// A fresh "Brush N" name. -fn new_preset_name(presets: &[paint::BrushPreset]) -> String { +pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String { (1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into()) } -fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) { +pub(crate) fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) { if let Err(e) = app.run(id, p) { app.ui.status = e; } } -fn full_uv() -> egui::Rect { +pub(crate) fn full_uv() -> egui::Rect { egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)) } -fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Size of the current brush (Photoshop's Brushes panel slider). - let before = app.session.tools.brush.clone(); - let mut b = before.clone(); - ui.horizontal(|ui| { - ui.label(RichText::new("Size").color(t.text_dim)); - let mut lv = b.size.max(1.0).ln(); - ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| { - let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None); - if r.changed() { - b.size = lv.exp().round().clamp(1.0, 5000.0); - } - r - }); - let mut s = b.size; - if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() { - b.size = s.round().clamp(1.0, 5000.0); - } - }); - commit(app, &before, &b); - ui.add_space(4.0); - ui.horizontal(|ui| { - icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); - ui.add_space(20.0); - ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0)); - }); - ui.add_space(6.0); - let filter = app.ui.brushes_panel.filter.trim().to_lowercase(); - let groups = grouped_presets(&app.session.tools.presets); - let brush = &app.session.tools.brush; - let mut clicked = None; - let mut toggle = None; - egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 1.0; - for (group, items) in groups { - let items: Vec = items - .into_iter() - .filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))) - .collect(); - if items.is_empty() { - continue; - } - let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, t.radius_sm, t.hover); - } - let x = r.left() + 4.0; - icons::paint( - ui, - egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), - if open { "chevron-down" } else { "chevron-right" }, - 12.0, - t.text_dim, - ); - icons::paint( - ui, - egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)), - if open { "folder-open" } else { "folder" }, - 14.0, - t.icon, - ); - ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text); - ui.painter().text( - egui::pos2(r.right() - 8.0, r.center().y), - egui::Align2::RIGHT_CENTER, - items.len().to_string(), - theme::medium(11.0), - t.text_faint, - ); - if resp.clicked() { - toggle = Some(group.clone()); - } - if !open { - continue; - } - for i in items { - let Some(p) = app.session.tools.presets.get(i) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); - if !ui.is_rect_visible(r) { - continue; - } - if is_current(&p.brush, brush) { - ui.painter().rect_filled(r, t.radius_sm, t.accent_soft); - } else if resp.hovered() { - ui.painter().rect_filled(r, t.radius_sm, t.hover); - } - let pb = &p.brush; - let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); - let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0)); - let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size)); - ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE); - ui.painter().text( - egui::pos2(cell.center().x, r.bottom() - 1.0), - egui::Align2::CENTER_BOTTOM, - format!("{}", pb.size.round() as i64), - egui::FontId::proportional(9.0), - t.text_faint, - ); - let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text); - let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0)); - ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE); - ui.painter().text( - egui::pos2(sr.right() + 12.0, r.center().y), - egui::Align2::LEFT_CENTER, - &p.name, - egui::FontId::proportional(12.0), - t.text_dim, - ); - if resp.clicked() { - clicked = Some(p.name.clone()); - } - } - ui.add_space(2.0); - } - }); - if let Some(g) = toggle { - let c = &mut app.ui.brushes_panel.collapsed; - match c.iter().position(|x| *x == g) { - Some(i) => { - c.remove(i); - } - None => c.push(g), - } - } - if let Some(name) = clicked { - run_or_status(app, "tools.setBrush", json!({ "preset": name })); - } - ui.add_space(4.0); - widgets::hairline(ui); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone()); - if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked() - && let Some(name) = current - { - run_or_status(app, "brush.presets.delete", json!({ "name": name })); - } - if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() { - let name = new_preset_name(&app.session.tools.presets); - run_or_status(app, "brush.presets.save", json!({ "name": name })); - } - if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() { - app.open_dialog_file(); - } - }); - }); - // Forget previews of presets that no longer exist. - let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect(); - brush_preview::with_cache(ui.ctx(), |c| { - c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n))); - }); -} - -/// The section list: enable boxes, names, the selection. Clicking a name shows the section and -/// turns it on (Photoshop); clicking a box only toggles it. +/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section +/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the +/// section when another preset is picked. fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) { let t = Tokens::get(ui.ctx()); ui.vertical(|ui| { @@ -355,9 +247,10 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting } else { ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside); } + let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0); if box_resp.clicked() { *flag = !*flag; - } else if resp.clicked() { + } else if resp.clicked() && !on_lock { *flag = true; } x += 20.0; @@ -367,7 +260,22 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting let color = if sel { t.text } else { t.text_dim }; let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) }; ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color); - if resp.clicked() { + let mut lock_clicked = false; + if let Some(lock) = section_lock(b, i) { + let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); + let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); + // Like Photoshop: the lock shows only when set or hovered. + if *lock || resp.hovered() || lresp.hovered() { + let tint = if *lock { t.text } else { t.text_faint }; + icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); + } + let tip = if *lock { "Unlock: picking a preset replaces these settings" } else { "Lock: keep these settings when picking another preset" }; + if lresp.on_hover_text(tip).clicked() { + *lock = !*lock; + lock_clicked = true; + } + } + if resp.clicked() && !lock_clicked { app.ui.brush_section = i; } if i == 0 { @@ -411,11 +319,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) })); } if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() { - b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() }; + b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() }; } }); }); - commit(app, &before, &b); + commit_gesture(app, ui.ctx(), &before, &b); } pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { @@ -457,7 +365,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::hairline(ui); ui.add_space(6.0); if app.ui.brush_tab == 1 { - brushes_tab(app, ui); + crate::brushes_tab::show(app, ui); } else { settings_tab(app, ui); } diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index d3f36ddc8f..46b6a738ad 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -34,6 +34,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.roundness = 0.4; b.hardness = 0.25; b.spacing = 0.6; + b.spacing_enabled = false; } 1 => { let sd = &mut b.shape_dynamics; @@ -43,6 +44,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) { sd.roundness = dynamic(0.5, Control::PenTilt, 0.25); sd.flip_x_jitter = true; sd.flip_y_jitter = true; + sd.tilt_scale = 1.25; + sd.brush_projection = true; } 2 => { let sc = &mut b.scattering; @@ -92,6 +95,9 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.transfer.enabled = true; b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1); b.transfer.flow = dynamic(0.25, Control::Fade, 0.3); + b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1); + b.transfer.mix = dynamic(0.45, Control::Fade, 0.2); + b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true }; } 7 => { let p = &mut b.pose; @@ -136,7 +142,7 @@ fn every_section_round_trips_through_set_brush() { let (id, p) = last_journal(&app).expect("journaled"); assert_eq!(id, "tools.setBrush"); let patch = &p["brush"]; - assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}"); + assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 8), "section {i}: {patch}"); // And the engine's view (`brush.get`) reads back the same settings. let got = app.session.execute("brush.get", json!({})).unwrap(); assert_eq!(serde_json::from_value::(got).unwrap(), after, "section {i}"); @@ -189,6 +195,144 @@ fn bad_patches_fail_without_touching_the_brush() { } } +#[test] +fn section_locks_round_trip_and_survive_preset_picks() { + let mut b = BrushSettings::default(); + for (i, (name, _)) in SECTIONS.iter().enumerate() { + assert_eq!(section_lock(&mut b, i).is_some(), i > 0, "{name} has a lock"); + } + let mut app = app(); + let before = app.session.tools.brush.clone(); + let mut after = before.clone(); + edit_section(2, &mut after); + for i in [2, 6, 8] { + *section_lock(&mut after, i).unwrap() = true; + } + commit(&mut app, &before, &after); + let (_, p) = last_journal(&app).unwrap(); + assert_eq!(p["brush"]["locks"], json!({"scattering": true, "transfer": true, "noise": true})); + // Picking a preset keeps the locked sections (and the locks), and the preset still reads as current. + let chalk = paint::presets::find(&app.session.tools.presets, "Chalk").unwrap().brush.clone(); + app.run("tools.setBrush", json!({"preset": "Chalk"})).unwrap(); + let b = &app.session.tools.brush; + assert_eq!(b.scattering, after.scattering); + assert!(b.locks.scattering && b.locks.noise); + assert_eq!(b.tip, chalk.tip); +} + +/// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. +fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { + let mut app = app(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +#[test] +fn options_bar_edits_are_one_set_brush_per_gesture() { + use egui_kittest::kittest::Queryable; + let mut h = options_bar_harness(crate::state::Tool::Brush); + let n = h.state().session.journal.len(); + h.run_steps(3); + assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing"); + // Drag the Opacity field left: many frames, one journal entry. + let label = h.get_by_label("Opacity").rect(); + let start = egui::pos2(label.right() + 40.0, label.center().y); + h.hover_at(start); + h.run(); + h.drag_at(start); + h.run(); + for k in 1..=8 { + h.hover_at(start - vec2(5.0 * k as f32, 0.0)); + h.run(); + } + h.drop_at(start - vec2(40.0, 0.0)); + h.run_steps(2); + let opacity = h.state().session.tools.brush.opacity; + assert!(opacity < 0.95, "the drag lowered the opacity: {opacity}"); + let j = &h.state().session.journal; + assert_eq!(j.len(), n + 1, "{:?}", &j[n..]); + let (id, p) = j.last().unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(p["brush"]["opacity"].as_f64().map(|v| v as f32), Some(opacity)); + // A click is its own gesture: the Pressure for Size switch sits left of its label. + let label = h.get_by_label("Pressure for Size").rect(); + let was = h.state().session.tools.brush.pressure_size; + let at = egui::pos2(label.left() - 18.0, label.center().y); + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + h.drop_at(at); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.pressure_size, !was); + assert_eq!(h.state().session.journal.len(), n + 2); + assert_eq!(h.state().session.journal.last().unwrap().1["brush"], json!({"pressureSize": !was})); + // Replaying the journal gives the same brush. + let mut s = photocraft_engine::Session::new(); + for (id, p) in &h.state().session.journal { + s.execute(id, p.clone()).unwrap(); + } + assert_eq!(s.tools.brush, h.state().session.tools.brush); +} + +#[test] +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() }); + } + 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, "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 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 }]); + assert_eq!(names(&app, "Beta"), ["B1", "A2"]); + apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), 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() })]); + 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() })]); + assert_eq!(names(&app, "Bees"), ["B1", "A2"]); + apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]); + assert_eq!(names(&app, "Alpha"), ["A1"]); + assert!(names(&app, "Bees").is_empty()); + // Every action went through a journaled command. + let ids: Vec<&str> = app.session.journal.iter().map(|(id, _)| id.as_str()).collect(); + for id in [ + "brush.presets.move", + "brush.presets.moveGroup", + "brush.presets.rename", + "brush.presets.renameGroup", + "brush.presets.delete", + "brush.presets.deleteGroup", + ] { + assert!(ids.contains(&id), "{id}: {ids:?}"); + } +} + fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { let mut app = app(); app.ui.panels.brush_settings = true; @@ -229,9 +373,13 @@ fn preview_strip_re_renders_only_when_the_brush_changes() { #[test] fn every_section_draws_without_issuing_commands() { - for tab in 0..2 { + for tab in 0..3 { for section in 0..SECTIONS.len() { - let mut h = harness(tab, section); + // Tab 2: the Brushes tab in grid view. + let mut h = harness(tab.min(1), section); + if tab == 2 { + h.state_mut().ui.brushes_panel.view = BrushesView::Grid; + } // A brush with every section on and a sampled tip. let mut b = BrushSettings::default(); for i in 0..SECTIONS.len() { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 28005ebc3c..8f90e32f26 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -328,12 +328,22 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { pct(ui, "Hardness", &mut b.hardness, 1.0); }); - signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0); + // Unchecked, the pointer's speed sets the spacing (Photoshop). + widgets::checkbox(ui, &mut b.spacing_enabled, "Spacing").on_hover_text("Off: the speed of the pointer sets the spacing"); + ui.add_space(2.0); + let on = b.spacing_enabled; + ui.add_enabled_ui(on, |ui| signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0)); } fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { let sd = &mut b.shape_dynamics; - dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false); + pct(ui, "Size Jitter", &mut sd.size.jitter, 1.0); + control_row(ui, "brush-size-ctl", &mut sd.size, false); + pct(ui, "Minimum Diameter", &mut sd.size.minimum, 1.0); + // Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop). + let tilt = sd.size.control == Control::PenTilt; + ui.add_enabled_ui(tilt, |ui| pct(ui, "Tilt Scale", &mut sd.tilt_scale, 2.0)); + ui.add_space(6.0); dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true); dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false); ui.horizontal(|ui| { @@ -341,6 +351,8 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { ui.add_space(8.0); widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter"); }); + ui.add_space(2.0); + widgets::checkbox(ui, &mut sd.brush_projection, "Brush Projection").on_hover_text("Apply the pen's tilt and rotation to the tip shape"); } fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) { @@ -519,6 +531,25 @@ fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) { let tr = &mut b.transfer; dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false); dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false); + dynamic(ui, "brush-wet-ctl", "Wetness Jitter", &mut tr.wetness, 1.0, Some("Minimum"), false); + dynamic(ui, "brush-mix-ctl", "Mix Jitter", &mut tr.mix, 1.0, Some("Minimum"), false); +} + +/// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options +/// bar; `paint.mixerBrush` uses them unless its params override them). +fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) { + let t = Tokens::get(ui.ctx()); + ui.add_space(4.0); + widgets::hairline(ui); + ui.add_space(4.0); + ui.label(RichText::new("Mixer Brush").font(theme::semibold(11.5)).color(t.text_dim)); + ui.add_space(2.0); + let m = &mut b.mixer; + pct(ui, "Wet", &mut m.wet, 1.0); + pct(ui, "Load", &mut m.load, 1.0); + pct(ui, "Mix", &mut m.mix, 1.0); + pct(ui, "Flow", &mut m.flow, 1.0); + widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers"); } /// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %. @@ -564,7 +595,10 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: 3 => texture(ui, b), 4 => dual_brush(ui, b, presets), 5 => color_dynamics(ui, b), - 6 => transfer(ui, b), + 6 => { + transfer(ui, b); + mixer_options(ui, b); + } 7 => pose(ui, b), 8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."), 9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."), diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs new file mode 100644 index 0000000000..471755b16b --- /dev/null +++ b/crates/ui-egui/src/brushes_tab.rs @@ -0,0 +1,415 @@ +//! Window › Brushes: the brush presets in collapsible groups, as a list (tip, size, stroke +//! 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. + +use egui::{Color32, RichText, Sense, Stroke, pos2, vec2}; +use photocraft_engine::paint::BrushPreset; +use serde_json::json; + +use crate::brush_panel::{BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status}; +use crate::brush_preview; +use crate::theme::{self, Tokens}; +use crate::{PhotocraftApp, icons, widgets}; + +/// What a drag in the Brushes tab carries. +#[derive(Clone, Debug, PartialEq)] +pub enum BrushDrag { + Preset(String), + Group(String), +} + +/// What the tab does after drawing (the presets are borrowed while it draws). +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + Select(String), + ToggleGroup(String), + Move { name: String, group: String, index: Option }, + MoveGroup { group: String, before: Option }, + Rename(Renaming), + Delete(String), + DeleteGroup(String), +} + +/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as +/// [`UNGROUPED`]. +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)> { + 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)?; + let mut idx = tp + usize::from(after); + if let Some(dp) = members.iter().position(|n| *n == dragged) + && dp < idx + { + idx -= 1; + } + Some((t.group.clone(), idx)) +} + +/// Turn the tab's actions into commands. +pub fn apply(app: &mut PhotocraftApp, acts: Vec) { + for a in acts { + match a { + Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })), + Action::ToggleGroup(g) => { + let c = &mut app.ui.brushes_panel.collapsed; + match c.iter().position(|x| *x == g) { + Some(i) => drop(c.remove(i)), + None => c.push(g), + } + } + Action::Move { name, group, index } => { + let mut p = json!({ "name": name, "group": group }); + 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::Rename(r) => { + let text = r.text.trim().to_string(); + if !text.is_empty() && text != r.name { + if r.group { + run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text })); + } else { + run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text })); + } + } + app.ui.brushes_panel.renaming = None; + } + 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 })), + } + } +} + +/// An accent insertion line on one edge of `r` while a drag hovers it. +fn drop_line(ui: &egui::Ui, r: egui::Rect, after: bool, vertical: bool, t: &Tokens) { + let s = Stroke::new(2.0, t.accent); + if vertical { + let x = if after { r.right() } else { r.left() }; + ui.painter().line_segment([pos2(x, r.top() + 2.0), pos2(x, r.bottom() - 2.0)], s); + } else { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left() + 4.0, y), pos2(r.right() - 4.0, y)], s); + } +} + +/// Is the pointer on the second half of `r` (lower in a list, right in a grid)? +fn second_half(ui: &egui::Ui, r: egui::Rect, grid: bool) -> bool { + ui.ctx().pointer_interact_pos().is_some_and(|p| if grid { p.x > r.center().x } else { p.y > r.center().y }) +} + +/// Drag, drop and context menu of one preset cell or row. +fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: &BrushPreset, presets: &[BrushPreset], grid: bool, acts: &mut Vec) { + let t = Tokens::get(ui.ctx()); + resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone())); + if let Some(d) = resp.dnd_hover_payload::() + && matches!(&*d, BrushDrag::Preset(n) if *n != p.name) + { + drop_line(ui, r, second_half(ui, r, grid), grid, &t); + } + 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)) + { + acts.push(Action::Move { name: n.clone(), group, index: Some(index) }); + } + if resp.clicked() { + acts.push(Action::Select(p.name.clone())); + } + resp.context_menu(|ui| { + if ui.button("Rename Brush…").clicked() { + acts.push(Action::Rename(Renaming { group: false, name: p.name.clone(), text: String::new() })); + ui.close(); + } + if ui.button("Delete Brush").clicked() { + acts.push(Action::Delete(p.name.clone())); + ui.close(); + } + }); +} + +fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], 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()); + if !ui.is_rect_visible(r) { + return; + } + if current { + ui.painter().rect_filled(r, t.radius_sm, t.accent_soft); + } else if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let pb = &p.brush; + let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); + let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0)); + let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size)); + ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE); + ui.painter().text( + pos2(cell.center().x, r.bottom() - 1.0), + egui::Align2::CENTER_BOTTOM, + format!("{}", pb.size.round() as i64), + egui::FontId::proportional(9.0), + t.text_faint, + ); + let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text); + let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0)); + ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE); + ui.painter().text(pos2(sr.right() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, &p.name, egui::FontId::proportional(12.0), t.text_dim); + preset_interactions(ui, &resp, r, p, presets, false, acts); +} + +fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(52.0, 58.0), Sense::click_and_drag()); + if !ui.is_rect_visible(r) { + return; + } + if current { + ui.painter().rect_filled(r, 3.0, t.accent_soft); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let pb = &p.brush; + let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); + let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 23.0), vec2(38.0, 38.0) * crate::brush_sections::thumb_scale(pb.size)); + ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE); + ui.painter().text( + pos2(r.center().x, r.bottom() - 7.0), + egui::Align2::CENTER_CENTER, + format!("{}", pb.size.round() as i64), + egui::FontId::proportional(9.5), + t.text_dim, + ); + let resp = resp.on_hover_text(&p.name); + 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) { + 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; + icons::paint( + ui, + egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), + if open { "chevron-down" } else { "chevron-right" }, + 12.0, + t.text_dim, + ); + icons::paint( + ui, + egui::Rect::from_min_size(pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)), + if open { "folder-open" } else { "folder" }, + 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); + 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.dnd_set_drag_payload(BrushDrag::Group(key.to_string())); + if let Some(d) = resp.dnd_hover_payload::() { + match &*d { + BrushDrag::Group(g) if g != key => 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); + } + _ => {} + } + } + 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 }), + _ => {} + } + } + if resp.clicked() { + acts.push(Action::ToggleGroup(label.to_string())); + } + resp.context_menu(|ui| { + if ui.button("Rename Group…").clicked() { + acts.push(Action::Rename(Renaming { group: true, name: key.to_string(), text: String::new() })); + ui.close(); + } + if ui.button("Delete Group").clicked() { + acts.push(Action::DeleteGroup(key.to_string())); + ui.close(); + } + }); +} + +/// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or +/// Cancel stops. +fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec) { + let Some(r) = app.ui.brushes_panel.renaming.as_mut() else { return }; + let t = Tokens::get(ui.ctx()); + if r.text.is_empty() { + r.text = r.name.clone(); + } + let mut cancel = false; + ui.horizontal(|ui| { + ui.label(RichText::new(if r.group { "Group name" } else { "Brush name" }).color(t.text_dim)); + let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(WIDTH - 230.0).id_salt("brush-rename")); + if !resp.has_focus() && !resp.lost_focus() { + resp.request_focus(); + } + let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if widgets::primary_button(ui, "OK", 52.0).clicked() || enter { + acts.push(Action::Rename(r.clone())); + } + if widgets::secondary_button(ui, "Cancel", 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } + }); + if cancel { + app.ui.brushes_panel.renaming = None; + } + ui.add_space(4.0); +} + +/// The Brushes tab. +pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // Size of the current brush (Photoshop's Brushes panel slider). + let before = app.session.tools.brush.clone(); + let mut b = before.clone(); + ui.horizontal(|ui| { + ui.label(RichText::new("Size").color(t.text_dim)); + let mut lv = b.size.max(1.0).ln(); + ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| { + let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None); + if r.changed() { + b.size = lv.exp().round().clamp(1.0, 5000.0); + } + r + }); + let mut s = b.size; + if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() { + b.size = s.round().clamp(1.0, 5000.0); + } + }); + commit_gesture(app, ui.ctx(), &before, &b); + ui.add_space(4.0); + ui.horizontal(|ui| { + icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); + ui.add_space(20.0); + ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 120.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let view = &mut app.ui.brushes_panel.view; + if icons::button(ui, "grid-2x2", 24.0, *view == BrushesView::Grid, "Grid view").clicked() { + *view = BrushesView::Grid; + } + if icons::button(ui, "align-justify", 24.0, *view == BrushesView::List, "List view").clicked() { + *view = BrushesView::List; + } + }); + }); + ui.add_space(6.0); + let mut acts = Vec::new(); + rename_bar(app, ui, &mut acts); + let filter = app.ui.brushes_panel.filter.trim().to_lowercase(); + let grid = app.ui.brushes_panel.view == BrushesView::Grid; + let presets = &app.session.tools.presets; + let groups = grouped_presets(presets); + let brush = &app.session.tools.brush; + let collapsed = &app.ui.brushes_panel.collapsed; + egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for (label, items) in groups { + let items: Vec = + items.into_iter().filter(|i| filter.is_empty() || presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))).collect(); + if items.is_empty() { + continue; + } + let key = group_key(presets, &label); + let open = !filter.is_empty() || !collapsed.contains(&label); + group_header(ui, &label, &key, open, items.len(), &mut acts); + if !open { + continue; + } + if grid { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + ui.add_space(20.0); + for i in items { + if let Some(p) = presets.get(i) { + grid_cell(ui, p, is_current(&p.brush, brush), presets, &mut acts); + } + } + }); + } else { + for i in items { + if let Some(p) = presets.get(i) { + list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts); + } + } + } + ui.add_space(2.0); + } + }); + // The dragged preset or group follows the pointer. + if let Some(d) = egui::DragAndDrop::payload::(ui.ctx()) + && let Some(p) = ui.ctx().pointer_interact_pos() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let name = match &*d { + BrushDrag::Preset(n) => n.clone(), + BrushDrag::Group(g) => { + if g.is_empty() { + UNGROUPED.to_string() + } else { + g.clone() + } + } + }; + egui::Area::new(egui::Id::new("brush-drag-label")).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name)); + }, + ); + } + apply(app, acts); + ui.add_space(4.0); + widgets::hairline(ui); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone()); + if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked() + && let Some(name) = current + { + run_or_status(app, "brush.presets.delete", json!({ "name": name })); + } + if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() { + let name = new_preset_name(&app.session.tools.presets); + run_or_status(app, "brush.presets.save", json!({ "name": name })); + } + if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() { + app.open_dialog_file(); + } + }); + }); + // Forget previews of presets that no longer exist. + let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect(); + brush_preview::with_cache(ui.ctx(), |c| { + c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n))); + }); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65da5e4aec..27b207ca37 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -173,6 +173,12 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest if let Some(i) = u("brushTab") { app.ui.brush_tab = (i as usize).min(1); } + if let Some(v) = p.get("brushesView").cloned() { + match serde_json::from_value(v) { + Ok(v) => app.ui.brushes_panel.view = v, + Err(e) => return err(format!("brushesView: {e} (list, grid)")), + } + } if let Some(size) = p.get("brushSize").and_then(Value::as_f64) { app.session.tools.brush.size = size as f32; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7cf35ff9b5..07ff671808 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod artboard_ui; pub mod brush_panel; pub mod brush_preview; pub mod brush_sections; +pub mod brushes_tab; pub mod camera_raw_ui; pub mod canvas; pub mod channel_view; diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 76ede892b5..0bc30073f4 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -110,7 +110,10 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) { // Keep the whole picker on screen (it is about 280 × 300 points). let pos = egui::pos2(x.min(screen.right() - 284.0).max(screen.left()), y.min(screen.bottom() - 310.0).max(screen.top())); let area = egui::Area::new(egui::Id::new("canvas-brush-picker")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut app.session.tools.brush)); + let before = app.session.tools.brush.clone(); + let mut b = before.clone(); + egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut b)); + crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b); }); let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p))); if outside { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1c10619137..e507ee5baa 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -360,8 +360,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; } let tool = app.ui.tool; + // Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1). if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection { - brush_preset_chip(ui, &mut app.session.tools.brush); + let before = app.session.tools.brush.clone(); + let mut b = before.clone(); + brush_preset_chip(ui, &mut b); + crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b); widgets::vline(ui, 22.0); } if crate::eraser_ui::options_bar(app, ui, tool) @@ -373,7 +377,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; } crate::paint_mouse::sync_tool_smoothing(app); - let b = &mut app.session.tools.brush; + let brush_before = app.session.tools.brush.clone(); + let mut brush = brush_before.clone(); + let b = &mut brush; match app.ui.tool { Tool::Brush | Tool::Eraser if t.pro => { brush_preset_chip(ui, b); @@ -637,6 +643,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. _ => {} } + crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush); }); }); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ee64943f9b..2d39724f11 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -263,12 +263,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if pressed(Key::D) { let _ = app.run("tools.defaultColors", json!({})); } - let b = &mut app.session.tools.brush; - if pressed(Key::OpenBracket) { - b.size = (b.size / 1.25).max(1.0).round(); - } - if pressed(Key::CloseBracket) { - b.size = (b.size * 1.25).min(2500.0).round().max(b.size + 1.0); + // [ and ] resize the brush through `tools.setBrush` (journaled, drivable). + let size = app.session.tools.brush.size; + let next = if pressed(Key::OpenBracket) { + (size / 1.25).max(1.0).round() + } else if pressed(Key::CloseBracket) { + (size * 1.25).min(2500.0).round().max(size + 1.0) + } else { + size + }; + if next != size { + let _ = app.run("tools.setBrush", serde_json::json!({ "brush": { "size": next } })); } }