mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 22:54:11 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de740b93ae | ||
|
|
2e295da20f | ||
|
|
ef28c01846 |
No files matched your search
@@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String>) -> 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<String> {
|
||||
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<Option<String>> {
|
||||
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<usize> {
|
||||
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
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<String> {
|
||||
let mut out: Vec<String> = 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<Value> {
|
||||
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<Value> {
|
||||
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<usize> = 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<Value> {
|
||||
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<BrushPreset>, Vec<BrushPreset>) = 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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -940,6 +940,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - `tips/<hash>.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<String>,
|
||||
/// Built-in presets the user deleted (by name).
|
||||
hidden_builtins: Vec<String>,
|
||||
/// Every preset's name in library (panel) order; empty when it is the default order.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<BrushPreset>,
|
||||
/// Built-in presets the user deleted.
|
||||
pub hidden_builtins: Vec<String>,
|
||||
/// The saved library order (preset names), empty when none was saved.
|
||||
pub order: Vec<String>,
|
||||
/// Files that were skipped (corrupt, oversized, missing tips).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -515,8 +520,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> 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<String> = 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<String> = 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<String> = presets.iter().map(|p| p.name.clone()).collect();
|
||||
let default_order: Vec<String> = 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<String> {
|
||||
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<String, usize> = 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();
|
||||
|
||||
@@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() {
|
||||
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "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::<Vec<_>>();
|
||||
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");
|
||||
|
||||
+33
-15
@@ -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<BrushSettings> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StrokePoint> = (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<StrokePoint> = (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());
|
||||
}
|
||||
@@ -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<f64>,
|
||||
time_acc: f64,
|
||||
/// Speed spacing (Spacing unchecked): one step per this many ms of stroke time.
|
||||
speed: Option<f64>,
|
||||
speed_acc: f64,
|
||||
}
|
||||
|
||||
impl PathWalker {
|
||||
pub fn new(build_up_interval_ms: Option<f64>) -> 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)),
|
||||
|
||||
+14
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+127
-219
@@ -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<String>,
|
||||
/// Case-insensitive name filter.
|
||||
pub filter: String,
|
||||
pub view: BrushesView,
|
||||
pub renaming: Option<Renaming>,
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<String>) -> 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<usize
|
||||
out
|
||||
}
|
||||
|
||||
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
|
||||
/// the full comparison clones the preset and its tip.
|
||||
/// Does the current brush match `preset` (everything but colour, size and the tool state that
|
||||
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
|
||||
/// comparison clones the preset and its tip.
|
||||
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> 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<usize> = 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);
|
||||
}
|
||||
|
||||
@@ -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::<BrushSettings>(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::<Vec<_>>();
|
||||
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() {
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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<usize> },
|
||||
MoveGroup { group: String, before: Option<String> },
|
||||
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<Action>) {
|
||||
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<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone()));
|
||||
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>()
|
||||
&& 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::<BrushDrag>()
|
||||
&& 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<Action>) {
|
||||
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<Action>) {
|
||||
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<Action>) {
|
||||
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::<BrushDrag>() {
|
||||
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::<BrushDrag>() {
|
||||
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<Action>) {
|
||||
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<usize> =
|
||||
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::<BrushDrag>(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)));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user