Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 de740b93ae ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush
- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
  Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
  and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
  drag group headers to reorder, context menus to rename or delete presets and groups; all via
  brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
  edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:11 -04:00
Brandon ThomasandClaude Opus 5.5 2e295da20f engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields
- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:11 -04:00
Brandon ThomasandClaude Opus 5.5 ef28c01846 paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush
Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:11 -04:00
26 changed files with 1833 additions and 277 deletions

No files matched your search

+91 -4
View File
@@ -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
+90
View File
@@ -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);
}
+336
View File
@@ -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();
}
}
+1
View File
@@ -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());
+6 -1
View File
@@ -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, &params) {
self.journal.push((id.to_string(), params));
}
Ok(r)
+31 -6
View File
@@ -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();
+35
View File
@@ -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
View File
@@ -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());
}
+45
View File
@@ -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());
+80 -2
View File
@@ -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 = &current.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
}
+175
View File
@@ -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());
}
+84 -7
View File
@@ -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
View File
@@ -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,
}
}
}
+35 -3
View File
@@ -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));
}
}
+1 -1
View File
@@ -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 {
+13 -2
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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);
}
+151 -3
View File
@@ -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() {
+37 -3
View File
@@ -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."),
+415
View File
@@ -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)));
});
}
+6
View File
@@ -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;
}
+1
View File
@@ -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;
+4 -1
View File
@@ -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 {
+9 -2
View File
@@ -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);
});
});
}
+11 -6
View File
@@ -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 } }));
}
}