mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 04:36:49 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ac5f672e | ||
|
|
cc418e0b08 | ||
|
|
b9809ca63c | ||
|
|
d94a8f617a | ||
|
|
043e050237 | ||
|
|
d8cf255c47 | ||
|
|
107302f05c | ||
|
|
1c4cb8307c | ||
|
|
433b84fae7 | ||
|
|
3389ba558b |
No files matched your search
@@ -11,7 +11,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
|
||||
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
|
||||
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
|
||||
| `docs/roadmap.md` | Milestones and the **current focus** |
|
||||
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
|
||||
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
|
||||
@@ -67,6 +67,10 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
|
||||
|
||||
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
|
||||
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
|
||||
round trips, workflow tests, performance), update that section with the dated figure.
|
||||
1. `docs/roadmap.md` → **Current focus**.
|
||||
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
3. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
|
||||
@@ -267,7 +267,7 @@ flatpak run ai.storyteller.photocraft
|
||||
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "paint.mixerBrush";
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let brush = resolve_brush(s, p, cmd)?;
|
||||
let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) };
|
||||
let sample_all = flag(p, "sampleAllLayers", false);
|
||||
// The brush's Mixer Brush options, overridden by the scalar params.
|
||||
let bm = &brush.mixer;
|
||||
let m = MixerSettings {
|
||||
wet: pct(p, "wet", bm.wet * 100.0),
|
||||
load: pct(p, "load", bm.load * 100.0),
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
@@ -543,10 +551,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
b = merge_brush(&b, &patch, cmd)?;
|
||||
s.tools.brush = b;
|
||||
let before = std::mem::replace(&mut s.tools.brush, b);
|
||||
// A coalesced gesture (one slider drag) journals as one call: remember the brush it started from.
|
||||
let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none());
|
||||
let continuing = |s: &Session, k: &str| {
|
||||
s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k)
|
||||
&& s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k))
|
||||
};
|
||||
match key {
|
||||
Some(k) if continuing(s, k) => {}
|
||||
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
|
||||
None => s.tools.brush_gesture = None,
|
||||
}
|
||||
Ok(brush_json(&s.tools.brush))
|
||||
}
|
||||
|
||||
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
|
||||
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
|
||||
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
|
||||
let mut c = b.clone();
|
||||
if skip.0 {
|
||||
c.tip = TipShape::Round;
|
||||
}
|
||||
if skip.1 {
|
||||
c.dual_brush.tip = TipShape::Round;
|
||||
}
|
||||
if skip.2 {
|
||||
c.texture.pattern = photocraft_paint::Pattern::default();
|
||||
}
|
||||
serde_json::to_value(&c).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal
|
||||
/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed).
|
||||
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
|
||||
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
|
||||
fn diff(a: &Value, b: &Value) -> Option<Value> {
|
||||
match (a, b) {
|
||||
(Value::Object(ao), Value::Object(bo)) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, bv) in bo {
|
||||
match ao.get(k) {
|
||||
Some(av) => {
|
||||
if let Some(d) = diff(av, bv) {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), bv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
// Enums with payloads (tips, patterns) are replaced whole.
|
||||
_ => (a != b).then(|| b.clone()),
|
||||
}
|
||||
}
|
||||
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same
|
||||
/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal
|
||||
/// entry, the patch from the brush before the gesture to the brush now. Returns true when the
|
||||
/// call was folded into the previous entry.
|
||||
pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool {
|
||||
if id != "tools.setBrush" {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false };
|
||||
let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false };
|
||||
if gk != key {
|
||||
return false;
|
||||
}
|
||||
let patch = brush_patch(start, &s.tools.brush);
|
||||
match s.journal.last_mut() {
|
||||
Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => {
|
||||
*lp = json!({ "brush": patch, "coalesce": key });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
|
||||
@@ -567,7 +654,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
|
||||
@@ -311,3 +311,93 @@ fn live_stroke_matches_the_committed_stroke() {
|
||||
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
|
||||
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_brush_controls_round_trip_through_set_brush() {
|
||||
let mut s = session(40, 40);
|
||||
let patch = json!({
|
||||
"spacingEnabled": false,
|
||||
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
|
||||
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
|
||||
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
|
||||
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
|
||||
});
|
||||
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
|
||||
let b = &s.tools.brush;
|
||||
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
|
||||
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
|
||||
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
|
||||
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
|
||||
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
|
||||
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
|
||||
let got = s.execute("brush.get", json!({})).unwrap();
|
||||
for (path, want) in [
|
||||
("/spacingEnabled", json!(false)),
|
||||
("/shapeDynamics/tiltScale", json!(1.5)),
|
||||
("/shapeDynamics/brushProjection", json!(true)),
|
||||
("/transfer/wetness/control", json!("penPressure")),
|
||||
("/locks/texture", json!(true)),
|
||||
("/mixer/sampleAllLayers", json!(true)),
|
||||
("/mixer/load", json!(0.25)),
|
||||
] {
|
||||
assert_eq!(got.pointer(path), Some(&want), "{path}");
|
||||
}
|
||||
let mut t = session(40, 40);
|
||||
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
// Locked sections survive picking a preset through the command.
|
||||
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
|
||||
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
|
||||
// Wrong types are errors and leave the brush alone.
|
||||
let before = s.tools.brush.clone();
|
||||
for bad in
|
||||
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
|
||||
{
|
||||
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
|
||||
}
|
||||
assert_eq!(s.tools.brush, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
|
||||
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
|
||||
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
|
||||
// A param still overrides the brush.
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
let n = s.journal.len();
|
||||
for k in 1..=10 {
|
||||
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
|
||||
s.execute("brush.get", json!({})).unwrap();
|
||||
}
|
||||
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
|
||||
let (id, p) = s.journal.last().unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
|
||||
// A new gesture is a new entry; so is an uncoalesced call.
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 3);
|
||||
// Another command in between ends the gesture.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 6);
|
||||
// Replay gives the same brush.
|
||||
let mut t = session(20, 20);
|
||||
for (id, p) in s.journal.iter().skip(1) {
|
||||
t.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a
|
||||
//! preset within or between groups, move, rename and delete whole groups.
|
||||
//!
|
||||
//! The library order is the panel order: groups appear in order of their first preset. Every
|
||||
//! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order
|
||||
//! and the order of every preset, built-ins included. A built-in that is renamed or moved to
|
||||
//! another group becomes the user's preset (built-ins are regenerated with their own group).
|
||||
|
||||
use photocraft_paint::BrushPreset;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Longest preset or group name accepted.
|
||||
pub const MAX_NAME: usize = 255;
|
||||
|
||||
fn text(p: &Value, k: &str, cmd: &str) -> Result<String> {
|
||||
let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
|
||||
if v.chars().count() > MAX_NAME {
|
||||
return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters")));
|
||||
}
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// A group name param: may be empty (the ungrouped presets), never absurdly long.
|
||||
fn group_param(p: &Value, k: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_of(presets: &[BrushPreset], name: &str) -> Option<usize> {
|
||||
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let name = text(p, k, cmd)?;
|
||||
index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))
|
||||
}
|
||||
|
||||
/// Groups in panel order (first appearance).
|
||||
pub fn group_order(presets: &[BrushPreset]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for p in presets {
|
||||
if !out.contains(&p.group) {
|
||||
out.push(p.group.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn position_in_group(presets: &[BrushPreset], i: usize) -> usize {
|
||||
presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count())
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.rename";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
|
||||
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
|
||||
}
|
||||
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
|
||||
if pr.name != new {
|
||||
pr.name = new.clone();
|
||||
pr.builtin = false;
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "name": new }))
|
||||
}
|
||||
|
||||
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.move";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let before = match p.get("before") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let b = find(s, p, "before", cmd)?;
|
||||
if b == i {
|
||||
return Err(bad(cmd, "`before` names the preset being moved"));
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
};
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = &mut s.tools.presets;
|
||||
let target = match (group_param(p, "group", cmd)?, before) {
|
||||
(Some(g), _) => g,
|
||||
(None, Some(b)) => lib[b].group.clone(),
|
||||
(None, None) => lib[i].group.clone(),
|
||||
};
|
||||
if let Some(b) = before
|
||||
&& lib[b].group != target
|
||||
{
|
||||
return Err(bad(cmd, format!("`before` is not in group `{target}`")));
|
||||
}
|
||||
let before_name = before.map(|b| lib[b].name.clone());
|
||||
let mut pr = lib.remove(i);
|
||||
if pr.group != target {
|
||||
pr.group = target.clone();
|
||||
// A built-in moved elsewhere is the user's now (built-ins regenerate in their own group).
|
||||
pr.builtin = false;
|
||||
}
|
||||
let members: Vec<usize> = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect();
|
||||
let pos = match (before_name, index) {
|
||||
(Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()),
|
||||
(None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) {
|
||||
Some(&k) => k,
|
||||
None => members.last().map_or(lib.len(), |k| k + 1),
|
||||
},
|
||||
(None, None) => members.last().map_or(lib.len(), |k| k + 1),
|
||||
};
|
||||
let pos = pos.min(lib.len());
|
||||
let name = pr.name.clone();
|
||||
lib.insert(pos, pr);
|
||||
let at = position_in_group(lib, pos);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "name": name, "group": target, "index": at }))
|
||||
}
|
||||
|
||||
fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> {
|
||||
if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) }
|
||||
}
|
||||
|
||||
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.moveGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = group_param(p, "before", cmd)?;
|
||||
if let Some(b) = &before {
|
||||
group_block(s, b, cmd)?;
|
||||
if *b == g {
|
||||
return Err(bad(cmd, "`before` names the group being moved"));
|
||||
}
|
||||
}
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = std::mem::take(&mut s.tools.presets);
|
||||
let (block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| x.group == g);
|
||||
let order = group_order(&rest);
|
||||
let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len());
|
||||
let pos = match (&before, index) {
|
||||
(Some(b), _) => first(b, &rest),
|
||||
(None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)),
|
||||
(None, None) => rest.len(),
|
||||
};
|
||||
let tail = rest.split_off(pos.min(rest.len()));
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
s.tools.presets = rest;
|
||||
s.brush_presets_changed();
|
||||
let at = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0);
|
||||
Ok(json!({ "group": g, "index": at }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.renameGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if new != g && s.tools.presets.iter().any(|x| x.group == new) {
|
||||
return Err(bad(cmd, format!("a group named `{new}` already exists")));
|
||||
}
|
||||
if new != g {
|
||||
for x in s.tools.presets.iter_mut().filter(|x| x.group == g) {
|
||||
x.group = new.clone();
|
||||
x.builtin = false;
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "group": new }))
|
||||
}
|
||||
|
||||
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.deleteGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = s.tools.presets.len();
|
||||
s.tools.presets.retain(|x| x.group != g);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
/// Brush preset organisation specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename),
|
||||
spec!(
|
||||
"brush.presets.move",
|
||||
"Move Brush",
|
||||
r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##,
|
||||
move_preset
|
||||
),
|
||||
spec!(
|
||||
"brush.presets.moveGroup",
|
||||
"Move Brush Group",
|
||||
r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##,
|
||||
move_group
|
||||
),
|
||||
spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group),
|
||||
spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names(s: &Session, g: &str) -> Vec<String> {
|
||||
s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn with_groups() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_within_and_between_groups() {
|
||||
let mut s = with_groups();
|
||||
let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap();
|
||||
assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0}));
|
||||
assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]);
|
||||
// Into another group, before a member (the group comes from `before`).
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap();
|
||||
assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()]));
|
||||
// Into a group by name (appended), and into a new group.
|
||||
s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap();
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma"));
|
||||
assert!(names(&s, "Alpha").is_empty());
|
||||
// A built-in moved to another group becomes the user's.
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap();
|
||||
let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap();
|
||||
assert!(!moved.builtin && moved.group == "Beta");
|
||||
assert_eq!(names(&s, "Beta")[0], b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_move_rename_and_delete() {
|
||||
let mut s = with_groups();
|
||||
let order = group_order(&s.tools.presets);
|
||||
let n = order.len();
|
||||
assert_eq!(&order[n - 2..], ["Alpha", "Beta"]);
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(group_order(&s.tools.presets)[0], "Beta");
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order");
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap();
|
||||
assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]);
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha"));
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap();
|
||||
assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]);
|
||||
assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err());
|
||||
let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert!(names(&s, "First").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_checks_names() {
|
||||
let mut s = with_groups();
|
||||
s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap();
|
||||
assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some());
|
||||
assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err());
|
||||
// Case-only renames of itself are fine.
|
||||
s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap();
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap();
|
||||
assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_without_changes() {
|
||||
let mut s = with_groups();
|
||||
let before = s.tools.presets.clone();
|
||||
let long = "x".repeat(MAX_NAME + 1);
|
||||
for (id, p) in [
|
||||
("brush.presets.move", json!({})),
|
||||
("brush.presets.move", json!({"name": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "A1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": -1})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": "first"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": 7})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": long})),
|
||||
("brush.presets.rename", json!({"name": "A1"})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": " "})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": long})),
|
||||
("brush.presets.moveGroup", json!({"group": "Nope"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})),
|
||||
("brush.presets.renameGroup", json!({"group": "Alpha"})),
|
||||
("brush.presets.deleteGroup", json!({"group": null})),
|
||||
("brush.presets.deleteGroup", json!({"group": "Nope"})),
|
||||
] {
|
||||
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets, before);
|
||||
// Huge indices clamp to the end.
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -940,6 +940,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::edit_cmds::specs());
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
v.extend(crate::brush_preset_cmds::specs());
|
||||
v.extend(crate::eraser_cmds::specs());
|
||||
v.extend(crate::preset_import_cmds::specs());
|
||||
v.extend(crate::retouch_cmds::specs());
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod analysis_cmds;
|
||||
pub mod artboard_cmds;
|
||||
pub mod automate_cmds;
|
||||
pub mod brush_cmds;
|
||||
pub mod brush_preset_cmds;
|
||||
pub mod build_info;
|
||||
mod canvas_geom;
|
||||
pub mod channel_cmds;
|
||||
@@ -198,6 +199,9 @@ pub struct ToolState {
|
||||
pub presets_rev: u64,
|
||||
/// Mixer Brush paint carried between strokes.
|
||||
pub mixer: photocraft_paint::mixer::MixerState,
|
||||
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
|
||||
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
|
||||
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -213,6 +217,7 @@ impl Default for ToolState {
|
||||
presets: photocraft_paint::presets::builtin(),
|
||||
presets_rev: 0,
|
||||
mixer: Default::default(),
|
||||
brush_gesture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,7 +341,7 @@ impl Session {
|
||||
edit_menu_cmds::after_command(self, id);
|
||||
automate_cmds::after_command(self, id);
|
||||
self.sync_preset_store();
|
||||
if spec.journal {
|
||||
if spec.journal && !brush_cmds::coalesce_journal(self, id, ¶ms) {
|
||||
self.journal.push((id.to_string(), params));
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - `tips/<hash>.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when
|
||||
//! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only
|
||||
//! rewrites a small JSON file; a tip no group references any more is deleted;
|
||||
//! - `index.json`: group order and the built-in presets the user deleted.
|
||||
//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop
|
||||
//! in the Brushes panel persists) and the built-in presets the user deleted.
|
||||
//!
|
||||
//! Built-in presets are never written (they are regenerated on start). The store syncs after
|
||||
//! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups
|
||||
@@ -324,6 +325,8 @@ struct IndexFile {
|
||||
groups: Vec<String>,
|
||||
/// Built-in presets the user deleted (by name).
|
||||
hidden_builtins: Vec<String>,
|
||||
/// Every preset's name in library (panel) order; empty when it is the default order.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so
|
||||
@@ -403,6 +406,8 @@ pub struct Opened {
|
||||
pub presets: Vec<BrushPreset>,
|
||||
/// Built-in presets the user deleted.
|
||||
pub hidden_builtins: Vec<String>,
|
||||
/// The saved library order (preset names), empty when none was saved.
|
||||
pub order: Vec<String>,
|
||||
/// Files that were skipped (corrupt, oversized, missing tips).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -515,8 +520,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
}
|
||||
}
|
||||
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
|
||||
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
|
||||
store.index = index;
|
||||
Opened { store, presets, hidden_builtins, warnings }
|
||||
Opened { store, presets, hidden_builtins, order, warnings }
|
||||
}
|
||||
|
||||
/// Read and decode tips, in parallel on native targets.
|
||||
@@ -615,10 +621,24 @@ impl PresetStore {
|
||||
// Index: group order and deleted built-ins.
|
||||
let hidden: Vec<String> = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect();
|
||||
let groups: Vec<String> = files.into_iter().filter(|f| self.groups.contains_key(f)).collect();
|
||||
let want = IndexFile { version: 1, groups, hidden_builtins: hidden };
|
||||
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins);
|
||||
// The full order is only saved once it differs from the default (built-ins, then the rest).
|
||||
let order: Vec<String> = presets.iter().map(|p| p.name.clone()).collect();
|
||||
let default_order: Vec<String> = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect();
|
||||
let builtins_in_order = {
|
||||
let names = builtin_names();
|
||||
let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name));
|
||||
let first = it.next().flatten();
|
||||
it.try_fold(first, |prev, cur| match (prev, cur) {
|
||||
(Some(a), Some(b)) if b > a => Some(Some(b)),
|
||||
_ => None,
|
||||
})
|
||||
.is_some()
|
||||
};
|
||||
let order = if builtins_in_order && order == default_order { Vec::new() } else { order };
|
||||
let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order };
|
||||
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order);
|
||||
if !same {
|
||||
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty();
|
||||
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty();
|
||||
let r = if empty && self.index.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -693,7 +713,7 @@ impl Session {
|
||||
/// built-in of the same name; presets created before the store finished loading win), hide
|
||||
/// deleted built-ins, then sync. Returns the load warnings.
|
||||
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
|
||||
let Opened { store, presets, hidden_builtins, mut warnings } = opened;
|
||||
let Opened { store, presets, hidden_builtins, order, mut warnings } = opened;
|
||||
let lib = &mut self.tools.presets;
|
||||
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
|
||||
for p in presets {
|
||||
@@ -703,6 +723,11 @@ impl Session {
|
||||
None => lib.push(p),
|
||||
}
|
||||
}
|
||||
// The saved panel order (stable: presets it doesn't name keep their order, at the end).
|
||||
if !order.is_empty() {
|
||||
let pos: HashMap<String, usize> = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect();
|
||||
lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
self.preset_store = Some(store);
|
||||
self.brush_presets_changed();
|
||||
self.sync_preset_store();
|
||||
|
||||
@@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() {
|
||||
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "group order survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_presets_and_groups_persists() {
|
||||
let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::<Vec<_>>();
|
||||
let dir = TempDir::new("reorder");
|
||||
let (mut s, _) = session(&dir);
|
||||
for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] {
|
||||
s.execute("brush.presets.save", json!({"name": n})).unwrap();
|
||||
s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
// Untouched order: no order list is written (the default order is implied).
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
// Reorder a built-in within its group, a user preset across groups, and whole groups.
|
||||
let builtin_group = s.tools.presets[0].group.clone();
|
||||
let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone();
|
||||
let first = s.tools.presets[0].name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap();
|
||||
s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap();
|
||||
assert_eq!(s.tools.presets[0].name, "Ink 2");
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(names(&t), names(&s), "the panel order survives a restart");
|
||||
assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in");
|
||||
// Renaming and deleting a group persist too.
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap();
|
||||
s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap();
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_and_oversized_files_are_skipped_with_a_warning() {
|
||||
let dir = TempDir::new("corrupt");
|
||||
|
||||
+33
-15
@@ -10,7 +10,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use photocraft_paint::{
|
||||
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
|
||||
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
|
||||
};
|
||||
use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip};
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
@@ -281,6 +281,25 @@ impl Mapper<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixer Brush tool options captured with a preset (Wet, Load, Mix, Flow, Sample All Layers).
|
||||
/// Key names are best effort (Photoshop doesn't document them); several spellings are read.
|
||||
fn mixer_options(&mut self, to: &Descriptor, m: &mut MixerSettings) {
|
||||
let first = |keys: &[&str]| keys.iter().find_map(|k| pct(to, k));
|
||||
let wet = first(&["wetness", "Wtns", "wet"]);
|
||||
let load = first(&["dryness", "load", "Ld "]);
|
||||
let mix = first(&["mix", "mixRatio", "Mx "]);
|
||||
if wet.is_none() && load.is_none() && mix.is_none() {
|
||||
return;
|
||||
}
|
||||
m.wet = wet.unwrap_or(m.wet).clamp(0.0, 1.0);
|
||||
m.load = load.unwrap_or(m.load).clamp(0.0, 1.0);
|
||||
m.mix = mix.unwrap_or(m.mix).clamp(0.0, 1.0);
|
||||
m.flow = pct(to, "flow").unwrap_or(m.flow).clamp(0.0, 1.0);
|
||||
if let Some(v) = ["sampleMerged", "useAllLayers", "sampleAllLayers", "Mrgd"].iter().find_map(|k| boolean(to, k)) {
|
||||
m.sample_all_layers = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(&mut self, d: &Descriptor) -> Option<BrushSettings> {
|
||||
for (k, _) in &d.items {
|
||||
let k = String::from_utf8_lossy(k.as_bytes()).to_string();
|
||||
@@ -298,8 +317,9 @@ impl Mapper<'_> {
|
||||
}
|
||||
b.angle = num(t, "Angl").unwrap_or(0.0) as f32;
|
||||
b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
|
||||
// `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight.
|
||||
b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) };
|
||||
// `Intr` off = the Spacing checkbox is off: the pointer's speed sets the spacing.
|
||||
b.spacing = pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0);
|
||||
b.spacing_enabled = boolean(t, "Intr") != Some(false);
|
||||
b.flip_x = boolean(t, "flipX").unwrap_or(false);
|
||||
b.flip_y = boolean(t, "flipY").unwrap_or(false);
|
||||
}
|
||||
@@ -316,13 +336,9 @@ impl Mapper<'_> {
|
||||
roundness,
|
||||
flip_x_jitter: boolean(d, "flipX").unwrap_or(false),
|
||||
flip_y_jitter: boolean(d, "flipY").unwrap_or(false),
|
||||
tilt_scale: pct(d, "tiltScale").unwrap_or(0.0).clamp(0.0, 2.0),
|
||||
brush_projection: boolean(d, "brushProjection").unwrap_or(false),
|
||||
};
|
||||
if pct(d, "tiltScale").is_some_and(|v| v > 0.0) {
|
||||
self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into());
|
||||
}
|
||||
if boolean(d, "brushProjection") == Some(true) {
|
||||
self.warnings.insert("Brush Projection is not supported".into());
|
||||
}
|
||||
}
|
||||
// Scattering.
|
||||
if boolean(d, "useScatter") == Some(true) {
|
||||
@@ -393,12 +409,13 @@ impl Mapper<'_> {
|
||||
}
|
||||
// Transfer.
|
||||
if boolean(d, "usePaintDynamics") == Some(true) {
|
||||
b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) };
|
||||
for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] {
|
||||
if self.dynamic(obj(d, k)).is_active() {
|
||||
self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported"));
|
||||
}
|
||||
}
|
||||
b.transfer = Transfer {
|
||||
enabled: true,
|
||||
opacity: self.dynamic(obj(d, "opVr")),
|
||||
flow: self.dynamic(obj(d, "prVr")),
|
||||
wetness: self.dynamic(obj(d, "wtVr")),
|
||||
mix: self.dynamic(obj(d, "mxVr")),
|
||||
};
|
||||
}
|
||||
// Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°).
|
||||
if boolean(d, "useBrushPose") == Some(true) {
|
||||
@@ -443,6 +460,7 @@ impl Mapper<'_> {
|
||||
if let Some(v) = boolean(to, "smoothingZoomCompensation") {
|
||||
b.smoothing.adjust_for_zoom = v;
|
||||
}
|
||||
self.mixer_options(to, &mut b.mixer);
|
||||
if mask_blend_is_set(to) {
|
||||
self.warnings.insert("tool blend modes stored with presets are not applied".into());
|
||||
}
|
||||
|
||||
@@ -226,6 +226,51 @@ fn big_tips_are_downsampled() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("downsampled")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_scale_projection_spacing_and_mixer_fields_map() {
|
||||
let tip = Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 30.0)).with("Spcn", prc(35.0)).with("Intr", Value::Boolean(false));
|
||||
let preset = Descriptor::new("brushPreset")
|
||||
.with("Nm ", t("Wet Pen"))
|
||||
.with("Brsh", Value::Descriptor(tip))
|
||||
.with("useTipDynamics", Value::Boolean(true))
|
||||
.with("szVr", var(3, 0.0, 0.0))
|
||||
.with("tiltScale", prc(150.0))
|
||||
.with("brushProjection", Value::Boolean(true))
|
||||
.with("usePaintDynamics", Value::Boolean(true))
|
||||
.with("wtVr", var(2, 30.0, 10.0))
|
||||
.with("mxVr", var(1, 45.0, 0.0))
|
||||
.with(
|
||||
"toolOptions",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("currentToolOptions")
|
||||
.with("wetness", prc(80.0))
|
||||
.with("dryness", prc(20.0))
|
||||
.with("mix", prc(65.0))
|
||||
.with("flow", prc(70.0))
|
||||
.with("sampleMerged", Value::Boolean(true)),
|
||||
),
|
||||
);
|
||||
let plain =
|
||||
Descriptor::new("brushPreset").with("Nm ", t("Plain")).with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 9.0))));
|
||||
let bytes = write_v6(2, &[], &[], &[preset, plain], true).unwrap();
|
||||
let imp = read_abr(&bytes, "Mixer").unwrap();
|
||||
let b = &imp.presets[0].brush;
|
||||
assert!(!b.spacing_enabled && (b.spacing - 0.35).abs() < 1e-6, "Intr off = Spacing unchecked, the value kept");
|
||||
let sd = &b.shape_dynamics;
|
||||
assert!(sd.brush_projection && (sd.tilt_scale - 1.5).abs() < 1e-6 && sd.size.control == Control::PenTilt);
|
||||
let tr = &b.transfer;
|
||||
assert_eq!((tr.wetness.control, tr.wetness.jitter, tr.wetness.minimum), (Control::PenPressure, 0.3, 0.1));
|
||||
assert_eq!((tr.mix.control, tr.mix.jitter), (Control::Fade, 0.45));
|
||||
let m = &b.mixer;
|
||||
assert_eq!((m.wet, m.load, m.mix, m.flow, m.sample_all_layers), (0.8, 0.2, 0.65, 0.7, true));
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("Tilt Scale") || w.contains("Projection") || w.contains("Mixer")), "{:?}", imp.warnings);
|
||||
// Defaults when the file says nothing.
|
||||
let p = &imp.presets[1].brush;
|
||||
assert!(p.spacing_enabled && !p.shape_dynamics.brush_projection && p.shape_dynamics.tilt_scale == 0.0);
|
||||
assert_eq!(p.mixer, photocraft_paint::MixerSettings::default());
|
||||
assert_eq!((p.transfer.wetness, p.transfer.mix), (Dynamic::default(), Dynamic::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
assert!(read_abr(b"", "g").is_err());
|
||||
|
||||
@@ -99,6 +99,11 @@ impl History {
|
||||
self.undo.len()
|
||||
}
|
||||
|
||||
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
|
||||
pub fn set_current_label(&mut self, label: impl Into<String>) {
|
||||
self.current_label = label.into();
|
||||
}
|
||||
|
||||
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
|
||||
/// last step can no longer be undone and its pixels are released.
|
||||
pub fn purge_last(&mut self) -> bool {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
use photocraft_color::BlendMode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mixer::MixerSettings;
|
||||
use crate::tile::GrayTile;
|
||||
|
||||
/// What drives a dynamic parameter (Photoshop's "Control" pop-ups).
|
||||
@@ -80,12 +81,19 @@ pub struct ShapeDynamics {
|
||||
pub enabled: bool,
|
||||
/// Size jitter / control; `minimum` is Photoshop's Minimum Diameter.
|
||||
pub size: Dynamic,
|
||||
/// Tilt Scale, 0..2 (0–200 %): with the size control on Pen Tilt, how much a tilted pen
|
||||
/// squashes the tip's height (before the tip angle is applied). At full tilt the height is
|
||||
/// scaled by `1 − tiltScale / 2`, so 0 leaves the tip alone and 200 % flattens it.
|
||||
pub tilt_scale: f32,
|
||||
/// Angle jitter (fraction of ±180°) / control.
|
||||
pub angle: Dynamic,
|
||||
/// Roundness jitter / control; `minimum` is Minimum Roundness.
|
||||
pub roundness: Dynamic,
|
||||
pub flip_x_jitter: bool,
|
||||
pub flip_y_jitter: bool,
|
||||
/// Brush Projection: the pen's tilt and barrel rotation are applied to the tip as a
|
||||
/// projection (the tip is foreshortened along the tilt direction and turns with the pen).
|
||||
pub brush_projection: bool,
|
||||
}
|
||||
|
||||
/// Scattering.
|
||||
@@ -254,13 +262,36 @@ impl Default for ColorDynamics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer: per-dab opacity ceiling and flow.
|
||||
/// Transfer: per-dab opacity ceiling and flow, plus the Mixer Brush's wetness and mix.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Transfer {
|
||||
pub enabled: bool,
|
||||
pub opacity: Dynamic,
|
||||
pub flow: Dynamic,
|
||||
/// Wetness Jitter / control (Mixer Brush only): scales the Wet setting per dab.
|
||||
pub wetness: Dynamic,
|
||||
/// Mix Jitter / control (Mixer Brush only): scales the Mix setting per dab.
|
||||
pub mix: Dynamic,
|
||||
}
|
||||
|
||||
/// The sections a lock can hold (the lock icons in Photoshop's Brush Settings list): a locked
|
||||
/// section keeps its settings when another brush preset is picked.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SectionLocks {
|
||||
pub shape_dynamics: bool,
|
||||
pub scattering: bool,
|
||||
pub texture: bool,
|
||||
pub dual_brush: bool,
|
||||
pub color_dynamics: bool,
|
||||
pub transfer: bool,
|
||||
pub pose: bool,
|
||||
pub noise: bool,
|
||||
pub wet_edges: bool,
|
||||
pub build_up: bool,
|
||||
pub smoothing: bool,
|
||||
pub protect_texture: bool,
|
||||
}
|
||||
|
||||
/// Brush Pose: stylus values used instead of (override) or for missing device data.
|
||||
@@ -327,6 +358,9 @@ pub struct BrushSettings {
|
||||
pub hardness: f32,
|
||||
/// Spacing between dabs as a fraction of the diameter.
|
||||
pub spacing: f32,
|
||||
/// Photoshop's Spacing checkbox. Off: the pointer's speed sets the spacing (one dab every
|
||||
/// [`crate::dynamics::SPEED_SPACING_MS`] of stroke time; without timestamps, one per input point).
|
||||
pub spacing_enabled: bool,
|
||||
/// Maximum coverage for the whole stroke.
|
||||
pub opacity: f32,
|
||||
/// Per-dab coverage.
|
||||
@@ -374,6 +408,10 @@ pub struct BrushSettings {
|
||||
pub smoothing: Smoothing,
|
||||
/// Keep the current texture (pattern + scale) when switching to another textured preset.
|
||||
pub protect_texture: bool,
|
||||
/// Sections whose settings stay when another preset is picked (tool state, like Photoshop's locks).
|
||||
pub locks: SectionLocks,
|
||||
/// Mixer Brush options (Wet, Load, Mix, Flow, Sample All Layers) used by `paint.mixerBrush`.
|
||||
pub mixer: MixerSettings,
|
||||
/// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay.
|
||||
pub seed: u64,
|
||||
}
|
||||
@@ -384,6 +422,7 @@ impl Default for BrushSettings {
|
||||
size: 20.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.1,
|
||||
spacing_enabled: true,
|
||||
opacity: 1.0,
|
||||
flow: 1.0,
|
||||
pressure_size: true,
|
||||
@@ -411,6 +450,8 @@ impl Default for BrushSettings {
|
||||
build_up_rate: 20.0,
|
||||
smoothing: Smoothing::default(),
|
||||
protect_texture: false,
|
||||
locks: SectionLocks::default(),
|
||||
mixer: MixerSettings::default(),
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
@@ -418,10 +459,47 @@ impl Default for BrushSettings {
|
||||
|
||||
impl BrushSettings {
|
||||
/// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool
|
||||
/// option, so it stays the tool's (Photoshop), and a protected texture stays too.
|
||||
/// option, so it stays the tool's (Photoshop), a protected texture stays too, and so does every
|
||||
/// section `current` has locked (the locks themselves are tool state and carry over).
|
||||
pub fn picked_over(self, current: &BrushSettings) -> Self {
|
||||
let mut b = self.with_protected_texture(current);
|
||||
b.smoothing = current.smoothing.clone();
|
||||
let l = ¤t.locks;
|
||||
if l.shape_dynamics {
|
||||
b.shape_dynamics = current.shape_dynamics.clone();
|
||||
}
|
||||
if l.scattering {
|
||||
b.scattering = current.scattering.clone();
|
||||
}
|
||||
if l.texture {
|
||||
b.texture = current.texture.clone();
|
||||
}
|
||||
if l.dual_brush {
|
||||
b.dual_brush = current.dual_brush.clone();
|
||||
}
|
||||
if l.color_dynamics {
|
||||
b.color_dynamics = current.color_dynamics.clone();
|
||||
}
|
||||
if l.transfer {
|
||||
b.transfer = current.transfer.clone();
|
||||
}
|
||||
if l.pose {
|
||||
b.pose = current.pose.clone();
|
||||
}
|
||||
if l.noise {
|
||||
b.noise = current.noise;
|
||||
}
|
||||
if l.wet_edges {
|
||||
b.wet_edges = current.wet_edges;
|
||||
}
|
||||
if l.build_up {
|
||||
b.build_up = current.build_up;
|
||||
b.build_up_rate = current.build_up_rate;
|
||||
}
|
||||
if l.protect_texture {
|
||||
b.protect_texture = current.protect_texture;
|
||||
}
|
||||
b.locks = current.locks.clone();
|
||||
b
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ fn jitter_is_deterministic_per_seed() {
|
||||
roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() },
|
||||
flip_x_jitter: true,
|
||||
flip_y_jitter: true,
|
||||
..Default::default()
|
||||
},
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) },
|
||||
..brush()
|
||||
@@ -638,3 +639,177 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
r.push(&pts);
|
||||
assert!(r.tail_preview().is_none());
|
||||
}
|
||||
|
||||
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
|
||||
|
||||
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
|
||||
fn extents(b: &BrushSettings, d: &Dab) -> (usize, usize) {
|
||||
let r = raster(b, d);
|
||||
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
|
||||
let w = (r.0.x0..r.0.x1).filter(|&x| at(&r, x, cy) > 0.5).count();
|
||||
let h = (r.0.y0..r.0.y1).filter(|&y| at(&r, cx, y) > 0.5).count();
|
||||
(w, h)
|
||||
}
|
||||
|
||||
fn pen(x: f64, y: f64, tilt_x: f32, tilt_y: f32, rotation: f32) -> StrokePoint {
|
||||
StrokePoint { tilt_x, tilt_y, rotation, ..StrokePoint::new(x, y, 1.0) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_scale_squashes_the_tip_height_with_pen_tilt() {
|
||||
// Size control on Pen Tilt with a 100 % minimum, so only Tilt Scale changes the shape.
|
||||
let mk = |scale: f32| BrushSettings {
|
||||
size: 40.0,
|
||||
shape_dynamics: ShapeDynamics {
|
||||
enabled: true,
|
||||
size: Dynamic { control: Control::PenTilt, minimum: 1.0, ..Default::default() },
|
||||
tilt_scale: scale,
|
||||
..Default::default()
|
||||
},
|
||||
..brush()
|
||||
};
|
||||
let upright = [pen(50.0, 50.0, 0.0, 0.0, 0.0)];
|
||||
let tilted = [pen(50.0, 50.0, 45.0, 0.0, 0.0)];
|
||||
// 0 % (the default) and an upright pen leave the tip round.
|
||||
let d = dabs_of(&mk(0.0), &tilted)[0];
|
||||
assert_eq!(d.roundness, 1.0);
|
||||
let d = dabs_of(&mk(2.0), &upright)[0];
|
||||
assert_eq!(d.roundness, 1.0);
|
||||
// 100 % at 45° tilt: height × (1 − 0.5 × 0.5) = 0.75; 200 %: 0.5.
|
||||
let d = dabs_of(&mk(1.0), &tilted)[0];
|
||||
assert!((d.roundness - 0.75).abs() < 1e-4, "{}", d.roundness);
|
||||
let d2 = dabs_of(&mk(2.0), &tilted)[0];
|
||||
assert!((d2.roundness - 0.5).abs() < 1e-4, "{}", d2.roundness);
|
||||
let (w, h) = extents(&mk(2.0), &d2);
|
||||
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
|
||||
// Only with the size control on Pen Tilt (Photoshop greys the slider otherwise).
|
||||
let mut b = mk(2.0);
|
||||
b.shape_dynamics.size.control = Control::PenPressure;
|
||||
assert_eq!(dabs_of(&b, &tilted)[0].roundness, 1.0);
|
||||
// Rendered strokes differ.
|
||||
let a = paint(&mk(0.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
|
||||
let c = paint(&mk(2.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
|
||||
let area = Rect::new(0, 0, 160, 80);
|
||||
assert!(alpha_sum(&c, area) < alpha_sum(&a, area) * 0.75, "{} vs {}", alpha_sum(&c, area), alpha_sum(&a, area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_projection_foreshortens_along_tilt_and_turns_with_rotation() {
|
||||
let mk = |on: bool| BrushSettings { size: 40.0, shape_dynamics: ShapeDynamics { enabled: true, brush_projection: on, ..Default::default() }, ..brush() };
|
||||
// Tilted 60° towards +x: the tip is foreshortened to cos 60° = ½ along x.
|
||||
let pts = [pen(50.0, 50.0, 60.0, 0.0, 0.0)];
|
||||
let d = dabs_of(&mk(true), &pts)[0];
|
||||
assert!((d.proj_scale - 0.5).abs() < 1e-4 && d.proj_angle.abs() < 1e-5, "{d:?}");
|
||||
let (w, h) = extents(&mk(true), &d);
|
||||
assert!((w as i32 - 20).abs() <= 2 && h >= 38, "{w}×{h}");
|
||||
// Tilted along y instead: foreshortened vertically.
|
||||
let d = dabs_of(&mk(true), &[pen(50.0, 50.0, 0.0, 60.0, 0.0)])[0];
|
||||
let (w, h) = extents(&mk(true), &d);
|
||||
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
|
||||
// Off: the tilt doesn't touch the tip.
|
||||
let d = dabs_of(&mk(false), &pts)[0];
|
||||
assert_eq!((d.proj_scale, d.angle), (1.0, 0.0));
|
||||
assert_eq!(extents(&mk(false), &d), (40, 40));
|
||||
// Barrel rotation turns the tip (an elliptical one shows it).
|
||||
let mut b = mk(true);
|
||||
b.roundness = 0.5;
|
||||
let d = dabs_of(&b, &[pen(50.0, 50.0, 0.0, 0.0, 90.0)])[0];
|
||||
assert!((d.angle.to_degrees() - 90.0).abs() < 1e-3);
|
||||
let (w, h) = extents(&b, &d);
|
||||
assert!(w < h, "{w}×{h}");
|
||||
// Dual-brush dabs are never projected.
|
||||
let ctx = BrushContext::new(&mk(true));
|
||||
let dd = Dab { proj_scale: 0.3, ..Dab::round(Point::new(50.0, 50.0), 20.0, 1.0) };
|
||||
let (r, mut a, mut c) = (ctx.dab_rect(&dd, true), Vec::new(), Vec::new());
|
||||
ctx.rasterize(&dd, true, r, &mut a);
|
||||
ctx.rasterize(&Dab { proj_scale: 1.0, ..dd }, true, r, &mut c);
|
||||
assert_eq!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_off_spaces_dabs_by_pointer_speed() {
|
||||
let mut b = BrushSettings { size: 20.0, spacing_enabled: false, ..brush() };
|
||||
let timed = |ms: f64| vec![StrokePoint { time: 0.0, ..StrokePoint::new(0.0, 10.0, 1.0) }, StrokePoint { time: ms, ..StrokePoint::new(100.0, 10.0, 1.0) }];
|
||||
// 100 px in 400 ms: a dab every 8 ms = every 2 px; in 40 ms: every 20 px.
|
||||
let slow = dabs_of(&b, &timed(400.0)).len();
|
||||
let fast = dabs_of(&b, &timed(40.0)).len();
|
||||
assert_eq!((slow, fast), (51, 6));
|
||||
// Never denser than half a pixel, however slow.
|
||||
assert!(dabs_of(&b, &timed(1.0e9)).len() <= 201);
|
||||
// No timestamps: one dab per input point.
|
||||
let pts: Vec<StrokePoint> = (0..7).map(|i| StrokePoint::new(f64::from(i) * 15.0, 10.0, 1.0)).collect();
|
||||
assert_eq!(dabs_of(&b, &pts).len(), 7);
|
||||
// Checked: fixed spacing (25 % of 20 px = 5 px) whatever the speed.
|
||||
b.spacing_enabled = true;
|
||||
assert_eq!(dabs_of(&b, &timed(400.0)).len(), dabs_of(&b, &timed(40.0)).len());
|
||||
// Chunked input gives the same dabs.
|
||||
b.spacing_enabled = false;
|
||||
let pts: Vec<StrokePoint> = (0..20).map(|i| StrokePoint { time: f64::from(i) * 13.0, ..StrokePoint::new(f64::from(i) * 7.0, 10.0, 1.0) }).collect();
|
||||
let one = dabs_of(&b, &pts);
|
||||
let mut g = crate::dynamics::DabGenerator::new(&b, 1.0);
|
||||
let (mut out, mut dual) = (Vec::new(), Vec::new());
|
||||
for c in pts.chunks(3) {
|
||||
g.push(c, &mut out, &mut dual);
|
||||
}
|
||||
g.finish(&mut out, &mut dual);
|
||||
assert_eq!(one, out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_sections_survive_picking_a_preset() {
|
||||
let presets = crate::presets::builtin();
|
||||
let chalk = crate::presets::find(&presets, "chalk").unwrap().brush.clone();
|
||||
let mut cur = BrushSettings {
|
||||
scattering: Scattering { enabled: true, count: 5, ..Default::default() },
|
||||
transfer: Transfer { enabled: true, wetness: Dynamic::jitter(0.4), ..Default::default() },
|
||||
noise: true,
|
||||
..brush()
|
||||
};
|
||||
// Unlocked: the preset's sections win.
|
||||
let next = chalk.clone().picked_over(&cur);
|
||||
assert_eq!(next.scattering, chalk.scattering);
|
||||
assert_eq!(next.noise, chalk.noise);
|
||||
// Locked: the current sections stay, and so do the locks.
|
||||
cur.locks = SectionLocks { scattering: true, transfer: true, noise: true, ..Default::default() };
|
||||
let next = chalk.clone().picked_over(&cur);
|
||||
assert_eq!(next.scattering, cur.scattering);
|
||||
assert_eq!(next.transfer, cur.transfer);
|
||||
assert!(next.noise);
|
||||
assert_eq!(next.locks, cur.locks);
|
||||
// Unlocked sections still come from the preset.
|
||||
assert_eq!(next.texture, chalk.texture);
|
||||
assert_eq!(next.tip, chalk.tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_wetness_and_mix_vary_per_dab() {
|
||||
let b = BrushSettings {
|
||||
transfer: Transfer {
|
||||
enabled: true,
|
||||
wetness: Dynamic::controlled(Control::PenPressure),
|
||||
mix: Dynamic { jitter: 1.0, minimum: 0.25, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
..brush()
|
||||
};
|
||||
let pts = [StrokePoint::new(0.0, 0.0, 0.3), StrokePoint::new(60.0, 0.0, 0.3)];
|
||||
let d = dabs_of(&b, &pts);
|
||||
assert!(d.iter().all(|x| (x.wet - 0.3).abs() < 1e-5));
|
||||
assert!(d.iter().all(|x| (0.25..=1.0).contains(&x.mix)) && d.iter().any(|x| x.mix < 0.9));
|
||||
// Transfer off: as set.
|
||||
let d = dabs_of(&brush(), &pts);
|
||||
assert!(d.iter().all(|x| x.wet == 1.0 && x.mix == 1.0));
|
||||
// New fields round-trip and old JSON loads with neutral values.
|
||||
let full = BrushSettings {
|
||||
spacing_enabled: false,
|
||||
shape_dynamics: ShapeDynamics { tilt_scale: 1.5, brush_projection: true, ..Default::default() },
|
||||
locks: SectionLocks { texture: true, ..Default::default() },
|
||||
mixer: crate::mixer::MixerSettings { wet: 0.9, sample_all_layers: true, ..Default::default() },
|
||||
..b
|
||||
};
|
||||
let back: BrushSettings = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap();
|
||||
assert_eq!(back, full);
|
||||
let old: BrushSettings = serde_json::from_str(r#"{"size": 12, "shapeDynamics": {"enabled": true}, "transfer": {"enabled": true}}"#).unwrap();
|
||||
assert!(old.spacing_enabled && !old.shape_dynamics.brush_projection && old.shape_dynamics.tilt_scale == 0.0 && old.locks == SectionLocks::default());
|
||||
assert_eq!(old.mixer, crate::mixer::MixerSettings::default());
|
||||
}
|
||||
@@ -13,6 +13,13 @@ use crate::{Dab, StrokePoint};
|
||||
/// Maximum pulled-string length at 100 % smoothing, in screen pixels.
|
||||
pub const MAX_STRING_PX: f64 = 100.0;
|
||||
|
||||
/// With Spacing unchecked, one dab per this many milliseconds of stroke time, so faster strokes
|
||||
/// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing").
|
||||
pub const SPEED_SPACING_MS: f64 = 8.0;
|
||||
|
||||
/// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer).
|
||||
const MIN_SPEED_STEP: f64 = 0.5;
|
||||
|
||||
#[inline]
|
||||
fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint {
|
||||
let ff = f as f32;
|
||||
@@ -147,11 +154,32 @@ pub struct PathWalker {
|
||||
/// Airbrush interval in ms (None = off).
|
||||
interval: Option<f64>,
|
||||
time_acc: f64,
|
||||
/// Speed spacing (Spacing unchecked): one step per this many ms of stroke time.
|
||||
speed: Option<f64>,
|
||||
speed_acc: f64,
|
||||
}
|
||||
|
||||
impl PathWalker {
|
||||
pub fn new(build_up_interval_ms: Option<f64>) -> Self {
|
||||
Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 }
|
||||
Self {
|
||||
last: None,
|
||||
next_at: 0.0,
|
||||
pending_first: None,
|
||||
initial_dir: None,
|
||||
dir: 0.0,
|
||||
step: 0,
|
||||
interval: build_up_interval_ms,
|
||||
time_acc: 0.0,
|
||||
speed: None,
|
||||
speed_acc: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement
|
||||
/// spreads them out. Segments without timestamps get one step at their end point.
|
||||
pub fn speed_spacing(mut self, interval_ms: f64) -> Self {
|
||||
self.speed = Some(interval_ms.max(0.1));
|
||||
self
|
||||
}
|
||||
|
||||
fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
|
||||
@@ -179,12 +207,36 @@ impl PathWalker {
|
||||
self.emit(f, out);
|
||||
}
|
||||
if len > 1e-9 {
|
||||
while self.next_at <= len + 1e-9 {
|
||||
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
|
||||
self.emit(q, out);
|
||||
self.next_at += step_len(&q).max(0.25);
|
||||
match self.speed {
|
||||
None => {
|
||||
while self.next_at <= len + 1e-9 {
|
||||
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
|
||||
self.emit(q, out);
|
||||
self.next_at += step_len(&q).max(0.25);
|
||||
}
|
||||
self.next_at -= len;
|
||||
}
|
||||
Some(iv) => {
|
||||
let dt = p.time - a.time;
|
||||
if dt > 0.0 && dt.is_finite() {
|
||||
self.speed_acc += dt;
|
||||
let max_n = (len / MIN_SPEED_STEP).ceil().max(1.0) as usize;
|
||||
let mut n = 0;
|
||||
while self.speed_acc >= iv && n < max_n {
|
||||
self.speed_acc -= iv;
|
||||
let f = ((dt - self.speed_acc) / dt).clamp(0.0, 1.0);
|
||||
self.emit(lerp_pt(&a, &p, f), out);
|
||||
n += 1;
|
||||
}
|
||||
if n == max_n {
|
||||
self.speed_acc = self.speed_acc.rem_euclid(iv);
|
||||
}
|
||||
} else {
|
||||
// No timestamps: one dab per input point.
|
||||
self.emit(p, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next_at -= len;
|
||||
}
|
||||
if let Some(iv) = self.interval {
|
||||
let dt = p.time - a.time;
|
||||
@@ -379,6 +431,22 @@ impl DabBuilder {
|
||||
fy = !fy;
|
||||
}
|
||||
}
|
||||
// Tilt Scale and Brush Projection (Shape Dynamics).
|
||||
let (mut proj_angle, mut proj_scale) = (0.0, 1.0);
|
||||
if sd.enabled {
|
||||
let tilt = s.point.tilt_x.hypot(s.point.tilt_y).clamp(0.0, 90.0);
|
||||
if sd.size.control == Control::PenTilt && sd.tilt_scale > 0.0 {
|
||||
let k = 1.0 - sd.tilt_scale.clamp(0.0, 2.0) * 0.5 * tilt / 90.0;
|
||||
roundness = (roundness * k).clamp(0.01, 1.0);
|
||||
}
|
||||
if sd.brush_projection {
|
||||
angle += s.point.rotation;
|
||||
if tilt > 0.0 {
|
||||
proj_angle = (-s.point.tilt_y).atan2(s.point.tilt_x);
|
||||
proj_scale = tilt.to_radians().cos().max(0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
let radius = (diameter / 2.0).max(0.5);
|
||||
// Scatter.
|
||||
let mut c = Point::new(s.point.x, s.point.y);
|
||||
@@ -403,6 +471,7 @@ impl DabBuilder {
|
||||
opacity = scalar(&tr.opacity, s, r(stream::OPACITY));
|
||||
flow *= scalar(&tr.flow, s, r(stream::FLOW));
|
||||
}
|
||||
let (wet, mix) = if tr.enabled { (scalar(&tr.wetness, s, r(stream::WET)), scalar(&tr.mix, s, r(stream::MIX))) } else { (1.0, 1.0) };
|
||||
if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) {
|
||||
opacity *= s.point.pressure.clamp(0.0, 1.0);
|
||||
}
|
||||
@@ -428,6 +497,10 @@ impl DabBuilder {
|
||||
color,
|
||||
depth,
|
||||
index: i,
|
||||
proj_angle,
|
||||
proj_scale,
|
||||
wet,
|
||||
mix,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -479,6 +552,10 @@ impl DualBuilder {
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
index: i,
|
||||
proj_angle: 0.0,
|
||||
proj_scale: 1.0,
|
||||
wet: 1.0,
|
||||
mix: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -519,7 +596,7 @@ impl DabGenerator {
|
||||
let dual_on = brush.dual_brush.enabled;
|
||||
Self {
|
||||
smoother: Smoother::new(&brush.smoothing, zoom),
|
||||
walker: PathWalker::new(interval),
|
||||
walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) },
|
||||
dual_walker: dual_on.then(|| PathWalker::new(None)),
|
||||
builder: DabBuilder::new(brush),
|
||||
dual: dual_on.then(|| DualBuilder::new(brush)),
|
||||
|
||||
+14
-2
@@ -31,9 +31,10 @@ pub mod rng;
|
||||
pub mod tile;
|
||||
|
||||
pub use brush::{
|
||||
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing,
|
||||
Texture, TipShape, Transfer,
|
||||
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, SectionLocks, ShapeDynamics,
|
||||
Smoothing, Texture, TipShape, Transfer,
|
||||
};
|
||||
pub use mixer::MixerSettings;
|
||||
pub use render::{BrushContext, StrokeRenderer, render_stroke};
|
||||
pub use tile::GrayTile;
|
||||
|
||||
@@ -94,6 +95,13 @@ pub struct Dab {
|
||||
pub depth: f32,
|
||||
/// Position in the stroke (0 = first).
|
||||
pub index: u64,
|
||||
/// Brush Projection: direction (radians, counter-clockwise, y up) along which the tip is
|
||||
/// foreshortened, and the factor (1 = no projection).
|
||||
pub proj_angle: f32,
|
||||
pub proj_scale: f32,
|
||||
/// Mixer Brush multipliers of Wet and Mix (Transfer › Wetness/Mix Jitter), 1 = as set.
|
||||
pub wet: f32,
|
||||
pub mix: f32,
|
||||
}
|
||||
|
||||
impl Dab {
|
||||
@@ -111,6 +119,10 @@ impl Dab {
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
index: 0,
|
||||
proj_angle: 0.0,
|
||||
proj_scale: 1.0,
|
||||
wet: 1.0,
|
||||
mix: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ pub struct MixerSettings {
|
||||
pub load: f32,
|
||||
pub mix: f32,
|
||||
pub flow: f32,
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0 }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +89,8 @@ pub fn apply_mixer_stroke(
|
||||
let mut acc = vec![0.0f32; n];
|
||||
let mut paint = vec![0.0f32; n];
|
||||
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
|
||||
// Transfer › Wetness / Mix Jitter scale the settings per dab.
|
||||
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
|
||||
// Sample the canvas under the dab.
|
||||
acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
|
||||
@@ -165,7 +169,7 @@ mod tests {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0 }, &mut st, None, false);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
|
||||
let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]);
|
||||
assert!(start > 0.95, "{start}");
|
||||
if expect_dry {
|
||||
@@ -186,7 +190,15 @@ mod tests {
|
||||
s.fill_rect(Rect::new(100, 0, 200, 20), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0 }, &mut st, None, false);
|
||||
apply_mixer_stroke(
|
||||
&mut s,
|
||||
None,
|
||||
&stroke(10.0),
|
||||
&MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0, ..Default::default() },
|
||||
&mut st,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
// Blue is dragged into the white half.
|
||||
let c = s.rgba(115, 10);
|
||||
assert!(c[2] > c[1] + 0.05 && c[1] < 0.95, "{c:?}");
|
||||
@@ -205,4 +217,24 @@ mod tests {
|
||||
assert!(!dmg.is_empty());
|
||||
assert_eq!(s.rgba(50, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_wetness_control_scales_pickup_per_dab() {
|
||||
// Wetness on Pen Pressure: no pressure means a dry brush (no pickup), full pressure picks up.
|
||||
let run = |pressure: f32| {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 200, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
let mut k = stroke(10.0);
|
||||
k.brush.transfer = crate::Transfer { enabled: true, wetness: crate::Dynamic::controlled(crate::Control::PenPressure), ..Default::default() };
|
||||
for p in &mut k.points {
|
||||
p.pressure = pressure;
|
||||
}
|
||||
apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
|
||||
st.pickup
|
||||
};
|
||||
assert!(run(0.0).is_none());
|
||||
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ fn size_dyn(size: Dynamic) -> ShapeDynamics {
|
||||
}
|
||||
|
||||
fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer {
|
||||
Transfer { enabled: true, opacity, flow }
|
||||
Transfer { enabled: true, opacity, flow, ..Default::default() }
|
||||
}
|
||||
|
||||
fn paper(seed: u32, depth: f32) -> Texture {
|
||||
|
||||
@@ -144,6 +144,12 @@ impl BrushContext {
|
||||
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
|
||||
let (sn, cs) = d.angle.sin_cos();
|
||||
let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 });
|
||||
// Brush Projection: stretch the sampling coordinates along the tilt direction, which
|
||||
// foreshortens the tip there by `proj_scale`.
|
||||
let proj = (!dual && d.proj_scale < 0.999).then(|| {
|
||||
let (ps, pc) = d.proj_angle.sin_cos();
|
||||
(pc, ps, 1.0 / d.proj_scale.max(0.05) - 1.0)
|
||||
});
|
||||
let r = d.radius;
|
||||
let ro = d.roundness.max(0.5 / r).min(1.0);
|
||||
let rm = (r * ro).max(0.5);
|
||||
@@ -163,8 +169,13 @@ impl BrushContext {
|
||||
for xx in 0..w {
|
||||
let x = rect.x0 + xx as i32;
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
// To y-up, rotate by -angle, flip.
|
||||
let (ux, uy) = (dx, -dy);
|
||||
// To y-up, project, rotate by -angle, flip.
|
||||
let (mut ux, mut uy) = (dx, -dy);
|
||||
if let Some((ax, ay, k)) = proj {
|
||||
let t = (ux * ax + uy * ay) * k;
|
||||
ux += t * ax;
|
||||
uy += t * ay;
|
||||
}
|
||||
let u = (ux * cs + uy * sn) * fx;
|
||||
let v = (-ux * sn + uy * cs) * fy;
|
||||
let (mut val, rn) = match mips {
|
||||
|
||||
@@ -53,6 +53,8 @@ pub mod stream {
|
||||
pub const DUAL_X: u64 = 16;
|
||||
pub const DUAL_Y: u64 = 17;
|
||||
pub const DUAL_FLIP: u64 = 18;
|
||||
pub const WET: u64 = 19;
|
||||
pub const MIX: u64 = 20;
|
||||
}
|
||||
|
||||
/// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed.
|
||||
|
||||
@@ -366,6 +366,13 @@ struct CurveUi {
|
||||
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
|
||||
pub const DRAG_OFF: f32 = 12.0;
|
||||
const HIT_RADIUS: f32 = 9.0;
|
||||
const CURVE_HANDLE_SIZE: f32 = 7.0;
|
||||
const CURVE_GRAPH_PADDING: f32 = 5.0;
|
||||
|
||||
fn curve_graph(full: Rect, side: f32) -> Rect {
|
||||
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
||||
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
|
||||
}
|
||||
|
||||
fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
|
||||
let pts: Vec<[f32; 2]> = v
|
||||
@@ -403,7 +410,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
let side = ui.available_width().clamp(120.0, 300.0);
|
||||
ui.add_space(4.0);
|
||||
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
|
||||
let graph = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
||||
let graph = curve_graph(full, side);
|
||||
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
let to_val =
|
||||
@@ -535,7 +542,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
}
|
||||
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let r = Rect::from_center_size(to_scr(*q), vec2(7.0, 7.0));
|
||||
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
|
||||
if Some(i) == st.sel {
|
||||
p.rect_filled(r, 0.0, t.text);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
|
||||
@@ -1404,6 +1411,20 @@ mod tests {
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
|
||||
for side in [120.0, 200.0, 300.0] {
|
||||
let full = Rect::from_min_size(pos2(100.0, 50.0), vec2(side, side + 14.0));
|
||||
let graph = curve_graph(full, side);
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
for q in [[0.0, 0.0], [255.0, 255.0]] {
|
||||
let r = Rect::from_center_size(to_scr(q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)).expand(1.0);
|
||||
assert!(r.left() >= full.left() && r.right() <= full.right());
|
||||
assert!(r.top() >= full.top() && r.bottom() <= full.bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_helpers() {
|
||||
for g in [0.3f32, 1.0, 1.8, 4.0] {
|
||||
|
||||
@@ -41,7 +41,11 @@ fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, Photocraf
|
||||
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
// The default Properties group gives way to Layers (#147) and scrolls taller editors;
|
||||
// these tests drive every control without scrolling, so they size it like a user would.
|
||||
app.ui.dock.heights.insert(crate::dock::Group::Properties, 560.0);
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
|
||||
+127
-219
@@ -8,11 +8,15 @@
|
||||
//!
|
||||
//! The panel never writes the session brush itself: each frame's edits become one
|
||||
//! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control
|
||||
//! channel and the MCP server see exactly what the panel did and can do the same.
|
||||
//! channel and the MCP server see exactly what the panel did and can do the same. A drag's calls
|
||||
//! share a `coalesce` key, so a whole gesture is one journal entry ([`commit_gesture`]).
|
||||
//!
|
||||
//! The lock beside a section name keeps that section's settings when another preset is picked
|
||||
//! (`locks` in the brush, applied by the engine). The Brushes tab lives in [`crate::brushes_tab`].
|
||||
|
||||
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
|
||||
use photocraft_engine::BrushSettings;
|
||||
use photocraft_engine::paint::{self, TipShape};
|
||||
use photocraft_engine::paint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -22,6 +26,7 @@ use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
pub use crate::brush_preview::{preview_pixels, preview_sig};
|
||||
pub use crate::brush_sections::section_body;
|
||||
pub use photocraft_engine::brush_cmds::brush_patch;
|
||||
|
||||
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
|
||||
pub const SECTIONS: [(&str, bool); 13] = [
|
||||
@@ -41,9 +46,31 @@ pub const SECTIONS: [(&str, bool); 13] = [
|
||||
];
|
||||
|
||||
/// Panel width and the stroke strip's size.
|
||||
const WIDTH: f32 = 540.0;
|
||||
pub(crate) const WIDTH: f32 = 540.0;
|
||||
const STRIP: (u32, u32) = (488, 76);
|
||||
|
||||
/// How the Brushes tab lists presets.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BrushesView {
|
||||
/// Rows: tip, size, stroke preview, name.
|
||||
#[default]
|
||||
List,
|
||||
/// Tip thumbnails with their sizes.
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// A rename in progress in the Brushes tab (a preset, or a group when `group` is set).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Renaming {
|
||||
pub group: bool,
|
||||
/// The preset (or group) being renamed.
|
||||
pub name: String,
|
||||
/// The text typed so far.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Brushes panel view state (serde, so the control channel can read and drive it).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -52,6 +79,8 @@ pub struct BrushesPanelState {
|
||||
pub collapsed: Vec<String>,
|
||||
/// Case-insensitive name filter.
|
||||
pub filter: String,
|
||||
pub view: BrushesView,
|
||||
pub renaming: Option<Renaming>,
|
||||
}
|
||||
|
||||
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
|
||||
@@ -72,63 +101,74 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
|
||||
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
|
||||
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
|
||||
let mut c = b.clone();
|
||||
if skip.0 {
|
||||
c.tip = TipShape::Round;
|
||||
}
|
||||
if skip.1 {
|
||||
c.dual_brush.tip = TipShape::Round;
|
||||
}
|
||||
if skip.2 {
|
||||
c.texture.pattern = paint::Pattern::default();
|
||||
}
|
||||
serde_json::to_value(&c).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal
|
||||
/// `tools.setBrush` patch (`{}` when nothing changed).
|
||||
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
|
||||
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
|
||||
fn diff(a: &Value, b: &Value) -> Option<Value> {
|
||||
match (a, b) {
|
||||
(Value::Object(ao), Value::Object(bo)) => {
|
||||
let mut out = Map::new();
|
||||
for (k, bv) in bo {
|
||||
match ao.get(k) {
|
||||
Some(av) => {
|
||||
if let Some(d) = diff(av, bv) {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), bv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
// Enums with payloads (tips, patterns) are replaced whole.
|
||||
_ => (a != b).then(|| b.clone()),
|
||||
}
|
||||
}
|
||||
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
|
||||
/// The lock behind section `i` (every section but Brush Tip Shape has one, as in Photoshop).
|
||||
pub fn section_lock(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
|
||||
let l = &mut b.locks;
|
||||
Some(match i {
|
||||
1 => &mut l.shape_dynamics,
|
||||
2 => &mut l.scattering,
|
||||
3 => &mut l.texture,
|
||||
4 => &mut l.dual_brush,
|
||||
5 => &mut l.color_dynamics,
|
||||
6 => &mut l.transfer,
|
||||
7 => &mut l.pose,
|
||||
8 => &mut l.noise,
|
||||
9 => &mut l.wet_edges,
|
||||
10 => &mut l.build_up,
|
||||
11 => &mut l.smoothing,
|
||||
12 => &mut l.protect_texture,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
|
||||
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
|
||||
send(app, before, after, None);
|
||||
}
|
||||
|
||||
fn send(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings, key: Option<String>) -> bool {
|
||||
if before == after {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let patch = brush_patch(before, after);
|
||||
if patch.as_object().is_some_and(Map::is_empty) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
|
||||
let mut p = json!({ "brush": patch });
|
||||
if let Some(k) = key {
|
||||
p["coalesce"] = json!(k);
|
||||
}
|
||||
if let Err(e) = app.run("tools.setBrush", p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
const GESTURE: &str = "brush-set-gesture";
|
||||
|
||||
/// The current brush-edit gesture number. A pointer press starts a new gesture (once per frame);
|
||||
/// [`commit_gesture`] ends one after an edit made with the pointer up (a click, a typed value).
|
||||
fn gesture(ctx: &egui::Context) -> u64 {
|
||||
let frame = ctx.cumulative_pass_nr();
|
||||
let pressed = ctx.input(|i| i.pointer.any_pressed());
|
||||
ctx.data_mut(|d| {
|
||||
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE));
|
||||
if pressed && g.1 != frame {
|
||||
g.0 += 1;
|
||||
g.1 = frame;
|
||||
}
|
||||
g.0
|
||||
})
|
||||
}
|
||||
|
||||
/// [`commit`] as part of a gesture: every call of one drag carries the same `coalesce` key, so
|
||||
/// the engine journals the drag as a single `tools.setBrush` (Rule 1: one command per gesture).
|
||||
/// Call it every frame (it tracks presses even when nothing changed).
|
||||
pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) {
|
||||
let g = gesture(ctx);
|
||||
if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) {
|
||||
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Group label for presets saved without a group.
|
||||
@@ -147,189 +187,41 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
|
||||
out
|
||||
}
|
||||
|
||||
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
|
||||
/// the full comparison clones the preset and its tip.
|
||||
/// Does the current brush match `preset` (everything but colour, size and the tool state that
|
||||
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
|
||||
/// comparison clones the preset and its tip.
|
||||
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
|
||||
preset.hardness == brush.hardness
|
||||
&& preset.spacing == brush.spacing
|
||||
&& preset.tip == brush.tip
|
||||
&& BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush
|
||||
&& BrushSettings {
|
||||
color: brush.color,
|
||||
size: brush.size,
|
||||
background: brush.background,
|
||||
smoothing: brush.smoothing.clone(),
|
||||
locks: brush.locks.clone(),
|
||||
..preset.clone()
|
||||
} == *brush
|
||||
}
|
||||
|
||||
/// A fresh "Brush N" name.
|
||||
fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
|
||||
pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
|
||||
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
|
||||
}
|
||||
|
||||
fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
|
||||
pub(crate) fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
|
||||
if let Err(e) = app.run(id, p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
fn full_uv() -> egui::Rect {
|
||||
pub(crate) fn full_uv() -> egui::Rect {
|
||||
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
|
||||
}
|
||||
|
||||
fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Size of the current brush (Photoshop's Brushes panel slider).
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Size").color(t.text_dim));
|
||||
let mut lv = b.size.max(1.0).ln();
|
||||
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
|
||||
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
|
||||
if r.changed() {
|
||||
b.size = lv.exp().round().clamp(1.0, 5000.0);
|
||||
}
|
||||
r
|
||||
});
|
||||
let mut s = b.size;
|
||||
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
|
||||
b.size = s.round().clamp(1.0, 5000.0);
|
||||
}
|
||||
});
|
||||
commit(app, &before, &b);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
|
||||
ui.add_space(20.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
|
||||
let groups = grouped_presets(&app.session.tools.presets);
|
||||
let brush = &app.session.tools.brush;
|
||||
let mut clicked = None;
|
||||
let mut toggle = None;
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for (group, items) in groups {
|
||||
let items: Vec<usize> = items
|
||||
.into_iter()
|
||||
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let x = r.left() + 4.0;
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
12.0,
|
||||
t.text_dim,
|
||||
);
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
|
||||
if open { "folder-open" } else { "folder" },
|
||||
14.0,
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - 8.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
items.len().to_string(),
|
||||
theme::medium(11.0),
|
||||
t.text_faint,
|
||||
);
|
||||
if resp.clicked() {
|
||||
toggle = Some(group.clone());
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
for i in items {
|
||||
let Some(p) = app.session.tools.presets.get(i) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
|
||||
if !ui.is_rect_visible(r) {
|
||||
continue;
|
||||
}
|
||||
if is_current(&p.brush, brush) {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
|
||||
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
egui::pos2(cell.center().x, r.bottom() - 1.0),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.0),
|
||||
t.text_faint,
|
||||
);
|
||||
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
|
||||
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
|
||||
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
egui::pos2(sr.right() + 12.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&p.name,
|
||||
egui::FontId::proportional(12.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(p.name.clone());
|
||||
}
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
if let Some(g) = toggle {
|
||||
let c = &mut app.ui.brushes_panel.collapsed;
|
||||
match c.iter().position(|x| *x == g) {
|
||||
Some(i) => {
|
||||
c.remove(i);
|
||||
}
|
||||
None => c.push(g),
|
||||
}
|
||||
}
|
||||
if let Some(name) = clicked {
|
||||
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
|
||||
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
|
||||
&& let Some(name) = current
|
||||
{
|
||||
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
|
||||
let name = new_preset_name(&app.session.tools.presets);
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Forget previews of presets that no longer exist.
|
||||
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
|
||||
brush_preview::with_cache(ui.ctx(), |c| {
|
||||
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
|
||||
});
|
||||
}
|
||||
|
||||
/// The section list: enable boxes, names, the selection. Clicking a name shows the section and
|
||||
/// turns it on (Photoshop); clicking a box only toggles it.
|
||||
/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section
|
||||
/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the
|
||||
/// section when another preset is picked.
|
||||
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
@@ -355,9 +247,10 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
} else {
|
||||
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
|
||||
}
|
||||
let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0);
|
||||
if box_resp.clicked() {
|
||||
*flag = !*flag;
|
||||
} else if resp.clicked() {
|
||||
} else if resp.clicked() && !on_lock {
|
||||
*flag = true;
|
||||
}
|
||||
x += 20.0;
|
||||
@@ -367,7 +260,22 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
let color = if sel { t.text } else { t.text_dim };
|
||||
let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) };
|
||||
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color);
|
||||
if resp.clicked() {
|
||||
let mut lock_clicked = false;
|
||||
if let Some(lock) = section_lock(b, i) {
|
||||
let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0));
|
||||
let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click());
|
||||
// Like Photoshop: the lock shows only when set or hovered.
|
||||
if *lock || resp.hovered() || lresp.hovered() {
|
||||
let tint = if *lock { t.text } else { t.text_faint };
|
||||
icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint);
|
||||
}
|
||||
let tip = if *lock { "Unlock: picking a preset replaces these settings" } else { "Lock: keep these settings when picking another preset" };
|
||||
if lresp.on_hover_text(tip).clicked() {
|
||||
*lock = !*lock;
|
||||
lock_clicked = true;
|
||||
}
|
||||
}
|
||||
if resp.clicked() && !lock_clicked {
|
||||
app.ui.brush_section = i;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -411,11 +319,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
|
||||
}
|
||||
if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() {
|
||||
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() };
|
||||
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() };
|
||||
}
|
||||
});
|
||||
});
|
||||
commit(app, &before, &b);
|
||||
commit_gesture(app, ui.ctx(), &before, &b);
|
||||
}
|
||||
|
||||
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
@@ -457,7 +365,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
if app.ui.brush_tab == 1 {
|
||||
brushes_tab(app, ui);
|
||||
crate::brushes_tab::show(app, ui);
|
||||
} else {
|
||||
settings_tab(app, ui);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.roundness = 0.4;
|
||||
b.hardness = 0.25;
|
||||
b.spacing = 0.6;
|
||||
b.spacing_enabled = false;
|
||||
}
|
||||
1 => {
|
||||
let sd = &mut b.shape_dynamics;
|
||||
@@ -43,6 +44,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
|
||||
sd.flip_x_jitter = true;
|
||||
sd.flip_y_jitter = true;
|
||||
sd.tilt_scale = 1.25;
|
||||
sd.brush_projection = true;
|
||||
}
|
||||
2 => {
|
||||
let sc = &mut b.scattering;
|
||||
@@ -92,6 +95,9 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.transfer.enabled = true;
|
||||
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
|
||||
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
|
||||
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
|
||||
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
|
||||
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
|
||||
}
|
||||
7 => {
|
||||
let p = &mut b.pose;
|
||||
@@ -136,7 +142,7 @@ fn every_section_round_trips_through_set_brush() {
|
||||
let (id, p) = last_journal(&app).expect("journaled");
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
let patch = &p["brush"];
|
||||
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
|
||||
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 8), "section {i}: {patch}");
|
||||
// And the engine's view (`brush.get`) reads back the same settings.
|
||||
let got = app.session.execute("brush.get", json!({})).unwrap();
|
||||
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
|
||||
@@ -189,6 +195,144 @@ fn bad_patches_fail_without_touching_the_brush() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_locks_round_trip_and_survive_preset_picks() {
|
||||
let mut b = BrushSettings::default();
|
||||
for (i, (name, _)) in SECTIONS.iter().enumerate() {
|
||||
assert_eq!(section_lock(&mut b, i).is_some(), i > 0, "{name} has a lock");
|
||||
}
|
||||
let mut app = app();
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut after = before.clone();
|
||||
edit_section(2, &mut after);
|
||||
for i in [2, 6, 8] {
|
||||
*section_lock(&mut after, i).unwrap() = true;
|
||||
}
|
||||
commit(&mut app, &before, &after);
|
||||
let (_, p) = last_journal(&app).unwrap();
|
||||
assert_eq!(p["brush"]["locks"], json!({"scattering": true, "transfer": true, "noise": true}));
|
||||
// Picking a preset keeps the locked sections (and the locks), and the preset still reads as current.
|
||||
let chalk = paint::presets::find(&app.session.tools.presets, "Chalk").unwrap().brush.clone();
|
||||
app.run("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
|
||||
let b = &app.session.tools.brush;
|
||||
assert_eq!(b.scattering, after.scattering);
|
||||
assert!(b.locks.scattering && b.locks.noise);
|
||||
assert_eq!(b.tip, chalk.tip);
|
||||
}
|
||||
|
||||
/// A harness that shows the options bar with `tool` in the Studio (non-pro) theme.
|
||||
fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = app();
|
||||
app.ui.tool = tool;
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::options_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_bar_edits_are_one_set_brush_per_gesture() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = options_bar_harness(crate::state::Tool::Brush);
|
||||
let n = h.state().session.journal.len();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing");
|
||||
// Drag the Opacity field left: many frames, one journal entry.
|
||||
let label = h.get_by_label("Opacity").rect();
|
||||
let start = egui::pos2(label.right() + 40.0, label.center().y);
|
||||
h.hover_at(start);
|
||||
h.run();
|
||||
h.drag_at(start);
|
||||
h.run();
|
||||
for k in 1..=8 {
|
||||
h.hover_at(start - vec2(5.0 * k as f32, 0.0));
|
||||
h.run();
|
||||
}
|
||||
h.drop_at(start - vec2(40.0, 0.0));
|
||||
h.run_steps(2);
|
||||
let opacity = h.state().session.tools.brush.opacity;
|
||||
assert!(opacity < 0.95, "the drag lowered the opacity: {opacity}");
|
||||
let j = &h.state().session.journal;
|
||||
assert_eq!(j.len(), n + 1, "{:?}", &j[n..]);
|
||||
let (id, p) = j.last().unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"]["opacity"].as_f64().map(|v| v as f32), Some(opacity));
|
||||
// A click is its own gesture: the Pressure for Size switch sits left of its label.
|
||||
let label = h.get_by_label("Pressure for Size").rect();
|
||||
let was = h.state().session.tools.brush.pressure_size;
|
||||
let at = egui::pos2(label.left() - 18.0, label.center().y);
|
||||
h.hover_at(at);
|
||||
h.run();
|
||||
h.drag_at(at);
|
||||
h.run();
|
||||
h.drop_at(at);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.tools.brush.pressure_size, !was);
|
||||
assert_eq!(h.state().session.journal.len(), n + 2);
|
||||
assert_eq!(h.state().session.journal.last().unwrap().1["brush"], json!({"pressureSize": !was}));
|
||||
// Replaying the journal gives the same brush.
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
for (id, p) in &h.state().session.journal {
|
||||
s.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
assert_eq!(s.tools.brush, h.state().session.tools.brush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_and_actions_reorder_presets() {
|
||||
use crate::brushes_tab::{Action, apply, drop_target, group_key};
|
||||
let mut app = app();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] {
|
||||
app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
|
||||
}
|
||||
let p = &app.session.tools.presets;
|
||||
// Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it.
|
||||
assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), 2)));
|
||||
assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), 1)));
|
||||
assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), 0)));
|
||||
assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), 1)));
|
||||
assert_eq!(drop_target(p, "A2", "Nope", true), None);
|
||||
assert_eq!(group_key(p, UNGROUPED), "");
|
||||
let (group, index) = drop_target(p, "A1", "A3", true).unwrap();
|
||||
apply(&mut app, vec![Action::Move { name: "A1".into(), group, index: Some(index) }]);
|
||||
let names = |app: &PhotocraftApp, g: &str| app.session.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&app, "Alpha"), ["A2", "A3", "A1"]);
|
||||
apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), index: None }]);
|
||||
assert_eq!(names(&app, "Beta"), ["B1", "A2"]);
|
||||
apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), before: Some("Alpha".into()) }]);
|
||||
let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets);
|
||||
assert!(order.iter().position(|g| g == "Beta") < order.iter().position(|g| g == "Alpha"));
|
||||
// Rename and delete from the context menu.
|
||||
apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), text: "Third".into() })]);
|
||||
assert!(paint::presets::find(&app.session.tools.presets, "Third").is_some());
|
||||
assert!(app.ui.brushes_panel.renaming.is_none());
|
||||
apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), text: "Bees".into() })]);
|
||||
assert_eq!(names(&app, "Bees"), ["B1", "A2"]);
|
||||
apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]);
|
||||
assert_eq!(names(&app, "Alpha"), ["A1"]);
|
||||
assert!(names(&app, "Bees").is_empty());
|
||||
// Every action went through a journaled command.
|
||||
let ids: Vec<&str> = app.session.journal.iter().map(|(id, _)| id.as_str()).collect();
|
||||
for id in [
|
||||
"brush.presets.move",
|
||||
"brush.presets.moveGroup",
|
||||
"brush.presets.rename",
|
||||
"brush.presets.renameGroup",
|
||||
"brush.presets.delete",
|
||||
"brush.presets.deleteGroup",
|
||||
] {
|
||||
assert!(ids.contains(&id), "{id}: {ids:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = app();
|
||||
app.ui.panels.brush_settings = true;
|
||||
@@ -229,9 +373,13 @@ fn preview_strip_re_renders_only_when_the_brush_changes() {
|
||||
|
||||
#[test]
|
||||
fn every_section_draws_without_issuing_commands() {
|
||||
for tab in 0..2 {
|
||||
for tab in 0..3 {
|
||||
for section in 0..SECTIONS.len() {
|
||||
let mut h = harness(tab, section);
|
||||
// Tab 2: the Brushes tab in grid view.
|
||||
let mut h = harness(tab.min(1), section);
|
||||
if tab == 2 {
|
||||
h.state_mut().ui.brushes_panel.view = BrushesView::Grid;
|
||||
}
|
||||
// A brush with every section on and a sampled tip.
|
||||
let mut b = BrushSettings::default();
|
||||
for i in 0..SECTIONS.len() {
|
||||
|
||||
@@ -328,12 +328,22 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
|
||||
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
|
||||
pct(ui, "Hardness", &mut b.hardness, 1.0);
|
||||
});
|
||||
signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0);
|
||||
// Unchecked, the pointer's speed sets the spacing (Photoshop).
|
||||
widgets::checkbox(ui, &mut b.spacing_enabled, "Spacing").on_hover_text("Off: the speed of the pointer sets the spacing");
|
||||
ui.add_space(2.0);
|
||||
let on = b.spacing_enabled;
|
||||
ui.add_enabled_ui(on, |ui| signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0));
|
||||
}
|
||||
|
||||
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let sd = &mut b.shape_dynamics;
|
||||
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false);
|
||||
pct(ui, "Size Jitter", &mut sd.size.jitter, 1.0);
|
||||
control_row(ui, "brush-size-ctl", &mut sd.size, false);
|
||||
pct(ui, "Minimum Diameter", &mut sd.size.minimum, 1.0);
|
||||
// Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop).
|
||||
let tilt = sd.size.control == Control::PenTilt;
|
||||
ui.add_enabled_ui(tilt, |ui| pct(ui, "Tilt Scale", &mut sd.tilt_scale, 2.0));
|
||||
ui.add_space(6.0);
|
||||
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true);
|
||||
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -341,6 +351,8 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
ui.add_space(8.0);
|
||||
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
widgets::checkbox(ui, &mut sd.brush_projection, "Brush Projection").on_hover_text("Apply the pen's tilt and rotation to the tip shape");
|
||||
}
|
||||
|
||||
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
@@ -519,6 +531,25 @@ fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let tr = &mut b.transfer;
|
||||
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-wet-ctl", "Wetness Jitter", &mut tr.wetness, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-mix-ctl", "Mix Jitter", &mut tr.mix, 1.0, Some("Minimum"), false);
|
||||
}
|
||||
|
||||
/// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options
|
||||
/// bar; `paint.mixerBrush` uses them unless its params override them).
|
||||
fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new("Mixer Brush").font(theme::semibold(11.5)).color(t.text_dim));
|
||||
ui.add_space(2.0);
|
||||
let m = &mut b.mixer;
|
||||
pct(ui, "Wet", &mut m.wet, 1.0);
|
||||
pct(ui, "Load", &mut m.load, 1.0);
|
||||
pct(ui, "Mix", &mut m.mix, 1.0);
|
||||
pct(ui, "Flow", &mut m.flow, 1.0);
|
||||
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
|
||||
}
|
||||
|
||||
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
|
||||
@@ -564,7 +595,10 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets:
|
||||
3 => texture(ui, b),
|
||||
4 => dual_brush(ui, b, presets),
|
||||
5 => color_dynamics(ui, b),
|
||||
6 => transfer(ui, b),
|
||||
6 => {
|
||||
transfer(ui, b);
|
||||
mixer_options(ui, b);
|
||||
}
|
||||
7 => pose(ui, b),
|
||||
8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."),
|
||||
9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."),
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Window › Brushes: the brush presets in collapsible groups, as a list (tip, size, stroke
|
||||
//! preview, name) or a grid of tips, with a size slider and a search field.
|
||||
//!
|
||||
//! Presets and groups are organised like Photoshop's panel: drag a preset within its group or
|
||||
//! into another group (onto a preset, or onto a group's header to append), drag a group's header
|
||||
//! to reorder groups, and right-click for Rename and Delete. Every change is a `brush.presets.*`
|
||||
//! command, so it is journaled, drivable, and persisted by the preset store.
|
||||
|
||||
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_engine::paint::BrushPreset;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::brush_panel::{BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status};
|
||||
use crate::brush_preview;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
/// What a drag in the Brushes tab carries.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BrushDrag {
|
||||
Preset(String),
|
||||
Group(String),
|
||||
}
|
||||
|
||||
/// What the tab does after drawing (the presets are borrowed while it draws).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Select(String),
|
||||
ToggleGroup(String),
|
||||
Move { name: String, group: String, index: Option<usize> },
|
||||
MoveGroup { group: String, before: Option<String> },
|
||||
Rename(Renaming),
|
||||
Delete(String),
|
||||
DeleteGroup(String),
|
||||
}
|
||||
|
||||
/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as
|
||||
/// [`UNGROUPED`].
|
||||
pub fn group_key(presets: &[BrushPreset], label: &str) -> String {
|
||||
if label == UNGROUPED && !presets.iter().any(|p| p.group == UNGROUPED) { String::new() } else { label.to_string() }
|
||||
}
|
||||
|
||||
/// Where a preset dropped onto `target` lands: (group, index in that group after the dragged
|
||||
/// preset is taken out). `after` = dropped on the target's lower (list) or right (grid) half.
|
||||
pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, usize)> {
|
||||
let t = presets.iter().find(|p| p.name == target)?;
|
||||
let members: Vec<&str> = presets.iter().filter(|p| p.group == t.group).map(|p| p.name.as_str()).collect();
|
||||
let tp = members.iter().position(|n| *n == target)?;
|
||||
let mut idx = tp + usize::from(after);
|
||||
if let Some(dp) = members.iter().position(|n| *n == dragged)
|
||||
&& dp < idx
|
||||
{
|
||||
idx -= 1;
|
||||
}
|
||||
Some((t.group.clone(), idx))
|
||||
}
|
||||
|
||||
/// Turn the tab's actions into commands.
|
||||
pub fn apply(app: &mut PhotocraftApp, acts: Vec<Action>) {
|
||||
for a in acts {
|
||||
match a {
|
||||
Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
|
||||
Action::ToggleGroup(g) => {
|
||||
let c = &mut app.ui.brushes_panel.collapsed;
|
||||
match c.iter().position(|x| *x == g) {
|
||||
Some(i) => drop(c.remove(i)),
|
||||
None => c.push(g),
|
||||
}
|
||||
}
|
||||
Action::Move { name, group, index } => {
|
||||
let mut p = json!({ "name": name, "group": group });
|
||||
if let Some(i) = index {
|
||||
p["index"] = json!(i);
|
||||
}
|
||||
run_or_status(app, "brush.presets.move", p);
|
||||
}
|
||||
Action::MoveGroup { group, before } => run_or_status(app, "brush.presets.moveGroup", json!({ "group": group, "before": before })),
|
||||
Action::Rename(r) => {
|
||||
let text = r.text.trim().to_string();
|
||||
if !text.is_empty() && text != r.name {
|
||||
if r.group {
|
||||
run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text }));
|
||||
} else {
|
||||
run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text }));
|
||||
}
|
||||
}
|
||||
app.ui.brushes_panel.renaming = None;
|
||||
}
|
||||
Action::Delete(name) => run_or_status(app, "brush.presets.delete", json!({ "name": name })),
|
||||
Action::DeleteGroup(g) => run_or_status(app, "brush.presets.deleteGroup", json!({ "group": g })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An accent insertion line on one edge of `r` while a drag hovers it.
|
||||
fn drop_line(ui: &egui::Ui, r: egui::Rect, after: bool, vertical: bool, t: &Tokens) {
|
||||
let s = Stroke::new(2.0, t.accent);
|
||||
if vertical {
|
||||
let x = if after { r.right() } else { r.left() };
|
||||
ui.painter().line_segment([pos2(x, r.top() + 2.0), pos2(x, r.bottom() - 2.0)], s);
|
||||
} else {
|
||||
let y = if after { r.bottom() } else { r.top() };
|
||||
ui.painter().line_segment([pos2(r.left() + 4.0, y), pos2(r.right() - 4.0, y)], s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the pointer on the second half of `r` (lower in a list, right in a grid)?
|
||||
fn second_half(ui: &egui::Ui, r: egui::Rect, grid: bool) -> bool {
|
||||
ui.ctx().pointer_interact_pos().is_some_and(|p| if grid { p.x > r.center().x } else { p.y > r.center().y })
|
||||
}
|
||||
|
||||
/// Drag, drop and context menu of one preset cell or row.
|
||||
fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: &BrushPreset, presets: &[BrushPreset], grid: bool, acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone()));
|
||||
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>()
|
||||
&& matches!(&*d, BrushDrag::Preset(n) if *n != p.name)
|
||||
{
|
||||
drop_line(ui, r, second_half(ui, r, grid), grid, &t);
|
||||
}
|
||||
if let Some(d) = resp.dnd_release_payload::<BrushDrag>()
|
||||
&& let BrushDrag::Preset(n) = &*d
|
||||
&& *n != p.name
|
||||
&& let Some((group, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid))
|
||||
{
|
||||
acts.push(Action::Move { name: n.clone(), group, index: Some(index) });
|
||||
}
|
||||
if resp.clicked() {
|
||||
acts.push(Action::Select(p.name.clone()));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Rename Brush…").clicked() {
|
||||
acts.push(Action::Rename(Renaming { group: false, name: p.name.clone(), text: String::new() }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete Brush").clicked() {
|
||||
acts.push(Action::Delete(p.name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag());
|
||||
if !ui.is_rect_visible(r) {
|
||||
return;
|
||||
}
|
||||
if current {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
|
||||
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(cell.center().x, r.bottom() - 1.0),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.0),
|
||||
t.text_faint,
|
||||
);
|
||||
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
|
||||
let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
|
||||
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(pos2(sr.right() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, &p.name, egui::FontId::proportional(12.0), t.text_dim);
|
||||
preset_interactions(ui, &resp, r, p, presets, false, acts);
|
||||
}
|
||||
|
||||
fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 58.0), Sense::click_and_drag());
|
||||
if !ui.is_rect_visible(r) {
|
||||
return;
|
||||
}
|
||||
if current {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent_soft);
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 23.0), vec2(38.0, 38.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(r.center().x, r.bottom() - 7.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.5),
|
||||
t.text_dim,
|
||||
);
|
||||
let resp = resp.on_hover_text(&p.name);
|
||||
preset_interactions(ui, &resp, r, p, presets, true, acts);
|
||||
}
|
||||
|
||||
/// A group's header: chevron, folder, name, count. Click toggles; drag reorders groups; a
|
||||
/// preset dropped here moves to the end of the group.
|
||||
fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: usize, acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let x = r.left() + 4.0;
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
12.0,
|
||||
t.text_dim,
|
||||
);
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
|
||||
if open { "folder-open" } else { "folder" },
|
||||
14.0,
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text);
|
||||
ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
|
||||
resp.dnd_set_drag_payload(BrushDrag::Group(key.to_string()));
|
||||
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>() {
|
||||
match &*d {
|
||||
BrushDrag::Group(g) if g != key => drop_line(ui, r, false, false, &t),
|
||||
BrushDrag::Preset(_) => {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(d) = resp.dnd_release_payload::<BrushDrag>() {
|
||||
match &*d {
|
||||
BrushDrag::Group(g) if g != key => acts.push(Action::MoveGroup { group: g.clone(), before: Some(key.to_string()) }),
|
||||
BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), index: None }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
acts.push(Action::ToggleGroup(label.to_string()));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Rename Group…").clicked() {
|
||||
acts.push(Action::Rename(Renaming { group: true, name: key.to_string(), text: String::new() }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete Group").clicked() {
|
||||
acts.push(Action::DeleteGroup(key.to_string()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or
|
||||
/// Cancel stops.
|
||||
fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec<Action>) {
|
||||
let Some(r) = app.ui.brushes_panel.renaming.as_mut() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if r.text.is_empty() {
|
||||
r.text = r.name.clone();
|
||||
}
|
||||
let mut cancel = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(if r.group { "Group name" } else { "Brush name" }).color(t.text_dim));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(WIDTH - 230.0).id_salt("brush-rename"));
|
||||
if !resp.has_focus() && !resp.lost_focus() {
|
||||
resp.request_focus();
|
||||
}
|
||||
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if widgets::primary_button(ui, "OK", 52.0).clicked() || enter {
|
||||
acts.push(Action::Rename(r.clone()));
|
||||
}
|
||||
if widgets::secondary_button(ui, "Cancel", 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
if cancel {
|
||||
app.ui.brushes_panel.renaming = None;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
/// The Brushes tab.
|
||||
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Size of the current brush (Photoshop's Brushes panel slider).
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Size").color(t.text_dim));
|
||||
let mut lv = b.size.max(1.0).ln();
|
||||
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
|
||||
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
|
||||
if r.changed() {
|
||||
b.size = lv.exp().round().clamp(1.0, 5000.0);
|
||||
}
|
||||
r
|
||||
});
|
||||
let mut s = b.size;
|
||||
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
|
||||
b.size = s.round().clamp(1.0, 5000.0);
|
||||
}
|
||||
});
|
||||
commit_gesture(app, ui.ctx(), &before, &b);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
|
||||
ui.add_space(20.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 120.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let view = &mut app.ui.brushes_panel.view;
|
||||
if icons::button(ui, "grid-2x2", 24.0, *view == BrushesView::Grid, "Grid view").clicked() {
|
||||
*view = BrushesView::Grid;
|
||||
}
|
||||
if icons::button(ui, "align-justify", 24.0, *view == BrushesView::List, "List view").clicked() {
|
||||
*view = BrushesView::List;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut acts = Vec::new();
|
||||
rename_bar(app, ui, &mut acts);
|
||||
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
|
||||
let grid = app.ui.brushes_panel.view == BrushesView::Grid;
|
||||
let presets = &app.session.tools.presets;
|
||||
let groups = grouped_presets(presets);
|
||||
let brush = &app.session.tools.brush;
|
||||
let collapsed = &app.ui.brushes_panel.collapsed;
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for (label, items) in groups {
|
||||
let items: Vec<usize> =
|
||||
items.into_iter().filter(|i| filter.is_empty() || presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))).collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = group_key(presets, &label);
|
||||
let open = !filter.is_empty() || !collapsed.contains(&label);
|
||||
group_header(ui, &label, &key, open, items.len(), &mut acts);
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
if grid {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
|
||||
ui.add_space(20.0);
|
||||
for i in items {
|
||||
if let Some(p) = presets.get(i) {
|
||||
grid_cell(ui, p, is_current(&p.brush, brush), presets, &mut acts);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for i in items {
|
||||
if let Some(p) = presets.get(i) {
|
||||
list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
// The dragged preset or group follows the pointer.
|
||||
if let Some(d) = egui::DragAndDrop::payload::<BrushDrag>(ui.ctx())
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
let name = match &*d {
|
||||
BrushDrag::Preset(n) => n.clone(),
|
||||
BrushDrag::Group(g) => {
|
||||
if g.is_empty() {
|
||||
UNGROUPED.to_string()
|
||||
} else {
|
||||
g.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
egui::Area::new(egui::Id::new("brush-drag-label")).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name));
|
||||
},
|
||||
);
|
||||
}
|
||||
apply(app, acts);
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
|
||||
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
|
||||
&& let Some(name) = current
|
||||
{
|
||||
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
|
||||
let name = new_preset_name(&app.session.tools.presets);
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Forget previews of presets that no longer exist.
|
||||
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
|
||||
brush_preview::with_cache(ui.ctx(), |c| {
|
||||
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
|
||||
});
|
||||
}
|
||||
@@ -1516,6 +1516,16 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
|
||||
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
|
||||
let color: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
|
||||
if color.len() == 4 && color[3] > 0.0 {
|
||||
let key = if mods.alt { "background" } else { "foreground" };
|
||||
let _ = app.run("tools.setColors", json!({ key: [color[0], color[1], color[2], 1.0] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool state machine. Shared by mouse input and automation.
|
||||
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
@@ -1528,6 +1538,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, mods);
|
||||
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
|
||||
let ev = crate::move_mods::filter_event(app, ev, mods);
|
||||
// Ruler, Count and Note tools.
|
||||
if crate::analysis_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
@@ -1541,6 +1553,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
if tool == Tool::Eyedropper {
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => {
|
||||
sample_eyedropper(app, x, y, mods);
|
||||
return;
|
||||
}
|
||||
ToolEvent::Up { .. } => return,
|
||||
}
|
||||
}
|
||||
// Move tool over a guide drags the guide (off the canvas deletes it).
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
|
||||
@@ -1573,16 +1594,6 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, pressure } => {
|
||||
if tool == Tool::Eyedropper {
|
||||
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
|
||||
let c: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
|
||||
if c.len() == 4 && c[3] > 0.0 {
|
||||
let key = if mods.alt { "background" } else { "foreground" };
|
||||
let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match tool {
|
||||
Tool::Pen => {
|
||||
crate::vector_ui::pen_down(app, x, y);
|
||||
@@ -1659,6 +1670,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
crate::move_mods::finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1856,6 +1868,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_drag_updates_the_sampled_colour() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [0, 0, 20, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [20, 0, 20, 20], "fill": "#00ff00"})).unwrap();
|
||||
app.ui.tool = Tool::Eyedropper;
|
||||
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
assert!(app.session.tools.foreground[0] > 0.99 && app.session.tools.foreground[1] < 0.01);
|
||||
|
||||
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_drag_shows_the_real_stroke_and_commits_it() {
|
||||
// The drag used to draw a hard, flat stand-in and only showed the soft brush on release.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+106
-10
@@ -29,6 +29,8 @@ pub enum Group {
|
||||
Color,
|
||||
/// Properties | Adjustments.
|
||||
Properties,
|
||||
/// Character | Paragraph (not in the default workspace; Window › Character opens it, #150).
|
||||
Character,
|
||||
/// Navigator | Histogram | Info.
|
||||
Navigator,
|
||||
/// History | Actions | Layer Comps.
|
||||
@@ -39,29 +41,54 @@ pub enum Group {
|
||||
|
||||
impl Group {
|
||||
/// Photoshop Essentials order, top to bottom.
|
||||
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
|
||||
pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Group::Color => "color",
|
||||
Group::Properties => "properties",
|
||||
Group::Character => "character",
|
||||
Group::Navigator => "navigator",
|
||||
Group::History => "history",
|
||||
Group::Layers => "layers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it.
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it, when the
|
||||
/// column has room (see [`DockLayout::heights_for`]).
|
||||
pub fn default_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 200.0,
|
||||
Group::Properties => 340.0,
|
||||
Group::Navigator => 230.0,
|
||||
Group::History => 220.0,
|
||||
Group::Color => 190.0,
|
||||
Group::Properties => 250.0,
|
||||
Group::Character => 270.0,
|
||||
Group::Navigator => 210.0,
|
||||
Group::History => 200.0,
|
||||
Group::Layers => 320.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a group left at its default height gives way down to so the filler (Layers) keeps
|
||||
/// [`Group::preferred_fill`] in a short column (#147). Content taller than this scrolls.
|
||||
pub fn compact_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 130.0,
|
||||
Group::Properties => 160.0,
|
||||
Group::Character => 160.0,
|
||||
Group::Navigator => 140.0,
|
||||
Group::History => 130.0,
|
||||
Group::Layers => 200.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the filling group asks for before default-sized groups above it get their
|
||||
/// full defaults: Layers wants room for about ten rows at 900 pt (#147).
|
||||
pub fn preferred_fill(self) -> f32 {
|
||||
match self {
|
||||
Group::Layers => 500.0,
|
||||
g => g.min_height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest height an expanded group can be dragged or squeezed to.
|
||||
pub fn min_height(self) -> f32 {
|
||||
match self {
|
||||
@@ -76,6 +103,7 @@ impl Group {
|
||||
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
|
||||
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
|
||||
Group::Properties => &["Properties", "Adjustments"],
|
||||
Group::Character => &["Character", "Paragraph"],
|
||||
Group::Navigator => &["Navigator", "Histogram", "Info"],
|
||||
Group::History => &["History", "Actions", "Layer Comps"],
|
||||
Group::Layers => &["Layers", "Channels", "Paths"],
|
||||
@@ -91,6 +119,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut tabs.color,
|
||||
Group::Properties => &mut tabs.properties,
|
||||
Group::Character => &mut tabs.character,
|
||||
Group::Navigator => &mut tabs.navigator,
|
||||
Group::History => &mut tabs.history,
|
||||
Group::Layers => &mut tabs.layers,
|
||||
@@ -106,6 +135,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => panels.color,
|
||||
Group::Properties => panels.properties,
|
||||
Group::Character => panels.character,
|
||||
Group::Navigator => panels.navigator,
|
||||
Group::History => panels.history,
|
||||
Group::Layers => panels.layers,
|
||||
@@ -116,6 +146,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut panels.color,
|
||||
Group::Properties => &mut panels.properties,
|
||||
Group::Character => &mut panels.character,
|
||||
Group::Navigator => &mut panels.navigator,
|
||||
Group::History => &mut panels.history,
|
||||
Group::Layers => &mut panels.layers,
|
||||
@@ -127,7 +158,8 @@ impl Group {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DockLayout {
|
||||
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
|
||||
/// Top-to-bottom order; a group missing here (say one added after the layout was saved)
|
||||
/// goes just below the nearest group that precedes it by default (or last).
|
||||
pub order: Vec<Group>,
|
||||
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
|
||||
pub heights: BTreeMap<Group, f32>,
|
||||
@@ -144,11 +176,23 @@ impl DockLayout {
|
||||
/// Every group once, in display order.
|
||||
pub fn order(&self) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
|
||||
for g in self.order.iter().chain(Group::ALL.iter()) {
|
||||
for g in &self.order {
|
||||
if !out.contains(g) {
|
||||
out.push(*g);
|
||||
}
|
||||
}
|
||||
// Missing groups slot in below their default predecessor, so a group new to an old
|
||||
// saved layout (Character) never lands below Layers and takes over as the filler.
|
||||
for (i, g) in Group::ALL.iter().enumerate() {
|
||||
if out.contains(g) {
|
||||
continue;
|
||||
}
|
||||
let prev = Group::ALL.iter().take(i).rev().find_map(|p| out.iter().position(|x| x == p));
|
||||
match prev {
|
||||
Some(at) => out.insert(at + 1, *g),
|
||||
None => out.push(*g),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -185,7 +229,9 @@ impl DockLayout {
|
||||
|
||||
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
|
||||
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
|
||||
/// rest; when the column is too short the others give way down to their minimum heights.
|
||||
/// rest. Groups the user never resized give way first, down to their compact heights, so
|
||||
/// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still
|
||||
/// too short every group gives way down to its minimum height.
|
||||
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
|
||||
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
|
||||
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
|
||||
@@ -205,6 +251,23 @@ impl DockLayout {
|
||||
if let Some(f) = filler {
|
||||
let gaps = GAP * shown.len().saturating_sub(1) as f32;
|
||||
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
|
||||
let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill());
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + pref_fill - avail).max(0.0);
|
||||
for i in (0..f).rev() {
|
||||
if deficit <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let Some(g) = shown.get(i) else { continue };
|
||||
if self.is_collapsed(*g) || self.heights.contains_key(g) {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hs.get_mut(i) {
|
||||
let give = (*h - g.compact_height()).max(0.0).min(deficit);
|
||||
*h -= give;
|
||||
deficit -= give;
|
||||
}
|
||||
}
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + min_fill - avail).max(0.0);
|
||||
// Squeeze the expanded groups nearest the filler first.
|
||||
@@ -269,6 +332,27 @@ fn rects_id() -> egui::Id {
|
||||
egui::Id::new("dock-group-rects")
|
||||
}
|
||||
|
||||
/// A group's tab strip as drawn last frame (screen points), for tests and automation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StripRects {
|
||||
pub group: Group,
|
||||
/// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu).
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The panel menu button.
|
||||
pub menu: Rect,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// The tab strips drawn last frame.
|
||||
pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
|
||||
ctx.data(|d| d.get_temp::<Vec<StripRects>>(strips_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn strips_id() -> egui::Id {
|
||||
egui::Id::new("dock-strip-rects")
|
||||
}
|
||||
|
||||
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
|
||||
/// group's tab content.
|
||||
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
|
||||
@@ -281,6 +365,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
let rects = rects_after_layout(&heights, area);
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
let mut dragging: Option<Group> = None;
|
||||
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
|
||||
for (i, (g, rect)) in rects.iter().copied().enumerate() {
|
||||
let collapsed = app.ui.dock.is_collapsed(g);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
|
||||
@@ -302,6 +387,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
.show(ui, |ui| body(app, ui, g, tab));
|
||||
}
|
||||
});
|
||||
strips.push(StripRects { group: g, tabs: resp.tabs.clone(), menu: resp.menu.rect, chevron: resp.chevron });
|
||||
// The body may switch tabs itself (Adjustments jumps back to Properties).
|
||||
if sel != before {
|
||||
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
|
||||
@@ -368,7 +454,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
};
|
||||
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(rects_id(), rects);
|
||||
d.insert_temp(strips_id(), strips);
|
||||
});
|
||||
ui.advance_cursor_after_rect(area);
|
||||
for a in actions {
|
||||
match a {
|
||||
@@ -422,6 +511,13 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
|
||||
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
|
||||
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
|
||||
let Some(&(n, nh)) = heights.get(j) else { return };
|
||||
// The first drag pins the other groups at the heights they show, so groups still at their
|
||||
// defaults (which give way to the filler) don't shift while this one is resized.
|
||||
for (k, (o, oh)) in heights.iter().enumerate() {
|
||||
if Some(k) != filler && !layout.is_collapsed(*o) {
|
||||
layout.heights.entry(*o).or_insert(*oh);
|
||||
}
|
||||
}
|
||||
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
|
||||
layout.heights.insert(g, new_h);
|
||||
if Some(j) != filler {
|
||||
|
||||
@@ -12,15 +12,15 @@ const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
|
||||
#[test]
|
||||
fn last_expanded_group_fills_the_column() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
|
||||
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
|
||||
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
|
||||
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
|
||||
assert!((total - 1200.0).abs() < 1e-3, "{hs:?}");
|
||||
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
|
||||
let mut l = DockLayout::default();
|
||||
l.set_collapsed(Group::Color, true);
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0);
|
||||
assert_eq!(hs[0].1, 28.0);
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// Layers collapsed: Properties becomes the filler.
|
||||
@@ -33,10 +33,10 @@ fn last_expanded_group_fills_the_column() {
|
||||
#[test]
|
||||
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0);
|
||||
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
|
||||
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.default_height());
|
||||
assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.compact_height());
|
||||
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
|
||||
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
|
||||
@@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
#[test]
|
||||
fn bad_stored_values_are_sanitised() {
|
||||
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
|
||||
l.heights.insert(Group::Color, bad);
|
||||
let h = l.height(Group::Color);
|
||||
@@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() {
|
||||
}
|
||||
// Unknown fields, wrong types and old UI state all load.
|
||||
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
|
||||
ui.as_object_mut().unwrap().remove("dock");
|
||||
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
|
||||
@@ -150,11 +150,13 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
let layers = rect_of(&h, Group::Layers);
|
||||
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
|
||||
drag(&mut h, split, split - vec2(0.0, 90.0));
|
||||
let color = rect_of(&h, Group::Color);
|
||||
drag(&mut h, split, split - vec2(0.0, 60.0));
|
||||
let props2 = rect_of(&h, Group::Properties);
|
||||
let layers2 = rect_of(&h, Group::Layers);
|
||||
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert!((props2.height() - (props.height() - 60.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place");
|
||||
assert_eq!(layers2.bottom(), layers.bottom());
|
||||
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
|
||||
// Dragging past the minimum stops at it.
|
||||
@@ -348,3 +350,219 @@ fn clicking_around_the_ui_keeps_the_panels_put() {
|
||||
h.state_mut().ui.dialogs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pro(theme: ThemeKind) -> bool {
|
||||
matches!(theme, ThemeKind::Pro | ThemeKind::ProMedium)
|
||||
}
|
||||
|
||||
/// Full app at `size` (1× scale) on a document with `n` layers named "Row 00", "Row 01", …
|
||||
fn app_harness_rows(size: egui::Vec2, theme: ThemeKind, n: usize) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(size).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.theme = theme;
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
for i in 0..n {
|
||||
app.run("layer.new.layer", json!({"name": format!("Row {i:02}")})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// Layer rows fully inside the Layers rows viewport.
|
||||
fn rows_in_view(h: &Harness<'static, PhotocraftApp>, n: usize) -> usize {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let layers = rect_of(h, Group::Layers);
|
||||
(0..n)
|
||||
.filter(|i| {
|
||||
h.query_all_by_label(&format!("Row {i:02}"))
|
||||
.map(|q| q.rect())
|
||||
.any(|r| r.left() >= layers.left() - 1.0 && r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// #147: the default workspace gives Layers the column's spare height: at least ten rows on a
|
||||
/// 900 pt window, and a usable list on a 720 pt one.
|
||||
#[test]
|
||||
fn default_layout_shows_ten_layer_rows_at_900pt() {
|
||||
for (size, want) in [(vec2(1440.0, 900.0), 10), (vec2(1280.0, 720.0), 4)] {
|
||||
let h = app_harness_rows(size, ThemeKind::ProMedium, 40);
|
||||
let rows = rows_in_view(&h, 40);
|
||||
let groups = last_rects(&h.ctx);
|
||||
assert!(rows >= want, "{size:?}: {rows} rows visible, want ≥ {want}: {groups:?}");
|
||||
// Color and Properties stay open, just not at the expense of Layers.
|
||||
for g in [Group::Color, Group::Properties] {
|
||||
assert!(rect_of(&h, g).height() >= g.compact_height() - 0.5, "{g:?} at {size:?}: {groups:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_give_way_to_layers_but_user_sizes_stay() {
|
||||
let l = DockLayout::default();
|
||||
// A tall column: everyone at their defaults, Layers takes the rest.
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0].1, Group::Color.default_height());
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// A short one: default-sized groups shrink toward their compact heights so Layers keeps
|
||||
// its preferred height, the one nearest Layers first.
|
||||
let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0);
|
||||
assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}");
|
||||
assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}");
|
||||
// Heights the user dragged to (saved in prefs) are kept as they are.
|
||||
let mut mine = DockLayout::default();
|
||||
mine.heights.insert(Group::Properties, 340.0);
|
||||
mine.heights.insert(Group::Color, 200.0);
|
||||
let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}");
|
||||
}
|
||||
|
||||
/// #150: Window › Character opens a Character | Paragraph group next to Properties (which
|
||||
/// stays), and toggles closed again; old saved layouts place it above Layers.
|
||||
#[test]
|
||||
fn window_character_opens_its_own_group_and_keeps_properties() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, _, _) = app_with_layers();
|
||||
let mut h = harness(app, vec2(1440.0, 900.0), theme);
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.character", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(app.ui.panels.character && app.ui.panels.properties && app.ui.dock_tabs.character == 0, "{theme:?}");
|
||||
let ch = rect_of(&h, Group::Character);
|
||||
assert!(ch.height() > 60.0, "{theme:?}: Character expanded: {ch:?}");
|
||||
if is_pro(theme) {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
assert!(props.height() > 60.0 && props.bottom() <= ch.top(), "{theme:?}: Properties visible above Character");
|
||||
}
|
||||
assert!(rect_of(&h, Group::Layers).top() > ch.bottom(), "{theme:?}: Layers stays the filler at the bottom");
|
||||
assert_eq!(crate::view_cmds::checked(h.state(), "window.panel.character"), Some(true));
|
||||
// Paragraph is the group's second tab; choosing it again on its tab closes the group.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character && h.state().ui.dock_tabs.character == 1);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties, "{theme:?}");
|
||||
assert!(!last_rects(&h.ctx).iter().any(|(g, _)| *g == Group::Character));
|
||||
// Type › Panels › Character Panel always shows it; Reset Workspace closes it.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties);
|
||||
}
|
||||
// A layout saved before the group existed keeps Layers last.
|
||||
let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() };
|
||||
assert_eq!(old.order(), Group::ALL.to_vec());
|
||||
let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
}
|
||||
|
||||
type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option<Rect>, Rect)>;
|
||||
|
||||
/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron
|
||||
/// and never run under the panel menu button.
|
||||
#[test]
|
||||
fn tab_strips_never_overlap_the_menu_button() {
|
||||
let overlap = |a: Rect, b: Rect| {
|
||||
let i = a.intersect(b);
|
||||
i.width() > 0.5 && i.height() > 0.5
|
||||
};
|
||||
for theme in ThemeKind::ALL {
|
||||
for scale in [1.0, 2.0] {
|
||||
for width in [180.0, 250.0, 290.0, 420.0] {
|
||||
for g in Group::ALL {
|
||||
let tabs = g.tabs(is_pro(theme));
|
||||
for sel in 0..tabs.len() {
|
||||
let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
move |ui, out: &mut StripProbe| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let mut s = sel;
|
||||
let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
*out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect()));
|
||||
},
|
||||
None,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.run_steps(3);
|
||||
let (shown, menu, chevron, card) = h.state().clone().unwrap();
|
||||
let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}");
|
||||
assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}");
|
||||
for (i, r) in &shown {
|
||||
assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}");
|
||||
assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card");
|
||||
}
|
||||
for w in shown.windows(2) {
|
||||
assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap");
|
||||
}
|
||||
if let Some(c) = chevron {
|
||||
assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps");
|
||||
} else {
|
||||
assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #151 in the real dock: every strip at 2× scale fits beside its menu button, and a tab in the
|
||||
/// chevron menu can still be chosen.
|
||||
#[test]
|
||||
fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let (mut app, _, _) = app_with_layers();
|
||||
app.ui.panels.history = true;
|
||||
app.ui.panels.navigator = true;
|
||||
app.ui.panels.character = true;
|
||||
let mut h = Harness::builder().with_size(vec2(900.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
let strips = last_strips(&h.ctx);
|
||||
assert!(strips.len() >= 5, "{theme:?}: {strips:?}");
|
||||
for s in &strips {
|
||||
for (i, r) in &s.tabs {
|
||||
assert!(r.intersect(s.menu).width() <= 0.5, "{theme:?} {:?}: tab {i} under the menu", s.group);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A strip too narrow for its tabs: pick a hidden tab from the chevron menu.
|
||||
let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state(
|
||||
|ui, sel: &mut usize| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
},
|
||||
0usize,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium);
|
||||
h.run_steps(3);
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("More panels").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Patterns").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
|
||||
}
|
||||
@@ -243,6 +243,10 @@ fn choice_label(v: &str) -> String {
|
||||
label(v)
|
||||
}
|
||||
|
||||
fn uses_logarithmic_slider(min: f32, max: f32) -> bool {
|
||||
min > 0.0 && max / min > 500.0
|
||||
}
|
||||
|
||||
/// Dialog body for filter commands.
|
||||
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -268,16 +272,20 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let logarithmic = min > 0.0 && max / min > 500.0;
|
||||
if logarithmic {
|
||||
// Scrub the value field directly; add a log-scaled slider for huge ranges.
|
||||
let mut lv = v.max(min.max(0.1)).ln();
|
||||
let r = crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
|
||||
let _ = r;
|
||||
let r2 = crate::widgets::slider(ui, &mut lv, min.max(0.1).ln()..=max.ln(), None);
|
||||
if r2.changed() {
|
||||
if uses_logarithmic_slider(min, max) {
|
||||
// Keep one numeric field for direct entry and one log-scaled slider for
|
||||
// wide ranges. slider_row would add a second, linear slider (#146).
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
|
||||
});
|
||||
});
|
||||
let mut lv = v.clamp(min, max).ln();
|
||||
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
|
||||
v = lv.exp();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
} else {
|
||||
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
|
||||
}
|
||||
@@ -421,6 +429,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_positive_ranges_use_the_logarithmic_slider_path() {
|
||||
assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius");
|
||||
assert!(uses_logarithmic_slider(1.0, 9999.0));
|
||||
assert!(!uses_logarithmic_slider(1.0, 500.0));
|
||||
assert!(!uses_logarithmic_slider(0.0, 1000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_filters_have_dialogs_or_run_directly() {
|
||||
for id in [
|
||||
|
||||
@@ -124,6 +124,11 @@ impl GpuCanvas {
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
|
||||
/// `Rgba8Unorm`.
|
||||
/// The device's largest 2D texture side (egui textures can use it too).
|
||||
pub fn max_texture_side(&self) -> usize {
|
||||
self.rs.device.limits().max_texture_dimension_2d as usize
|
||||
}
|
||||
|
||||
pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat {
|
||||
let px = size[0] as u64 * size[1] as u64;
|
||||
let high = match self.high {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Reveal the active layer in the Layers panel (#152), like Photoshop: whenever the active
|
||||
//! layer changes, by whatever means (a click on the canvas with Auto-Select, Select › layer
|
||||
//! commands, ⌥[ / ⌥], undo, automation), its closed parent groups open (`layer.setExpanded`)
|
||||
//! and the panel scrolls just enough to show its row, unless the row is already in view.
|
||||
//!
|
||||
//! Only a change of the active layer reveals: closing the group that holds it, or scrolling
|
||||
//! away, is left alone until the next change.
|
||||
|
||||
use egui::{Rect, pos2};
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Last active layer the panel saw: (document index, layer).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Seen(usize, Option<LayerId>);
|
||||
|
||||
fn seen_id() -> egui::Id {
|
||||
egui::Id::new("layers-reveal-seen")
|
||||
}
|
||||
|
||||
/// Called once per Layers panel frame, before its rows are listed. When the active layer
|
||||
/// changed since the last frame, opens its closed parent groups and returns it: the row to
|
||||
/// scroll into view (see [`scroll_to_row`]). The first frame of a document only records it.
|
||||
pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<LayerId> {
|
||||
let index = app.session.active_index()?;
|
||||
let active = app.session.active()?.active_layer;
|
||||
let now = Seen(index, active);
|
||||
let before = ctx.data(|d| d.get_temp::<Seen>(seen_id()));
|
||||
ctx.data_mut(|d| d.insert_temp(seen_id(), now));
|
||||
match before {
|
||||
Some(Seen(i, prev)) if i == index && prev != active => {}
|
||||
_ => return None,
|
||||
}
|
||||
let id = active?;
|
||||
for group in closed_ancestors(app, id) {
|
||||
// A view change (no history step); a failure just leaves the group closed.
|
||||
let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true}));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The groups containing `id` that are closed, outermost first.
|
||||
fn closed_ancestors(app: &PhotocraftApp, id: LayerId) -> Vec<LayerId> {
|
||||
let Some(doc) = app.session.active().map(|s| &s.doc) else { return Vec::new() };
|
||||
let Some(path) = doc.path_of(id) else { return Vec::new() };
|
||||
(1..path.len()).filter_map(|n| doc.layer_at(path.get(..n)?)).filter(|l| matches!(&l.content, LayerContent::Group(g) if !g.expanded)).map(|l| l.id).collect()
|
||||
}
|
||||
|
||||
/// After drawing the row that started at `top` in the Layers scroll area, scroll it into view
|
||||
/// if any of it is hidden (no animation: Photoshop jumps).
|
||||
pub fn scroll_to_row(ui: &egui::Ui, top: f32) {
|
||||
let bottom = ui.min_rect().bottom().max(top);
|
||||
let row = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), bottom));
|
||||
let view = ui.clip_rect();
|
||||
if row.top() < view.top() - 0.5 || row.bottom() > view.bottom() + 0.5 {
|
||||
ui.scroll_to_rect_animation(row, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layer_reveal_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! #152: a new active layer opens its parent groups and is scrolled into view.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::dock::{Group, last_rects};
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
/// 30 layers above a closed group holding `inner` (near the bottom of the panel).
|
||||
fn tall_doc() -> (PhotocraftApp, LayerId, LayerId, LayerId, LayerId) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
app.run("layer.new.layer", json!({"name": "Deep inside"})).unwrap();
|
||||
let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.unwrap();
|
||||
let inner = active(&app);
|
||||
app.run("layer.groupLayers", json!({})).unwrap();
|
||||
let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap();
|
||||
app.run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
app.run("layer.select", json!({"layer": group.0})).unwrap();
|
||||
let mut stack = Vec::new();
|
||||
for i in 0..30 {
|
||||
app.run("layer.new.layer", json!({"name": format!("Stack {i:02}")})).unwrap();
|
||||
stack.push(active(&app));
|
||||
}
|
||||
app.sync_views();
|
||||
(app, inner, group, stack[29], stack[28])
|
||||
}
|
||||
|
||||
fn harness(app: PhotocraftApp, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// The Layers panel row of `name`, if drawn.
|
||||
fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers)?.1;
|
||||
h.query_all_by_label(name).map(|n| n.rect()).find(|r| layers.contains(r.center()) || (r.left() >= layers.left() && r.right() <= layers.right()))
|
||||
}
|
||||
|
||||
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
|
||||
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
|
||||
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
|
||||
}
|
||||
|
||||
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
|
||||
matches!(&app.session.active().unwrap().doc.layer(group).unwrap().content, photocraft_doc::LayerContent::Group(g) if g.expanded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, inner, group, top, below_top) = tall_doc();
|
||||
let mut h = harness(app, theme);
|
||||
let top_row = row(&h, "Stack 29").expect("top row drawn");
|
||||
assert!(in_view(&h, top_row), "{theme:?}: the panel starts at the top");
|
||||
assert!(row(&h, "Deep inside").is_none(), "{theme:?}: hidden in its closed group");
|
||||
|
||||
// Select › layer, Auto-Select, automation: all end in a new active layer.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(expanded(h.state(), group), "{theme:?}: the parent group opened");
|
||||
let r = row(&h, "Deep inside").expect("row listed once its group is open");
|
||||
assert!(in_view(&h, r), "{theme:?}: scrolled into view: {r:?}");
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: the top scrolled away");
|
||||
|
||||
// Back to the top layer: scrolls back up.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: scrolled back to the top");
|
||||
|
||||
// A layer already in view doesn't move the list.
|
||||
let before = row(&h, "Stack 29").unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": below_top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(row(&h, "Stack 29").unwrap(), before, "{theme:?}: no scroll for a visible row");
|
||||
|
||||
// Closing the group by hand is respected until the active layer changes again.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
|
||||
// Undo and other commands that change the active layer reveal it too: undoing a new
|
||||
// layer deep in the list makes the top layer active again.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(expanded(h.state(), group));
|
||||
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_survives_odd_documents() {
|
||||
// No document, a document without layers in groups, a layer deleted between frames.
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = harness(app, ThemeKind::ProMedium);
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.delete", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("file.close", json!({})).ok();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -44,6 +45,7 @@ mod icon_data;
|
||||
pub mod icons;
|
||||
pub mod layer_menu_ui;
|
||||
pub mod layer_props_ui;
|
||||
mod layer_reveal;
|
||||
pub mod layer_row_ui;
|
||||
pub mod layer_style;
|
||||
pub mod layer_tree_ui;
|
||||
@@ -52,6 +54,7 @@ pub mod liquify_ui;
|
||||
pub mod menu_catalog;
|
||||
pub mod menu_nav;
|
||||
pub mod menus;
|
||||
pub mod move_mods;
|
||||
pub mod new_doc_ui;
|
||||
pub mod notices;
|
||||
pub mod outline;
|
||||
@@ -76,9 +79,11 @@ pub mod smart_ui;
|
||||
pub mod snap_ui;
|
||||
pub mod state;
|
||||
pub mod stylus;
|
||||
mod tab_strip;
|
||||
pub mod theme;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
pub mod transform_tex;
|
||||
pub mod transform_tool;
|
||||
pub mod type_panels_ui;
|
||||
pub mod type_tool;
|
||||
@@ -248,6 +253,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) doc_hist: Option<(DocId, u64, f64, std::sync::Arc<tone::Histograms>)>,
|
||||
/// Free Transform preview (document without the moving pixels + their texture).
|
||||
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
|
||||
/// Move-tool ⇧/⌥ drag state (move_mods).
|
||||
pub(crate) move_mods: move_mods::MoveDrag,
|
||||
/// Live Layer Style dialog preview: (key over revision + style fields, document with the style applied).
|
||||
pub(crate) style_preview: Option<(u64, Option<std::sync::Arc<Document>>)>,
|
||||
/// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui).
|
||||
@@ -341,6 +348,7 @@ impl PhotocraftApp {
|
||||
clip_external: false,
|
||||
clip_read_for_paste: false,
|
||||
transform_preview: None,
|
||||
move_mods: Default::default(),
|
||||
style_preview: None,
|
||||
distort: Default::default(),
|
||||
camera_raw: None,
|
||||
|
||||
@@ -169,12 +169,14 @@ impl LiquifyDialog {
|
||||
let d = self.field.apply_stroke(&s);
|
||||
self.strokes.push(s);
|
||||
self.mark(d, true);
|
||||
self.render_dirty();
|
||||
}
|
||||
|
||||
fn rebuild(&mut self) {
|
||||
self.field = LiquifyField::from_strokes(self.canvas, self.cell, &self.strokes);
|
||||
self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]);
|
||||
self.mask_dirty = true;
|
||||
self.render_dirty();
|
||||
}
|
||||
|
||||
fn undo(&mut self) {
|
||||
@@ -754,6 +756,34 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_rerenders_the_proxy_before_the_next_texture_upload() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_layer();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap();
|
||||
for ev in [
|
||||
ToolEvent::Down { x: 35.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Move { x: 55.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Up { x: 55.0, y: 40.0 },
|
||||
ToolEvent::Down { x: 70.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Move { x: 90.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Up { x: 90.0, y: 40.0 },
|
||||
] {
|
||||
pointer(&mut app, ev, egui::Modifiers::NONE);
|
||||
}
|
||||
|
||||
let before = app.distort.liquify.as_ref().unwrap().out.clone();
|
||||
control(&mut app, &json!({"undo": true})).unwrap();
|
||||
let d = app.distort.liquify.as_ref().unwrap();
|
||||
assert_eq!(d.strokes.len(), 1);
|
||||
assert_ne!(d.out, before, "undo must update the pixels shown by the existing texture");
|
||||
|
||||
let mut expected = vec![[0u8; 4]; d.proxy.w * d.proxy.h];
|
||||
d.proxy.render(&d.field, [0, 0, d.proxy.w, d.proxy.h], &mut expected);
|
||||
assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_handler_undoes_liquify_even_when_egui_owns_keyboard_focus() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -849,6 +849,7 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
|
||||
p.layers = layers;
|
||||
p.history = history;
|
||||
p.properties = props;
|
||||
p.character = false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Move-tool modifier keys, as in Photoshop (#90):
|
||||
//!
|
||||
//! - ⇧ while dragging locks the move to the dominant axis (horizontal or vertical). The axis is
|
||||
//! re-evaluated as the drag goes on, so moving far enough the other way switches it; a small
|
||||
//! hysteresis keeps it from flickering near the diagonal. Free Transform uses the same rule with
|
||||
//! eight directions ([`constrain`]).
|
||||
//! - ⌥ held when the drag starts duplicates the selected layer(s) (groups included) once the
|
||||
//! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one
|
||||
//! history step ("Duplicate + Move"). ⇧⌥ combines both.
|
||||
//! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free
|
||||
//! Transform is active they nudge the box instead.
|
||||
//!
|
||||
//! The hooks are small and local: [`filter_event`] rewrites pointer events before the Move tool
|
||||
//! sees them and [`finish`] folds the history after the drag, so the Move drag pipeline itself is
|
||||
//! untouched.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ToolEvent;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool).
|
||||
pub const MOVE_DIRECTIONS: u32 = 4;
|
||||
/// Directions ⇧ locks a Free Transform body drag to: the axes and the 45° diagonals.
|
||||
pub const TRANSFORM_DIRECTIONS: u32 = 8;
|
||||
/// Extra angle (degrees) the pointer must pass the half-way line by before the locked axis
|
||||
/// switches, so it doesn't flicker when dragging near the diagonal.
|
||||
const HYSTERESIS_DEG: f64 = 8.0;
|
||||
/// ⌥-drag duplicates only once the pointer has moved this far (screen points), so an ⌥-click
|
||||
/// without a drag doesn't make a copy (Photoshop).
|
||||
const DUPLICATE_THRESHOLD: f64 = 2.0;
|
||||
/// History label of an ⌥-drag / ⌥-nudge.
|
||||
pub const DUPLICATE_MOVE_LABEL: &str = "Duplicate + Move";
|
||||
|
||||
/// Locks the drag offset `d` to the nearest of `n` evenly spaced directions (0° = right). `prev`
|
||||
/// is the previously locked offset: its direction is kept until `d` is clearly nearer another one.
|
||||
pub fn constrain(d: [f64; 2], prev: Option<[f64; 2]>, n: u32) -> [f64; 2] {
|
||||
let len = d[0].hypot(d[1]);
|
||||
if n == 0 || !len.is_finite() || len == 0.0 {
|
||||
return d;
|
||||
}
|
||||
let step = std::f64::consts::TAU / f64::from(n);
|
||||
let a = d[1].atan2(d[0]);
|
||||
let mut k = (a / step).round();
|
||||
if let Some(p) = prev.filter(|p| p[0].hypot(p[1]) > 0.0) {
|
||||
let kp = (p[1].atan2(p[0]) / step).round();
|
||||
// Angular distance from `d` to the previous direction, wrapped to [0, π].
|
||||
let off = (a - kp * step).rem_euclid(std::f64::consts::TAU);
|
||||
let off = off.min(std::f64::consts::TAU - off);
|
||||
if off < step / 2.0 + HYSTERESIS_DEG.to_radians() {
|
||||
k = kp;
|
||||
}
|
||||
}
|
||||
let (s, c) = (k * step).sin_cos();
|
||||
let t = (d[0] * c + d[1] * s).max(0.0);
|
||||
// Snap tiny float noise so axis-locked moves stay exactly on the axis.
|
||||
let clean = |v: f64| if v.abs() < 1e-9 { 0.0 } else { v };
|
||||
[clean(t * c), clean(t * s)]
|
||||
}
|
||||
|
||||
/// Per-drag state (one Move-tool gesture at a time).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MoveDrag {
|
||||
/// Where the drag started (document pixels); `None` when no Move drag is in progress.
|
||||
start: Option<[f64; 2]>,
|
||||
/// ⌥ was held when the drag started.
|
||||
alt: bool,
|
||||
/// The last offset after ⇧ locking (keeps the locked axis stable).
|
||||
last: Option<[f64; 2]>,
|
||||
/// History length before the ⌥-drag duplicated (set once it has).
|
||||
dup_from: Option<usize>,
|
||||
}
|
||||
|
||||
fn past_len(app: &PhotocraftApp) -> Option<usize> {
|
||||
app.session.active().map(|st| st.history.past_len())
|
||||
}
|
||||
|
||||
fn moving(app: &PhotocraftApp) -> bool {
|
||||
app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move)
|
||||
}
|
||||
|
||||
/// Duplicates the selected layers (the copies become the selection), remembering the history
|
||||
/// length before so [`fold_history`] can merge the copy and the move into one step.
|
||||
fn duplicate(app: &mut PhotocraftApp) -> Option<usize> {
|
||||
let before = past_len(app)?;
|
||||
match app.run("layer.duplicate", json!({})) {
|
||||
Ok(_) => Some(before),
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges every history step after `from` into one "Duplicate + Move" step.
|
||||
fn fold_history(app: &mut PhotocraftApp, from: usize) {
|
||||
let Some(st) = app.session.active_mut() else { return };
|
||||
while st.history.past_len() > from + 1 {
|
||||
if !st.history.purge_last() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if st.history.past_len() == from + 1 {
|
||||
st.history.set_current_label(DUPLICATE_MOVE_LABEL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites a Move-tool pointer event for the held modifiers: ⇧ locks it to an axis, and the
|
||||
/// first real movement of an ⌥-drag duplicates the layers being moved. Other tools pass through.
|
||||
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
|
||||
if app.ui.tool != Tool::Move || app.ui.transform.is_some() {
|
||||
app.move_mods = MoveDrag::default();
|
||||
return ev;
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
app.move_mods = MoveDrag { start: Some([x, y]), alt: mods.alt, last: None, dup_from: None };
|
||||
ev
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
let [x, y] = drag_to(app, [x, y], mods);
|
||||
ToolEvent::Move { x, y, pressure }
|
||||
}
|
||||
ToolEvent::Up { x, y } => {
|
||||
let [x, y] = drag_to(app, [x, y], mods);
|
||||
ToolEvent::Up { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
|
||||
let Some(start) = app.move_mods.start.filter(|_| moving(app)) else { return p };
|
||||
let mut d = [p[0] - start[0], p[1] - start[1]];
|
||||
if mods.shift {
|
||||
d = constrain(d, app.move_mods.last, MOVE_DIRECTIONS);
|
||||
}
|
||||
app.move_mods.last = Some(d);
|
||||
let zoom = f64::from(app.current_zoom().max(0.01));
|
||||
if app.move_mods.alt && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
|
||||
app.move_mods.dup_from = duplicate(app);
|
||||
// Only once per drag, even if duplicating failed.
|
||||
app.move_mods.alt = false;
|
||||
}
|
||||
[start[0] + d[0], start[1] + d[1]]
|
||||
}
|
||||
|
||||
/// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step.
|
||||
pub fn finish(app: &mut PhotocraftApp) {
|
||||
let st = std::mem::take(&mut app.move_mods);
|
||||
if let Some(from) = st.dup_from {
|
||||
fold_history(app, from);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px;
|
||||
/// ⌥ duplicates the layers first. Returns true when a key was used.
|
||||
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
if app.ui.tool != Tool::Move && app.ui.transform.is_none() {
|
||||
return false;
|
||||
}
|
||||
use egui::{Key, Modifiers};
|
||||
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||||
for (key, ux, uy) in keys {
|
||||
for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
|
||||
if ctx.input_mut(|i| i.consume_key(mods, key)) {
|
||||
let k = if mods.shift { 10.0 } else { 1.0 };
|
||||
nudge(app, ux * k, uy * k, mods.alt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Moves the selected layers (or the Free Transform box) by `(dx, dy)` pixels.
|
||||
pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
|
||||
if let Some(t) = app.ui.transform.as_mut() {
|
||||
if t.warp.is_none() {
|
||||
t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]);
|
||||
t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if app.drag.is_some() {
|
||||
return;
|
||||
}
|
||||
let from = if duplicate_first { duplicate(app) } else { None };
|
||||
if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
if let Some(from) = from {
|
||||
fold_history(app, from);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_picks_the_dominant_axis() {
|
||||
assert!(near(constrain([10.0, 3.0], None, 4), [10.0, 0.0]));
|
||||
assert!(near(constrain([-2.0, -9.0], None, 4), [0.0, -9.0]));
|
||||
assert!(near(constrain([0.0, 0.0], None, 4), [0.0, 0.0]));
|
||||
// Eight directions: a near-diagonal drag lands on 45°.
|
||||
let d = constrain([10.0, 9.0], None, 8);
|
||||
assert!((d[0] - d[1]).abs() < 1e-9 && d[0] > 9.0, "{d:?}");
|
||||
assert!(near(constrain([10.0, 1.0], None, 8), [10.0, 0.0]));
|
||||
// Hostile input never panics and stays finite or passes through.
|
||||
let _ = constrain([f64::NAN, 1.0], None, 4);
|
||||
let _ = constrain([f64::INFINITY, 1.0], Some([f64::NAN, 0.0]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_axis_switches_once_the_pointer_is_clearly_nearer_the_other_one() {
|
||||
let h = constrain([20.0, 2.0], None, 4);
|
||||
assert!(near(h, [20.0, 0.0]));
|
||||
// Just past the diagonal: hysteresis keeps horizontal.
|
||||
let still = constrain([20.0, 21.0], Some(h), 4);
|
||||
assert!(near(still, [20.0, 0.0]), "{still:?}");
|
||||
// Far enough the other way: switches to vertical.
|
||||
let v = constrain([20.0, 40.0], Some(h), 4);
|
||||
assert!(near(v, [0.0, 40.0]), "{v:?}");
|
||||
// And back.
|
||||
assert!(near(constrain([30.0, 20.0], Some(v), 4), [30.0, 0.0]));
|
||||
}
|
||||
|
||||
fn app_with_layer() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session
|
||||
.edit("paint", |doc, a| {
|
||||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.tool_options.move_auto_select = false;
|
||||
// Snapping (to the document centre with a 1:1 test view) would shift the expected offsets.
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
app
|
||||
}
|
||||
|
||||
fn bounds(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> photocraft_geom::Rect {
|
||||
app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
fn drag(app: &mut PhotocraftApp, pts: &[[f64; 2]], mods: egui::Modifiers) {
|
||||
crate::canvas::tool_event(app, ToolEvent::Down { x: pts[0][0], y: pts[0][1], pressure: 1.0 }, mods);
|
||||
for p in &pts[1..pts.len() - 1] {
|
||||
crate::canvas::tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, mods);
|
||||
}
|
||||
let e = pts[pts.len() - 1];
|
||||
crate::canvas::tool_event(app, ToolEvent::Up { x: e[0], y: e[1] }, mods);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_moves_along_one_axis_only() {
|
||||
let mut app = app_with_layer();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
drag(&mut app, &[[16.0, 16.0], [22.0, 18.0], [26.0, 19.0]], egui::Modifiers::SHIFT);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 8, 34, 24));
|
||||
// Switching axis mid-drag: the end point decides.
|
||||
drag(&mut app, &[[16.0, 16.0], [22.0, 17.0], [18.0, 30.0], [17.0, 36.0]], egui::Modifiers::SHIFT);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 28, 34, 44));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_duplicates_and_moves_the_copy_in_one_step() {
|
||||
let mut app = app_with_layer();
|
||||
let orig = app.session.active().unwrap().active_layer.unwrap();
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
let h0 = app.session.active().unwrap().history.past_len();
|
||||
drag(&mut app, &[[16.0, 16.0], [20.0, 16.0], [26.0, 21.0]], egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 1, "one copy");
|
||||
let copy = st.active_layer.unwrap();
|
||||
assert_ne!(copy, orig);
|
||||
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24), "original untouched");
|
||||
assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(18, 13, 34, 29));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), h0 + 1, "one undo step");
|
||||
assert_eq!(st.history.undo_label(), Some(DUPLICATE_MOVE_LABEL));
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
|
||||
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
// ⌥-click without a drag makes no copy.
|
||||
drag(&mut app, &[[16.0, 16.0], [16.0, 16.0]], egui::Modifiers::ALT);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
|
||||
// ⇧⌥: a copy moved along one axis.
|
||||
drag(&mut app, &[[16.0, 16.0], [30.0, 19.0]], egui::Modifiers::SHIFT | egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 1);
|
||||
assert_eq!(bounds(&app, st.active_layer.unwrap()), photocraft_geom::Rect::new(22, 8, 38, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_duplicates_every_selected_layer() {
|
||||
let mut app = app_with_layer();
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
let b = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
let h0 = app.session.active().unwrap().history.past_len();
|
||||
drag(&mut app, &[[16.0, 16.0], [26.0, 16.0]], egui::Modifiers::ALT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), n0 + 2);
|
||||
assert_eq!(st.history.past_len(), h0 + 1);
|
||||
assert_eq!(bounds(&app, a), photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
assert!(st.doc.layer(b).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudge_moves_layers_or_the_transform_box() {
|
||||
let mut app = app_with_layer();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
nudge(&mut app, 10.0, 0.0, false);
|
||||
nudge(&mut app, 0.0, -1.0, false);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
|
||||
let n0 = app.session.active().unwrap().doc.layers.len();
|
||||
nudge(&mut app, 1.0, 0.0, true);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0 + 1);
|
||||
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
|
||||
let ctx = egui::Context::default();
|
||||
crate::transform_tool::begin(&mut app, &ctx).unwrap();
|
||||
let q0 = app.ui.transform.as_ref().unwrap().quad;
|
||||
nudge(&mut app, 0.0, 10.0, false);
|
||||
assert_eq!(app.ui.transform.as_ref().unwrap().quad[0], [q0[0][0], q0[0][1] + 10.0]);
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,10 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// Keep the whole picker on screen (it is about 280 × 300 points).
|
||||
let pos = egui::pos2(x.min(screen.right() - 284.0).max(screen.left()), y.min(screen.bottom() - 310.0).max(screen.top()));
|
||||
let area = egui::Area::new(egui::Id::new("canvas-brush-picker")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut app.session.tools.brush));
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut b));
|
||||
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
|
||||
});
|
||||
let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p)));
|
||||
if outside {
|
||||
|
||||
@@ -360,8 +360,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
|
||||
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection {
|
||||
brush_preset_chip(ui, &mut app.session.tools.brush);
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
brush_preset_chip(ui, &mut b);
|
||||
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
|
||||
widgets::vline(ui, 22.0);
|
||||
}
|
||||
if crate::eraser_ui::options_bar(app, ui, tool)
|
||||
@@ -373,7 +377,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let b = &mut app.session.tools.brush;
|
||||
let brush_before = app.session.tools.brush.clone();
|
||||
let mut brush = brush_before.clone();
|
||||
let b = &mut brush;
|
||||
match app.ui.tool {
|
||||
Tool::Brush | Tool::Eraser if t.pro => {
|
||||
brush_preset_chip(ui, b);
|
||||
@@ -637,6 +643,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
|
||||
_ => {}
|
||||
}
|
||||
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -767,6 +774,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
|
||||
let shown: Vec<Group> = [
|
||||
(Group::Color, p.color),
|
||||
(Group::Properties, t.pro && p.properties),
|
||||
(Group::Character, p.character),
|
||||
(Group::Navigator, p.navigator),
|
||||
(Group::History, p.history),
|
||||
(Group::Layers, p.layers),
|
||||
@@ -801,6 +809,7 @@ fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Gro
|
||||
(Group::Color, _) => color_picker(app, ui),
|
||||
(Group::Properties, 0) => properties_body(app, ui),
|
||||
(Group::Properties, _) => adjustments_grid(app, ui),
|
||||
(Group::Character, tab) => crate::type_tool::character_panel(app, ui, tab == 1),
|
||||
(Group::Navigator, 0) => navigator(app, ui),
|
||||
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
|
||||
(Group::Navigator, _) => info_panel(app, ui),
|
||||
@@ -1046,6 +1055,8 @@ fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> {
|
||||
}
|
||||
|
||||
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// A new active layer opens its parent groups and is scrolled into view (#152).
|
||||
let reveal = crate::layer_reveal::track(app, ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
empty(ui, "No document");
|
||||
return;
|
||||
@@ -1175,7 +1186,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
|
||||
let top = ui.cursor().top();
|
||||
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
|
||||
if reveal == Some(l.id) {
|
||||
crate::layer_reveal::scroll_to_row(ui, top);
|
||||
}
|
||||
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
|
||||
effect_rows(app, ui, l, depth);
|
||||
}
|
||||
|
||||
@@ -111,10 +111,11 @@ fn put_focus(h: &mut Harness<'_, PhotocraftApp>, place: Place) {
|
||||
}
|
||||
Place::OpacityField => {
|
||||
// The Layers panel's Opacity field: the DragValue in the right dock reading 100.
|
||||
let layers = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("Layers drawn").1;
|
||||
let at = h
|
||||
.query_all_by_role(Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.center().x > 1100.0 && r.center().y > 500.0)
|
||||
.filter(|r| layers.contains(r.center()))
|
||||
.min_by(|a, b| a.center().y.total_cmp(&b.center().y))
|
||||
.expect("Layers opacity field")
|
||||
.center();
|
||||
|
||||
@@ -200,6 +200,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Move tool / Free Transform: arrows nudge (⇧ ×10, ⌥ duplicates first).
|
||||
if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// Pen path in progress: ↩ finishes (open path), Esc cancels.
|
||||
if app.ui.pen.is_some() {
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
|
||||
@@ -263,12 +267,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 } }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ pub struct Panels {
|
||||
/// Window › Brush Settings (F5): floating, like Photoshop's.
|
||||
#[serde(default)]
|
||||
pub brush_settings: bool,
|
||||
/// Window › Character / Paragraph: the Character | Paragraph dock group (#150).
|
||||
#[serde(default)]
|
||||
pub character: bool,
|
||||
}
|
||||
|
||||
impl Default for Panels {
|
||||
@@ -241,6 +244,7 @@ impl Default for Panels {
|
||||
options_bar: true,
|
||||
status_bar: true,
|
||||
brush_settings: false,
|
||||
character: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,6 +514,8 @@ pub struct DockTabs {
|
||||
pub navigator: usize,
|
||||
/// History | Actions.
|
||||
pub history: usize,
|
||||
/// Character | Paragraph.
|
||||
pub character: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Dock tab strips that fit any width (#151), like Photoshop's: when the tabs don't fit beside
|
||||
//! the panel menu button they shrink, eliding their labels with '…', and once they are at their
|
||||
//! minimum width the ones that still don't fit move into a » chevron menu at the end of the
|
||||
//! strip. The selected tab always stays on the strip, and no tab ever runs under the menu button.
|
||||
|
||||
use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// Which tabs a strip shows, at what widths, and which overflow into the chevron menu.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TabFit {
|
||||
/// `(tab index, width)` for the tabs on the strip, in tab order.
|
||||
pub shown: Vec<(usize, f32)>,
|
||||
/// Tabs in the chevron menu, in tab order (empty: no chevron).
|
||||
pub overflow: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Fit tabs with `natural` widths into `avail` points. Tabs shrink (never below `min_w`, or
|
||||
/// their natural width if smaller) before any overflow; overflowing tabs leave `chevron_w`
|
||||
/// for the chevron. `selected` is kept on the strip whatever happens.
|
||||
pub fn fit(natural: &[f32], selected: usize, avail: f32, min_w: f32, chevron_w: f32) -> TabFit {
|
||||
let clean = |w: f32| if w.is_finite() { w.max(0.0) } else { 0.0 };
|
||||
let avail = clean(avail);
|
||||
let min_w = clean(min_w);
|
||||
let natural: Vec<f32> = natural.iter().map(|w| clean(*w)).collect();
|
||||
let floor = |w: f32| w.min(min_w);
|
||||
let all: Vec<usize> = (0..natural.len()).collect();
|
||||
if natural.iter().sum::<f32>() <= avail {
|
||||
return TabFit { shown: all.iter().map(|&i| (i, natural.get(i).copied().unwrap_or(0.0))).collect(), overflow: Vec::new() };
|
||||
}
|
||||
if natural.iter().map(|w| floor(*w)).sum::<f32>() <= avail {
|
||||
return TabFit { shown: squeeze(&natural, &all, avail, min_w), overflow: Vec::new() };
|
||||
}
|
||||
// Overflow: the selected tab first, then the others in order, while they fit at their minimum.
|
||||
let budget = (avail - clean(chevron_w)).max(0.0);
|
||||
let sel = selected.min(natural.len().saturating_sub(1));
|
||||
let mut keep: Vec<usize> = Vec::new();
|
||||
let mut used = 0.0;
|
||||
for i in std::iter::once(sel).chain(all.iter().copied().filter(|i| *i != sel)) {
|
||||
let w = natural.get(i).map_or(0.0, |w| floor(*w));
|
||||
if used + w <= budget || (i == sel && !natural.is_empty()) {
|
||||
keep.push(i);
|
||||
used += w;
|
||||
}
|
||||
}
|
||||
keep.sort_unstable();
|
||||
let mut shown = squeeze(&natural, &keep, budget, min_w);
|
||||
// A strip too narrow even for the selected tab at its minimum: it takes what there is.
|
||||
if used > budget
|
||||
&& let Some(s) = shown.iter_mut().find(|(i, _)| *i == sel)
|
||||
{
|
||||
s.1 = budget;
|
||||
}
|
||||
let overflow = all.into_iter().filter(|i| !keep.contains(i)).collect();
|
||||
TabFit { shown, overflow }
|
||||
}
|
||||
|
||||
/// Widths for the `idx` tabs filling `total`: the widest shrink first (a common cap), none
|
||||
/// below `min(natural, min_w)`.
|
||||
fn squeeze(natural: &[f32], idx: &[usize], total: f32, min_w: f32) -> Vec<(usize, f32)> {
|
||||
let ws: Vec<f32> = idx.iter().map(|&i| natural.get(i).copied().unwrap_or(0.0)).collect();
|
||||
let sum_at = |cap: f32| ws.iter().map(|w| w.min(cap.max(w.min(min_w)))).sum::<f32>();
|
||||
let (mut lo, mut hi) = (min_w, ws.iter().copied().fold(min_w, f32::max));
|
||||
if sum_at(hi) > total {
|
||||
for _ in 0..32 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if sum_at(mid) <= total {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lo = hi;
|
||||
}
|
||||
idx.iter().zip(ws).map(|(&i, w)| (i, w.min(lo.max(w.min(min_w))))).collect()
|
||||
}
|
||||
|
||||
/// A label laid out on one row no wider than `max_w`, cut with '…' when it doesn't fit.
|
||||
pub fn elided(ui: &Ui, text: &str, font: egui::FontId, color: egui::Color32, max_w: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: max_w.max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// What a strip reported this frame.
|
||||
pub struct StripOut {
|
||||
pub double_clicked: bool,
|
||||
/// Rects of the tabs on the strip, `(tab index, rect)`.
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Width of the » overflow button.
|
||||
const CHEVRON_W: f32 = 18.0;
|
||||
|
||||
/// Draw the tabs of a strip in `area` (the strip minus the menu button). `paint_tab` draws one
|
||||
/// tab: (ui, rect, index, label galley, response, active).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tabs_in(
|
||||
ui: &mut Ui,
|
||||
id: egui::Id,
|
||||
area: Rect,
|
||||
tabs: &[&str],
|
||||
selected: &mut usize,
|
||||
font: egui::FontId,
|
||||
pad: f32,
|
||||
min_w: f32,
|
||||
active: impl Fn(usize) -> bool,
|
||||
mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc<egui::Galley>, &Response, bool),
|
||||
) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let natural: Vec<f32> = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect();
|
||||
let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W);
|
||||
let mut x = area.left();
|
||||
let mut out = StripOut { double_clicked: false, tabs: Vec::with_capacity(f.shown.len()), chevron: None };
|
||||
for &(i, w) in &f.shown {
|
||||
let Some(name) = tabs.get(i) else { continue };
|
||||
let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height()));
|
||||
let resp = ui.interact(r, id.with(("tab", i)), Sense::click());
|
||||
// The padding gives way (down to a third) before the label is cut.
|
||||
let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0));
|
||||
let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5;
|
||||
paint_tab(ui, r, i, galley, &resp, active(i));
|
||||
let resp = if cut { resp.on_hover_text(*name) } else { resp };
|
||||
out.double_clicked |= resp.double_clicked();
|
||||
if resp.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
out.tabs.push((i, r));
|
||||
x = r.right();
|
||||
}
|
||||
if !f.overflow.is_empty() {
|
||||
let r = Rect::from_min_size(pos2(x, area.top()), vec2(CHEVRON_W, area.height()));
|
||||
let resp = ui.interact(r, id.with("tab-overflow"), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
crate::icons::paint(ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "More panels"));
|
||||
let resp = resp.on_hover_text("More panels");
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(140.0);
|
||||
for &i in &f.overflow {
|
||||
if let Some(name) = tabs.get(i)
|
||||
&& ui.button(*name).clicked()
|
||||
{
|
||||
*selected = i;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
out.chevron = Some(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Photoshop-grammar strip (Pro themes): flat tabs on the dark strip; `menu_left` is where the
|
||||
/// panel menu button starts.
|
||||
pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let area = Rect::from_min_max(strip.min, pos2(menu_left.max(strip.left()), strip.bottom()));
|
||||
let sel = *selected;
|
||||
tabs_in(
|
||||
ui,
|
||||
id,
|
||||
area,
|
||||
tabs,
|
||||
selected,
|
||||
egui::FontId::proportional(11.5),
|
||||
22.0,
|
||||
40.0,
|
||||
|i| i == sel && !collapsed,
|
||||
|ui, r, i, galley, resp, active| {
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
|
||||
}
|
||||
let color = if active {
|
||||
t.text
|
||||
} else if resp.hovered() {
|
||||
t.text_dim
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Studio strip: pill tabs in `area` (the row minus the menu button).
|
||||
pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let sel = *selected;
|
||||
tabs_in(
|
||||
ui,
|
||||
id,
|
||||
area,
|
||||
tabs,
|
||||
selected,
|
||||
theme::medium(12.5),
|
||||
20.0,
|
||||
44.0,
|
||||
|i| i == sel,
|
||||
|ui, r, _, galley, resp, active| {
|
||||
// 2 pt between pills, as the old horizontal layout had.
|
||||
let r = Rect::from_min_max(r.min, pos2((r.right() - 2.0).max(r.left()), r.bottom()));
|
||||
if active {
|
||||
crate::widgets::surface(ui, r, t.hover, true);
|
||||
if !t.bevel {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
let color = if active { t.text } else { t.text_dim };
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn total(f: &TabFit) -> f32 {
|
||||
f.shown.iter().map(|(_, w)| w).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_that_fit_keep_their_widths() {
|
||||
let f = fit(&[60.0, 70.0, 80.0], 0, 400.0, 40.0, 18.0);
|
||||
assert_eq!(f.shown, vec![(0, 60.0), (1, 70.0), (2, 80.0)]);
|
||||
assert!(f.overflow.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_strips_shrink_the_widest_tabs_first() {
|
||||
let f = fit(&[50.0, 70.0, 120.0], 0, 200.0, 40.0, 18.0);
|
||||
assert!(f.overflow.is_empty());
|
||||
assert!((total(&f) - 200.0).abs() < 0.1, "{f:?}");
|
||||
assert_eq!(f.shown[0], (0, 50.0), "a tab narrower than the cap keeps its width");
|
||||
assert_eq!(f.shown[1], (1, 70.0));
|
||||
assert!((f.shown[2].1 - 80.0).abs() < 0.1, "only the widest shrank: {f:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_keeps_the_selected_tab_and_leaves_room_for_the_chevron() {
|
||||
let natural = [80.0, 90.0, 85.0, 95.0];
|
||||
for sel in 0..4 {
|
||||
let f = fit(&natural, sel, 120.0, 40.0, 18.0);
|
||||
assert!(f.shown.iter().any(|(i, _)| *i == sel), "{sel}: {f:?}");
|
||||
assert!(!f.overflow.is_empty() && !f.overflow.contains(&sel));
|
||||
assert!(total(&f) <= 120.0 - 18.0 + 1e-3, "{f:?}");
|
||||
assert_eq!(f.shown.len() + f.overflow.len(), 4);
|
||||
assert!(f.shown.windows(2).all(|w| w[0].0 < w[1].0), "tab order kept");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_never_panic_or_go_negative() {
|
||||
for avail in [0.0, -10.0, 5.0, f32::NAN, f32::INFINITY] {
|
||||
for natural in [&[][..], &[f32::NAN, 50.0][..], &[1e9, -5.0, 30.0][..]] {
|
||||
for sel in [0, 1, 99] {
|
||||
let f = fit(natural, sel, avail, 40.0, 18.0);
|
||||
assert!(f.shown.iter().all(|(_, w)| w.is_finite() && *w >= 0.0), "{avail} {natural:?} {f:?}");
|
||||
assert_eq!(f.shown.len() + f.overflow.len(), natural.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Textures for the Free Transform live preview (#91).
|
||||
//!
|
||||
//! The moving pixels are uploaded at full resolution (up to the GPU's texture limit) and drawn on
|
||||
//! a mesh through the box's homography. What the preview samples depends on how big a source
|
||||
//! pixel ends up on screen (view zoom × transform scale):
|
||||
//!
|
||||
//! - shrinking (< ½ screen px per source px): a box-filtered level of a lazily built mip chain, so
|
||||
//! zoomed-out previews neither shimmer nor alias;
|
||||
//! - around 1:1 or enlarging: the full-resolution level, bilinear;
|
||||
//! - Nearest Neighbor interpolation, or a view zoomed in to ≥ 200% on pixels that aren't
|
||||
//! enlarged by the transform: the full-resolution level with nearest filtering, so pixels stay
|
||||
//! crisp like the canvas around them.
|
||||
//!
|
||||
//! Levels and filter variants are uploaded on first use and cached for the session.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions};
|
||||
|
||||
/// One uploaded texture: its mip level, filter and the uv extent covering the source.
|
||||
struct Level {
|
||||
level: u32,
|
||||
nearest: bool,
|
||||
tex: TextureHandle,
|
||||
uv: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct PreviewTextures {
|
||||
name: String,
|
||||
/// CPU copies of the levels built so far (level 0 = full resolution), premultiplied.
|
||||
images: Mutex<Vec<Arc<ColorImage>>>,
|
||||
/// uv extent of level 0 that covers the source exactly (< 1 when level 0 was padded).
|
||||
base_uv: [f32; 2],
|
||||
uploaded: Mutex<Vec<Level>>,
|
||||
}
|
||||
|
||||
/// A source pixel shown at this many screen pixels or more counts as "zoomed in" (nearest).
|
||||
const CRISP_ZOOM: f32 = 2.0;
|
||||
|
||||
impl PreviewTextures {
|
||||
/// `image` covers the source with `base_uv` of its extent; `name` keys the texture in egui.
|
||||
pub fn new(ctx: &egui::Context, name: String, image: ColorImage, base_uv: [f32; 2]) -> Self {
|
||||
let image = Arc::new(image);
|
||||
let tex = ctx.load_texture(format!("{name}-0-linear"), Arc::clone(&image), TextureOptions::LINEAR);
|
||||
let base_uv = [base_uv[0].clamp(0.0, 1.0), base_uv[1].clamp(0.0, 1.0)];
|
||||
Self { name, images: Mutex::new(vec![image]), base_uv, uploaded: Mutex::new(vec![Level { level: 0, nearest: false, tex, uv: base_uv }]) }
|
||||
}
|
||||
|
||||
/// Size of the full-resolution level in texels.
|
||||
pub fn size(&self) -> [usize; 2] {
|
||||
self.images.lock().unwrap_or_else(PoisonError::into_inner).first().map_or([0, 0], |i| i.size)
|
||||
}
|
||||
|
||||
/// The level and filter for a source pixel shown `screen_px` screen pixels wide, where the
|
||||
/// transform itself scales by `scale` and the view zoom is `zoom`.
|
||||
pub fn choose(zoom: f32, scale: f32, nearest_interpolation: bool) -> (u32, bool) {
|
||||
let s = zoom * scale;
|
||||
if !s.is_finite() || s <= 0.0 {
|
||||
return (0, false);
|
||||
}
|
||||
if s < 0.5 {
|
||||
// log2(1/s) levels down, at most 12 (4096× smaller).
|
||||
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation);
|
||||
}
|
||||
let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05);
|
||||
(0, crisp)
|
||||
}
|
||||
|
||||
/// Texture and uv extent to draw with (uploading a level / filter on first use).
|
||||
pub fn pick(&self, ctx: &egui::Context, zoom: f32, scale: f32, nearest_interpolation: bool) -> (TextureId, [f32; 2]) {
|
||||
let (level, nearest) = Self::choose(zoom, scale, nearest_interpolation);
|
||||
let mut up = self.uploaded.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest) {
|
||||
return (l.tex.id(), l.uv);
|
||||
}
|
||||
let Some((img, uv)) = self.level_image(level) else {
|
||||
// Can't build it (shouldn't happen): fall back to the first upload.
|
||||
return up.first().map_or((TextureId::default(), [1.0, 1.0]), |l| (l.tex.id(), l.uv));
|
||||
};
|
||||
let opts = if nearest { TextureOptions::NEAREST } else { TextureOptions::LINEAR };
|
||||
let tex = ctx.load_texture(format!("{}-{level}-{}", self.name, if nearest { "nearest" } else { "linear" }), img, opts);
|
||||
let id = tex.id();
|
||||
up.push(Level { level, nearest, tex, uv });
|
||||
(id, uv)
|
||||
}
|
||||
|
||||
/// Level `n` of the mip chain (built on demand by 2×2 box filtering) and its uv extent.
|
||||
fn level_image(&self, n: u32) -> Option<(Arc<ColorImage>, [f32; 2])> {
|
||||
let mut imgs = self.images.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while imgs.len() <= n as usize {
|
||||
let prev = imgs.last()?;
|
||||
if prev.size[0] <= 1 && prev.size[1] <= 1 {
|
||||
break;
|
||||
}
|
||||
let next = Arc::new(halve(prev));
|
||||
imgs.push(next);
|
||||
}
|
||||
let k = (imgs.len() - 1).min(n as usize);
|
||||
let img = imgs.get(k)?.clone();
|
||||
let base = imgs.first()?.size;
|
||||
// Level k covers size·2^k base texels (the last row/column may be padding).
|
||||
let f = (1u64 << k) as f32;
|
||||
let uv = [self.base_uv[0] * base[0] as f32 / (img.size[0] as f32 * f).max(1.0), self.base_uv[1] * base[1] as f32 / (img.size[1] as f32 * f).max(1.0)];
|
||||
Some((img, [uv[0].min(1.0), uv[1].min(1.0)]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 2×2 box filter of a premultiplied image (odd edges repeat the last row/column).
|
||||
fn halve(img: &ColorImage) -> ColorImage {
|
||||
let [w, h] = img.size;
|
||||
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
|
||||
let mut out = vec![Color32::TRANSPARENT; nw * nh];
|
||||
let px = |x: usize, y: usize| img.pixels.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or_default();
|
||||
for (y, row) in out.chunks_mut(nw).enumerate() {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let q = [px(2 * x, 2 * y), px(2 * x + 1, 2 * y), px(2 * x, 2 * y + 1), px(2 * x + 1, 2 * y + 1)];
|
||||
let avg = |c: fn(&Color32) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::<u32>() + 2) / 4) as u8;
|
||||
*o = Color32::from_rgba_premultiplied(avg(Color32::r), avg(Color32::g), avg(Color32::b), avg(Color32::a));
|
||||
}
|
||||
}
|
||||
ColorImage::new([nw, nh], out)
|
||||
}
|
||||
|
||||
/// Reads `b` of `surf` as premultiplied texels, at full resolution when it fits in `max_side`
|
||||
/// (else box-filtered down by the smallest integer factor that fits). Returns the image and the
|
||||
/// uv extent of it that covers `b`.
|
||||
pub fn read_surface(surf: &photocraft_raster::Surface, b: photocraft_geom::Rect, max_side: usize) -> (ColorImage, [f32; 2]) {
|
||||
let (w, h) = (b.width() as usize, b.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return (ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]);
|
||||
}
|
||||
let k = w.max(h).div_ceil(max_side.max(1)).max(1);
|
||||
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut px = vec![Color32::TRANSPARENT; tw * th];
|
||||
// One band of `k` source rows per output row, in parallel on native targets.
|
||||
let band = |ty: usize, out: &mut [Color32]| {
|
||||
let y0 = b.y0 + (ty * k) as i32;
|
||||
let y1 = (y0 + k as i32).min(b.y1);
|
||||
let rows = (y1 - y0).max(0) as usize;
|
||||
let mut buf = vec![[0u8; 4]; w * rows];
|
||||
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y0, b.x1, y1), &mut buf);
|
||||
if k == 1 {
|
||||
for (o, p) in out.iter_mut().zip(&buf) {
|
||||
*o = if p[3] == 255 { Color32::from_rgb(p[0], p[1], p[2]) } else { Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (tx, o) in out.iter_mut().enumerate() {
|
||||
let (x0, x1) = (tx * k, ((tx + 1) * k).min(w));
|
||||
let mut acc = [0u32; 4];
|
||||
let mut n = 0u32;
|
||||
for r in 0..rows {
|
||||
for p in buf.get(r * w + x0..r * w + x1).unwrap_or(&[]) {
|
||||
let c = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
|
||||
for (a, v) in acc.iter_mut().zip(c.to_array()) {
|
||||
*a += u32::from(v);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
let v = acc.map(|a| ((a + n / 2) / n) as u8);
|
||||
*o = Color32::from_rgba_premultiplied(v[0], v[1], v[2], v[3]);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
px.par_chunks_mut(tw).enumerate().for_each(|(ty, out)| band(ty, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (ty, out) in px.chunks_mut(tw).enumerate() {
|
||||
band(ty, out);
|
||||
}
|
||||
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
|
||||
(ColorImage::new([tw, th], px), uv)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_and_filter_follow_the_on_screen_size() {
|
||||
assert_eq!(PreviewTextures::choose(1.0, 1.0, false), (0, false));
|
||||
assert_eq!(PreviewTextures::choose(3.0, 1.0, false), (0, true), "zoomed in: crisp texels");
|
||||
assert_eq!(PreviewTextures::choose(3.0, 2.0, false), (0, false), "enlarged by the transform: smooth");
|
||||
assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true));
|
||||
assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false));
|
||||
assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false));
|
||||
assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false));
|
||||
assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_resolution_unless_over_the_texture_limit() {
|
||||
let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
// A one-pixel checkerboard: any downsampling would turn it grey.
|
||||
for y in 0..300 {
|
||||
for x in 0..500 {
|
||||
if (x + y) % 2 == 0 {
|
||||
s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = photocraft_geom::Rect::new(0, 0, 500, 300);
|
||||
let (img, uv) = read_surface(&s, r, 8192);
|
||||
assert_eq!(img.size, [500, 300]);
|
||||
assert_eq!(uv, [1.0, 1.0]);
|
||||
assert_eq!(img.pixels[0], Color32::WHITE);
|
||||
assert_eq!(img.pixels[1].a(), 0);
|
||||
// Over the limit: box-filtered (grey-ish average), uv covers the padded edge.
|
||||
let (small, uv) = read_surface(&s, r, 256);
|
||||
assert_eq!(small.size, [250, 150]);
|
||||
assert!((100..=155).contains(&small.pixels[0].a()), "{:?}", small.pixels[0]);
|
||||
assert_eq!(uv, [1.0, 1.0]);
|
||||
let (odd, uv) = read_surface(&s, photocraft_geom::Rect::new(0, 0, 301, 3), 256);
|
||||
assert_eq!(odd.size, [151, 2]);
|
||||
assert!(uv[0] < 1.0 && uv[1] < 1.0);
|
||||
// Empty rect: a placeholder, no panic.
|
||||
assert_eq!(read_surface(&s, photocraft_geom::Rect::new(5, 5, 5, 9), 256).0.size, [1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_levels_are_built_on_demand() {
|
||||
let ctx = egui::Context::default();
|
||||
let img = ColorImage::new([5, 3], vec![Color32::WHITE; 15]);
|
||||
let t = PreviewTextures::new(&ctx, "t".into(), img, [1.0, 1.0]);
|
||||
assert_eq!(t.size(), [5, 3]);
|
||||
let (_, uv) = t.pick(&ctx, 0.5, 0.5, false);
|
||||
// Level 2 is 2×1 texels covering 8×4 base texels.
|
||||
assert!((uv[0] - 5.0 / 8.0).abs() < 1e-6 && (uv[1] - 3.0 / 4.0).abs() < 1e-6, "{uv:?}");
|
||||
let (a, _) = t.pick(&ctx, 1.0, 1.0, false);
|
||||
let (b, _) = t.pick(&ctx, 4.0, 1.0, false);
|
||||
assert_ne!(a, b, "a nearest-filtered copy for zoomed-in views");
|
||||
assert_eq!(t.pick(&ctx, 4.0, 1.0, false).0, b, "cached");
|
||||
// Asking for more levels than exist stops at 1×1.
|
||||
let _ = t.pick(&ctx, 1e-6, 1.0, false);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
//! engine's `edit.transform` (one quad for scale/rotate/skew/distort/perspective).
|
||||
//!
|
||||
//! Gestures follow Photoshop CC: corner drag scales proportionally (⇧ for free), edges scale one
|
||||
//! axis, ⌥ scales about the reference point, ⌘-drag a corner distorts, drag outside rotates
|
||||
//! (⇧ snaps to 15°), drag inside moves, the reference point can be dragged. ↩ commits, Esc cancels.
|
||||
//! axis, ⌥ scales about the reference point, ⌘-drag a corner distorts (⌘⌥⇧: perspective), ⌘-drag
|
||||
//! an edge skews (⇧ along the edge), drag outside rotates (⇧ snaps to 15°), drag inside moves
|
||||
//! (⇧ locks to 8 directions), the reference point can be dragged and ⌥-click puts it under the
|
||||
//! pointer. Arrow keys nudge the box (move_mods.rs). ↩ commits, Esc cancels.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,12 +19,12 @@ use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform};
|
||||
use crate::state::TransformSession;
|
||||
|
||||
/// Preview state that isn't serialisable: the document without the transformed pixels, and a
|
||||
/// texture of those pixels.
|
||||
/// Preview state that isn't serialisable: the document without the transformed pixels, and
|
||||
/// full-resolution textures of those pixels (transform_tex.rs).
|
||||
pub struct TransformPreview {
|
||||
pub session: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub texture: egui::TextureHandle,
|
||||
pub texture: crate::transform_tex::PreviewTextures,
|
||||
pub opacity: f32,
|
||||
gesture: Option<Gesture>,
|
||||
/// Warp-mode drag: (control point, pointer start, mesh points at the start).
|
||||
@@ -61,8 +63,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
||||
} else if let Some(l) = pd.layer_mut(id) {
|
||||
l.visible = false;
|
||||
}
|
||||
let image = preview_image(&doc, id, lifted.as_ref(), b);
|
||||
let texture = ctx.load_texture(format!("transform-{session}"), image, egui::TextureOptions::LINEAR);
|
||||
// egui reports the renderer's limit in the app; offscreen harnesses only through the device.
|
||||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||||
let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side);
|
||||
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv);
|
||||
app.transform_preview =
|
||||
Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: layer.opacity * layer.fill_opacity, gesture: None, warp_drag: None });
|
||||
app.ui.transform = Some(TransformSession {
|
||||
@@ -102,7 +106,8 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
|
||||
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(120, 170, 255, (m * 110.0) as u8);
|
||||
}
|
||||
}
|
||||
let texture = ctx.load_texture(format!("transform-{session}"), egui::ColorImage::new([tw, th], px), egui::TextureOptions::LINEAR);
|
||||
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
|
||||
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), egui::ColorImage::new([tw, th], px), uv);
|
||||
let mut pd = (*doc).clone();
|
||||
pd.selection = None;
|
||||
app.transform_preview = Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: 1.0, gesture: None, warp_drag: None });
|
||||
@@ -162,12 +167,16 @@ pub fn leave_warp(app: &mut PhotocraftApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture of the moving pixels over `b`, at most 2048 px on the long side.
|
||||
fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster::Surface>, b: photocraft_geom::Rect) -> egui::ColorImage {
|
||||
let (w, h) = (b.width() as usize, b.height() as usize);
|
||||
let k = w.max(h).div_ceil(2048).max(1);
|
||||
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut px = vec![Color32::TRANSPARENT; tw * th];
|
||||
/// Texels of the moving pixels over `b`: full resolution unless the long side exceeds the GPU's
|
||||
/// `max_side` (#91: this used to be capped at 2048 px, so big layers previewed blurry), plus the
|
||||
/// uv extent covering `b`.
|
||||
fn preview_image(
|
||||
doc: &Document,
|
||||
id: LayerId,
|
||||
lifted: Option<&photocraft_raster::Surface>,
|
||||
b: photocraft_geom::Rect,
|
||||
max_side: usize,
|
||||
) -> (egui::ColorImage, [f32; 2]) {
|
||||
let layer = doc.layer(id);
|
||||
// Groups and type/fill layers preview from a flattened render of just that layer.
|
||||
let rendered;
|
||||
@@ -189,17 +198,8 @@ fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster:
|
||||
Some(&rendered)
|
||||
}
|
||||
};
|
||||
let Some(surf) = surf else { return egui::ColorImage::new([tw, th], px) };
|
||||
let mut row = vec![[0u8; 4]; w];
|
||||
for ty in 0..th {
|
||||
let y = b.y0 + (ty * k) as i32;
|
||||
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y, b.x1, y + 1), &mut row);
|
||||
for tx in 0..tw {
|
||||
let p = row[(tx * k).min(w - 1)];
|
||||
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
|
||||
}
|
||||
}
|
||||
egui::ColorImage::new([tw, th], px)
|
||||
let Some(surf) = surf else { return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]) };
|
||||
crate::transform_tex::read_surface(surf, b, max_side)
|
||||
}
|
||||
|
||||
fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||||
@@ -303,7 +303,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
}
|
||||
let Some(pv) = app.transform_preview.as_mut() else { return false };
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => pv.gesture = Some(Gesture { hit: hit(&t, [x, y], tol), start: [x, y], quad0: t.quad, pivot0: t.pivot }),
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
let mut h = hit(&t, [x, y], tol);
|
||||
// ⌥-click away from the handles moves the reference point there (and drags it).
|
||||
if mods.alt
|
||||
&& matches!(h, Hit::Inside | Hit::Outside)
|
||||
&& let Some(s) = app.ui.transform.as_mut()
|
||||
{
|
||||
s.pivot = [x, y];
|
||||
h = Hit::Pivot;
|
||||
}
|
||||
pv.gesture = Some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||||
}
|
||||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
|
||||
let g = pv.gesture;
|
||||
if matches!(ev, ToolEvent::Up { .. }) {
|
||||
@@ -321,6 +332,14 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
|
||||
let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]);
|
||||
match g.hit {
|
||||
Hit::Inside => {
|
||||
// ⇧ locks the move to the axes and diagonals; the current offset keeps the lock stable.
|
||||
let (dx, dy) = if mods.shift {
|
||||
let prev = [s.quad[0][0] - g.quad0[0][0], s.quad[0][1] - g.quad0[0][1]];
|
||||
let d = crate::move_mods::constrain([dx, dy], Some(prev), crate::move_mods::TRANSFORM_DIRECTIONS);
|
||||
(d[0], d[1])
|
||||
} else {
|
||||
(dx, dy)
|
||||
};
|
||||
s.quad = g.quad0.map(|q| [q[0] + dx, q[1] + dy]);
|
||||
s.pivot = [g.pivot0[0] + dx, g.pivot0[1] + dy];
|
||||
}
|
||||
@@ -342,11 +361,52 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
|
||||
[c[0] + x * cs - y * sn, c[1] + x * sn + y * cs]
|
||||
});
|
||||
}
|
||||
Hit::Corner(i) if mods.command && mods.alt && mods.shift => {
|
||||
// Perspective: the corner moves along the dominant axis and the corner sharing that
|
||||
// side moves the opposite way.
|
||||
s.quad = g.quad0;
|
||||
let horizontal = dx.abs() >= dy.abs();
|
||||
// Corners 0..3 = TL, TR, BR, BL: horizontal drags pair along the top/bottom edge,
|
||||
// vertical ones along the left/right edge.
|
||||
let j = match (i, horizontal) {
|
||||
(0, true) => 1,
|
||||
(1, true) => 0,
|
||||
(2, true) => 3,
|
||||
(3, true) => 2,
|
||||
(0, false) => 3,
|
||||
(3, false) => 0,
|
||||
(1, false) => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let (mx, my) = if horizontal { (dx, 0.0) } else { (0.0, dy) };
|
||||
s.quad[i] = [g.quad0[i][0] + mx, g.quad0[i][1] + my];
|
||||
s.quad[j] = [g.quad0[j][0] - mx, g.quad0[j][1] - my];
|
||||
}
|
||||
Hit::Corner(i) if mods.command => {
|
||||
// Distort: move the corner freely.
|
||||
s.quad = g.quad0;
|
||||
s.quad[i] = [g.quad0[i][0] + dx, g.quad0[i][1] + dy];
|
||||
}
|
||||
Hit::Edge(i) if mods.command => {
|
||||
// Skew: the edge's two corners move together (⇧: only along the edge).
|
||||
let (a, b) = (i, (i + 1) % 4);
|
||||
let (mut mx, mut my) = (dx, dy);
|
||||
if mods.shift {
|
||||
let e = [g.quad0[b][0] - g.quad0[a][0], g.quad0[b][1] - g.quad0[a][1]];
|
||||
let l2 = e[0] * e[0] + e[1] * e[1];
|
||||
let t = if l2 > 0.0 { (dx * e[0] + dy * e[1]) / l2 } else { 0.0 };
|
||||
(mx, my) = (t * e[0], t * e[1]);
|
||||
}
|
||||
s.quad = g.quad0;
|
||||
s.quad[a] = [g.quad0[a][0] + mx, g.quad0[a][1] + my];
|
||||
s.quad[b] = [g.quad0[b][0] + mx, g.quad0[b][1] + my];
|
||||
if mods.alt {
|
||||
// ⌥: the opposite edge skews the other way (about the centre).
|
||||
let (c, d) = ((i + 2) % 4, (i + 3) % 4);
|
||||
s.quad[c] = [g.quad0[c][0] - mx, g.quad0[c][1] - my];
|
||||
s.quad[d] = [g.quad0[d][0] - mx, g.quad0[d][1] - my];
|
||||
}
|
||||
}
|
||||
Hit::Corner(_) | Hit::Edge(_) => {
|
||||
// Work in the box's own (unit) frame so rotated/skewed boxes scale along their axes.
|
||||
let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], g.quad0) else { return };
|
||||
@@ -524,13 +584,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
|
||||
if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) {
|
||||
// A 24×24 grid keeps perspective previews straight.
|
||||
let n = 24;
|
||||
let mut mesh = egui::Mesh::with_texture(pv.texture.id());
|
||||
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), quad_scale(t), t.interpolation == "nearest");
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||||
let (x, y) = h.apply(u, v);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint });
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
@@ -565,13 +626,14 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
|
||||
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
|
||||
let r = t.rect;
|
||||
let n = 32;
|
||||
let mut mesh = egui::Mesh::with_texture(pv.texture.id());
|
||||
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest");
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||||
let (x, y) = w.map(r[0] + u * (r[2] - r[0]), r[1] + v * (r[3] - r[1]));
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint });
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
@@ -640,6 +702,16 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
|
||||
}
|
||||
}
|
||||
|
||||
/// How much the box enlarges its pixels: the longest edge relative to the original (picks the
|
||||
/// preview's texture level and filter).
|
||||
fn quad_scale(t: &TransformSession) -> f32 {
|
||||
let (w0, h0) = ((t.rect[2] - t.rect[0]).max(1e-9), (t.rect[3] - t.rect[1]).max(1e-9));
|
||||
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let q = &t.quad;
|
||||
let s = (len(q[0], q[1]) / w0).max(len(q[3], q[2]) / w0).max(len(q[0], q[3]) / h0).max(len(q[1], q[2]) / h0);
|
||||
if s.is_finite() { s as f32 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Scale (%), angle (°) and translation implied by the current quad (affine readout).
|
||||
fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
|
||||
let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
|
||||
@@ -914,6 +986,111 @@ mod tests {
|
||||
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
|
||||
}
|
||||
|
||||
fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": size, "height": size})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session
|
||||
.edit("paint", |doc, a| {
|
||||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(fill, &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// A context that reports a real GPU's texture limit (the default reports 2048).
|
||||
fn gpu_ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
// Clear the font atlas delta: epaint panics on dropping unapplied deltas in debug builds.
|
||||
let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |_| {});
|
||||
out.textures_delta.clear();
|
||||
ctx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_texture_is_full_resolution() {
|
||||
// #91: the preview used to be sampled down to 2048 px, so big layers looked blurry at 100%.
|
||||
let mut app = app_with_square(3000, photocraft_geom::Rect::new(10, 20, 2810, 2420));
|
||||
let ctx = gpu_ctx();
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
let pv = app.transform_preview.as_ref().unwrap();
|
||||
assert_eq!(pv.texture.size(), [2800, 2400], "one texel per layer pixel");
|
||||
// At 100% the texture has at least as many texels as the box covers screen pixels.
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_body_drag_locks_to_eight_directions_and_switches() {
|
||||
let mut s = session();
|
||||
let g = Gesture { hit: Hit::Inside, start: [40.0, 20.0], quad0: s.quad, pivot0: s.pivot };
|
||||
apply_drag(&mut s, g, [60.0, 23.0], egui::Modifiers::SHIFT);
|
||||
assert!(close(s.quad, corners([20.0, 0.0, 120.0, 50.0])), "horizontal: {:?}", s.quad);
|
||||
apply_drag(&mut s, g, [60.0, 39.0], egui::Modifiers::SHIFT);
|
||||
assert!((s.quad[0][0] - s.quad[0][1]).abs() < 1e-9 && s.quad[0][0] > 15.0, "diagonal: {:?}", s.quad[0]);
|
||||
apply_drag(&mut s, g, [41.0, 60.0], egui::Modifiers::SHIFT);
|
||||
assert!(close(s.quad, corners([0.0, 40.0, 100.0, 90.0])), "vertical: {:?}", s.quad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_moves_the_reference_point_and_edges_skew() {
|
||||
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
|
||||
let ctx = egui::Context::default();
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
assert!(pointer(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::ALT));
|
||||
pointer(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::ALT);
|
||||
let t = app.ui.transform.as_ref().unwrap();
|
||||
assert_eq!(t.pivot, [40.0, 40.0]);
|
||||
assert_eq!(t.quad, corners(t.rect), "the box didn't move");
|
||||
// ⌘ on an edge skews: both corners of the top edge move.
|
||||
let mut s = session();
|
||||
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND);
|
||||
assert_eq!((s.quad[0], s.quad[1], s.quad[2]), ([20.0, 5.0], [120.0, 5.0], [100.0, 50.0]));
|
||||
// ⌘⇧: only along the edge.
|
||||
let mut s = session();
|
||||
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
|
||||
assert_eq!((s.quad[0], s.quad[1]), ([20.0, 0.0], [120.0, 0.0]));
|
||||
// ⌘⌥⇧ on a corner: perspective (the paired corner mirrors).
|
||||
let mut s = session();
|
||||
drag(&mut s, [100.0, 0.0], [101.0, -10.0], egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT);
|
||||
assert_eq!((s.quad[1], s.quad[2]), ([100.0, -10.0], [100.0, 60.0]));
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-ui-egui transform_preview_bench -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn transform_preview_bench() {
|
||||
let (w, h) = (6000, 4000);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": w, "height": h})).unwrap();
|
||||
app.sync_views();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session.execute("filter.render.clouds", json!({})).unwrap();
|
||||
let ctx = gpu_ctx();
|
||||
let t0 = std::time::Instant::now();
|
||||
begin(&mut app, &ctx).unwrap();
|
||||
let begin_ms = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let size = app.transform_preview.as_ref().unwrap().texture.size();
|
||||
rotate_about_pivot(&mut app, 0.05);
|
||||
let frame = |zoom: f32| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let _ = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||||
let xf = ViewXform { rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)), zoom, center: [3000.0, 2000.0], flip: false };
|
||||
draw_overlay(&app, &painter, &xf);
|
||||
});
|
||||
t0.elapsed().as_secs_f64() * 1e3
|
||||
};
|
||||
let first: Vec<f64> = [1.0, 0.25, 3.0].iter().map(|z| frame(*z)).collect();
|
||||
let n = 50;
|
||||
let steady = (0..n).map(|i| frame([1.0, 0.25, 3.0][i % 3])).sum::<f64>() / n as f64;
|
||||
eprintln!(
|
||||
"transform preview {w}x{h}: texture {size:?}, begin {begin_ms:.1} ms, first frame per level (1x, 0.25x, 3x) {first:.1?} ms, steady frame {steady:.3} ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_selection_moves_only_the_outline() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
+149
-128
@@ -803,6 +803,22 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
|
||||
|
||||
/// Properties panel sections for a type layer: Character and Paragraph (Photoshop CC layout).
|
||||
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
type_sections(app, ui, true, true);
|
||||
}
|
||||
|
||||
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
|
||||
/// group. Without a type layer (or a type edit) there is nothing to show.
|
||||
pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) {
|
||||
if styles_at(app).is_none() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Select a type layer to edit its character and paragraph settings.").color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
type_sections(app, ui, !paragraph, paragraph);
|
||||
}
|
||||
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
|
||||
let Some((c, para)) = styles_at(app) else { return };
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let section = |ui: &mut egui::Ui, title: &str| {
|
||||
@@ -810,141 +826,146 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.label(egui::RichText::new(title).font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
};
|
||||
section(ui, "Character");
|
||||
let mut fam = c.font_family.clone();
|
||||
ui.horizontal(|ui| {
|
||||
if font_picker(ui, &mut fam) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
|
||||
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
|
||||
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
});
|
||||
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
|
||||
ui.horizontal(|ui| {
|
||||
let k = shown_scale(app);
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
apply(app, ui.ctx(), json!({"size": v / k}));
|
||||
}
|
||||
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
|
||||
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"leading": v / k}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
.to_string();
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
apply(app, ui.ctx(), json!({"tracking": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"verticalScale": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"horizontalScale": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"baselineShift": v}));
|
||||
}
|
||||
// Colour chip.
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
if character {
|
||||
section(ui, "Character");
|
||||
let mut fam = c.font_family.clone();
|
||||
ui.horizontal(|ui| {
|
||||
if font_picker(ui, &mut fam) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, 3.0, bg);
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let col = if on { t.text } else { t.text_dim };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
ui.horizontal(|ui| {
|
||||
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
|
||||
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
|
||||
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let k = shown_scale(app);
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
apply(app, ui.ctx(), json!({"size": v / k}));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
|
||||
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"leading": v / k}));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), props);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
}
|
||||
});
|
||||
.to_string();
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
apply(app, ui.ctx(), json!({"tracking": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"verticalScale": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"horizontalScale": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"baselineShift": v}));
|
||||
}
|
||||
// Colour chip.
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, 3.0, bg);
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let col = if on { t.text } else { t.text_dim };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), props);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !paragraph {
|
||||
return;
|
||||
}
|
||||
section(ui, "Paragraph");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
|
||||
@@ -209,9 +209,10 @@ fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
|
||||
"window.panel.paths" => ("layers", 2),
|
||||
"window.panel.layers" => ("layers", 0),
|
||||
"window.panel.adjustments" => ("properties", 1),
|
||||
"window.panel.properties" | "window.panel.character" | "window.panel.paragraph" | "type.panels.character" | "type.panels.paragraph" => {
|
||||
("properties", 0)
|
||||
}
|
||||
"window.panel.properties" => ("properties", 0),
|
||||
// Their own Character | Paragraph group, so Properties stays open (#150).
|
||||
"window.panel.character" | "type.panels.character" => ("character", 0),
|
||||
"window.panel.paragraph" | "type.panels.paragraph" => ("character", 1),
|
||||
"window.panel.swatches" => ("color", usize::from(pro)),
|
||||
"window.panel.color" => ("color", usize::from(!pro)),
|
||||
_ => return None,
|
||||
@@ -225,6 +226,7 @@ fn panel_state<'a>(app: &'a mut PhotocraftApp, panel: &str) -> (&'a mut bool, &'
|
||||
"history" => (&mut p.history, &mut t.history),
|
||||
"layers" => (&mut p.layers, &mut t.layers),
|
||||
"color" => (&mut p.color, &mut t.color),
|
||||
"character" => (&mut p.character, &mut t.character),
|
||||
_ => (&mut p.properties, &mut t.properties),
|
||||
}
|
||||
}
|
||||
@@ -356,6 +358,7 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
"history" => (p.history, t.history),
|
||||
"layers" => (p.layers, t.layers),
|
||||
"color" => (p.color, t.color),
|
||||
"character" => (p.character, t.character),
|
||||
_ => (p.properties, t.properties),
|
||||
};
|
||||
return Some(vis && cur == tab);
|
||||
|
||||
@@ -33,6 +33,11 @@ pub struct CardResponse {
|
||||
pub menu: Response,
|
||||
/// A tab was double-clicked (Photoshop collapses the group).
|
||||
pub tab_double_clicked: bool,
|
||||
/// Rects of the tabs on the strip, `(tab index, rect)`; tabs that don't fit are in the
|
||||
/// chevron menu instead (#151).
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions.
|
||||
@@ -52,29 +57,18 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
|
||||
// Registered before the tabs so they keep their clicks; drags fall through to it.
|
||||
let strip_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0));
|
||||
let strip = ui.interact(strip_rect, ui.id().with((id, "strip")), Sense::click_and_drag());
|
||||
let mut double = false;
|
||||
let menu = ui
|
||||
.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (i, name) in tabs.iter().enumerate() {
|
||||
let r = pill_tab(ui, name, *selected == i);
|
||||
double |= r.double_clicked();
|
||||
if r.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)))
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
// Pill tabs left of the menu button; they elide or overflow into a chevron (#151).
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
|
||||
let tip = format!("{} options", tabs.get(*selected).copied().unwrap_or(id));
|
||||
let menu_rect = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0));
|
||||
let menu = crate::icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(menu_rect)), "ellipsis", 22.0, false, &tip);
|
||||
let area = Rect::from_min_max(row.min, pos2((menu_rect.left() - 4.0).max(row.left()), row.bottom()));
|
||||
let tabs_out = crate::tab_strip::pill_tabs(ui, ui.id().with((id, "tabs")), area, tabs, selected);
|
||||
if !collapsed {
|
||||
ui.add_space(6.0);
|
||||
body(ui, *selected);
|
||||
}
|
||||
CardResponse { strip, menu, tab_double_clicked: double }
|
||||
CardResponse { strip, menu, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
|
||||
})
|
||||
.inner;
|
||||
ui.add_space(6.0);
|
||||
@@ -91,34 +85,9 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
|
||||
let strip_resp = ui.interact(strip, ui.id().with((id, "strip")), Sense::click_and_drag());
|
||||
let rounding = if collapsed { CornerRadius::same(3) } else { CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 } };
|
||||
ui.painter().rect_filled(strip, rounding, t.tab_strip);
|
||||
let mut x = strip.left();
|
||||
let mut double = false;
|
||||
for (i, name) in tabs.iter().enumerate() {
|
||||
let galley = ui.painter().layout_no_wrap((*name).to_owned(), egui::FontId::proportional(11.5), t.text);
|
||||
let r = Rect::from_min_size(pos2(x, strip.top()), vec2(galley.size().x + 22.0, strip.height()));
|
||||
let resp = ui.interact(r, ui.id().with((id, "tab", i)), Sense::click());
|
||||
let active = *selected == i && !collapsed;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
|
||||
}
|
||||
let color = if active {
|
||||
t.text
|
||||
} else if resp.hovered() {
|
||||
t.text_dim
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
double |= resp.double_clicked();
|
||||
if resp.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
// Panel menu (hamburger).
|
||||
// Panel menu (hamburger); the tabs stay left of it, eliding or overflowing (#151).
|
||||
let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0));
|
||||
let tabs_out = crate::tab_strip::pro_tabs(ui, ui.id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed);
|
||||
let mresp = ui.interact(menu, ui.id().with((id, "menu")), Sense::click());
|
||||
let c = if mresp.hovered() { t.text } else { t.text_faint };
|
||||
for k in 0..3 {
|
||||
@@ -137,7 +106,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
|
||||
});
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: double }
|
||||
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
|
||||
}
|
||||
|
||||
pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
|
||||
|
||||
+53
-37
@@ -30,42 +30,60 @@ readiness; treat it as an upper bound.
|
||||
|
||||
## Honest parity assessment (2026-10-05)
|
||||
|
||||
Written after the 0.2.0 release and the first wave of real user feedback. **We are far from real
|
||||
Photoshop parity.** `parity.md` says 625/625 menu items are live, but that only means each item
|
||||
dispatches *a* command. In the first day of public use, users hit broken basics that all counted as
|
||||
"live": text selection offset from the cursor, shortcuts that didn't fire after clicking a panel
|
||||
(214 failures in an audit), panels resizing themselves, an immovable crop frame, folders that
|
||||
wouldn't collapse, and lag on layout-style PSDs.
|
||||
This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it
|
||||
before picking work. Update it (with dated measurements) when the numbers move; don't restate the
|
||||
menu-parity number in its place.
|
||||
|
||||
| Area | State |
|
||||
|---|---|
|
||||
| Core editing (layers, masks, selections, adjustments, filters, transforms, blend modes) | Broad coverage; engine quality decent; interaction/UI quality only started catching up after 0.2.0. |
|
||||
| PSD fidelity | Composite oracle 113/170 (~66%). All adjustment layers and blend modes round-trip; Photoshop smart filters in PSDs, some effect shapes and text-engine details remain. psd-tools corpus being added (#81). |
|
||||
| Tools | ~20 Photoshop tools missing entirely: Pencil, Mixer Brush (as a tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame. |
|
||||
| Text | Engine works; advanced typography (OpenType features, text-on-path editing, full paragraph composer parity) and PSD text fidelity are partial. |
|
||||
| Painting | Brush model close to Photoshop after #136/#174; no pen pressure on macOS/Linux (#79), no Mixer Brush tool, no Art History Brush. |
|
||||
| AI / generative | ~0%: no Generative Fill/Expand (#41, deferred), Neural Filters or Remove tool. Classical Select Subject / Content-Aware Fill only. |
|
||||
| Ecosystem | No .8BF plug-ins (by decision: sandboxed WebAssembly plug-ins instead), no ExtendScript/UXP/.atn actions, no Adobe Fonts / Libraries / cloud documents. |
|
||||
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF decode; Nikon compressed NEF, CR3, RAF fall back to the embedded preview (clean-room limits, #50). |
|
||||
| Performance / robustness | Strong on large rasters (14k+ on the GPU, banded memory); layout-style documents were laggy (#125/#128 in progress); real-file testing has only just begun. |
|
||||
**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real
|
||||
Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional
|
||||
workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have
|
||||
**not yet been validated by users**, and this week proved our tests miss what users hit:
|
||||
on first public use they found broken basics (text selection offset, 214 shortcut failures, panels
|
||||
resizing themselves, an immovable crop frame, folders that wouldn't collapse, lag on layout PSDs)
|
||||
that all counted as "live" in `parity.md`.
|
||||
|
||||
**Estimate.** Feature surface: roughly **55–70%** of what a typical Photoshop user touches exists in
|
||||
some form. "A working professional could switch today": roughly **20–35%**; professionals live in
|
||||
the details (exact tool behaviour, PSD fidelity on their own files, typography, AI, plug-ins).
|
||||
True 1:1 parity is **many months** of focused work, and some areas need product decisions rather
|
||||
than effort (generative AI backend, the 8BF ecosystem, clean-room RAW limits).
|
||||
**Overall.** Feature surface: roughly **60–70%** of what a typical Photoshop user touches exists in
|
||||
some form. "A professional could switch for daily work": roughly **25–35%**. True 1:1 parity is
|
||||
**many months** of focused work, and some areas need product decisions rather than effort.
|
||||
Confidence: moderate — the next users of 0.2.x will move these numbers either way.
|
||||
|
||||
**Making it measurable** (replace guesses with numbers; update this section as they land):
|
||||
1. **Workflow acceptance tests:** 30–50 real tasks (e.g. retouch a portrait, build a social post
|
||||
with text and effects, composite with masks and adjustment layers, prepare a CMYK print file),
|
||||
each scripted end to end and checked against Photoshop's output, run on every build. The pass
|
||||
rate becomes the headline parity number.
|
||||
2. **Real-file corpus:** users' shareable PSDs plus the psd-tools set (#81), with pass-rate floors
|
||||
that only go up (`crates/io/tests/corpus.rs`).
|
||||
3. **Recurring visual QA:** screenshot sweeps of realistic documents (`cargo run -p photocraft-engine
|
||||
--example designer_psd`). The first sweep found 14 defects (#147–#157) that unit tests missed.
|
||||
4. **User reports:** fast turnaround from report → issue → fix; ask reporters for OS, document
|
||||
size, layer count and a screenshot.
|
||||
### By dimension
|
||||
|
||||
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|
||||
|---|---|---|---|
|
||||
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
|
||||
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
|
||||
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. |
|
||||
| Smart filters / text / effect shapes in PSDs | Not measured: the corpus barely contains them | unknown (probably low–medium) | A Photoshop-authored reference set is being built (`corpus/photoshop`). |
|
||||
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
|
||||
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. |
|
||||
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
|
||||
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pressure on Windows and web only | medium | macOS/Linux pressure blocked on the windowing layer (#79). |
|
||||
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
|
||||
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
|
||||
| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). |
|
||||
| Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. |
|
||||
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF | medium | Nikon compressed NEF, CR3, RAF blocked by clean-room limits (#50). |
|
||||
| AI / generative | none | ~0% | Deferred by decision (#41). |
|
||||
| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. |
|
||||
| Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. |
|
||||
|
||||
### Where we're going (priority order)
|
||||
|
||||
1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them.
|
||||
2. **PSD fidelity to 170/170** with round trips, plus the Photoshop-authored reference set for smart
|
||||
filters, the text engine and effect shapes; enforce floors in `corpus.rs`.
|
||||
3. **Workflow acceptance tests:** 30–50 real tasks (retouch a portrait, social post with text and
|
||||
effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and
|
||||
checked against Photoshop's output on every build. Their pass rate becomes the headline parity
|
||||
number.
|
||||
4. **Missing tools,** starting with the Pen variants and Direct Selection, Patch / Content-Aware
|
||||
Move, Pencil, Rotate View, Perspective Crop.
|
||||
5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49).
|
||||
6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast
|
||||
turnaround on user reports (OS, document size, layer count, screenshot).
|
||||
7. Later / needs decisions: generative AI backend (#41), scripting compatibility (ExtendScript /
|
||||
UXP / .atn), Flathub (#173), macOS/Linux pen pressure (#79).
|
||||
|
||||
## Current focus (infrastructure before the long tail)
|
||||
|
||||
@@ -77,10 +95,8 @@ Landed on 2026-10-01:
|
||||
(`docs/releasing.md`).
|
||||
|
||||
Next:
|
||||
1. **Quality of what exists** (from user feedback after 0.2.0, see the assessment above): workflow
|
||||
acceptance tests, real-file corpus, visual QA, and the open user issues. 0.2.0 shipped signed and
|
||||
notarized on 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be
|
||||
obtained.
|
||||
1. **Follow "Where we're going" in the assessment above.** 0.2.0 shipped signed and notarized on
|
||||
2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be obtained.
|
||||
2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels
|
||||
curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal
|
||||
modes in Lab documents; Photoshop smart filters in `SoLd`.
|
||||
|
||||
Reference in new issue
Block a user