mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 22:24:10 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8ba488d3 | ||
|
|
574d0d3d53 | ||
|
|
a4380b2953 | ||
|
|
8877a812f9 | ||
|
|
b22deaa944 | ||
|
|
f5c6f4bb65 | ||
|
|
f857b4a31f | ||
|
|
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.
|
||||
|
||||
@@ -51,3 +51,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
|
||||
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
|
||||
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
|
||||
|
||||
`cargo xtask corpus --psd-tools` fetches every PSD/PSB of the psd-tools test set
|
||||
([`tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files), MIT,
|
||||
Copyright (c) 2019 Kota Yamaguchi) at a pinned commit into `corpus/psd-tools/`, verified against
|
||||
the sha256 list in `xtask/psd-tools-corpus.sha256`, with the upstream `LICENSE` next to them.
|
||||
Generated
+1
@@ -3681,6 +3681,7 @@ dependencies = [
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -528,6 +528,14 @@ pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
// Feathers blur across tiles: read the cached, canvas-wide combined mask.
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
@@ -865,9 +873,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
}
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
|
||||
// Pass-through groups composite their children straight into the backdrop.
|
||||
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
|
||||
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !effects::has_effects(layer)
|
||||
{
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
|
||||
+82
-17
@@ -1,7 +1,8 @@
|
||||
//! A layer's effective mask (pixel mask × vector mask) as one surface, for backends that sample
|
||||
//! masks from textures (the GPU compositor). Rasterising a vector mask costs a path coverage
|
||||
//! pass, so results are cached per mask state; unchanged masks return the same tiles (cheap to
|
||||
//! clone, and the GPU sees no change).
|
||||
//! A layer's effective mask (pixel mask × vector mask, each feathered) as one surface, for
|
||||
//! backends that sample masks from textures (the GPU compositor) and for feathered masks on the
|
||||
//! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results
|
||||
//! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the
|
||||
//! GPU sees no change).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -33,7 +34,7 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}", layer.vector_mask).hash(&mut h);
|
||||
if let Some(m) = &layer.mask {
|
||||
(m.enabled, m.density.to_bits()).hash(&mut h);
|
||||
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
|
||||
format!("{:?}", m.surface.default_pixel()).hash(&mut h);
|
||||
for (c, t) in m.surface.tiles() {
|
||||
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
@@ -42,12 +43,66 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, exactly as the CPU
|
||||
/// compositor applies them) as a single-channel f32 surface over `canvas`, or `None` when the
|
||||
/// layer has no enabled vector mask (use the pixel mask directly). Values outside the computed
|
||||
/// area are the surface's default.
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
|
||||
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
|
||||
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
|
||||
const FEATHER_SIGMA: f32 = 1.0;
|
||||
/// Photoshop's feather tops out at 1000 px.
|
||||
const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA;
|
||||
|
||||
/// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]).
|
||||
pub fn has_feather(layer: &Layer) -> bool {
|
||||
layer.mask.as_ref().is_some_and(|m| m.enabled && feather_sigma(m.feather) > 0.0)
|
||||
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
|
||||
/// is exact here since the planes extend into constant mask regions).
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if sigma <= 0.0 || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
let mut line = Vec::new();
|
||||
let mut pass = |v: &mut [f32], len: usize, count: usize, at: &dyn Fn(usize, usize) -> usize| {
|
||||
for k in 0..count {
|
||||
for _ in 0..3 {
|
||||
line.clear();
|
||||
line.extend((0..len).map(|i| v[at(k, i)]));
|
||||
let get = |i: isize| line[i.clamp(0, len as isize - 1) as usize];
|
||||
let mut acc: f32 = (-(r as isize)..=r as isize).map(get).sum();
|
||||
let n = (2 * r + 1) as f32;
|
||||
for i in 0..len {
|
||||
v[at(k, i)] = acc / n;
|
||||
acc += get(i as isize + r as isize + 1) - get(i as isize - r as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
pass(v, w, h, &|row, i| row * w + i);
|
||||
pass(v, h, w, &|col, i| i * w + col);
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather,
|
||||
/// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or
|
||||
/// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel
|
||||
/// mask directly). Values outside the computed area are the surface's default.
|
||||
pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled)?;
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled);
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather)));
|
||||
if vm.is_none() && sp <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = key(layer, canvas);
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -60,22 +115,26 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
}
|
||||
// Far outside the path the vector mask is constant.
|
||||
let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1);
|
||||
let v_out = photocraft_vector::vector_mask_values(vm, far)[0];
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]);
|
||||
let p_def = pixel.map_or(1.0, |m| {
|
||||
let d = m.surface.default_pixel().first().copied().unwrap_or(1.0);
|
||||
1.0 - m.density * (1.0 - d)
|
||||
});
|
||||
let grow = |r: Rect, sigma: f32| {
|
||||
let m = (sigma * 3.0).ceil() as i32 + 2;
|
||||
if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) }
|
||||
};
|
||||
// Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel
|
||||
// mask's painted tiles when the vector mask lets them through outside the path.
|
||||
let mut area = match vm.path.control_bounds() {
|
||||
Some((x0, y0, x1, y1)) => Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2),
|
||||
// mask's painted tiles when the vector mask lets them through outside the path, each grown
|
||||
// by its feather.
|
||||
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
|
||||
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
|
||||
None => Rect::EMPTY,
|
||||
};
|
||||
if let Some(m) = pixel
|
||||
&& v_out > 0.0
|
||||
{
|
||||
let b = m.surface.content_bounds();
|
||||
let b = grow(m.surface.content_bounds(), sp);
|
||||
area = if area.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
@@ -87,10 +146,16 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let area = area.intersect(&canvas);
|
||||
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
|
||||
if !area.is_empty() {
|
||||
let mut v = photocraft_vector::vector_mask_values(vm, area);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = match vm {
|
||||
Some(vm) => photocraft_vector::vector_mask_values(vm, area),
|
||||
None => vec![1.0; w * h],
|
||||
};
|
||||
gaussian(&mut v, w, h, sv);
|
||||
if let Some(m) = pixel {
|
||||
let mut pm = Vec::new();
|
||||
m.values_into(area, &mut pm);
|
||||
gaussian(&mut pm, w, h, sp);
|
||||
for (a, b) in v.iter_mut().zip(pm) {
|
||||
*a *= b;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,41 @@ fn isolated_group_differs_from_pass_through() {
|
||||
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
|
||||
}
|
||||
|
||||
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
|
||||
/// so an adjustment inside it no longer changes the layers beneath.
|
||||
#[test]
|
||||
fn pass_through_group_below_full_fill_is_isolated() {
|
||||
let make = |fill: f32| {
|
||||
let mut d = doc_white(2, 2);
|
||||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
g.blend = BlendMode::PassThrough;
|
||||
g.fill_opacity = fill;
|
||||
d.layers.push(g);
|
||||
px(&d, 0, 0)
|
||||
};
|
||||
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
|
||||
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
|
||||
/// combined mask share `masks::combined_mask`).
|
||||
#[test]
|
||||
fn mask_feather_blurs_the_edge() {
|
||||
let mut d = doc_white(64, 8);
|
||||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::hide_all();
|
||||
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
|
||||
l.mask = Some(m);
|
||||
d.layers.push(l.clone());
|
||||
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
|
||||
if let Some(m) = &mut d.layers[1].mask {
|
||||
m.feather = 8.0;
|
||||
}
|
||||
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
|
||||
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
|
||||
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_once() {
|
||||
let mut d = doc_white(2, 2);
|
||||
|
||||
@@ -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");
|
||||
|
||||
+22
-9
@@ -92,15 +92,7 @@ impl MapProgram {
|
||||
/// the reach each field was computed with.
|
||||
pub fn windows(&self, d: Rect, region: Rect, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<Rect> {
|
||||
let n = self.stages.len();
|
||||
let reach_of = |reach: &[i32], i: Option<In>| match i {
|
||||
Some(In::Val(k)) => reach[k],
|
||||
Some(In::Field(f)) => field_radius(f, field_reach(f)),
|
||||
_ => 0,
|
||||
};
|
||||
let mut reach = vec![0i32; n];
|
||||
for (i, s) in self.stages.iter().enumerate() {
|
||||
reach[i] = reach_of(&reach, s.a).max(reach_of(&reach, s.b)).max(reach_of(&reach, s.s)) + s.radius;
|
||||
}
|
||||
let reach = self.stage_reach(field_reach);
|
||||
let mut need = vec![Rect::EMPTY; n];
|
||||
for (i, s) in self.stages.iter().enumerate() {
|
||||
if s.out.is_some() {
|
||||
@@ -123,6 +115,27 @@ impl MapProgram {
|
||||
need
|
||||
}
|
||||
|
||||
/// Per stage, how far (px) its output depends on the shape.
|
||||
fn stage_reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<i32> {
|
||||
let mut reach: Vec<i32> = Vec::with_capacity(self.stages.len());
|
||||
for s in &self.stages {
|
||||
let of = |i: Option<In>| match i {
|
||||
Some(In::Val(k)) => reach.get(k).copied().unwrap_or(0),
|
||||
Some(In::Field(f)) => field_radius(f, field_reach(f)),
|
||||
_ => 0,
|
||||
};
|
||||
let r = of(s.a).max(of(s.b)).max(of(s.s)) + s.radius;
|
||||
reach.push(r);
|
||||
}
|
||||
reach
|
||||
}
|
||||
|
||||
/// How far (px) any final map depends on the shape: maps computed over a window are exact
|
||||
/// at least this far inside it.
|
||||
pub fn reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> i32 {
|
||||
self.stage_reach(field_reach).into_iter().zip(&self.stages).filter(|(_, s)| s.out.is_some()).map(|(r, _)| r).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Physical temporary index per stage (None for final maps), reusing temporaries after
|
||||
/// their last read; returns (assignment, temporaries needed).
|
||||
pub fn temps(&self) -> (Vec<Option<usize>>, usize) {
|
||||
|
||||
+566
-155
File diff suppressed because it is too large.
Load diff
@@ -539,8 +539,10 @@ impl<'a> Planner<'a> {
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
|
||||
|
||||
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !has_effects(layer)
|
||||
{
|
||||
let before = self.retain(backdrop);
|
||||
|
||||
+156
-27
@@ -13,8 +13,14 @@ struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
comp: Compositor,
|
||||
/// The same device with a simulated texture limit of [`PAGED_LIMIT`]: every check also runs
|
||||
/// through layer pages and per-cell effect maps.
|
||||
paged: Compositor,
|
||||
}
|
||||
|
||||
/// Simulated texture limit (pages and chunks of 256 px).
|
||||
const PAGED_LIMIT: u32 = 256;
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||||
@@ -38,7 +44,9 @@ fn gpu() -> Option<Gpu> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Gpu { device, queue, comp })
|
||||
let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
|
||||
paged.set_texture_limit(PAGED_LIMIT);
|
||||
Some(Gpu { device, queue, comp, paged })
|
||||
}
|
||||
|
||||
/// Any adapter and device, for the fallback-path tests (which don't need 32-bit float targets).
|
||||
@@ -88,11 +96,10 @@ fn base_doc(w: u32, h: u32) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
let cpu = photocraft_compose::flatten(doc);
|
||||
let out = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
/// Largest premultiplied difference between `cpu` and `gpu` pixels, and its index.
|
||||
fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) {
|
||||
let mut worst = (0.0f32, 0usize);
|
||||
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
|
||||
for (i, (c, o)) in cpu.iter().zip(gpu).enumerate() {
|
||||
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
|
||||
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
|
||||
for k in 0..4 {
|
||||
@@ -102,9 +109,29 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = doc.size.width as usize;
|
||||
let (x, y) = (worst.1 % w, worst.1 / w);
|
||||
assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]);
|
||||
worst
|
||||
}
|
||||
|
||||
/// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the
|
||||
/// tolerance, else the stats of the unpaged render.
|
||||
fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result<photocraft_gpu::Stats, String> {
|
||||
let cpu = photocraft_compose::render(doc, rect);
|
||||
let mut first = None;
|
||||
for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] {
|
||||
let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?;
|
||||
let worst = worst_diff(&cpu.px, &out);
|
||||
let w = rect.width() as usize;
|
||||
let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32);
|
||||
if worst.0 > TOL {
|
||||
return Err(format!("{what}{label}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
|
||||
}
|
||||
first.get_or_insert(stats);
|
||||
}
|
||||
first.ok_or_else(|| format!("{what}: no render"))
|
||||
}
|
||||
|
||||
fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -673,25 +700,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
||||
|
||||
/// GPU vs CPU over the whole document: Err with the worst pixel if over the tolerance.
|
||||
fn fx_diff(g: &mut Gpu, doc: &Document, what: &str) -> Result<photocraft_gpu::Stats, String> {
|
||||
let cpu = photocraft_compose::flatten(doc);
|
||||
let (out, stats) = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).map_err(|e| format!("{what}: {e}"))?;
|
||||
let mut worst = (0.0f32, 0usize);
|
||||
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
|
||||
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
|
||||
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
|
||||
for k in 0..4 {
|
||||
let d = (pc[k] - po[k]).abs();
|
||||
if d.is_nan() || d > worst.0 {
|
||||
worst = (if d.is_nan() { 9.0 } else { d }, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = doc.size.width as usize;
|
||||
let (x, y) = (worst.1 % w, worst.1 / w);
|
||||
if worst.0 > TOL {
|
||||
return Err(format!("{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
|
||||
}
|
||||
Ok(stats)
|
||||
diff_rect(g, doc, doc.bounds(), what)
|
||||
}
|
||||
|
||||
fn fx_check(g: &mut Gpu, doc: &Document, what: &str) -> photocraft_gpu::Stats {
|
||||
@@ -927,8 +936,13 @@ fn as_text(src: Layer) -> Layer {
|
||||
l
|
||||
}
|
||||
|
||||
/// Held by tests that change the process-wide text gamma, and by tests with type layers that
|
||||
/// run long enough to see such a change.
|
||||
static TEXT_GAMMA: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn type_layers_blend_with_text_gamma() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Color] {
|
||||
let mut d = base_doc(64, 48);
|
||||
@@ -1168,3 +1182,118 @@ fn unbuildable_pipelines_are_an_error_not_a_panic() {
|
||||
assert!(Compositor::try_new_with_format(&device, f).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- documents larger than the texture limit ------------------------------------------------
|
||||
|
||||
/// A document several pages wide under [`PAGED_LIMIT`]: pixel layers crossing page borders (one
|
||||
/// masked, one off-canvas), a big effect layer whose region exceeds the limit (maps built per
|
||||
/// page cell), a type layer with a shadow, an adjustment, a clipped group and a gradient fill.
|
||||
fn big_doc() -> Document {
|
||||
let (w, h) = (1100, 900);
|
||||
let mut d = fx_doc(w, h, SampleType::U8);
|
||||
// Right over the background: adjustment results round to 8 bits, which would turn sub-LSB
|
||||
// float differences of blends beneath into whole steps.
|
||||
let mut adj = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
|
||||
adj.opacity = 0.6;
|
||||
adj.mask = Some(mask(Rect::new(240, 0, 800, 520), 83, 0.0));
|
||||
d.layers.push(adj);
|
||||
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(-30, 200, 700, 820), 81, 0.0);
|
||||
top.blend = BlendMode::Multiply;
|
||||
top.mask = Some(mask(Rect::new(100, 240, 600, 700), 82, 1.0));
|
||||
d.layers.push(top);
|
||||
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
|
||||
let mut big = blob("big", d.pixel_format(), 520.0, 450.0, 330.0, [0.9, 0.4, 0.2]);
|
||||
big.effects.items = stack;
|
||||
big.effects.items.push(Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.6)), GlowTechnique::Softer, 40.0, 0.2, GlowSource::Edge)));
|
||||
d.layers.push(big);
|
||||
let mut text = as_text(blob("type", d.pixel_format(), 900.0, 150.0, 90.0, [0.1, 0.2, 0.9]));
|
||||
text.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 135.0, 12.0, 20.0, 0.0))];
|
||||
d.layers.push(text);
|
||||
let a = noise_layer("ga", PixelFormat::RGBA8, Rect::new(200, 500, 1000, 880), 84, 0.6);
|
||||
let mut c = noise_layer("gc", PixelFormat::RGBA8, Rect::new(0, 600, 1100, 700), 85, 0.0);
|
||||
c.clipped = true;
|
||||
c.blend = BlendMode::Screen;
|
||||
let mut grp = Layer::group("g", vec![a, c]);
|
||||
grp.opacity = 0.9;
|
||||
d.layers.push(grp);
|
||||
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
|
||||
let mut grad = Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle: 30.0, scale: 0.8, style: GradientStyle::Radial, reverse: false }));
|
||||
grad.opacity = 0.3;
|
||||
grad.blend = BlendMode::Overlay;
|
||||
d.layers.push(grad);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_larger_than_the_texture_limit() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = big_doc();
|
||||
assert!(g.paged.supports(&d).is_ok());
|
||||
// Every pass sees page borders, and effect maps and blurs crossing them.
|
||||
check(&mut g, &d, "big document");
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert!(s.cells >= 20 && s.chunks == s.cells, "{s:?}");
|
||||
// A viewport off the page grid covers only its cells.
|
||||
let view = Rect::new(300, 330, 790, 610);
|
||||
diff_rect(&mut g, &d, view, "viewport").unwrap_or_else(|e| panic!("{e}"));
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, view).unwrap().1;
|
||||
assert_eq!(s.cells, 6, "{s:?}");
|
||||
// A dab across a page corner on the effect layer, and an edit elsewhere.
|
||||
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(500, 490, 530, 530), &[0.1, 0.1, 0.9, 1.0]);
|
||||
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(250, 250, 270, 270), &[0.0, 0.0, 0.0, 0.0]);
|
||||
check(&mut g, &d, "after dabs");
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert_eq!((s.tiles_uploaded, s.fx_shapes), (0, 0), "nothing changed since the last render: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_are_evicted_under_the_budget() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = big_doc();
|
||||
check(&mut g, &d, "warm");
|
||||
let all = g.paged.resident_bytes();
|
||||
// A budget of one page: everything but the cell being drawn is evicted as the render goes.
|
||||
let page = u64::from(g.paged.page_size()).pow(2) * 4;
|
||||
g.paged.set_resident_budget(page);
|
||||
for round in 0..3 {
|
||||
let cpu = photocraft_compose::flatten(&d);
|
||||
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||||
let worst = worst_diff(&cpu.px, &out);
|
||||
assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
|
||||
assert!(s.evicted > 0, "round {round}: {s:?}");
|
||||
assert!(g.paged.resident_bytes() < all / 4, "{} of {all}", g.paged.resident_bytes());
|
||||
// Evicted pages come back with the edits made meanwhile.
|
||||
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(10 + round * 300, 300, 60 + round * 300, 340), &[0.9, 0.9, 0.1, 1.0]);
|
||||
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(200 + round * 200, 420, 240 + round * 200, 470), &[0.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
check(&mut g, &d, "after eviction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_view_stays_resident_after_a_full_refresh() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(g) = gpu() else { return };
|
||||
let d = big_doc();
|
||||
let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
|
||||
comp.set_texture_limit(PAGED_LIMIT);
|
||||
let view = Rect::new(520, 300, 760, 500);
|
||||
photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, view).unwrap();
|
||||
// A budget holding just the view's pages: a full refresh evicts as it goes, and draws the
|
||||
// view's cells last, so the next edit in the view uploads nothing.
|
||||
// The working set of a region is what rendering it makes resident.
|
||||
assert_eq!(comp.working_set(&d, view), comp.resident_bytes());
|
||||
assert!(comp.working_set(&d, d.bounds()) > 4 * comp.resident_bytes());
|
||||
comp.set_resident_budget(comp.resident_bytes());
|
||||
assert!(comp.fits_budget(&d, view) && !comp.fits_budget(&d, d.bounds()));
|
||||
comp.set_focus(Some(view));
|
||||
for _ in 0..2 {
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert!(s.evicted > 0, "{s:?}");
|
||||
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap();
|
||||
assert_eq!(s.tiles_uploaded, 0, "{s:?}");
|
||||
let cpu = photocraft_compose::render(&d, view);
|
||||
assert!(worst_diff(&cpu.px, &out).0 <= TOL);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ photocraft-codecs = { workspace = true }
|
||||
photocraft-raw = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
photocraft-format = { path = "../format" }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -28,4 +29,3 @@ thiserror = { workspace = true }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
proptest = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
+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());
|
||||
|
||||
@@ -244,6 +244,70 @@ pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> D
|
||||
.with("Trns", Value::List(trns))
|
||||
}
|
||||
|
||||
/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the
|
||||
/// same curve as the colour stops, Classic interpolation), densely sampled.
|
||||
fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> {
|
||||
let mut stops = Vec::new();
|
||||
let mut mids = Vec::new();
|
||||
if let Some(Value::List(items)) = grad.get("Trns") {
|
||||
for it in items {
|
||||
if let Value::Descriptor(s) = it {
|
||||
let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0;
|
||||
stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0)));
|
||||
mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut order: Vec<usize> = (0..stops.len()).collect();
|
||||
order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0));
|
||||
let mids: Vec<f32> = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect();
|
||||
let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32);
|
||||
let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops };
|
||||
baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect()
|
||||
}
|
||||
|
||||
/// Piecewise-linear value of sorted `(location, v)` stops at `t` (clamped at the ends).
|
||||
fn lerp_stops<T: Copy>(stops: &[(f32, T)], t: f32, mix: impl Fn(T, T, f32) -> T) -> Option<T> {
|
||||
let first = stops.first()?;
|
||||
if t <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if t <= b.0 {
|
||||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
|
||||
return Some(mix(a.1, b.1, k));
|
||||
}
|
||||
}
|
||||
stops.last().map(|s| s.1)
|
||||
}
|
||||
|
||||
/// Folds a gradient's opacity stops into its colour stops' alpha (the model keeps one stop
|
||||
/// list): stops at every colour and opacity location, colours and opacities each interpolated
|
||||
/// linearly. Without opacity stops (or all opaque) the colour stops are returned unchanged.
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels).
|
||||
pub(crate) fn with_opacity_stops(stops: Vec<(f32, Color)>, opacity: &[(f32, f32)]) -> Vec<(f32, Color)> {
|
||||
let mut op: Vec<(f32, f32)> = opacity.iter().copied().filter(|(t, a)| t.is_finite() && a.is_finite()).collect();
|
||||
if op.iter().all(|(_, a)| *a >= 1.0) || stops.is_empty() {
|
||||
return stops;
|
||||
}
|
||||
op.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut sorted = stops;
|
||||
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut locs: Vec<f32> = sorted.iter().map(|s| s.0).chain(op.iter().map(|s| s.0)).collect();
|
||||
locs.sort_by(f32::total_cmp);
|
||||
locs.dedup();
|
||||
let mix_color =
|
||||
|a: Color, b: Color, k: f32| Color { c: std::array::from_fn(|i| a.c[i] + (b.c[i] - a.c[i]) * k), alpha: a.alpha + (b.alpha - a.alpha) * k, ..a };
|
||||
locs.into_iter()
|
||||
.filter_map(|t| {
|
||||
let c = lerp_stops(&sorted, t, mix_color)?;
|
||||
let a = lerp_stops(&op, t, |a, b, k| a + (b - a) * k)?;
|
||||
Some((t, Color { alpha: (c.alpha * a).clamp(0.0, 1.0), ..c }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a fill block (`SoCo`, `GdFl`, `PtFl`).
|
||||
pub fn parse_fill(key: &[u8; 4], data: &[u8]) -> Option<Fill> {
|
||||
// Block data may carry trailing padding after the descriptor.
|
||||
@@ -260,9 +324,11 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
|
||||
let angle = num(d.get("Angl")).unwrap_or(90.0) as f32;
|
||||
let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0);
|
||||
let (mut stops, _) = get_desc(d, "Grad").map(|g| gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default();
|
||||
let opacity = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default();
|
||||
if stops.is_empty() {
|
||||
stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)];
|
||||
}
|
||||
let stops = with_opacity_stops(stops, &opacity);
|
||||
Some(Fill::Gradient { stops, angle, scale, style: gradient_style(d), reverse: bool_of(d, "Rvrs") })
|
||||
}
|
||||
b"PtFl" => {
|
||||
@@ -431,6 +497,31 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a
|
||||
/// gradient fill's opacity stops reach the model (as stop alpha) and survive a write.
|
||||
#[test]
|
||||
fn gradient_fill_opacity_stops_become_alpha() {
|
||||
use photocraft_color::Color;
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
|
||||
let f = Fill::Gradient {
|
||||
stops: vec![(0.0, red), (1.0, Color { alpha: 0.0, ..red })],
|
||||
angle: 90.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let (k, data) = super::write_fill(&f);
|
||||
let Some(Fill::Gradient { stops, .. }) = super::parse_fill(&k, &data) else { panic!("gradient") };
|
||||
let alpha_at = |t: f32| stops.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map(|s| s.1.alpha).unwrap();
|
||||
assert_eq!(alpha_at(0.0), 1.0);
|
||||
assert!(alpha_at(1.0) < 0.01, "{stops:?}");
|
||||
assert!(stops.iter().all(|s| s.1.c[0] == 1.0 && s.1.c[1] == 0.0));
|
||||
// Opaque gradients keep their colour stops untouched.
|
||||
let opaque = super::with_opacity_stops(vec![(0.0, red), (1.0, Color::WHITE)], &[(0.0, 1.0), (1.0, 1.0)]);
|
||||
assert_eq!(opaque, vec![(0.0, red), (1.0, Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_ranges_map_to_blend_if() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
|
||||
@@ -181,6 +181,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
|
||||
}
|
||||
// Generic path (Lab, CMYK and others) via the raster model conversion.
|
||||
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
|
||||
// Multichannel documents keep their channels apart (no layer): composite them.
|
||||
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return Ok(photocraft_compose::flatten(&doc).px);
|
||||
}
|
||||
let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px)
|
||||
|
||||
+33
-18
@@ -101,7 +101,7 @@ impl Ex {
|
||||
(to_psd_rect(r), ch)
|
||||
}
|
||||
|
||||
fn mask(&self, m: &LayerMask) -> (MaskData, Option<ChannelData>) {
|
||||
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
|
||||
let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() };
|
||||
let default = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let r = s.content_bounds();
|
||||
@@ -114,18 +114,10 @@ impl Ex {
|
||||
if !m.enabled {
|
||||
flags |= PsdMask::FLAG_DISABLED;
|
||||
}
|
||||
let density = (m.density < 1.0).then(|| q255(m.density));
|
||||
let feather = (m.feather != 0.0).then_some(f64::from(m.feather));
|
||||
let parameters = (density.is_some() || feather.is_some()).then(|| {
|
||||
let parameters = mask_parameters(Some((m.density, m.feather)), vector);
|
||||
if parameters.is_some() {
|
||||
flags |= PsdMask::FLAG_PARAMETERS;
|
||||
MaskParameters {
|
||||
flags: u8::from(density.is_some()) | u8::from(feather.is_some()) << 1,
|
||||
user_density: density,
|
||||
user_feather: feather,
|
||||
vector_density: None,
|
||||
vector_feather: None,
|
||||
}
|
||||
});
|
||||
}
|
||||
let pm = PsdMask {
|
||||
rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) },
|
||||
default_color: q255(default),
|
||||
@@ -133,6 +125,7 @@ impl Ex {
|
||||
trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() },
|
||||
parameters,
|
||||
real: None,
|
||||
real_first: true,
|
||||
};
|
||||
(MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)))
|
||||
}
|
||||
@@ -157,10 +150,23 @@ impl Ex {
|
||||
None => (PsdRect::default(), self.empty_channels()),
|
||||
};
|
||||
let mut mask = MaskData::None;
|
||||
// Vector mask density/feather live in the mask parameters, with or without a user mask.
|
||||
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
|
||||
if let Some(m) = &l.mask {
|
||||
let (md, ch) = self.mask(m);
|
||||
let (md, ch) = self.mask(m, vector);
|
||||
mask = md;
|
||||
channels.extend(ch);
|
||||
} else if let Some(parameters) = mask_parameters(None, vector) {
|
||||
// Parameters only: no user mask channel, so nothing hides on re-import.
|
||||
mask = MaskData::Mask(PsdMask {
|
||||
rect: PsdRect::default(),
|
||||
default_color: 255,
|
||||
flags: PsdMask::FLAG_PARAMETERS,
|
||||
parameters: Some(parameters),
|
||||
real: None,
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
});
|
||||
}
|
||||
let mut flags = LayerFlags(0);
|
||||
flags.set_hidden(!l.visible);
|
||||
@@ -321,11 +327,6 @@ impl Ex {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(vm) = &l.vector_mask
|
||||
&& (vm.density < 1.0 || vm.feather != 0.0)
|
||||
{
|
||||
self.warnings.push(format!("layer \"{}\": vector mask density/feather are not written to PSD", l.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels for a fill layer: Photoshop's cached rendering while valid,
|
||||
@@ -779,3 +780,17 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
||||
};
|
||||
(file, ex.warnings)
|
||||
}
|
||||
|
||||
/// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when
|
||||
/// every value is the default (density 1, feather 0).
|
||||
fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option<MaskParameters> {
|
||||
let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d)));
|
||||
let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f)));
|
||||
let p =
|
||||
MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) };
|
||||
let flags = u8::from(p.user_density.is_some())
|
||||
| u8::from(p.user_feather.is_some()) << 1
|
||||
| u8::from(p.vector_density.is_some()) << 2
|
||||
| u8::from(p.vector_feather.is_some()) << 3;
|
||||
(flags != 0).then_some(MaskParameters { flags, ..p })
|
||||
}
|
||||
@@ -73,6 +73,23 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
|
||||
rec.layer_mask()?.real
|
||||
}
|
||||
|
||||
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
|
||||
/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a
|
||||
/// feather, or when it stores no pixels and fills with a pattern.
|
||||
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<photocraft_doc::Fill> {
|
||||
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
|
||||
return None;
|
||||
}
|
||||
let k = fill_key?;
|
||||
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
|
||||
let p = rec.layer_mask().and_then(|m| m.parameters);
|
||||
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
|
||||
// Without stored pixels a pattern-filled shape cannot be rasterized on its own (the
|
||||
// compositor resolves the document's patterns for fill layers).
|
||||
let unrendered_pattern = (rec.rect.is_empty() || rec.rect.size().is_err()) && matches!(fill, photocraft_doc::Fill::Pattern { .. });
|
||||
(soft || unrendered_pattern).then_some(fill)
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn warn(&mut self, s: impl Into<String>) {
|
||||
self.warnings.push(s.into());
|
||||
@@ -247,11 +264,22 @@ impl Ctx<'_> {
|
||||
warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)),
|
||||
stack_mode: None,
|
||||
})
|
||||
} else if let Some(f) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
|
||||
// A shape whose vector mask has a density or feather is a fill layer seen through a
|
||||
// soft vector mask: the fill shows beyond the path, which the stored pixels (the
|
||||
// shape alone) lack. Import it as exactly that.
|
||||
LayerContent::Fill(f)
|
||||
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
|
||||
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
|
||||
let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() };
|
||||
let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone());
|
||||
crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi);
|
||||
// No stored pixels (32-bit documents, files saved without layer pixels): render the
|
||||
// shape from its path and fill, as Photoshop does when it opens the file.
|
||||
if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) {
|
||||
let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32);
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas));
|
||||
}
|
||||
LayerContent::Shape(sh)
|
||||
} else if let Some(k) = fill_key {
|
||||
match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) {
|
||||
|
||||
+107
-7
@@ -75,30 +75,84 @@ pub fn path_from_resource(data: &[u8], w: u32, h: u32) -> Option<Path> {
|
||||
PathData::from_bytes(data).ok().map(|p| path_from_records(&p, w, h))
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags).
|
||||
/// Layer vector data (`vmsk`/`vsms`) → model path, with Photoshop's reading of the records
|
||||
/// (matching its rendering of the psd-tools corpus):
|
||||
/// - without subpaths, the initial-fill record alone decides coverage (1 = everything);
|
||||
/// - with subpaths the initial-fill record is ignored; a first component that subtracts starts
|
||||
/// from a full canvas (the shape is inverted);
|
||||
/// - a subpath with operation -1 continues the previous component (same operation).
|
||||
///
|
||||
/// The model applies the first subpath as "combine" and inverts the final result, so a leading
|
||||
/// subtract becomes `inverted` with every later operation replaced by its complement dual
|
||||
/// (combine ↔ subtract; exclude is self-dual; intersect has no dual and is kept as is).
|
||||
fn layer_path_from_records(p: &PathData, w: u32, h: u32) -> Path {
|
||||
let mut path = path_from_records(p, w, h);
|
||||
if path.subpaths.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let ops: Vec<i16> = p.subpaths().iter().map(|s| s.operation).collect();
|
||||
let mut prev = PathOp::Combine;
|
||||
for (s, &raw) in path.subpaths.iter_mut().zip(&ops) {
|
||||
if raw == -1 {
|
||||
s.op = prev;
|
||||
}
|
||||
prev = s.op;
|
||||
}
|
||||
path.inverted = path.subpaths.first().is_some_and(|s| s.op == PathOp::Subtract);
|
||||
if path.inverted {
|
||||
for s in path.subpaths.iter_mut().skip(1) {
|
||||
s.op = match s.op {
|
||||
PathOp::Combine => PathOp::Subtract,
|
||||
PathOp::Subtract => PathOp::Combine,
|
||||
op => op,
|
||||
};
|
||||
}
|
||||
if let Some(first) = path.subpaths.first_mut() {
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags). Coverage semantics: an
|
||||
/// empty path covers everything when `inverted` (shape layers; vector masks flip this, see
|
||||
/// [`vector_mask_from_block`]).
|
||||
pub fn path_from_vmsk(data: &[u8], w: u32, h: u32) -> Option<(Path, u32)> {
|
||||
let b = VectorMaskBlock::from_bytes(data).ok()?;
|
||||
let mut p = path_from_records(&b.path, w, h);
|
||||
let mut p = layer_path_from_records(&b.path, w, h);
|
||||
if b.flags & VectorMaskBlock::FLAG_INVERT != 0 {
|
||||
p.inverted = !p.inverted;
|
||||
}
|
||||
Some((p, b.flags))
|
||||
}
|
||||
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled).
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled). An empty path is written
|
||||
/// as the initial-fill record (Photoshop's form); otherwise inversion uses the invert flag.
|
||||
pub fn vmsk_bytes(path: &Path, flags: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
let mut p = path.clone();
|
||||
let mut flags = flags & !VectorMaskBlock::FLAG_INVERT;
|
||||
if p.inverted {
|
||||
if p.inverted && !p.subpaths.is_empty() {
|
||||
flags |= VectorMaskBlock::FLAG_INVERT;
|
||||
p.inverted = false;
|
||||
}
|
||||
// The model draws the first subpath as "combine"; a stored leading subtract would read back
|
||||
// as an inverted shape.
|
||||
if let Some(first) = p.subpaths.first_mut()
|
||||
&& first.op == PathOp::Subtract
|
||||
{
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
VectorMaskBlock { version: 3, flags, path: path_to_records(&p, w, h) }.to_bytes()
|
||||
}
|
||||
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block.
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block. A vector mask without subpaths reveals
|
||||
/// everything unless inverted (see `photocraft_vector::vector_mask_values`), the opposite of a
|
||||
/// shape's coverage, hence the flip.
|
||||
pub fn vector_mask_from_block(data: &[u8], w: u32, h: u32) -> Option<VectorMask> {
|
||||
let (path, flags) = path_from_vmsk(data, w, h)?;
|
||||
let (mut path, flags) = path_from_vmsk(data, w, h)?;
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
Some(VectorMask {
|
||||
path,
|
||||
enabled: flags & VectorMaskBlock::FLAG_DISABLED == 0,
|
||||
@@ -117,7 +171,11 @@ pub fn vector_mask_bytes(m: &VectorMask, w: u32, h: u32) -> Vec<u8> {
|
||||
if !m.linked {
|
||||
flags |= VectorMaskBlock::FLAG_NOT_LINKED;
|
||||
}
|
||||
vmsk_bytes(&m.path, flags, w, h)
|
||||
let mut path = m.path.clone();
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
vmsk_bytes(&path, flags, w, h)
|
||||
}
|
||||
|
||||
/// Compares the parts of a vector mask stored in the block (density/feather live elsewhere).
|
||||
@@ -515,6 +573,48 @@ mod tests {
|
||||
assert_eq!(op_from_psd(-1), PathOp::Combine);
|
||||
}
|
||||
|
||||
fn vmsk_with(initial_fill: bool, ops: &[i16]) -> Vec<u8> {
|
||||
let square = |x: f64| PsdSubpath {
|
||||
closed: true,
|
||||
operation: 0,
|
||||
knots: [(x, 0.25), (x + 0.25, 0.25), (x + 0.25, 0.5), (x, 0.5)].iter().map(|&p| PsdKnot { linked: false, pre: p, anchor: p, post: p }).collect(),
|
||||
};
|
||||
let subs: Vec<PsdSubpath> = ops.iter().enumerate().map(|(i, &op)| PsdSubpath { operation: op, ..square(0.1 * i as f64) }).collect();
|
||||
VectorMaskBlock { version: 3, flags: 0, path: PathData::from_subpaths(&subs, initial_fill) }.to_bytes()
|
||||
}
|
||||
|
||||
/// psd-tools vector-mask.psd / vector-mask2.psd / vector-mask3.psd: Photoshop's initial-fill
|
||||
/// record only matters without subpaths; a leading subtract inverts; -1 continues the
|
||||
/// previous component.
|
||||
#[test]
|
||||
fn layer_path_semantics_follow_photoshop() {
|
||||
// Initial fill with a combine subpath: a plain (not inverted) shape.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[1]), 100, 100).unwrap();
|
||||
assert!(!p.inverted);
|
||||
// Leading subtract: everything but the shape; later ops become their complements.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[2, 1, 2, 0]), 100, 100).unwrap();
|
||||
assert!(p.inverted);
|
||||
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Subtract, PathOp::Combine, PathOp::Exclude]);
|
||||
// -1 continues the previous component's operation.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(false, &[1, 2, -1]), 100, 100).unwrap();
|
||||
assert_eq!(p.subpaths[2].op, PathOp::Subtract);
|
||||
// No subpaths: a vector mask with initial fill reveals all, without hides all; a shape
|
||||
// with initial fill covers everything.
|
||||
let reveal = vector_mask_from_block(&vmsk_with(true, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&reveal, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![1.0; 4]);
|
||||
let hide = vector_mask_from_block(&vmsk_with(false, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&hide, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![0.0; 4]);
|
||||
assert!(path_from_vmsk(&vmsk_with(true, &[]), 100, 100).unwrap().0.inverted);
|
||||
// Our own writer round-trips each case.
|
||||
for m in [reveal, hide] {
|
||||
assert_eq!(vector_mask_from_block(&vector_mask_bytes(&m, 100, 100), 100, 100), Some(m));
|
||||
}
|
||||
let mut lead = sample_path();
|
||||
lead.subpaths[0].op = PathOp::Subtract;
|
||||
let back = path_from_vmsk(&vmsk_bytes(&lead, 0, 64, 64), 64, 64).unwrap().0;
|
||||
assert!(!back.inverted, "a stored leading subtract is drawn as combine by the model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_mask_roundtrip_with_flags() {
|
||||
let mut m = VectorMask::new(sample_path());
|
||||
|
||||
+374
-93
@@ -1,22 +1,49 @@
|
||||
//! Optional real-file corpus: `corpus/psd/**/*.{psd,psb}` at the workspace
|
||||
//! root (gitignored; see `corpus/psd/SOURCES.md`). Skips silently if absent.
|
||||
//! Optional real-file corpora (gitignored, never committed; each test skips
|
||||
//! silently when its corpus is absent):
|
||||
//!
|
||||
//! - `corpus/psd/**/*.{psd,psb}`: a hand-picked mix of small ag-psd and
|
||||
//! psd-tools samples (see `corpus/psd/SOURCES.md`). Enforced with
|
||||
//! `PHOTOCRAFT_CORPUS` (a directory, or `1` for the default location).
|
||||
//! - `corpus/psd-tools/**/*.{psd,psb}`: the complete psd-tools test set
|
||||
//! (MIT) at a pinned commit, fetched and sha256-verified by
|
||||
//! `cargo xtask corpus --psd-tools`. Enforced with
|
||||
//! `PHOTOCRAFT_PSDTOOLS_CORPUS` (a directory, or `1`).
|
||||
//!
|
||||
//! For each file: parse → document → flatten, compared with the file's own
|
||||
//! merged composite (Photoshop's rendering) as the oracle; then document →
|
||||
//! PSD → document → flatten, compared with the first flatten (our own export
|
||||
//! must not change what the document looks like). Prints a per-file table.
|
||||
//! A panic anywhere is caught and reported as CRASH (Rule 9).
|
||||
//!
|
||||
//! Without `PHOTOCRAFT_CORPUS` the comparisons are only reported: differences
|
||||
//! are expected where features are not yet rendered (effects, text engine,
|
||||
//! smart filters). With `PHOTOCRAFT_CORPUS` set (to the corpus directory, or
|
||||
//! to `1` for the default location) the run asserts that the oracle pass
|
||||
//! count and the export round-trip count do not fall below their floors.
|
||||
//! Raise the floors when they improve; never lower them.
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to fail on import/export errors.
|
||||
//! Oracles: files without layers (Bitmap, Indexed, plain flat files) compare
|
||||
//! our import with the merged image as decoded by the psd crate. When
|
||||
//! Photoshop wrote no real merged image (Maximize Compatibility off: blank
|
||||
//! image data) or the merged image would decode through our own model code
|
||||
//! (Multichannel), the embedded thumbnail (resource 1036, Photoshop's own
|
||||
//! render, JPEG, ≤ 160 px) is the oracle: our composite is box-downsampled to
|
||||
//! its size and both are lightly blurred; PASS (thumbnail) needs a mean error
|
||||
//! ≤ 3/255 with ≤ 2 % of pixels off by more than 24/255. That bar is strict
|
||||
//! (calibrated on files that match their merged image, many thumbnails being
|
||||
//! stale or resampled differently), so a thumbnail PASS is real evidence while
|
||||
//! a thumbnail DIFF may be the thumbnail's fault. A file SKIPs only when no
|
||||
//! oracle exists (no real composite and no usable thumbnail).
|
||||
//!
|
||||
//! Without the env var the comparisons are only reported: differences are
|
||||
//! expected where features are not yet rendered (effects, text engine, smart
|
||||
//! filters, knockout). With it set the run asserts no crashes, and that the
|
||||
//! oracle pass count and the export round-trip count do not fall below the
|
||||
//! source's floors. Raise the floors when they improve; never lower them.
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to also fail on import/export errors
|
||||
//! (files listed in `KNOWN_BAD` excepted).
|
||||
//!
|
||||
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
|
||||
//! asserts import + flatten return (Ok or Err) without panicking.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::PsdFile;
|
||||
@@ -36,111 +63,365 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
const PASS_TOL: f32 = 2.0 / 255.0;
|
||||
/// Export → re-import must render within one 8-bit step of the imported document.
|
||||
const ROUNDTRIP_TOL: f32 = 1.0 / 255.0 + 1e-5;
|
||||
/// Files whose flatten matches Photoshop's merged image (`corpus/psd`, 170 files).
|
||||
const PASS_FLOOR: usize = 113;
|
||||
/// Files whose export → re-import renders the same as the import.
|
||||
const ROUNDTRIP_FLOOR: usize = 169;
|
||||
|
||||
#[test]
|
||||
fn corpus_import_flatten_oracle() {
|
||||
let env = std::env::var_os("PHOTOCRAFT_CORPUS").filter(|v| !v.is_empty());
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
|
||||
/// One corpus directory with its own floors.
|
||||
struct Source {
|
||||
label: &'static str,
|
||||
/// Enforcing env var: a directory, or `1` for `default_dir`.
|
||||
env: &'static str,
|
||||
/// Default location, relative to the workspace root.
|
||||
default_dir: &'static str,
|
||||
/// Files whose flatten matches their oracle (merged image, or thumbnail when there is none).
|
||||
pass_floor: usize,
|
||||
/// Files whose export → re-import renders the same as the import.
|
||||
roundtrip_floor: usize,
|
||||
}
|
||||
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 121 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 133, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
|
||||
/// 202 vs the merged image + 10 vs the thumbnail.
|
||||
const PSD_TOOLS: Source =
|
||||
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 212, roundtrip_floor: 307 };
|
||||
|
||||
/// The corpus directory and whether floors are enforced; `None` when absent.
|
||||
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
|
||||
let env = std::env::var_os(src.env).filter(|v| !v.is_empty());
|
||||
let root = match &env {
|
||||
Some(v) if v != "1" => PathBuf::from(v),
|
||||
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"),
|
||||
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(src.default_dir),
|
||||
};
|
||||
if !root.is_dir() {
|
||||
assert!(env.is_none(), "PHOTOCRAFT_CORPUS is set but {} is not a directory", root.display());
|
||||
return;
|
||||
assert!(env.is_none(), "{} is set but {} is not a directory", src.env, root.display());
|
||||
return None;
|
||||
}
|
||||
Some((root, env.is_some()))
|
||||
}
|
||||
|
||||
fn files_in(root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
collect(root, &mut files);
|
||||
files.sort();
|
||||
let (mut pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff) = (0, Vec::new());
|
||||
files
|
||||
}
|
||||
|
||||
fn panic_message(e: &(dyn std::any::Any + Send)) -> String {
|
||||
e.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// Thumbnail oracle: mean absolute error and the share of pixels off by more than
|
||||
/// `THUMB_BAD`, after box-downsampling our composite (over white) to the JPEG thumbnail's size.
|
||||
const THUMB_MEAN_TOL: f32 = 3.0 / 255.0;
|
||||
const THUMB_BAD: f32 = 24.0 / 255.0;
|
||||
const THUMB_BAD_FRAC: f32 = 0.02;
|
||||
|
||||
/// Photoshop's embedded thumbnail (resource 1036: a 28-byte header, then JFIF) as RGB floats.
|
||||
fn thumbnail(file: &PsdFile) -> Option<(u32, u32, Vec<[f32; 3]>)> {
|
||||
let r = file.resource(photocraft_psd::resources::ids::THUMBNAIL)?;
|
||||
let format = u32::from_be_bytes(r.data.get(..4)?.try_into().ok()?);
|
||||
if format != 1 {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(r.data.get(28..)?).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
Some((img.width(), img.height(), rgba.as_chunks::<4>().0.iter().map(|p| [0, 1, 2].map(|c| f32::from(p[c]) / 255.0)).collect()))
|
||||
}
|
||||
|
||||
/// 3×3 box blur of an RGB plane (edges clamp): absorbs the sub-pixel offsets between our box
|
||||
/// downsampling and Photoshop's thumbnail resampling, and JPEG ringing.
|
||||
fn blur3(v: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0; 3]; v.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for dy in [-1i64, 0, 1] {
|
||||
for dx in [-1i64, 0, 1] {
|
||||
let (sx, sy) = ((x as i64 + dx).clamp(0, w as i64 - 1) as usize, (y as i64 + dy).clamp(0, h as i64 - 1) as usize);
|
||||
for c in 0..3 {
|
||||
acc[c] += v[sy * w + sx][c];
|
||||
}
|
||||
}
|
||||
}
|
||||
out[y * w + x] = acc.map(|a| a / 9.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compares `ours` (canvas-size straight RGBA) with the thumbnail: our composite over white is
|
||||
/// box-downsampled to the thumbnail's size, both are lightly blurred, and the result is the mean
|
||||
/// absolute error and the share of pixels off by more than `THUMB_BAD`. `None` without a
|
||||
/// thumbnail, or when it is not smaller than the canvas (tiny documents: not a real reduction).
|
||||
fn thumbnail_oracle(file: &PsdFile, ours: &[[f32; 4]]) -> Option<(f32, f32)> {
|
||||
let (tw, th, thumb) = thumbnail(file)?;
|
||||
let (w, h) = (file.header.width as usize, file.header.height as usize);
|
||||
let (tw, th) = (tw as usize, th as usize);
|
||||
if tw < 8 || th < 8 || tw > w || th > h || ours.len() != w * h || thumb.len() != tw * th {
|
||||
return None;
|
||||
}
|
||||
let mut small = Vec::with_capacity(tw * th);
|
||||
for ty in 0..th {
|
||||
for tx in 0..tw {
|
||||
let (x0, x1) = (tx * w / tw, ((tx + 1) * w / tw).max(tx * w / tw + 1).min(w));
|
||||
let (y0, y1) = (ty * h / th, ((ty + 1) * h / th).max(ty * h / th + 1).min(h));
|
||||
let mut acc = [0.0f32; 3];
|
||||
for y in y0..y1 {
|
||||
for p in &ours[y * w + x0..y * w + x1] {
|
||||
for c in 0..3 {
|
||||
acc[c] += p[c] * p[3] + 1.0 - p[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((x1 - x0) * (y1 - y0)) as f32;
|
||||
small.push(acc.map(|a| a / n));
|
||||
}
|
||||
}
|
||||
let (a, b) = (blur3(&small, tw, th), blur3(&thumb, tw, th));
|
||||
let (mut sum, mut bad) = (0.0f32, 0usize);
|
||||
for (p, t) in a.iter().zip(&b) {
|
||||
let d: [f32; 3] = std::array::from_fn(|c| (p[c] - t[c]).abs());
|
||||
sum += (d[0] + d[1] + d[2]) / 3.0;
|
||||
bad += usize::from(d.iter().any(|v| *v > THUMB_BAD));
|
||||
}
|
||||
let n = (tw * th) as f32;
|
||||
Some((sum / n, bad as f32 / n))
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Pass,
|
||||
/// Matched the embedded thumbnail (no real merged image to compare with).
|
||||
ThumbPass,
|
||||
Diff,
|
||||
Skip,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Oracle + round trip for one file. Returns the oracle outcome and whether the export round trip
|
||||
/// rendered the same (`None` when it could not run).
|
||||
fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
let known_bad = KNOWN_BAD.iter().any(|(f, _)| name.ends_with(f));
|
||||
let file = match PsdFile::from_bytes(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let tag = if known_bad { "KNOWN-BAD" } else { "PARSE-ERROR" };
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} {tag} {e}", "-", "-", "-");
|
||||
return (if known_bad { Outcome::Skip } else { Outcome::Error }, None);
|
||||
}
|
||||
};
|
||||
let imp = match import(name, bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// Export must always succeed, re-parse and re-import.
|
||||
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
|
||||
Ok(r) => {
|
||||
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
|
||||
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
match import(name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} REIMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} EXPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
// Our own export must not change how the document renders.
|
||||
let again = photocraft_compose::flatten(&reimported).px;
|
||||
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
|
||||
let layers = doc.layer_count();
|
||||
// Oracle choice: the merged image (Photoshop's composite; for files without layers it is the
|
||||
// whole image, decoded independently by the psd crate). When Photoshop wrote no real merged
|
||||
// image (Maximize Compatibility off) or it decodes through our own model code (Multichannel),
|
||||
// its embedded thumbnail is the oracle at thumbnail size.
|
||||
let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == photocraft_psd::ColorMode::Multichannel;
|
||||
let merged = if use_thumb { None } else { merged_composite(&file).ok() };
|
||||
let Some(merged) = merged else {
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
return match thumbnail_oracle(&file, &ours) {
|
||||
Some((mean, frac)) => {
|
||||
let ok = mean <= THUMB_MEAN_TOL && frac <= THUMB_BAD_FRAC;
|
||||
let status = if ok { "PASS (thumbnail)" } else { "DIFF (thumbnail)" };
|
||||
eprintln!("{name:<60} {layers:>6} {mean:>9.4} {:>7.2}% {status} {}", 100.0 * frac, notes.join(" | "));
|
||||
(if ok { Outcome::ThumbPass } else { Outcome::Diff }, Some(rt))
|
||||
}
|
||||
None => {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no real composite and no thumbnail)", "-", "-");
|
||||
(Outcome::Skip, Some(rt))
|
||||
}
|
||||
};
|
||||
};
|
||||
// Without an alpha channel in the merged image, Photoshop flattened the document over white.
|
||||
let ours = if file.merged_has_alpha() {
|
||||
ours
|
||||
} else {
|
||||
ours.iter()
|
||||
.map(|p| {
|
||||
let w = 1.0 - p[3];
|
||||
[p[0] * p[3] + w, p[1] * p[3] + w, p[2] * p[3] + w, 1.0]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let m = common::max_diff(&ours, &merged);
|
||||
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
|
||||
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
|
||||
let (outcome, status) = if m <= PASS_TOL { (Outcome::Pass, "PASS") } else { (Outcome::Diff, "DIFF") };
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
if std::env::var_os("CORPUS_THUMB_CALIBRATE").is_some()
|
||||
&& let Some((mean, frac)) = thumbnail_oracle(&file, &ours)
|
||||
{
|
||||
eprintln!("THUMBCAL {status} {mean:.4} {frac:.4} {name}");
|
||||
}
|
||||
(outcome, Some(rt))
|
||||
}
|
||||
|
||||
fn run_oracle(src: &Source) {
|
||||
let Some((root, enforce)) = locate(src) else { return };
|
||||
let files = files_in(&root);
|
||||
let (mut pass, mut thumb_pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff, mut crashes) = (0, Vec::new(), Vec::new());
|
||||
eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%");
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let bytes = std::fs::read(p).unwrap_or_default();
|
||||
let file = match PsdFile::from_bytes(&bytes) {
|
||||
Ok(f) => f,
|
||||
let (outcome, rt) = match catch_unwind(AssertUnwindSafe(|| check_file(&name, &bytes))) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} PARSE-ERROR {e}", "-", "-", "-");
|
||||
errors += 1;
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("{name:<60} CRASH {msg}");
|
||||
crashes.push(format!("{name}: {msg}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let imp = match import(&name, &bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// Export must always succeed, re-parse and re-import.
|
||||
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
|
||||
Ok(r) => {
|
||||
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
|
||||
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
match import(&name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} REIMPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} EXPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
// Our own export must not change how the document renders.
|
||||
let again = photocraft_compose::flatten(&reimported).px;
|
||||
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
|
||||
if rt <= ROUNDTRIP_TOL {
|
||||
rt_same += 1;
|
||||
} else {
|
||||
rt_diff.push(format!("{name} ({rt:.4})"));
|
||||
match outcome {
|
||||
Outcome::Pass => pass += 1,
|
||||
Outcome::ThumbPass => thumb_pass += 1,
|
||||
Outcome::Diff => diff += 1,
|
||||
Outcome::Skip => skipped += 1,
|
||||
Outcome::Error => errors += 1,
|
||||
}
|
||||
let layers = doc.layer_count();
|
||||
if file.has_real_merged_data() == Some(false) || file.layers().is_empty() {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no layers or no real composite)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
match rt {
|
||||
Some(rt) if rt <= ROUNDTRIP_TOL => rt_same += 1,
|
||||
Some(rt) => rt_diff.push(format!("{name} ({rt:.4})")),
|
||||
None => {}
|
||||
}
|
||||
let Ok(merged) = merged_composite(&file) else {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (merged not decodable)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let m = common::max_diff(&ours, &merged);
|
||||
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
|
||||
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
|
||||
let status = if m <= PASS_TOL {
|
||||
pass += 1;
|
||||
"PASS"
|
||||
} else {
|
||||
diff += 1;
|
||||
"DIFF"
|
||||
};
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
}
|
||||
eprintln!("io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", files.len());
|
||||
eprintln!("io corpus: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
|
||||
let label = src.label;
|
||||
eprintln!(
|
||||
"{label}: {} files: {} pass ({pass} vs the merged image <= 2/255, {thumb_pass} vs the thumbnail), {diff} differ, {skipped} skipped, {errors} errors, {} crashes",
|
||||
files.len(),
|
||||
pass + thumb_pass,
|
||||
crashes.len()
|
||||
);
|
||||
let pass = pass + thumb_pass;
|
||||
eprintln!("{label}: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
|
||||
if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() {
|
||||
assert_eq!(errors, 0);
|
||||
}
|
||||
if env.is_some() {
|
||||
assert!(pass >= PASS_FLOOR, "oracle pass count {pass} fell below the floor {PASS_FLOOR}");
|
||||
assert!(rt_same >= ROUNDTRIP_FLOOR, "export round trip {rt_same} fell below the floor {ROUNDTRIP_FLOOR}: {rt_diff:?}");
|
||||
if enforce {
|
||||
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
|
||||
assert!(pass >= src.pass_floor, "{label}: oracle pass count {pass} fell below the floor {}", src.pass_floor);
|
||||
assert!(rt_same >= src.roundtrip_floor, "{label}: export round trip {rt_same} fell below the floor {}: {rt_diff:?}", src.roundtrip_floor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic xorshift for the mutation sweep.
|
||||
fn next(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
/// Truncations and byte corruptions of every corpus file: import (and flatten when the document
|
||||
/// is small) must return without panicking. Files are mutated in their first 64 KB, where the
|
||||
/// header, resources and layer records live.
|
||||
fn run_mutations(src: &Source) {
|
||||
// Minutes of work: only when the corpus is enforced.
|
||||
let Some((root, true)) = locate(src) else { return };
|
||||
let enforce = true;
|
||||
let files = files_in(&root);
|
||||
let (mut runs, mut panics, mut slow) = (0usize, Vec::new(), Vec::new());
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let Ok(bytes) = std::fs::read(p) else { continue };
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ bytes.len() as u64;
|
||||
let span = bytes.len().clamp(1, 64 * 1024);
|
||||
let mut variants: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for k in 1..8 {
|
||||
let cut = bytes.len() * k / 8;
|
||||
variants.push((format!("truncate@{cut}"), bytes[..cut].to_vec()));
|
||||
}
|
||||
for _ in 0..24 {
|
||||
let mut m = bytes.clone();
|
||||
let flips = 1 + (next(&mut seed) % 4) as usize;
|
||||
for _ in 0..flips {
|
||||
let at = (next(&mut seed) as usize) % span;
|
||||
if let Some(b) = m.get_mut(at) {
|
||||
*b = match next(&mut seed) % 3 {
|
||||
0 => 0xFF,
|
||||
1 => 0x00,
|
||||
_ => *b ^ (1 << (next(&mut seed) % 8)),
|
||||
};
|
||||
}
|
||||
}
|
||||
variants.push(("mutate".into(), m));
|
||||
}
|
||||
for (what, data) in variants {
|
||||
runs += 1;
|
||||
let t = Instant::now();
|
||||
let r = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(imp) = import(&name, &data) {
|
||||
let b = imp.document.bounds();
|
||||
if i64::from(b.width()) * i64::from(b.height()) <= 4_000_000 {
|
||||
let _ = photocraft_compose::flatten(&imp.document);
|
||||
}
|
||||
}
|
||||
}));
|
||||
let secs = t.elapsed().as_secs_f32();
|
||||
if secs > 20.0 {
|
||||
slow.push(format!("{name} {what}: {secs:.1}s"));
|
||||
}
|
||||
if let Err(e) = r {
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("CRASH {name} {what}: {msg}");
|
||||
panics.push(format!("{name} {what}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{}: {runs} mutated imports, {} panics, {} slow (> 20 s): {slow:?}", src.label, panics.len(), slow.len());
|
||||
if enforce {
|
||||
assert!(panics.is_empty(), "{}: mutated files panicked (Rule 9): {panics:?}", src.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_import_flatten_oracle() {
|
||||
run_oracle(&MIXED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_import_flatten_oracle() {
|
||||
run_oracle(&PSD_TOOLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_mutations_never_panic() {
|
||||
run_mutations(&PSD_TOOLS);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Synthetic reproductions of failures found by the psd-tools corpus
|
||||
//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus
|
||||
//! files themselves are never committed: each test rebuilds the relevant
|
||||
//! structure from our own documents.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerMask, Path, ShapeLayer, Size, Subpath, VectorMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::layer::CHANNEL_REAL_USER_MASK;
|
||||
use photocraft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version};
|
||||
|
||||
fn doc(depth: SampleType) -> Document {
|
||||
Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, depth, Color::WHITE)
|
||||
}
|
||||
|
||||
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Path {
|
||||
Path::new(vec![Subpath::polygon(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)])])
|
||||
}
|
||||
|
||||
fn reimport(file: &PsdFile) -> ImportResult {
|
||||
import("x.psd", &file.to_bytes().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// psd-tools 32bit.psd, 300dpi.psd, transparentbg.psd, vector-mask2.psd: shape layers whose
|
||||
/// record stores no pixels (empty rect; every 32-bit shape layer) imported as invisible. They
|
||||
/// are now rendered from their path and fill.
|
||||
#[test]
|
||||
fn shape_layer_without_stored_pixels_renders_from_its_path() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = doc(depth);
|
||||
let sh = ShapeLayer { path: square(8.0, 8.0, 24.0, 24.0), fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Shape 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Drop the shape record's pixels, as Photoshop does for 32-bit documents.
|
||||
let bits = file.header.depth;
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"vmsk").is_some() || rec.block(b"vsms").is_some());
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, bits, Version::Psd).unwrap();
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "{depth:?}: shape interior renders black: {:?}", f.get(16, 16));
|
||||
assert!(f.get(2, 2)[0] > 0.99, "{depth:?}: outside stays white");
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools mask-density-vectormask.psd, mask_parameters.psd: vector mask density and feather
|
||||
/// were dropped on export (only the user mask's parameters were written).
|
||||
#[test]
|
||||
fn vector_mask_density_and_feather_survive_export() {
|
||||
for with_user_mask in [false, true] {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 16.0, 32.0));
|
||||
vm.density = 128.0 / 255.0;
|
||||
vm.feather = 1.5;
|
||||
l.vector_mask = Some(vm);
|
||||
if with_user_mask {
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 204.0 / 255.0;
|
||||
m.feather = 2.5;
|
||||
l.mask = Some(m);
|
||||
}
|
||||
d.layers.push(l);
|
||||
let r = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("density")), "{:?}", r.warnings);
|
||||
let b = import("x.psd", &r.bytes).unwrap().document;
|
||||
let vm = b.layers[1].vector_mask.as_ref().unwrap();
|
||||
assert_eq!((vm.density, vm.feather), (128.0 / 255.0, 1.5), "user mask: {with_user_mask}");
|
||||
assert_eq!(b.layers[1].mask.is_some(), with_user_mask);
|
||||
if let Some(m) = &b.layers[1].mask {
|
||||
assert_eq!((m.density, m.feather), (204.0 / 255.0, 2.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-density-layervectormask.psd: a layer with both a user
|
||||
/// mask and a vector mask stores the "real" user mask (channel -3) and mask parameters; the
|
||||
/// real-mask fields precede the parameters, so the -3 channel failed to decode.
|
||||
#[test]
|
||||
fn real_user_mask_with_parameters_is_decoded() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 0.5;
|
||||
l.mask = Some(m);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 32.0, 32.0));
|
||||
vm.density = 0.5;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Add a real user mask hiding the left half (0) inside its rect, revealing elsewhere.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
let MaskData::Mask(pm) = &mut rec.mask else { panic!("mask record") };
|
||||
assert!(pm.parameters.is_some());
|
||||
let real_rect = photocraft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 };
|
||||
pm.real = Some(RealMask { flags: 0, background: 255, rect: real_rect });
|
||||
pm.trailing.clear();
|
||||
rec.channels.push(ChannelData::encode(CHANNEL_REAL_USER_MASK, Compression::Rle, &[0u8; 16 * 32], 16, 32, 8, Version::Psd).unwrap());
|
||||
let imp = reimport(&file);
|
||||
assert!(imp.warnings.iter().all(|w| !w.contains("could not be decoded")), "{:?}", imp.warnings);
|
||||
let mask = imp.document.layers[1].mask.as_ref().unwrap();
|
||||
// Hidden at density 0.5: 1 - 0.5 · (1 - 0).
|
||||
assert!((mask.value(4, 4) - 0.5).abs() < 0.01, "real mask hides the left half: {}", mask.value(4, 4));
|
||||
assert_eq!(mask.value(24, 4), 1.0, "and reveals outside its rect");
|
||||
assert_eq!(mask.density, 128.0 / 255.0);
|
||||
assert_eq!(imp.document.layers[1].vector_mask.as_ref().map(|v| v.density), Some(128.0 / 255.0));
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose
|
||||
/// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the
|
||||
/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask.
|
||||
#[test]
|
||||
fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK)));
|
||||
let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0));
|
||||
vm.density = 0.8;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Photoshop stores the shape alone: replace the record's pixels with the path's coverage.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"SoCo").is_some() && rec.block(b"vmsk").is_some());
|
||||
rec.rect = photocraft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 };
|
||||
for ch in &mut rec.channels {
|
||||
if ch.id >= -1 {
|
||||
let v = if ch.id == -1 { 255 } else { 0 };
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[v; 16 * 16], 16, 16, 8, Version::Psd).unwrap();
|
||||
}
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let l = &imp.document.layers[1];
|
||||
assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name());
|
||||
assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0));
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "inside the path: black");
|
||||
assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2));
|
||||
}
|
||||
|
||||
/// psd-tools adjustment-fillers.psd: a solid-colour shape whose vector path is empty with the
|
||||
/// initial-fill record set (it covers the canvas) and no stored pixels rendered nothing.
|
||||
#[test]
|
||||
fn shape_without_pixels_and_full_initial_fill_covers_the_canvas() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut path = Path::new(Vec::new());
|
||||
path.inverted = true;
|
||||
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Color Fill 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, 8, Version::Psd).unwrap();
|
||||
}
|
||||
let f = photocraft_compose::flatten(&reimport(&file).document);
|
||||
assert!(f.get(0, 0)[0] < 0.01 && f.get(31, 31)[0] < 0.01, "covers everything: {:?}", f.get(0, 0));
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -118,6 +123,14 @@ impl History {
|
||||
self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum()
|
||||
}
|
||||
|
||||
/// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`]
|
||||
/// bounds by [`History::max_bytes`]).
|
||||
pub fn pixel_bytes(&self, current: &Document) -> usize {
|
||||
let mut seen = HashSet::new();
|
||||
let own = tile_bytes(current, &mut seen);
|
||||
self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
|
||||
}
|
||||
|
||||
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
|
||||
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
|
||||
/// dropped; the most recent one is always kept so the last step can be undone. Returns how
|
||||
@@ -257,6 +270,7 @@ mod tests {
|
||||
assert_eq!(h.trim(&cur), 3);
|
||||
assert_eq!(h.past_len(), 3);
|
||||
assert_eq!(h.unique_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile, "the current tile plus history's");
|
||||
assert_eq!(h.entries()[0], "paint 2");
|
||||
// A budget smaller than one state still keeps the last step undoable.
|
||||
h.max_bytes = 1;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,7 +48,7 @@ ChannelData { id, compression: Option<Compression>, data } // encoded bytes ke
|
||||
ChannelData::encode(id, compression, decoded, w, h, depth, version)
|
||||
ChannelData::decode(w, h, depth, version); set_decoded(...)
|
||||
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing }
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
|
||||
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
|
||||
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)
|
||||
|
||||
|
||||
+101
-8
@@ -176,6 +176,21 @@ pub struct LayerMask {
|
||||
pub real: Option<RealMask>,
|
||||
/// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.).
|
||||
pub trailing: Vec<u8>,
|
||||
/// When both are present, the real-mask fields come before the mask parameters. Photoshop
|
||||
/// writes this order (the published spec lists the parameters first); `false` keeps a file
|
||||
/// that uses the spec's order byte-stable.
|
||||
#[cfg_attr(feature = "serde", serde(default = "default_true"))]
|
||||
pub real_first: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Byte length of the mask parameter values announced by the parameter flags byte `pf`.
|
||||
fn mask_parameter_bytes(pf: u8) -> usize {
|
||||
usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0)
|
||||
}
|
||||
|
||||
impl LayerMask {
|
||||
@@ -190,19 +205,34 @@ impl LayerMask {
|
||||
|
||||
/// The simple 20-byte form.
|
||||
pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self {
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0] }
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0], real_first: true }
|
||||
}
|
||||
/// `true` if the disabled flag is set.
|
||||
pub fn disabled(&self) -> bool {
|
||||
self.flags & Self::FLAG_DISABLED != 0
|
||||
}
|
||||
|
||||
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
|
||||
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share.
|
||||
fn real_before_parameters(d: &[u8]) -> bool {
|
||||
let fits =
|
||||
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
|
||||
!fits(18) && fits(36)
|
||||
}
|
||||
|
||||
fn parse(d: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(d);
|
||||
let rect = Rect::read(&mut r)?;
|
||||
let default_color = r.u8()?;
|
||||
let flags = r.u8()?;
|
||||
let parameters = if flags & Self::FLAG_PARAMETERS != 0 {
|
||||
let read_real = |r: &mut Reader<'_>| -> Result<Option<RealMask>> {
|
||||
Ok(if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(r)? }) } else { None })
|
||||
};
|
||||
let has_parameters = flags & Self::FLAG_PARAMETERS != 0;
|
||||
let real_first = has_parameters && Self::real_before_parameters(d);
|
||||
let mut real = if real_first { read_real(&mut r)? } else { None };
|
||||
let parameters = if has_parameters {
|
||||
let pf = r.u8()?;
|
||||
Some(MaskParameters {
|
||||
flags: pf,
|
||||
@@ -214,14 +244,28 @@ impl LayerMask {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let real = if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) } else { None };
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() })
|
||||
if !real_first {
|
||||
real = read_real(&mut r)?;
|
||||
}
|
||||
// The order only matters when both are present; otherwise default to Photoshop's.
|
||||
let real_first = real_first || real.is_none() || parameters.is_none();
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first })
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut Vec<u8>) {
|
||||
self.rect.write(out);
|
||||
out.put_u8(self.default_color);
|
||||
out.put_u8(self.flags);
|
||||
let write_real = |out: &mut Vec<u8>| {
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
}
|
||||
};
|
||||
if self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
if let Some(p) = &self.parameters {
|
||||
out.put_u8(p.flags);
|
||||
if let Some(v) = p.user_density {
|
||||
@@ -237,10 +281,8 @@ impl LayerMask {
|
||||
out.put_f64(v);
|
||||
}
|
||||
}
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
if !self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
out.put(&self.trailing);
|
||||
}
|
||||
@@ -755,6 +797,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop stores the real-mask fields before the mask parameters (psd-tools
|
||||
/// mask-density-layervectormask.psd): user + vector mask with both densities set.
|
||||
#[test]
|
||||
fn real_mask_before_parameters_photoshop_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(15, -1, 18, 10).write(&mut d);
|
||||
d.extend([0, LayerMask::FLAG_PARAMETERS | 8]);
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 32, 8).write(&mut d);
|
||||
d.extend([0b0101, 64, 64, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 32, 8)));
|
||||
assert_eq!(m.real.map(|r| r.background), Some(255));
|
||||
let p = m.parameters.unwrap();
|
||||
assert_eq!((p.user_density, p.vector_density), (Some(64), Some(64)));
|
||||
assert_eq!(m.trailing, vec![0]);
|
||||
assert!(m.real_first);
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
|
||||
/// The spec's order (parameters, then real fields) still parses and stays byte-stable.
|
||||
#[test]
|
||||
fn real_mask_after_parameters_spec_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS]);
|
||||
d.extend([0b0011, 200]);
|
||||
d.extend(1.5f64.to_be_bytes());
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 3, 3).write(&mut d);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert!(!m.real_first);
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 3, 3)));
|
||||
assert_eq!(m.parameters.unwrap().user_feather, Some(1.5));
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
// Parameters alone (no real mask) keep reading in the spec's order.
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS, 0b1010]);
|
||||
d.extend(1.0f64.to_be_bytes());
|
||||
d.extend(2.0f64.to_be_bytes());
|
||||
d.extend([0, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real, None);
|
||||
assert_eq!(m.parameters.map(|p| (p.user_feather, p.vector_feather)), Some((Some(1.0), Some(2.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_sizes() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -281,6 +281,7 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr
|
||||
parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }),
|
||||
real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }),
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
};
|
||||
next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask));
|
||||
layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` at the
|
||||
//! workspace root when present (fetched by `xtask corpus`; never committed).
|
||||
//! Skips silently when the directory does not exist.
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` and
|
||||
//! `corpus/psd-tools/**/*.{psd,psb}` (the full psd-tools set, fetched by
|
||||
//! `cargo xtask corpus --psd-tools`) at the workspace root when present
|
||||
//! (never committed). Skips silently when neither directory exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -24,16 +25,21 @@ const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools
|
||||
|
||||
#[test]
|
||||
fn corpus_parse_and_byte_stable() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
|
||||
for dir in ["corpus/psd", "corpus/psd-tools"] {
|
||||
parse_and_byte_stable(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(dir));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_and_byte_stable(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
collect(root, &mut files);
|
||||
files.sort();
|
||||
let mut failures = Vec::new();
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
|
||||
let bytes = match std::fs::read(p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -61,6 +67,6 @@ fn corpus_parse_and_byte_stable() {
|
||||
},
|
||||
}
|
||||
}
|
||||
eprintln!("corpus: {} files, {} failures", files.len(), failures.len());
|
||||
eprintln!("corpus {}: {} files, {} failures", root.display(), files.len(), failures.len());
|
||||
assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! CJK font fallback: which installed system fonts cover Japanese, Simplified Chinese,
|
||||
//! Traditional Chinese and Korean, and in which order to try them for the user's locale.
|
||||
//!
|
||||
//! Han characters are shared between the four, but their preferred glyph forms differ, so the
|
||||
//! font for the locale's script goes first: Japanese forms only for a Japanese locale, Chinese
|
||||
//! forms for zh-Hans / zh-Hant, Korean (Hanja-capable) fonts for ko. Kana always prefers a
|
||||
//! Japanese font, Hangul a Korean one and Bopomofo a Traditional Chinese one.
|
||||
//!
|
||||
//! Used by the egui shell (UI fallback fonts, loaded lazily) and by [`crate::fonts::FontDb`]
|
||||
//! (Type tool fallback families).
|
||||
|
||||
/// A CJK writing system with its own preferred fonts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CjkScript {
|
||||
Japanese,
|
||||
SimplifiedChinese,
|
||||
TraditionalChinese,
|
||||
Korean,
|
||||
}
|
||||
|
||||
/// The kind of CJK character that needs a fallback font.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CjkChar {
|
||||
/// Ideographs, CJK punctuation and full-width forms: font choice follows the locale.
|
||||
Han,
|
||||
Kana,
|
||||
Hangul,
|
||||
Bopomofo,
|
||||
}
|
||||
|
||||
impl CjkChar {
|
||||
/// The script whose fonts should be tried first for this character.
|
||||
pub fn preferred(self, order: &[CjkScript; 4]) -> CjkScript {
|
||||
match self {
|
||||
CjkChar::Kana => CjkScript::Japanese,
|
||||
CjkChar::Hangul => CjkScript::Korean,
|
||||
CjkChar::Bopomofo => CjkScript::TraditionalChinese,
|
||||
CjkChar::Han => order[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a character that needs a CJK font, or `None` for everything else.
|
||||
pub fn classify(c: char) -> Option<CjkChar> {
|
||||
let u = c as u32;
|
||||
Some(match u {
|
||||
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7AF | 0xD7B0..=0xD7FF | 0xFFA0..=0xFFDC => CjkChar::Hangul,
|
||||
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF65..=0xFF9F | 0x1B000..=0x1B16F => CjkChar::Kana,
|
||||
0x3100..=0x312F | 0x31A0..=0x31BF => CjkChar::Bopomofo,
|
||||
0x2E80..=0x2FDF | 0x3000..=0x303F | 0x3190..=0x319F | 0x31C0..=0x31EF | 0x3200..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF => CjkChar::Han,
|
||||
0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFF64 | 0xFFE0..=0xFFEF | 0x20000..=0x3FFFF => CjkChar::Han,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`.
|
||||
/// Non-CJK (or unknown) locales get Simplified Chinese first, so Japanese forms only win
|
||||
/// for shared Han when the locale is Japanese.
|
||||
pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] {
|
||||
use CjkScript::*;
|
||||
let tag = locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-");
|
||||
let mut parts = tag.split('-');
|
||||
let lang = parts.next().unwrap_or("");
|
||||
let rest: Vec<&str> = parts.collect();
|
||||
match lang {
|
||||
"ja" => [Japanese, SimplifiedChinese, TraditionalChinese, Korean],
|
||||
"ko" => [Korean, TraditionalChinese, SimplifiedChinese, Japanese],
|
||||
"zh" | "yue" => {
|
||||
let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
|
||||
let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg"));
|
||||
if hant && !hans || lang == "yue" && !hans {
|
||||
[TraditionalChinese, SimplifiedChinese, Japanese, Korean]
|
||||
} else {
|
||||
[SimplifiedChinese, TraditionalChinese, Japanese, Korean]
|
||||
}
|
||||
}
|
||||
_ => [SimplifiedChinese, TraditionalChinese, Japanese, Korean],
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's UI locale (cached). `PHOTOCRAFT_LOCALE` overrides; then macOS's preferred
|
||||
/// language list, the Windows user locale, and finally `LC_ALL` / `LC_MESSAGES` / `LANG` /
|
||||
/// `LANGUAGE`. `None` on the web or when nothing is set.
|
||||
pub fn ui_locale() -> Option<&'static str> {
|
||||
static LOCALE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
LOCALE.get_or_init(detect_locale).as_deref()
|
||||
}
|
||||
|
||||
/// [`script_order`] for [`ui_locale`].
|
||||
pub fn ui_script_order() -> [CjkScript; 4] {
|
||||
script_order(ui_locale())
|
||||
}
|
||||
|
||||
fn detect_locale() -> Option<String> {
|
||||
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && v != "C" && v != "POSIX" && !v.starts_with("C."));
|
||||
if let Some(v) = env("PHOTOCRAFT_LOCALE") {
|
||||
return Some(v);
|
||||
}
|
||||
if let Some(v) = os_locale() {
|
||||
return Some(v);
|
||||
}
|
||||
["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"].iter().find_map(|k| env(k)).map(|v| v.split(':').next().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_locale() -> Option<String> {
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let path = std::path::Path::new(&home).join("Library/Preferences/.GlobalPreferences.plist");
|
||||
let meta = std::fs::metadata(&path).ok()?;
|
||||
if meta.len() > 4 << 20 {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
bplist_first_language(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_locale() -> Option<String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let out = std::process::Command::new("reg")
|
||||
.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// " LocaleName REG_SZ ja-JP"
|
||||
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn os_locale() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// First entry of `AppleLanguages` in a binary property list (macOS `.GlobalPreferences.plist`).
|
||||
/// A minimal, bounds-checked reader of the documented `bplist00` format; `None` on anything else.
|
||||
pub fn bplist_first_language(b: &[u8]) -> Option<String> {
|
||||
if b.len() < 40 || !b.starts_with(b"bplist00") {
|
||||
return None;
|
||||
}
|
||||
let t = b.get(b.len() - 32..)?;
|
||||
let offset_size = *t.get(6)? as usize;
|
||||
let ref_size = *t.get(7)? as usize;
|
||||
let be = |s: &[u8]| s.iter().fold(0u64, |a, &x| (a << 8) | x as u64);
|
||||
let num_objects = usize::try_from(be(t.get(8..16)?)).ok()?;
|
||||
let top = usize::try_from(be(t.get(16..24)?)).ok()?;
|
||||
let table = usize::try_from(be(t.get(24..32)?)).ok()?;
|
||||
if !(1..=8).contains(&offset_size) || !(1..=8).contains(&ref_size) || num_objects > b.len() {
|
||||
return None;
|
||||
}
|
||||
let offset = |obj: usize| -> Option<usize> {
|
||||
if obj >= num_objects {
|
||||
return None;
|
||||
}
|
||||
let at = table.checked_add(obj.checked_mul(offset_size)?)?;
|
||||
usize::try_from(be(b.get(at..at.checked_add(offset_size)?)?)).ok()
|
||||
};
|
||||
// (marker high nibble, count, start of payload)
|
||||
let header = |obj: usize| -> Option<(u8, usize, usize)> {
|
||||
let at = offset(obj)?;
|
||||
let m = *b.get(at)?;
|
||||
let (kind, low) = (m >> 4, (m & 0x0F) as usize);
|
||||
if low != 0x0F || !matches!(kind, 0x5 | 0x6 | 0xA | 0xD) {
|
||||
return Some((kind, low, at + 1));
|
||||
}
|
||||
let im = *b.get(at + 1)?;
|
||||
if im >> 4 != 0x1 {
|
||||
return None;
|
||||
}
|
||||
let n = 1usize.checked_shl((im & 0x0F) as u32)?;
|
||||
let count = usize::try_from(be(b.get(at + 2..at.checked_add(2 + n)?)?)).ok()?;
|
||||
Some((kind, count, at + 2 + n))
|
||||
};
|
||||
let string = |obj: usize| -> Option<String> {
|
||||
let (kind, n, start) = header(obj)?;
|
||||
match kind {
|
||||
0x5 => b.get(start..start.checked_add(n)?).map(|s| String::from_utf8_lossy(s).into_owned()),
|
||||
0x6 => {
|
||||
let s = b.get(start..start.checked_add(n.checked_mul(2)?)?)?;
|
||||
Some(String::from_utf16_lossy(&s.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect::<Vec<_>>()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let obj_ref = |at: usize| -> Option<usize> { usize::try_from(be(b.get(at..at.checked_add(ref_size)?)?)).ok() };
|
||||
let (kind, n, start) = header(top)?;
|
||||
if kind != 0xD {
|
||||
return None;
|
||||
}
|
||||
for i in 0..n {
|
||||
let key = obj_ref(start.checked_add(i.checked_mul(ref_size)?)?)?;
|
||||
if string(key).as_deref() != Some("AppleLanguages") {
|
||||
continue;
|
||||
}
|
||||
let val = obj_ref(start.checked_add(n.checked_add(i)?.checked_mul(ref_size)?)?)?;
|
||||
let (akind, an, astart) = header(val)?;
|
||||
if akind != 0xA || an == 0 {
|
||||
return None;
|
||||
}
|
||||
return string(obj_ref(astart)?).filter(|s| !s.is_empty());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Family names (as the Type tool's font database knows them) per script, most preferred first.
|
||||
pub fn families(script: CjkScript) -> &'static [&'static str] {
|
||||
match script {
|
||||
CjkScript::Japanese => &["Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "IPAexGothic"],
|
||||
CjkScript::SimplifiedChinese => {
|
||||
&["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Heiti SC"]
|
||||
}
|
||||
CjkScript::TraditionalChinese => &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", "Hiragino Sans CNS", "Heiti TC"],
|
||||
CjkScript::Korean => &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "NanumGothic", "UnDotum"],
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed font file to try: its path and the family of the wanted face in a collection
|
||||
/// (empty: face 0). Matching by family name keeps working when a `.ttc` reorders its faces.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FontFile {
|
||||
pub path: std::path::PathBuf,
|
||||
pub family: &'static str,
|
||||
}
|
||||
|
||||
/// System font files for `script`, most preferred first. Paths that don't exist are included;
|
||||
/// callers skip them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn font_files(script: CjkScript) -> Vec<FontFile> {
|
||||
use CjkScript::*;
|
||||
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family };
|
||||
if cfg!(target_os = "macos") {
|
||||
let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family });
|
||||
match script {
|
||||
Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")],
|
||||
SimplifiedChinese => pingfang("PingFang SC")
|
||||
.into_iter()
|
||||
.chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")])
|
||||
.collect(),
|
||||
TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
|
||||
Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")],
|
||||
}
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family };
|
||||
match script {
|
||||
Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")],
|
||||
SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")],
|
||||
TraditionalChinese => vec![w("msjh.ttc", "Microsoft JhengHei"), w("msjh.ttf", ""), w("mingliu.ttc", "")],
|
||||
Korean => vec![w("malgun.ttf", ""), w("gulim.ttc", "")],
|
||||
}
|
||||
} else {
|
||||
// One Noto Sans CJK collection covers all four scripts; the face picks the glyph forms.
|
||||
let noto = match script {
|
||||
Japanese => "Noto Sans CJK JP",
|
||||
SimplifiedChinese => "Noto Sans CJK SC",
|
||||
TraditionalChinese => "Noto Sans CJK TC",
|
||||
Korean => "Noto Sans CJK KR",
|
||||
};
|
||||
let mut v: Vec<FontFile> = [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-VF.ttc",
|
||||
]
|
||||
.iter()
|
||||
.map(|p| f(p, noto))
|
||||
.collect();
|
||||
v.extend(match script {
|
||||
Japanese => vec![
|
||||
f("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", ""),
|
||||
],
|
||||
SimplifiedChinese | TraditionalChinese => vec![
|
||||
f("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", ""),
|
||||
f("/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", ""),
|
||||
f("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", ""),
|
||||
f("/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", ""),
|
||||
],
|
||||
Korean => vec![
|
||||
f("/usr/share/fonts/truetype/nanum/NanumGothic.ttf", ""),
|
||||
f("/usr/share/fonts/nanum/NanumGothic.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/unfonts-core/UnDotum.ttf", ""),
|
||||
f("/usr/share/fonts/un-core/UnDotum.ttf", ""),
|
||||
],
|
||||
});
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Broad-coverage fonts tried after every script's own fonts (they cover Han, kana and Hangul
|
||||
/// but look worse than the script fonts).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn last_resort_files() -> Vec<FontFile> {
|
||||
let f = |p: &str| FontFile { path: p.into(), family: "" };
|
||||
if cfg!(target_os = "macos") {
|
||||
vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }]
|
||||
} else {
|
||||
vec![f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), f("/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf")]
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS ships PingFang as a pre-installed "mobile asset" outside `/System/Library/Fonts`
|
||||
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<hash>.asset/AssetData/PingFang.ttc`).
|
||||
/// Looks there (a few small directory listings) and in the pre-Catalina location.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn mac_pingfang() -> Option<std::path::PathBuf> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let old = PathBuf::from("/System/Library/Fonts/PingFang.ttc");
|
||||
if old.is_file() {
|
||||
return Some(old);
|
||||
}
|
||||
let roots = [Path::new("/System/Library/AssetsV2"), Path::new("/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs")];
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(root) else { continue };
|
||||
for e in rd.flatten().take(512) {
|
||||
if !e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
||||
continue;
|
||||
}
|
||||
let Ok(assets) = std::fs::read_dir(e.path()) else { continue };
|
||||
for a in assets.flatten().take(4096) {
|
||||
let p = a.path().join("AssetData/PingFang.ttc");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
|
||||
/// empty or no face matches. Never panics on malformed data.
|
||||
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
|
||||
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
|
||||
if family.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
|
||||
for (i, f) in c.iter().enumerate() {
|
||||
let Ok(f) = f else { continue };
|
||||
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
|
||||
if named {
|
||||
return i as u32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use CjkScript::*;
|
||||
|
||||
#[test]
|
||||
fn locale_orders() {
|
||||
assert_eq!(script_order(Some("ja_JP.UTF-8"))[0], Japanese);
|
||||
assert_eq!(script_order(Some("ja"))[0], Japanese);
|
||||
assert_eq!(script_order(Some("zh_CN.UTF-8"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("zh-Hans-CN"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("zh-Hant-TW"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh_TW"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh-HK"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh-Hans-HK"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("ko-KR"))[0], Korean);
|
||||
// Non-CJK and unknown: Japanese forms are never first.
|
||||
for l in [None, Some(""), Some("en_US.UTF-8"), Some("C"), Some("de"), Some("!!@@")] {
|
||||
let o = script_order(l);
|
||||
assert_eq!(o[0], SimplifiedChinese);
|
||||
assert_ne!(o[0], Japanese);
|
||||
}
|
||||
// Every order is a permutation of all four scripts.
|
||||
for l in ["ja", "ko", "zh-TW", "zh", "en"] {
|
||||
let o = script_order(Some(l));
|
||||
for s in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
|
||||
assert!(o.contains(&s), "{l}: {o:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_fallback_follows_locale() {
|
||||
let pos = |v: &[&str], f: &str| v.iter().position(|x| *x == f).unwrap();
|
||||
let ja = crate::fonts::fallback_candidates(&script_order(Some("ja_JP")));
|
||||
assert!(pos(&ja, "Hiragino Sans") < pos(&ja, "PingFang SC"));
|
||||
assert!(pos(&ja, "Noto Sans CJK JP") < pos(&ja, "Noto Sans CJK SC"));
|
||||
let sc = crate::fonts::fallback_candidates(&script_order(Some("zh_CN")));
|
||||
assert!(pos(&sc, "PingFang SC") < pos(&sc, "Hiragino Sans"));
|
||||
assert!(pos(&sc, "Microsoft YaHei") < pos(&sc, "Microsoft JhengHei"));
|
||||
let tc = crate::fonts::fallback_candidates(&script_order(Some("zh-Hant")));
|
||||
assert!(pos(&tc, "PingFang TC") < pos(&tc, "PingFang SC"));
|
||||
let ko = crate::fonts::fallback_candidates(&script_order(Some("ko")));
|
||||
assert!(pos(&ko, "Apple SD Gothic Neo") < pos(&ko, "PingFang SC"));
|
||||
assert!(pos(&ko, "Malgun Gothic") < pos(&ko, "Yu Gothic"));
|
||||
// Arial Unicode covers Han too: it must not pre-empt the script fonts.
|
||||
for v in [&ja, &sc, &tc, &ko] {
|
||||
assert!(pos(v, "Arial Unicode MS") > pos(v, "Noto Sans CJK KR"));
|
||||
assert!(pos(v, "Noto Sans") < pos(v, "Hiragino Sans"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_chars() {
|
||||
let order = script_order(Some("en"));
|
||||
assert_eq!(classify('a'), None);
|
||||
assert_eq!(classify('é'), None);
|
||||
assert_eq!(classify('图'), Some(CjkChar::Han));
|
||||
assert_eq!(classify('圖'), Some(CjkChar::Han));
|
||||
assert_eq!(classify('レ'), Some(CjkChar::Kana));
|
||||
assert_eq!(classify('ー'), Some(CjkChar::Kana));
|
||||
assert_eq!(classify('카'), Some(CjkChar::Hangul));
|
||||
assert_eq!(classify('ㄅ'), Some(CjkChar::Bopomofo));
|
||||
assert_eq!(CjkChar::Kana.preferred(&order), Japanese);
|
||||
assert_eq!(CjkChar::Hangul.preferred(&order), Korean);
|
||||
assert_eq!(CjkChar::Han.preferred(&order), SimplifiedChinese);
|
||||
assert_eq!(CjkChar::Han.preferred(&script_order(Some("ja"))), Japanese);
|
||||
}
|
||||
|
||||
/// `plutil -convert binary1` of a dict with AKLastLocale, AppleLanguages
|
||||
/// [zh-Hant-TW, en-US] and AppleLocale.
|
||||
const SAMPLE: &[u8] = &[
|
||||
0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd3, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x5b, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x63, 0x61,
|
||||
0x6c, 0x65, 0x5c, 0x41, 0x4b, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x5e, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x61, 0x6e, 0x67,
|
||||
0x75, 0x61, 0x67, 0x65, 0x73, 0x55, 0x7a, 0x68, 0x5f, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x5f, 0x55, 0x53, 0xa2, 0x07, 0x08, 0x5a, 0x7a, 0x68, 0x2d, 0x48,
|
||||
0x61, 0x6e, 0x74, 0x2d, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x2d, 0x55, 0x53, 0x08, 0x0f, 0x1b, 0x28, 0x37, 0x3d, 0x43, 0x46, 0x51, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x57,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn reads_apple_languages_and_rejects_garbage() {
|
||||
assert_eq!(bplist_first_language(SAMPLE).as_deref(), Some("zh-Hant-TW"));
|
||||
assert_eq!(script_order(bplist_first_language(SAMPLE).as_deref())[0], TraditionalChinese);
|
||||
assert_eq!(bplist_first_language(b""), None);
|
||||
assert_eq!(bplist_first_language(b"bplist00"), None);
|
||||
assert_eq!(bplist_first_language(&[0xFF; 64]), None);
|
||||
// Every truncation and every single-byte corruption must fail gracefully, never panic.
|
||||
for n in 0..SAMPLE.len() {
|
||||
let _ = bplist_first_language(&SAMPLE[..n]);
|
||||
}
|
||||
for i in 0..SAMPLE.len() {
|
||||
for v in [0x00, 0x0F, 0x5F, 0xAF, 0xDF, 0xFF] {
|
||||
let mut b = SAMPLE.to_vec();
|
||||
b[i] = v;
|
||||
let _ = bplist_first_language(&b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_index_handles_bad_data() {
|
||||
assert_eq!(face_index_for_family(b"", "PingFang SC"), 0);
|
||||
assert_eq!(face_index_for_family(b"ttcf\0\0\0\0garbage", "PingFang SC"), 0);
|
||||
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
assert_eq!(face_index_for_family(inter, "Inter"), 0);
|
||||
assert_eq!(face_index_for_family(inter, ""), 0);
|
||||
}
|
||||
|
||||
/// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn pingfang_faces_by_name() {
|
||||
let Some(p) = mac_pingfang() else { return };
|
||||
let Ok(bytes) = std::fs::read(p) else { return };
|
||||
let sc = face_index_for_family(&bytes, "PingFang SC");
|
||||
let tc = face_index_for_family(&bytes, "PingFang TC");
|
||||
assert_ne!(sc, tc);
|
||||
}
|
||||
}
|
||||
+22
-25
@@ -23,30 +23,22 @@ pub const BUNDLED: &[(&str, &[u8])] = &[
|
||||
("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")),
|
||||
];
|
||||
|
||||
/// Families tried (if installed) after the requested one, for missing glyphs.
|
||||
const FALLBACK_CANDIDATES: &[&str] = &[
|
||||
"Noto Sans",
|
||||
"Arial Unicode MS",
|
||||
"Segoe UI",
|
||||
"DejaVu Sans",
|
||||
"Geeza Pro",
|
||||
"Arial Hebrew",
|
||||
"Noto Sans Arabic",
|
||||
"Noto Sans Hebrew",
|
||||
// Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried.
|
||||
"Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Yu Gothic",
|
||||
"Meiryo",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans JP",
|
||||
"IPAexGothic",
|
||||
"Noto Sans CJK SC",
|
||||
"PingFang SC",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Noto Color Emoji",
|
||||
];
|
||||
/// Families tried (if installed) after the requested one, for missing glyphs. The CJK families
|
||||
/// ([`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
|
||||
const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"];
|
||||
/// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get
|
||||
/// the locale's forms instead of Arial Unicode's.
|
||||
const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
|
||||
|
||||
/// Every fallback family candidate, CJK ordered for `order` (see [`crate::cjk::script_order`]).
|
||||
pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static str> {
|
||||
let mut v = FALLBACK_CANDIDATES.to_vec();
|
||||
for s in order {
|
||||
v.extend_from_slice(crate::cjk::families(*s));
|
||||
}
|
||||
v.extend_from_slice(FALLBACK_LAST);
|
||||
v
|
||||
}
|
||||
|
||||
/// One face in the database (for font menus).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -121,6 +113,10 @@ impl FontDb {
|
||||
for d in system_font_dirs() {
|
||||
collect_font_files(&d, 0, &mut files);
|
||||
}
|
||||
// PingFang (the macOS Chinese UI font) lives outside the font folders.
|
||||
if cfg!(target_os = "macos") {
|
||||
files.extend(crate::cjk::mac_pingfang());
|
||||
}
|
||||
for f in files {
|
||||
self.fcx.collection.load_fonts_from_paths([f]);
|
||||
}
|
||||
@@ -161,7 +157,8 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fallbacks = FALLBACK_CANDIDATES.iter().filter(|f| c.family_id(f).is_some()).map(|s| s.to_string()).collect();
|
||||
let order = crate::cjk::ui_script_order();
|
||||
self.fallbacks = fallback_candidates(&order).into_iter().filter(|f| c.family_id(f).is_some()).map(str::to_string).collect();
|
||||
}
|
||||
|
||||
/// Families available after the requested one (bundled default + installed coverage fonts).
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cjk;
|
||||
pub mod engine_data;
|
||||
pub mod fonts;
|
||||
pub mod glyphs;
|
||||
|
||||
@@ -18,6 +18,7 @@ photocraft-engine = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
photocraft-algo = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Huge-document benchmark (issue #49): canvas refreshes of a 20000×20000 (or `--size`) 8-bit
|
||||
//! RGB document with eleven layers (full-canvas pixel layers, soft blobs, an adjustment, type
|
||||
//! layers with effects, a full-width band whose effect region is wider than the GPU texture
|
||||
//! limit, a group with a clipped layer), on a device created the way the app creates it.
|
||||
//!
|
||||
//! One operation per run, so the process's peak memory is that operation's (plus building the
|
||||
//! document):
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo build --release -p photocraft-ui-egui --example huge_doc_bench
|
||||
//! for op in full view; do
|
||||
//! /usr/bin/time -l target/release/examples/huge_doc_bench --size 20000x20000 --op $op 2>&1 | grep -E 'ms|MB|maximum resident|peak memory'
|
||||
//! done
|
||||
//! # The CPU fallback, for comparison:
|
||||
//! PHOTOCRAFT_CPU_COMPOSE=1 /usr/bin/time -l target/release/examples/huge_doc_bench --op full
|
||||
//! ```
|
||||
//!
|
||||
//! `full`: the first refresh (every page uploaded, every effect map built) and three repeats.
|
||||
//! `view`: refreshes of a 2560×1440 viewport (zoomed in) at three places, after the first
|
||||
//! refresh, then the same after a brush dab in each.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui_wgpu::RenderState;
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Adjustment, Document, Effect, FxCommon, FxPaint, Layer, LayerContent, Shadow, StrokeFx, StrokePosition, TextLayer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
|
||||
use rayon::prelude::*;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// A layer over `r` whose RGBA8 pixels are `f(x, y)`, written in parallel-generated bands.
|
||||
fn layer_from(name: &str, r: Rect, f: impl Fn(i32, i32) -> [u8; 4] + Sync) -> Layer {
|
||||
let mut l = Layer::raster(name, PixelFormat::RGBA8);
|
||||
let s = l.surface_mut().expect("raster");
|
||||
let mut y = r.y0;
|
||||
while y < r.y1 {
|
||||
let band = Rect::new(r.x0, y, r.x1, (y + 256).min(r.y1));
|
||||
let w = band.width() as usize;
|
||||
let mut bytes = vec![0u8; w * band.height() as usize * 4];
|
||||
bytes.par_chunks_mut(w * 4).enumerate().for_each(|(row, px)| {
|
||||
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
p.copy_from_slice(&f(band.x0 + i as i32, band.y0 + row as i32));
|
||||
}
|
||||
});
|
||||
s.write_interleaved(band, &bytes);
|
||||
y = band.y1;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn hash(x: i32, y: i32) -> u8 {
|
||||
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 251) as u8
|
||||
}
|
||||
|
||||
/// A soft-edged disc of colour `c` centred in `r`.
|
||||
fn blob(name: &str, r: Rect, c: [u8; 3]) -> Layer {
|
||||
let (cx, cy, rad) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0, r.width().min(r.height()) as f32 / 2.0 - 8.0);
|
||||
layer_from(name, r, |x, y| {
|
||||
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
|
||||
let a = ((rad - d) / 6.0).clamp(0.0, 1.0);
|
||||
[c[0], c[1].wrapping_add(hash(x, y) / 16), c[2], (a * 255.0) as u8]
|
||||
})
|
||||
}
|
||||
|
||||
/// A type layer: rows of glyph-like blocks rasterised into its cache.
|
||||
fn text(name: &str, r: Rect) -> Layer {
|
||||
let glyphs = layer_from(name, r, |x, y| {
|
||||
let (gx, gy) = ((x - r.x0) % 90, (y - r.y0) % 160);
|
||||
let on = gx < 70 && gy < 120 && !(gx > 20 && gx < 50 && gy > 30 && gy < 90);
|
||||
[20, 30, 200, if on { 255 } else { 0 }]
|
||||
});
|
||||
let mut l = Layer::new(name, LayerContent::Text(TextLayer { cache: glyphs.surface().cloned(), ..Default::default() }));
|
||||
l.effects.items = vec![
|
||||
Effect::DropShadow(Shadow {
|
||||
common: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
color: Color::rgb(0.0, 0.0, 0.0),
|
||||
angle: 120.0,
|
||||
use_global_light: false,
|
||||
distance: 12.0,
|
||||
spread: 0.0,
|
||||
size: 16.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise: 0.0,
|
||||
knocks_out: true,
|
||||
}),
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 4.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)),
|
||||
}),
|
||||
];
|
||||
l
|
||||
}
|
||||
|
||||
fn build(w: u32, h: u32) -> Document {
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let mut d = Document::new("huge", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(layer_from("background", Rect::new(0, 0, wi, hi), |x, y| {
|
||||
let (fx, fy) = (x as f32 / wi as f32, y as f32 / hi as f32);
|
||||
let n = hash(x, y) / 8;
|
||||
[(80.0 + 120.0 * fx) as u8 + n, (60.0 + 140.0 * fy) as u8 + n, (150.0 - 60.0 * fx * fy) as u8 + n, 255]
|
||||
}));
|
||||
let mut over = layer_from("texture", Rect::new(0, 0, wi * 3 / 5, hi), |x, y| [200, 180u8.wrapping_add(hash(x, y) / 4), 120, 90 + hash(y, x) / 2]);
|
||||
over.blend = BlendMode::Multiply;
|
||||
over.opacity = 0.7;
|
||||
d.layers.push(over);
|
||||
let mut adj = Layer::new("exposure", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.3, offset: 0.0, gamma: 1.05 }));
|
||||
adj.opacity = 0.8;
|
||||
d.layers.push(adj);
|
||||
for (i, (x, y)) in [(0.15, 0.2), (0.55, 0.35), (0.3, 0.7)].into_iter().enumerate() {
|
||||
let (x, y) = ((wi as f32 * x) as i32, (hi as f32 * y) as i32);
|
||||
let mut b = blob(&format!("blob {i}"), Rect::new(x, y, x + wi / 6, y + wi / 6), [220, 60 + 60 * i as u8, 40]);
|
||||
b.blend = if i == 1 { BlendMode::Screen } else { BlendMode::Normal };
|
||||
d.layers.push(b);
|
||||
}
|
||||
// A full-width band with effects: its effect region is wider than a 16384 px texture.
|
||||
let mut band = layer_from("band", Rect::new(40, hi / 2, wi - 40, hi / 2 + 1200), |x, y| [240, 240, 230, if (x / 64 + y / 64) % 5 == 0 { 0 } else { 230 }]);
|
||||
band.effects.items = vec![
|
||||
Effect::DropShadow(Shadow {
|
||||
common: FxCommon::new(BlendMode::Multiply, 0.6),
|
||||
color: Color::rgb(0.0, 0.0, 0.1),
|
||||
angle: 90.0,
|
||||
use_global_light: false,
|
||||
distance: 20.0,
|
||||
spread: 0.0,
|
||||
size: 24.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise: 0.0,
|
||||
knocks_out: true,
|
||||
}),
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 3.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)),
|
||||
}),
|
||||
];
|
||||
d.layers.push(band);
|
||||
d.layers.push(text("title", Rect::new(wi / 10, hi / 12, wi / 10 + 6000, hi / 12 + 900)));
|
||||
d.layers.push(text("caption", Rect::new(wi / 2, hi * 5 / 6, wi / 2 + 4000, hi * 5 / 6 + 500)));
|
||||
let a = blob("group base", Rect::new(wi * 2 / 3, hi / 10, wi * 2 / 3 + wi / 4, hi / 10 + wi / 4), [40, 160, 90]);
|
||||
let mut c = layer_from("clipped", Rect::new(wi * 2 / 3, hi / 10, wi, hi / 10 + wi / 8), |x, y| [250, 230, 40, 120 + hash(x, y) / 3]);
|
||||
c.clipped = true;
|
||||
c.blend = BlendMode::Overlay;
|
||||
let mut g = Layer::group("group", vec![a, c]);
|
||||
g.opacity = 0.9;
|
||||
d.layers.push(g);
|
||||
d
|
||||
}
|
||||
|
||||
/// A headless GPU canvas on a device created like the app's.
|
||||
fn canvas() -> Option<(GpuCanvas, RenderState)> {
|
||||
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
|
||||
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
|
||||
let l = rs.device.limits();
|
||||
eprintln!("adapter: {} (max texture {})", rs.adapter.get_info().name, l.max_texture_dimension_2d);
|
||||
Some((GpuCanvas::new(&rs), rs))
|
||||
}
|
||||
|
||||
/// One canvas refresh (everything, or `damage`), waiting for the GPU. Returns the path taken.
|
||||
fn refresh(g: &GpuCanvas, rs: &RenderState, doc: &Document, damage: Option<Rect>) -> (String, Option<String>) {
|
||||
let r = g.refresh(doc.id.0, doc, damage, None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
let kind = if r.kind.starts_with("gpu") { format!("{}, {} tiles up", r.kind, r.uploads) } else { r.kind.to_string() };
|
||||
(kind, r.fallback)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) =
|
||||
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(20000), b.parse().unwrap_or(20000)))).unwrap_or((20000, 20000));
|
||||
let op = arg(&args, "--op").unwrap_or_else(|| "full".into());
|
||||
let Some((g, rs)) = canvas() else {
|
||||
eprintln!("no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let t = Instant::now();
|
||||
let mut doc = build(w, h);
|
||||
println!("document {w}×{h} ({:.0} MP), {} layers, built in {:.0} ms", (w as f64 * h as f64) / 1e6, doc.walk().len(), ms(t));
|
||||
|
||||
// The compositor budget the app would use: Memory Usage (default 8 GB, `--memory-mb`) less
|
||||
// the document's pixels, within a quarter of RAM (`PHOTOCRAFT_RAM_MB` simulates a smaller
|
||||
// machine). The view is the first viewport (`view`) or the whole document (`full`).
|
||||
let allowance = arg(&args, "--memory-mb").and_then(|v| v.parse::<u64>().ok()).unwrap_or(8192) << 20;
|
||||
let pixels: u64 = doc
|
||||
.walk()
|
||||
.iter()
|
||||
.flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface)))
|
||||
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
|
||||
.sum();
|
||||
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
|
||||
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram);
|
||||
g.set_memory_budget(budget);
|
||||
println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20);
|
||||
let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440);
|
||||
g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() }));
|
||||
|
||||
let t = Instant::now();
|
||||
let (kind, fallback) = refresh(&g, &rs, &doc, None);
|
||||
println!("first refresh ({kind:<8}) {:>9.0} ms{}", ms(t), fallback.map_or(String::new(), |f| format!(" [fallback: {f}]")));
|
||||
let report = |g: &GpuCanvas| {
|
||||
if let Some((format, bytes)) = g.texture_info(doc.id.0) {
|
||||
println!("canvas texture {format:?}: {} MB with mips", bytes >> 20);
|
||||
}
|
||||
if let Some((pages, fx)) = g.compositor_bytes() {
|
||||
println!("compositor: {} MB resident pages, {} MB effect maps", pages >> 20, fx >> 20);
|
||||
}
|
||||
};
|
||||
report(&g);
|
||||
match op.as_str() {
|
||||
"full" => {
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, None);
|
||||
println!("full refresh ({kind:<8}) {:>9.0} ms", ms(t));
|
||||
}
|
||||
}
|
||||
"view" => {
|
||||
let views: Vec<Rect> = [(0.1, 0.1), (0.45, 0.48), (0.7, 0.8)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Rect::from_xywh((w as f32 * x) as i32, (h as f32 * y) as i32, 2560, 1440).intersect(&doc.bounds()))
|
||||
.collect();
|
||||
for v in &views {
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
|
||||
println!("viewport refresh ({kind:<8}) {:>9.0} ms at {v:?}", ms(t));
|
||||
}
|
||||
// A dab on the band (an effect layer) inside each viewport, then its refresh.
|
||||
let band = doc.layers.iter().position(|l| l.name == "band").unwrap_or(0);
|
||||
for v in &views {
|
||||
let c = Rect::from_xywh(v.x0 + 600, h as i32 / 2 + 300, 120, 120);
|
||||
if let Some(s) = doc.layers.get_mut(band).and_then(Layer::surface_mut) {
|
||||
s.fill_rect(c, &[0.1, 0.3, 0.9, 1.0]);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
|
||||
println!("dab + viewport refresh ({kind:<8}) {:>6.0} ms", ms(t));
|
||||
}
|
||||
}
|
||||
other => eprintln!("unknown --op {other}"),
|
||||
}
|
||||
report(&g);
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
+115
-50
@@ -40,6 +40,8 @@ pub struct Drag {
|
||||
/// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with
|
||||
/// Preferences › Tools › Right-click with painting tools set to Erase.
|
||||
pub erase: bool,
|
||||
/// ⇧ constraint of a painting stroke (stroke_constraint.rs).
|
||||
pub constrain: Option<crate::stroke_constraint::Axis>,
|
||||
}
|
||||
|
||||
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
|
||||
@@ -460,6 +462,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
return true;
|
||||
}
|
||||
let partial = present && damage.is_some();
|
||||
gpu_budget(app, &gpu, idx, partial, visible);
|
||||
let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref());
|
||||
if let Some(e) = &r.fallback
|
||||
&& app.perf.gpu_fallback.as_deref() != Some(e.as_str())
|
||||
@@ -480,6 +483,23 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
|
||||
/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels
|
||||
/// counted once per structural change, not per brush dab) and whenever the preference changes.
|
||||
fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) {
|
||||
gpu.set_focus(Some(visible));
|
||||
let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20);
|
||||
if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.documents().get(idx) else { return };
|
||||
let pixels = st.history.pixel_bytes(&st.doc) as u64;
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
|
||||
gpu.set_memory_budget(budget);
|
||||
app.perf.gpu_budget_mb = budget >> 20;
|
||||
app.perf.gpu_budget_allowance = allowance;
|
||||
}
|
||||
|
||||
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
|
||||
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
|
||||
if crate::adjust_preview::on_layer(app, idx) {
|
||||
@@ -1132,7 +1152,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Tools follow the left button; the right one opens the Brush Preset picker or erases
|
||||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below.
|
||||
if tool == Tool::Zoom && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) {
|
||||
(buttons.started, buttons.dragged, buttons.stopped) = (false, false, false);
|
||||
}
|
||||
// A drag is only recognised once the pointer has moved past egui's click distance: the
|
||||
// gesture starts where the button went down, not where it is now (#123).
|
||||
if buttons.started
|
||||
@@ -1187,6 +1211,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::rulers::draw_guides(app, &painter, &xf, &doc);
|
||||
}
|
||||
draw_drag_preview(app, &painter, &xf);
|
||||
crate::zoom_tool::draw(&ctx, &painter);
|
||||
draw_transform_controls(app, &painter, &xf);
|
||||
crate::paint_mouse::show_picker(app, &ctx);
|
||||
crate::snap_ui::draw(app, &painter, &xf);
|
||||
@@ -1243,7 +1268,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
|
||||
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
|
||||
// The Background Eraser always shows its sampling hotspot (Photoshop).
|
||||
if cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser {
|
||||
// Quick Selection shows its +/− badge there instead.
|
||||
if (cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser) && tool != Tool::QuickSelection {
|
||||
crosshair(3.0);
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
@@ -1273,6 +1299,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
_ => egui::CursorIcon::Crosshair,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
// Selection tools: + / − / × badge for the effective mode (#170). A gesture keeps the
|
||||
// mode it started with (⇧ then constrains the marquee instead of adding).
|
||||
let held = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers));
|
||||
let mods = app.drag.as_ref().map_or(held, |d| d.modifiers);
|
||||
if let Some(b) = crate::tool_feedback::badge(app, tool, mods) {
|
||||
crate::tool_feedback::draw_badge(&painter, p, b, tool == Tool::QuickSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
if primary {
|
||||
@@ -1393,10 +1426,10 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
if let Some(h) = hover {
|
||||
pts.push(h);
|
||||
}
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
|
||||
for p in &pts[..app.ui.polygon.len()] {
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
for p in pts.iter().take(app.ui.polygon.len()) {
|
||||
painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
if let Some(c) = app.ui.crop_rect {
|
||||
@@ -1480,19 +1513,19 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
}
|
||||
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.modifiers),
|
||||
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
|
||||
// Marching ants, visible on any pixels (#172).
|
||||
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
|
||||
if d.tool == Tool::EllipseMarquee {
|
||||
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE)));
|
||||
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
|
||||
let pts = if d.tool == Tool::EllipseMarquee {
|
||||
crate::tool_feedback::ellipse_points(r)
|
||||
} else {
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
|
||||
}
|
||||
vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]
|
||||
};
|
||||
crate::tool_feedback::draw_ants(painter, &pts, true);
|
||||
}
|
||||
Tool::Lasso => {
|
||||
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
|
||||
if pts.len() > 1 {
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
|
||||
}
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
}
|
||||
Tool::Gradient => {
|
||||
let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32);
|
||||
@@ -1516,6 +1549,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 +1571,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 +1586,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 +1627,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);
|
||||
@@ -1625,8 +1669,18 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase);
|
||||
app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods, erase });
|
||||
// ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs).
|
||||
let active = app.session.active().map(|st| st.doc.id);
|
||||
let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p);
|
||||
let mut points = vec![[x, y, pressure as f64]];
|
||||
if let Some(p) = from {
|
||||
points.insert(0, [p[0], p[1], pressure as f64]);
|
||||
}
|
||||
app.drag = Some(Drag { tool, start: from.unwrap_or([x, y]), points, modifiers: mods, erase, constrain: None });
|
||||
app.stylus.begin_stroke();
|
||||
if from.is_some() {
|
||||
app.stylus.record_point();
|
||||
}
|
||||
app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None };
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
@@ -1636,11 +1690,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_move(app, x, y);
|
||||
}
|
||||
if let Some(d) = &mut app.drag
|
||||
&& d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25)
|
||||
{
|
||||
d.points.push([x, y, pressure as f64]);
|
||||
app.stylus.record_point();
|
||||
let zoom = app.current_zoom();
|
||||
if let Some(d) = &mut app.drag {
|
||||
// ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs).
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
if d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) {
|
||||
d.points.push([x, y, pressure as f64]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
}
|
||||
feed_live_stroke(app);
|
||||
}
|
||||
@@ -1653,18 +1711,28 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_up(app);
|
||||
}
|
||||
let zoom = app.current_zoom();
|
||||
let Some(mut d) = app.drag.take() else { return };
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
|
||||
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
crate::move_mods::finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
|
||||
// Where the next ⇧-click line starts.
|
||||
if crate::stroke_constraint::connects(d.tool)
|
||||
&& let Some(st) = app.session.active()
|
||||
{
|
||||
app.last_stroke_end = Some((st.doc.id, [end[0], end[1]]));
|
||||
}
|
||||
if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) {
|
||||
return;
|
||||
}
|
||||
@@ -1708,18 +1776,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize];
|
||||
let mode = if !d.modifiers.shift && !d.modifiers.alt {
|
||||
bar
|
||||
} else if d.modifiers.shift && d.modifiers.alt {
|
||||
"intersect"
|
||||
} else if d.modifiers.shift {
|
||||
"add"
|
||||
} else if d.modifiers.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
let mode = selection_mode(app, d.modifiers);
|
||||
let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);
|
||||
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));
|
||||
}
|
||||
@@ -1783,16 +1840,9 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
|
||||
/// The cursor badge announces the same mode (`tool_feedback`).
|
||||
fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
|
||||
if m.shift && m.alt {
|
||||
"intersect"
|
||||
} else if m.shift {
|
||||
"add"
|
||||
} else if m.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]
|
||||
}
|
||||
crate::tool_feedback::selection_mode(Tool::Lasso, app.ui.selection_mode, m)
|
||||
}
|
||||
|
||||
/// Close the polygonal lasso and make the selection.
|
||||
@@ -1856,6 +1906,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.
|
||||
|
||||
@@ -17,6 +17,8 @@ enum Row {
|
||||
Color(usize),
|
||||
Alpha(usize),
|
||||
QuickMask,
|
||||
/// The selected layer's mask: a temporary channel, listed while the layer is selected.
|
||||
LayerMask,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -27,6 +29,7 @@ impl Row {
|
||||
Row::Color(k) => json!({ "color": k }),
|
||||
Row::Alpha(i) => json!(i),
|
||||
Row::QuickMask => json!("quickMask"),
|
||||
Row::LayerMask => json!("mask"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,25 +53,38 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let doc = st.doc.clone();
|
||||
let view = st.channel_view.clone();
|
||||
let active_layer = st.active_layer;
|
||||
let mode = doc.pixel_format().mode;
|
||||
let colors = mode.color_channels();
|
||||
let quick = doc.quick_mask.is_some();
|
||||
let ctx = ui.ctx().clone();
|
||||
let thumbs = app.channel_thumbs(&ctx);
|
||||
// The active layer's mask (Photoshop lists it, in italics, below the colour channels).
|
||||
let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()).cloned();
|
||||
let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?)));
|
||||
let mask_targeted = masked.is_some() && app.ui.mask_target && view.target == ChannelTarget::Composite && !quick;
|
||||
// Multichannel images are their ink channels only (no composite or colour rows).
|
||||
let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel;
|
||||
let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] };
|
||||
if colors > 1 && !multichannel {
|
||||
rows.extend((0..colors).map(Row::Color));
|
||||
}
|
||||
if masked.is_some() {
|
||||
rows.push(Row::LayerMask);
|
||||
}
|
||||
rows.extend((0..doc.channels.len()).map(Row::Alpha));
|
||||
if quick {
|
||||
rows.push(Row::QuickMask);
|
||||
}
|
||||
let shown_colors = if colors > 1 { colors } else { 0 };
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
|
||||
let footer = 34.0;
|
||||
egui::ScrollArea::vertical().max_height((ui.available_height() - footer).max(60.0)).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let fill = ui.available_height() > footer + 60.0;
|
||||
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
|
||||
let mut mask_click = None;
|
||||
let mut drawn = Vec::new();
|
||||
egui::ScrollArea::vertical().max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| {
|
||||
for row in rows {
|
||||
let (name, thumb_idx, slot, visible, selected) = match row {
|
||||
Row::Composite => (
|
||||
@@ -76,14 +92,14 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
0,
|
||||
Some(2),
|
||||
view.visible_colors(colors) == colors,
|
||||
view.target == ChannelTarget::Composite && !quick,
|
||||
view.target == ChannelTarget::Composite && !quick && !mask_targeted,
|
||||
),
|
||||
Row::Color(k) => (
|
||||
channel_cmds::color_names(mode)[k].to_string(),
|
||||
1 + k,
|
||||
Some(3 + k),
|
||||
view.color_visible(k),
|
||||
(view.target == ChannelTarget::Composite && !quick) || view.target == ChannelTarget::Color(k),
|
||||
(view.target == ChannelTarget::Composite && !quick && !mask_targeted) || view.target == ChannelTarget::Color(k),
|
||||
),
|
||||
Row::Alpha(i) => (
|
||||
doc.channels[i].name.clone(),
|
||||
@@ -95,6 +111,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Row::QuickMask => {
|
||||
("Quick Mask".to_string(), 1 + shown_colors + doc.channels.len(), None, !view.quick_mask_hidden, view.target == ChannelTarget::Composite)
|
||||
}
|
||||
Row::LayerMask => (masked.as_ref().map(|l| format!("{} Mask", l.name)).unwrap_or_default(), usize::MAX, None, false, mask_targeted),
|
||||
};
|
||||
let row_h = if t.pro { 36.0 } else { 40.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
@@ -110,15 +127,24 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 11.0), vec2(22.0, 22.0));
|
||||
let eye_resp = ui.interact(eye, ui.id().with(("chan-eye", format!("{:?}", row.reference()))), Sense::click());
|
||||
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
|
||||
if eye_resp.clicked() {
|
||||
// The layer mask's eye slot stays empty: its overlay view isn't supported yet.
|
||||
if row != Row::LayerMask {
|
||||
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
|
||||
}
|
||||
if eye_resp.clicked() && row != Row::LayerMask {
|
||||
actions.push(("channel.setVisible".into(), json!({ "channel": row.reference(), "visible": !visible })));
|
||||
}
|
||||
let ts = if t.pro { 28.0 } else { 30.0 };
|
||||
let thumb = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
if let Some(tex) = thumbs.get(thumb_idx) {
|
||||
painter.image(*tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let cell = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
// Thumbnails keep the document's aspect ratio (the textures are letterboxed squares).
|
||||
let (thumb, uv) = fit_thumb(cell, doc.size.width, doc.size.height);
|
||||
let tex = if row == Row::LayerMask { mask_tex } else { thumbs.get(thumb_idx).copied() };
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex, thumb, uv, Color32::WHITE);
|
||||
}
|
||||
drawn.push((name.clone(), thumb));
|
||||
// Rows are painted: name them for screen readers and UI tests.
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
|
||||
// Spot channels: a swatch of the ink on the thumbnail corner.
|
||||
if let Row::Alpha(i) = row
|
||||
&& let Some((ink, _)) = doc.channels[i].spot
|
||||
@@ -127,12 +153,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
painter.rect_filled(Rect::from_min_size(thumb.right_bottom() - vec2(9.0, 9.0), vec2(9.0, 9.0)), 0.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
}
|
||||
painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }), StrokeKind::Outside);
|
||||
let italic = row == Row::QuickMask || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
|
||||
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
let galley = painter.layout_job(job);
|
||||
let text_pos = pos2(thumb.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
painter.galley(text_pos, galley, t.text);
|
||||
if let Some(slot) = slot {
|
||||
painter.text(
|
||||
@@ -146,9 +172,19 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let rename_id = egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference())));
|
||||
if resp.clicked() && !eye_resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if m.command {
|
||||
if m.command && row == Row::LayerMask {
|
||||
let layer = masked.as_ref().map(|l| l.id.0);
|
||||
actions.push(("select.loadSelection".into(), json!({ "channel": "mask", "layer": layer, "operation": load_operation(m) })));
|
||||
} else if m.command {
|
||||
actions.push(("select.loadSelection".into(), json!({ "channel": row.reference(), "operation": load_operation(m) })));
|
||||
} else if row == Row::LayerMask {
|
||||
// Targets the mask, as clicking its thumbnail in the Layers panel does.
|
||||
mask_click = Some(true);
|
||||
actions.push(("channel.target".into(), json!({ "channel": "composite" })));
|
||||
} else {
|
||||
if matches!(row, Row::Composite | Row::Color(_)) {
|
||||
mask_click = Some(false);
|
||||
}
|
||||
let target = if row == Row::QuickMask { json!("composite") } else { row.reference() };
|
||||
actions.push(("channel.target".into(), json!({ "channel": target })));
|
||||
}
|
||||
@@ -200,6 +236,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Row::Composite | Row::Color(_) => {
|
||||
item(ui, a, "Duplicate Channel", "channel.duplicate", json!({ "channel": row.reference() }));
|
||||
}
|
||||
Row::LayerMask => {
|
||||
let enabled = masked.as_ref().and_then(|l| l.mask.as_ref()).is_none_or(|m| m.enabled);
|
||||
item(ui, a, if enabled { "Disable Layer Mask" } else { "Enable Layer Mask" }, "layer.layerMask.enabled", json!({}));
|
||||
item(ui, a, "Delete Layer Mask", "layer.layerMask.delete", json!({}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
item(ui, a, "New Channel…", "channel.new", json!({}));
|
||||
@@ -235,6 +276,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn));
|
||||
if let Some(on) = mask_click {
|
||||
app.ui.mask_target = on;
|
||||
}
|
||||
for (id, p) in actions {
|
||||
if id == "ui.renameChannel" {
|
||||
if let Some(i) = p.as_u64().and_then(|i| doc.channels.get(i as usize)) {
|
||||
@@ -247,6 +292,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn thumbs_id() -> egui::Id {
|
||||
egui::Id::new("channel-thumb-rects")
|
||||
}
|
||||
|
||||
/// The Channels rows drawn last frame: (name, thumbnail rect).
|
||||
pub fn recorded(ctx: &egui::Context) -> Vec<(String, Rect)> {
|
||||
ctx.data(|d| d.get_temp(thumbs_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The part of a square `cell` a `w`×`h` document fills, and the matching UVs of a square,
|
||||
/// letterboxed thumbnail texture: thumbnails keep the document's aspect ratio.
|
||||
pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) {
|
||||
let (w, h) = (w.max(1) as f32, h.max(1) as f32);
|
||||
let (fw, fh) = (w / w.max(h), h / w.max(h));
|
||||
let size = vec2(cell.width() * fw, cell.height() * fh).max(vec2(1.0, 1.0));
|
||||
let uv = Rect::from_center_size(pos2(0.5, 0.5), vec2(fw, fh));
|
||||
(Rect::from_center_size(cell.center(), size), uv)
|
||||
}
|
||||
|
||||
fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) {
|
||||
if ui.button(label).clicked() {
|
||||
actions.push((cmd.into(), p));
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Lazy CJK fallback fonts for the UI.
|
||||
//!
|
||||
//! The bundled Inter / JetBrains Mono have no Japanese, Chinese or Korean glyphs, and the OS
|
||||
//! fonts that do are large (Hiragino ~10 MB, Apple SD Gothic Neo 28 MB, PingFang 78 MB, Noto
|
||||
//! Sans CJK ~20 MB). Instead of reading them at startup, an egui plugin scans each frame's text
|
||||
//! for CJK characters no registered font covers, and registers the next system font for that
|
||||
//! character's script (`ctx.add_font`, active from the next frame, which it requests). Fonts are
|
||||
//! appended at the lowest priority to every family, so Latin text keeps Inter.
|
||||
//!
|
||||
//! Script order follows the UI locale ([`photocraft_text::cjk::script_order`]): Kana prefers a
|
||||
//! Japanese font, Hangul a Korean one, Bopomofo a Traditional Chinese one, and Han the
|
||||
//! locale's script (Japanese forms only for a Japanese locale). At most one font is read per
|
||||
//! frame, each file at most once, and once every script has been tried the scan stops.
|
||||
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily, FontId, Shape};
|
||||
use photocraft_text::cjk::{self, CjkChar, CjkScript, FontFile};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
|
||||
pub const MAX_FONT_BYTES: u64 = 128 << 20;
|
||||
/// Name prefix of the registered fallback fonts.
|
||||
pub const FONT_PREFIX: &str = "system-cjk";
|
||||
|
||||
/// Where fonts come from; swapped out in tests.
|
||||
#[derive(Clone)]
|
||||
pub struct Sources {
|
||||
pub locale: fn() -> Option<String>,
|
||||
pub files: fn(CjkScript) -> Vec<FontFile>,
|
||||
pub last_resort: fn() -> Vec<FontFile>,
|
||||
}
|
||||
|
||||
impl Sources {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new }
|
||||
}
|
||||
}
|
||||
|
||||
/// The lazy loader's state: which scripts were tried and which files were read.
|
||||
pub struct CjkFallback {
|
||||
sources: Sources,
|
||||
order: Option<[CjkScript; 4]>,
|
||||
tried: Vec<CjkScript>,
|
||||
last_resort_tried: bool,
|
||||
loaded: Vec<PathBuf>,
|
||||
/// Fonts registered so far: (name, path, face index).
|
||||
pub registered: Vec<(String, PathBuf, u32)>,
|
||||
}
|
||||
|
||||
impl CjkFallback {
|
||||
pub fn new(sources: Sources) -> Self {
|
||||
Self { sources, order: None, tried: Vec::new(), last_resort_tried: false, loaded: Vec::new(), registered: Vec::new() }
|
||||
}
|
||||
|
||||
/// Script order for the UI locale (resolved on first use, not at startup).
|
||||
pub fn order(&mut self) -> [CjkScript; 4] {
|
||||
*self.order.get_or_insert_with(|| cjk::script_order((self.sources.locale)().as_deref()))
|
||||
}
|
||||
|
||||
/// Every script and the last-resort fonts have been tried: nothing more to load.
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.tried.len() >= 4 && self.last_resort_tried
|
||||
}
|
||||
|
||||
/// The next font to register for a character of kind `kind` that no current font covers:
|
||||
/// the first readable file of the first untried script (the character's preferred script,
|
||||
/// then the locale order), then the last-resort fonts. `None` when nothing is left.
|
||||
pub fn next_font(&mut self, kind: CjkChar) -> Option<(String, FontData)> {
|
||||
let order = self.order();
|
||||
let scripts: Vec<CjkScript> = std::iter::once(kind.preferred(&order)).chain(order).collect();
|
||||
for s in scripts {
|
||||
if self.tried.contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.tried.push(s);
|
||||
let files = (self.sources.files)(s);
|
||||
if let Some(f) = self.load_first(&files) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
if !self.last_resort_tried {
|
||||
self.last_resort_tried = true;
|
||||
let files = (self.sources.last_resort)();
|
||||
return self.load_first(&files);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_first(&mut self, files: &[FontFile]) -> Option<(String, FontData)> {
|
||||
for f in files {
|
||||
if self.loaded.contains(&f.path) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&f.path) else { continue };
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&f.path) else { continue };
|
||||
let index = cjk::face_index_for_family(&bytes, f.family);
|
||||
self.loaded.push(f.path.clone());
|
||||
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
|
||||
log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display());
|
||||
self.registered.push((name.clone(), f.path.clone(), index));
|
||||
let mut data = FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
return Some((name, data));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// First CJK character in the frame's text that the UI fonts can't draw.
|
||||
fn missing_char(ctx: &egui::Context, shapes: &[egui::epaint::ClippedShape]) -> Option<char> {
|
||||
fn collect(shape: &Shape, out: &mut Vec<char>) {
|
||||
match shape {
|
||||
Shape::Text(t) => {
|
||||
let text = &t.galley.job.text;
|
||||
if !text.is_ascii() {
|
||||
for c in text.chars().filter(|c| cjk::classify(*c).is_some()) {
|
||||
if out.len() >= 256 {
|
||||
return;
|
||||
}
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut chars = Vec::new();
|
||||
for s in shapes {
|
||||
collect(&s.shape, &mut chars);
|
||||
}
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let font = FontId::proportional(12.0);
|
||||
ctx.fonts_mut(|f| chars.into_iter().find(|c| !f.has_glyph(&font, *c)))
|
||||
}
|
||||
|
||||
/// Registers `name` at the lowest priority in every font family.
|
||||
fn add_to_all_families(ctx: &egui::Context, name: String, data: FontData) {
|
||||
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
|
||||
for f in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
if !families.contains(&f) {
|
||||
families.push(f);
|
||||
}
|
||||
}
|
||||
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
|
||||
ctx.add_font(FontInsert { name, data, families });
|
||||
}
|
||||
|
||||
/// The egui plugin that watches drawn text and loads fallback fonts on demand.
|
||||
pub struct CjkFontPlugin(pub CjkFallback);
|
||||
|
||||
impl egui::Plugin for CjkFontPlugin {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"photocraft-cjk-fonts"
|
||||
}
|
||||
|
||||
fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
|
||||
if self.0.exhausted() {
|
||||
return;
|
||||
}
|
||||
let Some(c) = missing_char(ctx, &output.shapes) else { return };
|
||||
let Some(kind) = cjk::classify(c) else { return };
|
||||
if let Some((name, data)) = self.0.next_font(kind) {
|
||||
add_to_all_families(ctx, name, data);
|
||||
ctx.request_repaint();
|
||||
} else if !self.0.exhausted() {
|
||||
// Nothing readable for this script; try the next one on the next frame.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the lazy CJK fallback (system fonts; nothing on the web).
|
||||
pub fn install(ctx: &egui::Context) {
|
||||
install_with(ctx, Sources::system());
|
||||
}
|
||||
|
||||
pub fn install_with(ctx: &egui::Context, sources: Sources) {
|
||||
ctx.add_plugin(CjkFontPlugin(CjkFallback::new(sources)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
fn fake_dir() -> PathBuf {
|
||||
let mut g = DIR.lock().unwrap();
|
||||
g.get_or_insert_with(|| {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("font.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||||
std::fs::write(dir.join("empty.ttf"), b"").unwrap();
|
||||
dir
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn fake(name: &str) -> FontFile {
|
||||
FontFile { path: fake_dir().join(name), family: "" }
|
||||
}
|
||||
|
||||
fn fake_sources(locale: fn() -> Option<String>) -> Sources {
|
||||
Sources {
|
||||
locale,
|
||||
// Korean has a readable "font", Japanese only broken files, Chinese none.
|
||||
files: |s| match s {
|
||||
CjkScript::Korean => vec![fake("missing.ttc"), fake("empty.ttf"), fake("font.ttf")],
|
||||
CjkScript::Japanese => vec![fake("empty.ttf")],
|
||||
_ => vec![],
|
||||
},
|
||||
last_resort: || vec![fake("font.ttf")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_preferred_script_skips_unreadable_and_exhausts() {
|
||||
let mut fb = CjkFallback::new(fake_sources(|| Some("en_US".into())));
|
||||
assert!(!fb.exhausted());
|
||||
// Hangul: Korean first; missing and empty files are skipped.
|
||||
let (name, data) = fb.next_font(CjkChar::Hangul).expect("korean font");
|
||||
assert!(name.starts_with(FONT_PREFIX));
|
||||
assert_eq!(data.index, 0);
|
||||
assert_eq!(fb.tried, vec![CjkScript::Korean]);
|
||||
// Han on an English locale: SC, TC, JA have nothing readable, and the last-resort file was
|
||||
// already loaded, so nothing new; everything is now tried.
|
||||
assert!(fb.next_font(CjkChar::Han).is_none());
|
||||
assert!(fb.exhausted());
|
||||
assert_eq!(fb.tried, vec![CjkScript::Korean, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Japanese]);
|
||||
assert_eq!(fb.registered.len(), 1, "a file is read at most once");
|
||||
assert!(fb.next_font(CjkChar::Kana).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_follows_locale() {
|
||||
for (loc, first) in [
|
||||
("ja_JP.UTF-8", CjkScript::Japanese),
|
||||
("zh-Hans-CN", CjkScript::SimplifiedChinese),
|
||||
("zh-Hant-TW", CjkScript::TraditionalChinese),
|
||||
("ko_KR", CjkScript::Korean),
|
||||
] {
|
||||
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new });
|
||||
fb.order = Some(cjk::script_order(Some(loc)));
|
||||
assert!(fb.next_font(CjkChar::Han).is_none());
|
||||
assert_eq!(fb.tried[0], first, "{loc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs frames showing `text` until fonts settle; returns whether every glyph is covered.
|
||||
fn render(ctx: &egui::Context, text: &str) -> bool {
|
||||
for _ in 0..12 {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
ui.label(text);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(12.0), &text.replace(' ', "")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_registration_triggers_only_for_cjk() {
|
||||
let ctx = egui::Context::default();
|
||||
install_with(&ctx, fake_sources(|| Some("ko".into())));
|
||||
let _ = render(&ctx, "Layer 1 – café");
|
||||
let n = ctx.fonts(|f| f.definitions().font_data.keys().filter(|k| k.starts_with(FONT_PREFIX)).count());
|
||||
assert_eq!(n, 0, "Latin text loads nothing");
|
||||
// Hangul isn't in the stand-in font, but the Korean candidate must have been registered.
|
||||
let _ = render(&ctx, "카드 배경");
|
||||
let fonts = ctx.fonts(|f| f.definitions().clone());
|
||||
let name = format!("{FONT_PREFIX}-0");
|
||||
assert!(fonts.font_data.contains_key(&name));
|
||||
for (fam, stack) in &fonts.families {
|
||||
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the real system fonts, every CJK sample renders (skipped per script when the
|
||||
/// machine has no font for it, e.g. CI Linux without Noto CJK).
|
||||
#[test]
|
||||
fn system_fonts_cover_cjk_samples() {
|
||||
let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file());
|
||||
let samples = [
|
||||
("图层", CjkScript::SimplifiedChinese),
|
||||
("圖層", CjkScript::TraditionalChinese),
|
||||
("카드 배경", CjkScript::Korean),
|
||||
("レイヤー", CjkScript::Japanese),
|
||||
];
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
for (text, script) in samples {
|
||||
if !available(script) {
|
||||
eprintln!("skipping {text}: no {script:?} system font");
|
||||
continue;
|
||||
}
|
||||
assert!(render(&ctx, text), "{text} still has missing glyphs");
|
||||
}
|
||||
// All at once in a fresh context too (several scripts in one frame).
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let all: String = samples.iter().filter(|(_, s)| available(*s)).map(|(t, _)| *t).collect::<Vec<_>>().join(" ");
|
||||
assert!(render(&ctx, &all), "{all}");
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,8 @@ pub struct Preview {
|
||||
/// Hash of the params the mask texture shows.
|
||||
mask_key: u64,
|
||||
mask: Option<egui::TextureHandle>,
|
||||
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash(text: &str) -> u64 {
|
||||
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
|
||||
|
||||
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
|
||||
/// session (Out Of Gamut uses the document's own proof setup).
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
|
||||
let area = proxy.bounds();
|
||||
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
|
||||
let pv = app.session.color.proof(proxy.id);
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
|
||||
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
|
||||
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
}
|
||||
let mut p = params.clone();
|
||||
// Eyedropper points are in document pixels; the proxy is 1/k the size.
|
||||
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
|
||||
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
|
||||
let _ = s.select_layer(id);
|
||||
}
|
||||
s.execute(COMMAND, p).ok()?;
|
||||
let d = s.active()?;
|
||||
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
|
||||
let d = s.active().ok_or("no preview document")?;
|
||||
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
}
|
||||
|
||||
/// Record why the preview failed (logged once per new reason), or clear it.
|
||||
fn report(app: &mut PhotocraftApp, error: Option<String>) {
|
||||
let Some(p) = app.color_range.as_mut() else { return };
|
||||
if p.error != error {
|
||||
if let Some(e) = &error {
|
||||
log::warn!("Color Range preview: {e}");
|
||||
}
|
||||
p.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
|
||||
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let side = doc.size.width.max(doc.size.height).max(1);
|
||||
let k = side.div_ceil(PREVIEW).max(1);
|
||||
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
|
||||
}
|
||||
let image_view = s(f, "__view", "selection") == "image";
|
||||
let params = params(f);
|
||||
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
|
||||
let size = [thumb.width as usize, thumb.height as usize];
|
||||
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
|
||||
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
|
||||
return None;
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
|
||||
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
|
||||
}
|
||||
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
|
||||
let mask = proxy_mask(app, &proxy, k, ¶ms).unwrap_or_else(|| vec![0.0; w * h]);
|
||||
let mask = match proxy_mask(app, &proxy, k, ¶ms) {
|
||||
Ok(m) => {
|
||||
report(app, None);
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
report(app, Some(e));
|
||||
vec![0.0; w * h]
|
||||
}
|
||||
};
|
||||
if mask.len() != w * h {
|
||||
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
|
||||
return None;
|
||||
}
|
||||
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
|
||||
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
pick(app, f, at, ui.input(|i| i.modifiers));
|
||||
}
|
||||
}
|
||||
// A preview that can't be drawn says why (#145) instead of staying blank.
|
||||
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
|
||||
ui.painter().rect_filled(frame, 0.0, t.canvas);
|
||||
let msg = format!(
|
||||
"Preview unavailable:
|
||||
{e}"
|
||||
);
|
||||
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
|
||||
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
|
||||
}
|
||||
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
|
||||
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
|
||||
Some(e) => format!("Color Range preview unavailable: {e}"),
|
||||
None => "Color Range preview".to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(c.sampling, |ui| {
|
||||
@@ -704,4 +742,51 @@ mod tests {
|
||||
app.run(COMMAND, params(&f)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
|
||||
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
|
||||
/// from the menu opens the dialog on screen and draws both previews.
|
||||
#[test]
|
||||
fn opens_and_previews_in_the_full_app_without_a_gpu() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
|
||||
app_with_doc()
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state().gpu.is_none(), "the CPU canvas");
|
||||
h.get_all_by_label("Select").next().expect("the Select menu").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Color Range…").click();
|
||||
h.run_steps(6);
|
||||
let id = dialog_id(h.state());
|
||||
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
|
||||
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
|
||||
assert!(h.query_by_label("Color Range preview").is_some());
|
||||
let p = h.state().color_range.as_ref().expect("the preview is cached");
|
||||
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "__view", json!("image"));
|
||||
let p = h.state().color_range.as_ref().unwrap();
|
||||
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "select", json!("reds"));
|
||||
ok(&mut h);
|
||||
h.run_steps(3);
|
||||
assert_eq!(coverage(h.state(), 5, 5), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_failure_is_shown_not_silent() {
|
||||
let mut h = harness(app_with_doc());
|
||||
open(h.state_mut());
|
||||
h.run_steps(3);
|
||||
// The engine refuses these params: the preview says so instead of staying blank.
|
||||
let id = dialog_id(h.state());
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
|
||||
h.run_steps(3);
|
||||
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
|
||||
assert!(err.is_some(), "the failure is recorded");
|
||||
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
|
||||
set(&mut h, "select", json!("reds"));
|
||||
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if let Some(m) = p.get("maskTarget").and_then(Value::as_bool) {
|
||||
app.ui.mask_target = m;
|
||||
}
|
||||
// Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect.
|
||||
if let Some(m) = p.get("selectionMode").and_then(Value::as_u64) {
|
||||
app.ui.selection_mode = m.min(3) as u8;
|
||||
}
|
||||
if let Some(tabs) = p.get("dockTabs") {
|
||||
let mut cur = serde_json::to_value(app.ui.dock_tabs).unwrap_or_default();
|
||||
if let (Some(c), Some(n)) = (cur.as_object_mut(), tabs.as_object()) {
|
||||
@@ -173,6 +177,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 {
|
||||
@@ -147,6 +152,37 @@ impl GpuCanvas {
|
||||
Some((d.format, bytes))
|
||||
}
|
||||
|
||||
/// Set the GPU memory the wgpu compositor may hold for layer pages and effect maps (see
|
||||
/// [`memory_budget`]); pages beyond it are evicted least recently used first.
|
||||
pub fn set_memory_budget(&self, bytes: u64) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.compositor_budget = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The compositor's memory budget (`None` until [`GpuCanvas::set_memory_budget`]).
|
||||
pub fn memory_budget(&self) -> Option<u64> {
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>()?.compositor_budget
|
||||
}
|
||||
|
||||
/// The document area the view shows: full refreshes composite it last, so its layer pages
|
||||
/// are the ones still resident for the edits that follow.
|
||||
pub fn set_focus(&self, focus: Option<photocraft_geom::Rect>) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.compositor_focus = focus;
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU bytes the wgpu compositor holds: resident layer pages and cached effect maps.
|
||||
pub fn compositor_bytes(&self) -> Option<(u64, usize)> {
|
||||
let r = self.rs.renderer.read();
|
||||
let c = r.callback_resources.get::<Resources>()?.compositor.as_ref()?;
|
||||
Some((c.resident_bytes(), c.fx_cache_bytes()))
|
||||
}
|
||||
|
||||
/// Set the checkerboard and gamut warning colours.
|
||||
pub fn set_style(&self, style: CanvasStyle) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
@@ -174,10 +210,8 @@ impl GpuCanvas {
|
||||
}
|
||||
match &res.compositor {
|
||||
Some(c) => c.supports(doc).is_ok(),
|
||||
None => {
|
||||
let max = self.rs.device.limits().max_texture_dimension_2d;
|
||||
doc.size.width <= max && doc.size.height <= max && photocraft_gpu::plan(doc).is_ok()
|
||||
}
|
||||
// Layers larger than the texture limit are stored in pages, so any size qualifies.
|
||||
None => photocraft_gpu::plan(doc).is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +320,10 @@ impl GpuCanvas {
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(b) = res.compositor_budget {
|
||||
comp.set_memory_budget(b);
|
||||
}
|
||||
comp.set_focus(res.compositor_focus);
|
||||
let key = doc.id.0;
|
||||
let format = self.format_for(doc.depth, size);
|
||||
let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format);
|
||||
@@ -294,6 +332,13 @@ impl GpuCanvas {
|
||||
res.compositor = Some(comp);
|
||||
return Err(e);
|
||||
}
|
||||
// A full refresh whose layer pages don't fit the memory budget would re-upload the
|
||||
// evicted ones every time (no faster than the CPU, and more memory): the banded CPU
|
||||
// compositor does it, and edits in the view (damage rects) stay on the GPU.
|
||||
if region == doc.bounds() && !comp.fits_budget(doc, region) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported("layers exceed the GPU memory budget; full refresh on the CPU".into()));
|
||||
}
|
||||
if fresh {
|
||||
let tex = DocTextures::new(device, res, size, self.tile, format);
|
||||
res.docs.insert(key, tex);
|
||||
@@ -642,6 +687,12 @@ pub struct Perf {
|
||||
pub gpu_uploads: u64,
|
||||
/// Why the last refresh fell back to the CPU compositor (None = GPU composited).
|
||||
pub gpu_fallback: Option<String>,
|
||||
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
|
||||
/// [`memory_budget`]).
|
||||
pub gpu_budget_mb: u64,
|
||||
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
|
||||
#[serde(skip)]
|
||||
pub gpu_budget_allowance: u64,
|
||||
/// Last command run through the app and its duration (ms).
|
||||
pub last_command: String,
|
||||
pub command_ms: f64,
|
||||
@@ -804,6 +855,50 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// GPU resources
|
||||
|
||||
/// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers.
|
||||
pub const MIN_GPU_BUDGET: u64 = 512 << 20;
|
||||
|
||||
/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage
|
||||
/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History
|
||||
/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at
|
||||
/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the
|
||||
/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the
|
||||
/// rest is uploaded per refresh as the view needs it.
|
||||
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>) -> u64 {
|
||||
let ram = ram.unwrap_or(16 << 30);
|
||||
allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET)
|
||||
}
|
||||
|
||||
/// Physical memory of this machine in bytes, if known. `PHOTOCRAFT_RAM_MB` overrides it (to
|
||||
/// simulate a smaller machine).
|
||||
pub fn physical_memory() -> Option<u64> {
|
||||
static RAM: std::sync::OnceLock<Option<u64>> = std::sync::OnceLock::new();
|
||||
*RAM.get_or_init(|| {
|
||||
if let Some(mb) = std::env::var("PHOTOCRAFT_RAM_MB").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
|
||||
return mb.checked_mul(1 << 20);
|
||||
}
|
||||
detect_physical_memory()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?;
|
||||
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb: u64 = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||||
kb.checked_mul(1024)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
view_bgl: wgpu::BindGroupLayout,
|
||||
tile_bgl: wgpu::BindGroupLayout,
|
||||
@@ -823,6 +918,10 @@ struct Resources {
|
||||
compositor: Option<photocraft_gpu::Compositor>,
|
||||
/// Why the wgpu compositor couldn't be created (then the CPU compositor is used).
|
||||
compositor_failed: Option<photocraft_gpu::Unsupported>,
|
||||
/// GPU memory the compositor may hold (`None`: its default), and the document area the
|
||||
/// view shows; applied before every composite.
|
||||
compositor_budget: Option<u64>,
|
||||
compositor_focus: Option<photocraft_geom::Rect>,
|
||||
encode_bgl: wgpu::BindGroupLayout,
|
||||
encode_pipeline: wgpu::RenderPipeline,
|
||||
lut_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1082,6 +1181,8 @@ impl Resources {
|
||||
out_linear: target.is_srgb(),
|
||||
compositor: None,
|
||||
compositor_failed: None,
|
||||
compositor_budget: None,
|
||||
compositor_focus: None,
|
||||
encode_bgl,
|
||||
encode_pipeline,
|
||||
}
|
||||
@@ -1574,6 +1675,19 @@ fn fs(in: V) -> @location(0) vec4<f32> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gpu_budget_follows_memory_usage_and_ram() {
|
||||
const G: u64 = 1 << 30;
|
||||
// What Memory Usage leaves after the document, within a quarter of RAM.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G);
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
// Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown.
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET);
|
||||
assert_eq!(memory_budget(64 * G, 0, None), 4 * G);
|
||||
assert!(physical_memory().is_none_or(|b| b >= 256 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_modes() {
|
||||
assert_eq!(filter_mode(0.25), (0.0, 2.0));
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Properties panel for pixel (and other bounded) layers, Photoshop 2026 style: a Transform section
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, and Quick Actions. Edits dispatch
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, the kind's sections and Quick Actions. Edits dispatch
|
||||
//! `edit.transform`, `layer.translate`, `layer.align.*` and selection commands.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section};
|
||||
use crate::theme::{ROW_GAP, Tokens};
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// `edit.transform` params that scale the content box `b` = [x0, y0, x1, y1] to `w` x `h`, keeping
|
||||
@@ -32,34 +33,14 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
|
||||
Some(json!({"layer": layer, "rect": b, "quad": quad}))
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = egui::Id::new(("layer-props-section", id));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
crate::icons::paint(
|
||||
ui,
|
||||
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
11.0,
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// A number field that reports a value once committed (drag released, Enter, focus lost).
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32) -> Option<f32> {
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) -> Option<f32> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 22.0), Sense::hover());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(12.0), t.text_dim);
|
||||
let key = egui::Id::new(("layer-props-field", id));
|
||||
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(current);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", 80.0);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width);
|
||||
if resp.dragged() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, v));
|
||||
return None;
|
||||
@@ -107,7 +88,12 @@ fn align_glyph(ui: &egui::Ui, r: Rect, kind: &str, color: egui::Color32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer.
|
||||
/// Width of the link toggle between the W and H fields (the X/Y row leaves the same gap).
|
||||
const LINK_W: f32 = 20.0;
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer: Transform, Align and Distribute, the kind's own
|
||||
/// sections (Character/Paragraph/Type Options for type, Appearance/Shape for shapes), then Quick
|
||||
/// Actions, all with the same collapsible headers (#155).
|
||||
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut run: Vec<(String, Value)> = Vec::new();
|
||||
@@ -117,32 +103,33 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let link_key = egui::Id::new("layer-props-link");
|
||||
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(true);
|
||||
let bb = [b.x0, b.y0, b.x1, b.y1];
|
||||
// Two label+field columns with the link toggle between them, filling the panel.
|
||||
let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, 20.0, linked, "Link width and height").clicked() {
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, "Link width and height").clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(link_key, !linked));
|
||||
}
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32) {
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0})));
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32) {
|
||||
ui.add_space(LINK_W + LABEL_GAP);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32})));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if section(ui, "align", "Align and Distribute") {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -164,25 +151,28 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
if section(ui, "quick", "Quick Actions") {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, id) in [("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")] {
|
||||
if photocraft_engine::commands::find(id).is_some() && widgets::secondary_button(ui, label, 0.0).clicked() {
|
||||
run.push((id.to_string(), json!({})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (id, p) in run {
|
||||
for (id, p) in run.drain(..) {
|
||||
if let Err(e) = app.run(&id, p) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Text(_) => crate::type_tool::type_properties(app, ui),
|
||||
LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id),
|
||||
_ => {}
|
||||
}
|
||||
// Re-read: a section above may have changed the layer (e.g. point to paragraph type).
|
||||
let content = app.session.active().and_then(|s| s.doc.layer(layer.id)).map_or_else(|| layer.content.clone(), |l| l.content.clone());
|
||||
if let Some(id) = quick_actions_ui(app, ui, &content) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const ICON_W: f32 = 14.0;
|
||||
/// Width of the effects triangle beside the fx badge.
|
||||
const TRIANGLE_W: f32 = 10.0;
|
||||
/// The blend-mode label is shown only while the name keeps at least this much room.
|
||||
const MIN_NAME_W: f32 = 64.0;
|
||||
pub const MIN_NAME_W: f32 = 64.0;
|
||||
|
||||
/// A right-hand row indicator.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -21,6 +21,8 @@ fn busy() -> photocraft_engine::Session {
|
||||
let a = s.execute("layer.new.layer", json!({"name": format!("{LONG} {depth}")})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
|
||||
// #153: the vector mask thumbnail and both link chains take row width too.
|
||||
s.execute("layer.vectorMask.revealAll", json!({"layer": a})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": a, "blend": "Multiply", "locks": {"all": true}})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({"layer": a})).unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": format!("{LONG} b{depth}")})).unwrap()["layer"].as_u64().unwrap();
|
||||
|
||||
@@ -17,11 +17,13 @@ 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;
|
||||
pub mod channels_panel;
|
||||
pub mod chrome_ui;
|
||||
pub mod cjk_fonts;
|
||||
pub mod color_picker_ui;
|
||||
pub mod color_range_ui;
|
||||
pub mod comps_ui;
|
||||
@@ -44,13 +46,17 @@ 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;
|
||||
pub mod links;
|
||||
pub mod liquify_ui;
|
||||
pub mod mask_thumbs_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;
|
||||
@@ -63,6 +69,7 @@ pub mod plugin_ui;
|
||||
pub mod prefs_ui;
|
||||
pub mod preset_files_ui;
|
||||
pub mod preset_panels;
|
||||
pub mod props_layout;
|
||||
pub mod proxy;
|
||||
pub mod puppet_ui;
|
||||
pub mod retouch_ui;
|
||||
@@ -74,10 +81,14 @@ pub mod slice_ui;
|
||||
pub mod smart_ui;
|
||||
pub mod snap_ui;
|
||||
pub mod state;
|
||||
pub mod stroke_constraint;
|
||||
pub mod stylus;
|
||||
mod tab_strip;
|
||||
pub mod theme;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
pub mod tool_feedback;
|
||||
pub mod transform_tex;
|
||||
pub mod transform_tool;
|
||||
pub mod type_panels_ui;
|
||||
pub mod type_tool;
|
||||
@@ -87,6 +98,7 @@ pub mod view_cmds;
|
||||
pub mod wide_angle_ui;
|
||||
pub mod widgets;
|
||||
pub mod workspace_ui;
|
||||
pub mod zoom_tool;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -200,6 +212,8 @@ pub struct PhotocraftApp {
|
||||
live_stroke: Option<canvas::LiveStroke>,
|
||||
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
|
||||
secondary_erase: bool,
|
||||
/// End of the last painting stroke: ⇧-click draws a straight line from it (#178).
|
||||
last_stroke_end: Option<(DocId, [f64; 2])>,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
|
||||
/// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen).
|
||||
@@ -219,7 +233,7 @@ pub struct PhotocraftApp {
|
||||
pub last_canvas_rect: egui::Rect,
|
||||
pub fps: f32,
|
||||
last_frame_time: f64,
|
||||
thumbs: HashMap<(photocraft_doc::LayerId, bool), (u64, egui::TextureHandle)>,
|
||||
thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>,
|
||||
/// Snapshots whose live layer/mask keys were last used to prune thumbnail handles.
|
||||
thumb_documents: Vec<(DocId, std::sync::Weak<Document>)>,
|
||||
/// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms.
|
||||
@@ -247,6 +261,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).
|
||||
@@ -306,6 +322,7 @@ impl PhotocraftApp {
|
||||
drag: None,
|
||||
live_stroke: None,
|
||||
secondary_erase: false,
|
||||
last_stroke_end: None,
|
||||
control_rx: None,
|
||||
pending_screenshots: Vec::new(),
|
||||
queued_screenshots: Vec::new(),
|
||||
@@ -340,6 +357,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,
|
||||
@@ -797,9 +815,12 @@ impl PhotocraftApp {
|
||||
let mut live = std::collections::HashSet::new();
|
||||
let mut pending: Vec<_> = documents.iter().flat_map(|st| &st.doc.layers).collect();
|
||||
while let Some(layer) = pending.pop() {
|
||||
live.insert((layer.id, false));
|
||||
live.insert((layer.id, mask_thumbs_ui::THUMB_LAYER));
|
||||
if layer.mask.is_some() {
|
||||
live.insert((layer.id, true));
|
||||
live.insert((layer.id, mask_thumbs_ui::THUMB_MASK));
|
||||
}
|
||||
if layer.vector_mask.is_some() {
|
||||
live.insert((layer.id, mask_thumbs_ui::THUMB_VECTOR));
|
||||
}
|
||||
if let Some(children) = layer.children() {
|
||||
pending.extend(children);
|
||||
@@ -823,7 +844,7 @@ impl PhotocraftApp {
|
||||
// Key by content, not document revision: COW tiles change pointer only when their pixels
|
||||
// change, so unrelated edits (e.g. painting another layer) don't rebuild this thumbnail.
|
||||
let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40;
|
||||
let key = (layer.id, false);
|
||||
let key = (layer.id, mask_thumbs_ui::THUMB_LAYER);
|
||||
if let Some((r, tex)) = self.thumbs.get(&key)
|
||||
&& *r == rev
|
||||
{
|
||||
@@ -841,7 +862,7 @@ impl PhotocraftApp {
|
||||
|
||||
pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId {
|
||||
let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40;
|
||||
let key = (id, true);
|
||||
let key = (id, mask_thumbs_ui::THUMB_MASK);
|
||||
if let Some((r, tex)) = self.thumbs.get(&key)
|
||||
&& *r == rev
|
||||
{
|
||||
@@ -855,7 +876,7 @@ impl PhotocraftApp {
|
||||
self.store_thumb(ctx, key, rev, img)
|
||||
}
|
||||
|
||||
fn store_thumb(&mut self, ctx: &egui::Context, key: (photocraft_doc::LayerId, bool), rev: u64, img: egui::ColorImage) -> egui::TextureId {
|
||||
fn store_thumb(&mut self, ctx: &egui::Context, key: (photocraft_doc::LayerId, u8), rev: u64, img: egui::ColorImage) -> egui::TextureId {
|
||||
match self.thumbs.get_mut(&key) {
|
||||
Some((r, tex)) => {
|
||||
tex.set(img, egui::TextureOptions::LINEAR);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Layers panel row: the mask thumbnails after the layer thumbnail (#153).
|
||||
//!
|
||||
//! Like Photoshop, each mask (the pixel mask, then the vector mask) gets a link chain and a
|
||||
//! thumbnail: the chain shows whether the mask moves with the layer and toggles it on click
|
||||
//! (`layer.layerMask.linked` / `layer.vectorMask.linked`); a disabled mask is crossed out with
|
||||
//! a red X and ⇧-clicking a mask thumbnail toggles it (`…enabled`). Vector masks render as a
|
||||
//! white (revealed) / grey (hidden) thumbnail, cached like the other row thumbnails.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use photocraft_doc::{Document, Layer, LayerId, VectorMask};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Width of the link-chain slot in front of a mask thumbnail.
|
||||
pub const CHAIN_W: f32 = 12.0;
|
||||
/// Thumbnail cache kinds (the second half of `PhotocraftApp::thumbs` keys).
|
||||
pub const THUMB_LAYER: u8 = 0;
|
||||
pub const THUMB_MASK: u8 = 1;
|
||||
pub const THUMB_VECTOR: u8 = 2;
|
||||
|
||||
/// Which of a layer's masks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MaskKind {
|
||||
Pixel,
|
||||
Vector,
|
||||
}
|
||||
|
||||
impl MaskKind {
|
||||
/// The engine command prefix for this mask.
|
||||
fn command(self) -> &'static str {
|
||||
match self {
|
||||
MaskKind::Pixel => "layer.layerMask",
|
||||
MaskKind::Vector => "layer.vectorMask",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
MaskKind::Pixel => "layer mask",
|
||||
MaskKind::Vector => "vector mask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(kind, enabled, linked)` for each of `l`'s masks, in row order.
|
||||
pub fn masks(l: &Layer) -> Vec<(MaskKind, bool, bool)> {
|
||||
let mut v = Vec::with_capacity(2);
|
||||
if let Some(m) = &l.mask {
|
||||
v.push((MaskKind::Pixel, m.enabled, m.linked));
|
||||
}
|
||||
if let Some(m) = &l.vector_mask {
|
||||
v.push((MaskKind::Vector, m.enabled, m.linked));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Width `l`'s masks take in a row with `ts`-point thumbnails (chain + thumbnail + gap each).
|
||||
pub fn width(l: &Layer, ts: f32) -> f32 {
|
||||
masks(l).len() as f32 * (CHAIN_W + ts + 6.0)
|
||||
}
|
||||
|
||||
/// Thumbnail size for `l`'s row: `ts`, shrunk (down to 14 pt) when the layer and mask
|
||||
/// thumbnails would leave the name less than its minimum of the `room` they share.
|
||||
pub fn thumb_size(l: &Layer, ts: f32, room: f32) -> f32 {
|
||||
let n = masks(l).len() as f32;
|
||||
let need = ts + 6.0 + width(l, ts) + crate::layer_row_ui::MIN_NAME_W;
|
||||
let short = need - room;
|
||||
if short <= 0.0 { ts } else { (ts - short / (1.0 + n)).clamp(14.0_f32.min(ts), ts) }
|
||||
}
|
||||
|
||||
/// Where a row's mask thumbnails and chains went.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MaskRects {
|
||||
pub thumbs: Vec<(MaskKind, Rect)>,
|
||||
pub chains: Vec<(MaskKind, Rect)>,
|
||||
/// A chain was clicked this frame (the row shouldn't treat the click as a selection).
|
||||
pub clicked: bool,
|
||||
}
|
||||
|
||||
impl MaskRects {
|
||||
pub fn thumb(&self, kind: MaskKind) -> Option<Rect> {
|
||||
self.thumbs.iter().find(|(k, _)| *k == kind).map(|(_, r)| *r)
|
||||
}
|
||||
/// The mask thumbnail under `p`, if any.
|
||||
pub fn hit(&self, p: Pos2) -> Option<MaskKind> {
|
||||
self.thumbs.iter().find(|(_, r)| r.expand(2.0).contains(p)).map(|(k, _)| *k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint `l`'s chains and mask thumbnails from `*x` (just right of the layer thumbnail plus its
|
||||
/// gap), advancing `*x` past them, and push chain clicks as commands.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
app: &mut PhotocraftApp,
|
||||
ctx: &egui::Context,
|
||||
ui: &egui::Ui,
|
||||
painter: &Painter,
|
||||
doc: &Document,
|
||||
l: &Layer,
|
||||
x: &mut f32,
|
||||
cy: f32,
|
||||
ts: f32,
|
||||
actions: &mut Vec<(String, Value)>,
|
||||
) -> MaskRects {
|
||||
let t = Tokens::get(ctx);
|
||||
let mut out = MaskRects::default();
|
||||
for (kind, enabled, linked) in masks(l) {
|
||||
// The chain sits in the gap before the thumbnail, centred between the two.
|
||||
let chain = Rect::from_min_max(pos2(*x - 4.0, cy - ts / 2.0), pos2(*x - 4.0 + CHAIN_W, cy + ts / 2.0));
|
||||
let resp = ui.interact(chain, ui.id().with(("mask-chain", l.id.0, kind == MaskKind::Vector)), Sense::click());
|
||||
if linked {
|
||||
paint_chain(painter, chain.center(), if resp.hovered() { t.text } else { t.icon });
|
||||
} else if resp.hovered() {
|
||||
paint_chain(painter, chain.center(), t.text_faint);
|
||||
}
|
||||
let (verb, what, name) = (if linked { "Unlink" } else { "Link" }, kind.label(), l.name.clone());
|
||||
let resp = resp.on_hover_text(if linked { "Unlink the mask from the layer" } else { "Link the mask to the layer" });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} {what} {name}")));
|
||||
if resp.clicked() {
|
||||
out.clicked = true;
|
||||
actions.push((format!("{}.linked", kind.command()), json!({"layer": l.id.0, "linked": !linked})));
|
||||
}
|
||||
out.chains.push((kind, chain));
|
||||
*x += CHAIN_W - 2.0;
|
||||
let r = Rect::from_min_size(pos2(*x, cy - ts / 2.0), vec2(ts, ts));
|
||||
if ui.is_rect_visible(r) {
|
||||
let tex = match kind {
|
||||
MaskKind::Pixel => l.mask.as_ref().map(|m| app.mask_thumb(ctx, doc, l.id, m)),
|
||||
MaskKind::Vector => l.vector_mask.as_ref().map(|m| app.vector_mask_thumb(ctx, doc, l.id, m)),
|
||||
};
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex, r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
if !enabled {
|
||||
// Photoshop's red X over a disabled mask.
|
||||
let st = Stroke::new(1.5, t.danger);
|
||||
let k = r.shrink(1.0);
|
||||
painter.line_segment([k.left_top(), k.right_bottom()], st);
|
||||
painter.line_segment([k.right_top(), k.left_bottom()], st);
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(
|
||||
r,
|
||||
if t.pro { 0.0 } else { 4.0 },
|
||||
Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
out.thumbs.push((kind, r));
|
||||
*x += ts + 6.0 + 2.0;
|
||||
}
|
||||
let rects = (out.thumbs.clone(), out.chains.clone());
|
||||
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::collections::HashMap<u64, Rects>>(rects_id()).insert(l.id.0, rects));
|
||||
out
|
||||
}
|
||||
|
||||
type Rects = (Vec<(MaskKind, Rect)>, Vec<(MaskKind, Rect)>);
|
||||
|
||||
fn rects_id() -> egui::Id {
|
||||
egui::Id::new("mask-thumb-rects")
|
||||
}
|
||||
|
||||
/// The mask thumbnails and chains last drawn for layer `id` (for tests and automation).
|
||||
pub fn recorded(ctx: &egui::Context, id: u64) -> Option<Rects> {
|
||||
ctx.data(|d| d.get_temp::<std::collections::HashMap<u64, Rects>>(rects_id())).and_then(|m| m.get(&id).cloned())
|
||||
}
|
||||
|
||||
/// A small vertical chain: two interlocking links.
|
||||
fn paint_chain(painter: &Painter, c: Pos2, color: Color32) {
|
||||
let st = Stroke::new(1.2, color);
|
||||
let link = vec2(5.0, 7.0);
|
||||
painter.rect_stroke(Rect::from_center_size(c - vec2(0.0, 2.5), link), 2.0, st, StrokeKind::Middle);
|
||||
painter.rect_stroke(Rect::from_center_size(c + vec2(0.0, 2.5), link), 2.0, st, StrokeKind::Middle);
|
||||
}
|
||||
|
||||
/// The command a click on mask thumbnail `kind` issues with modifiers `m`, if it is one that
|
||||
/// replaces the normal click (⇧: toggle the mask on/off, like Photoshop).
|
||||
pub fn click_command(l: &Layer, kind: MaskKind, m: egui::Modifiers) -> Option<(String, Value)> {
|
||||
if !m.shift || m.command {
|
||||
return None;
|
||||
}
|
||||
let enabled = masks(l).into_iter().find(|(k, _, _)| *k == kind)?.1;
|
||||
Some((format!("{}.enabled", kind.command()), json!({"layer": l.id.0, "enabled": !enabled})))
|
||||
}
|
||||
|
||||
/// Cheap identity of a vector mask's geometry and state.
|
||||
pub fn vector_fingerprint(m: &VectorMask) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut mix = |v: u64| h = (h ^ v).wrapping_mul(0x100_0000_01b3);
|
||||
mix(u64::from(m.path.inverted) | u64::from(m.path.fill_rule == photocraft_doc::FillRule::EvenOdd) << 1);
|
||||
mix(u64::from(m.density.to_bits()));
|
||||
for s in &m.path.subpaths {
|
||||
mix(u64::from(s.closed) | (s.op as u64) << 1 | (s.knots.len() as u64) << 8);
|
||||
for k in &s.knots {
|
||||
for p in [k.anchor, k.in_ctrl, k.out_ctrl] {
|
||||
mix(p.x.to_bits());
|
||||
mix(p.y.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A `side`-pixel square thumbnail of vector mask `m`: white where it reveals, grey where it
|
||||
/// hides, letterboxed to the document's aspect like the other row thumbnails. Disabled masks
|
||||
/// still show their shape (the row crosses them out).
|
||||
pub fn vector_thumb_image(doc: &Document, m: &VectorMask, side: usize) -> egui::ColorImage {
|
||||
let (w, h) = (f64::from(doc.size.width.max(1)), f64::from(doc.size.height.max(1)));
|
||||
let s = side as f64 / w.max(h);
|
||||
let (ox, oy) = ((side as f64 - w * s) / 2.0, (side as f64 - h * s) / 2.0);
|
||||
let shown = VectorMask { path: m.path.transform(&photocraft_geom::Affine { m: [s, 0.0, 0.0, s, ox, oy] }), enabled: true, ..m.clone() };
|
||||
let n = side as i32;
|
||||
let values = photocraft_vector::vector_mask_values(&shown, photocraft_geom::Rect::new(0, 0, n, n));
|
||||
let (x0, y0, x1, y1) = (ox.floor() as usize, oy.floor() as usize, (ox + w * s).ceil() as usize, (oy + h * s).ceil() as usize);
|
||||
let mut px = vec![Color32::TRANSPARENT; side * side];
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (tx, ty) = (i % side, i / side);
|
||||
if tx < x0 || ty < y0 || tx >= x1 || ty >= y1 {
|
||||
continue;
|
||||
}
|
||||
let v = values.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
*p = Color32::from_gray((128.0 + 127.0 * v + 0.5) as u8);
|
||||
}
|
||||
egui::ColorImage::new([side, side], px)
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Cached thumbnail of `l`'s vector mask (see [`vector_thumb_image`]).
|
||||
pub fn vector_mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: LayerId, m: &VectorMask) -> egui::TextureId {
|
||||
let rev = vector_fingerprint(m) ^ (u64::from(doc.size.width) << 40) ^ (u64::from(doc.size.height) << 20);
|
||||
let key = (id, THUMB_VECTOR);
|
||||
if let Some((r, tex)) = self.thumbs.get(&key)
|
||||
&& *r == rev
|
||||
{
|
||||
return tex.id();
|
||||
}
|
||||
self.store_thumb(ctx, key, rev, vector_thumb_image(doc, m, 64))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,204 @@
|
||||
//! #153: mask thumbnails, link chains and the red X in Layers rows; #154: the layer mask in
|
||||
//! Channels, the layer's path in Paths, aspect-correct channel thumbnails, the Paths footer.
|
||||
|
||||
use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{MaskKind, THUMB_VECTOR, recorded, vector_thumb_image};
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// A 200×100 document: "Masked" (pixel + vector mask) and a "Badge" shape layer.
|
||||
fn session() -> (photocraft_engine::Session, u64, u64) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({"layer": masked})).unwrap();
|
||||
let path = json!({"subpaths": [{"knots": [[20, 10], [120, 10], [120, 90], [20, 90]]}]});
|
||||
s.execute("layer.vectorMask.add", json!({"layer": masked, "path": path})).unwrap();
|
||||
let shape =
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [140, 20, 40, 40], "fill": "#cc3333", "name": "Badge"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layer": masked})).unwrap();
|
||||
(s, masked, shape)
|
||||
}
|
||||
|
||||
fn harness(session: photocraft_engine::Session, tab: usize, ppp: f32, dock_width: f32) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(session, crate::Services::default())
|
||||
});
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = crate::control::ControlRequest::new(
|
||||
"ui.set",
|
||||
json!({"theme": "promedium", "dockWidth": dock_width, "dockTabs": {"layers": tab}, "dock": {"collapsed": ["color", "properties", "history", "navigator"]}}),
|
||||
);
|
||||
crate::control::handle(h.state_mut(), &ctx, &req);
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
fn click_with(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: Modifiers) {
|
||||
h.hover_at(at);
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn layer(h: &Harness<'_, PhotocraftApp>, id: u64) -> photocraft_doc::Layer {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_show_both_mask_thumbnails_with_chains_and_cache_the_vector_one() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let (s, masked, _) = session();
|
||||
let h = harness(s, 0, ppp, 290.0);
|
||||
let (thumbs, chains) = recorded(&h.ctx, masked).expect("mask thumbnails drawn");
|
||||
assert_eq!(thumbs.iter().map(|(k, _)| *k).collect::<Vec<_>>(), [MaskKind::Pixel, MaskKind::Vector]);
|
||||
assert_eq!(chains.len(), 2);
|
||||
// layer thumbnail | chain | pixel mask | chain | vector mask, without overlaps.
|
||||
for i in 0..2 {
|
||||
assert!(chains[i].1.right() <= thumbs[i].1.left() + 0.01, "@{ppp}x chain {i} before its thumbnail");
|
||||
}
|
||||
assert!(thumbs[0].1.right() <= chains[1].1.left() + 0.01);
|
||||
let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == masked).unwrap();
|
||||
assert!(row.name.unwrap().left() >= thumbs[1].1.right(), "@{ppp}x the name starts after the masks");
|
||||
assert!(h.state().thumbs.contains_key(&(photocraft_doc::LayerId(masked), THUMB_VECTOR)), "vector thumbnail cached");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_vector_mask_prunes_its_thumbnail() {
|
||||
let (s, masked, _) = session();
|
||||
let mut h = harness(s, 0, 1.0, 290.0);
|
||||
let key = (photocraft_doc::LayerId(masked), THUMB_VECTOR);
|
||||
assert!(h.state().thumbs.contains_key(&key));
|
||||
h.state_mut().run("layer.vectorMask.delete", json!({"layer": masked})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().thumbs.contains_key(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_toggles_linking_and_it_survives_psd() {
|
||||
let (s, masked, _) = session();
|
||||
let mut h = harness(s, 0, 1.0, 290.0);
|
||||
let p = h.get_by_label("Unlink layer mask Masked").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert!(!layer(&h, masked).mask.unwrap().linked);
|
||||
let p = h.get_by_label("Unlink vector mask Masked").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert!(!layer(&h, masked).vector_mask.unwrap().linked);
|
||||
let doc = (*h.state().session.active().unwrap().doc).clone();
|
||||
let bytes = photocraft_io::export(&doc, "m.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("m.psd", &bytes).unwrap().document;
|
||||
let l = back.walk().into_iter().map(|(_, _, l)| l.clone()).find(|l| l.name == "Masked").unwrap();
|
||||
assert!(!l.mask.unwrap().linked && !l.vector_mask.unwrap().linked, "unlinked state round-trips PSD");
|
||||
// Clicking the (now empty) chain slot links again.
|
||||
let p = h.get_by_label("Link layer mask Masked").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert!(layer(&h, masked).mask.unwrap().linked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_disables_a_mask() {
|
||||
let (s, masked, _) = session();
|
||||
let mut h = harness(s, 0, 1.0, 290.0);
|
||||
for kind in [MaskKind::Pixel, MaskKind::Vector] {
|
||||
let r = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == kind).unwrap().1;
|
||||
click_with(&mut h, r.center(), Modifiers::SHIFT);
|
||||
h.run_steps(2);
|
||||
}
|
||||
let l = layer(&h, masked);
|
||||
assert!(!l.mask.unwrap().enabled && !l.vector_mask.unwrap().enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_thumbnail_is_white_inside_grey_outside_at_the_document_aspect() {
|
||||
let (s, masked, _) = session();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let vm = doc.layer(photocraft_doc::LayerId(masked)).unwrap().vector_mask.clone().unwrap();
|
||||
let img = vector_thumb_image(&doc, &vm, 64);
|
||||
let at = |x: usize, y: usize| img.pixels[y * 64 + x];
|
||||
// 200×100 doc in 64 px: 0.32 scale, rows 16..48. The mask covers x 20..120, y 10..90.
|
||||
assert_eq!(at(20, 32), egui::Color32::from_gray(255), "inside reveals");
|
||||
assert_eq!(at(55, 32), egui::Color32::from_gray(128), "outside hides");
|
||||
assert_eq!(at(20, 4), egui::Color32::TRANSPARENT, "letterbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_list_the_layer_mask_and_keep_the_aspect() {
|
||||
let (s, masked, _) = session();
|
||||
let mut h = harness(s, 1, 1.0, 290.0);
|
||||
let rows = crate::channels_panel::recorded(&h.ctx);
|
||||
let names: Vec<_> = rows.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["RGB", "Red", "Green", "Blue", "Masked Mask"], "the mask is a temporary row below the colour channels");
|
||||
for (n, r) in &rows {
|
||||
assert!((r.width() / r.height() - 2.0).abs() < 0.05, "{n}: 2:1 thumbnail, got {r:?}");
|
||||
}
|
||||
assert!(!h.state().ui.mask_target);
|
||||
let p = h.get_by_label("Masked Mask").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert!(h.state().ui.mask_target, "clicking the mask row targets the mask");
|
||||
assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"));
|
||||
h.run_steps(2);
|
||||
let p = h.get_by_label("RGB").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert!(!h.state().ui.mask_target, "the composite targets the pixels again");
|
||||
// A layer without a mask has no mask row.
|
||||
h.state_mut().run("layer.layerMask.delete", json!({"layer": masked})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(crate::channels_panel::recorded(&h.ctx).iter().all(|(n, _)| !n.ends_with("Mask")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_list_the_layer_path_above_a_bottom_footer() {
|
||||
let (s, masked, shape) = session();
|
||||
let mut h = harness(s, 2, 1.0, 290.0);
|
||||
let row = h.get_by_label("Masked Vector Mask").rect();
|
||||
let footer = crate::vector_ui::paths_footer(&h.ctx).expect("footer drawn");
|
||||
assert!(footer.top() > row.bottom(), "buttons below the rows");
|
||||
let group = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).unwrap().1;
|
||||
assert!(group.bottom() - footer.bottom() < 16.0, "footer at the panel bottom: footer {footer:?}, group {group:?}");
|
||||
assert!(footer.bottom() - row.bottom() > 100.0, "the footer is pinned, not right below the rows");
|
||||
h.state_mut().run("layer.select", json!({"layer": shape})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Badge Shape Path").is_some());
|
||||
assert!(h.query_by_label("Masked Vector Mask").is_none(), "only the selected layer's path");
|
||||
// Selecting the temporary row targets the layer's path: Load as selection uses it.
|
||||
let p = h.get_by_label("Badge Shape Path").rect().center();
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.selected_path.as_deref(), Some("layer"));
|
||||
// Footer: fill, stroke, load as selection, … (24 pt buttons, 2 pt apart).
|
||||
let footer = crate::vector_ui::paths_footer(&h.ctx).unwrap();
|
||||
let p = pos2(footer.left() + 2.0 * 26.0 + 12.0, footer.center().y);
|
||||
click_with(&mut h, p, Modifiers::NONE);
|
||||
let sel = h.state().session.active().unwrap().doc.selection.clone().expect("selection from the shape path");
|
||||
let b = sel.content_bounds();
|
||||
assert!(b.x0.abs_diff(140) <= 1 && b.width().abs_diff(40) <= 1, "{b:?}");
|
||||
let _ = masked;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_never_panic_on_odd_documents() {
|
||||
// Rows render for a 1×1 and a very wide document, and the fitted thumbnails stay finite.
|
||||
for (w, hgt) in [(1, 1), (2000, 3)] {
|
||||
for tab in 0..3 {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": hgt})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
|
||||
s.execute("layer.vectorMask.revealAll", json!({})).unwrap();
|
||||
let _ = harness(s, tab, 1.0, 250.0);
|
||||
}
|
||||
}
|
||||
let (r, uv) = crate::channels_panel::fit_thumb(Rect::from_min_size(pos2(0.0, 0.0), vec2(28.0, 28.0)), 0, 0);
|
||||
assert!(r.is_finite() && uv.is_finite() && r.width() > 0.0);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Native-menu behaviour for the menu bar's menus (#138): a menu or submenu taller than the
|
||||
//! window scrolls (mouse wheel / trackpad, and the ▲ / ▼ arrows at its top and bottom that scroll
|
||||
//! while the pointer rests on them, like macOS and Windows menus on a small display), and the
|
||||
//! keyboard drives an open menu: ↑ / ↓ move the highlight (and keep it in view), → opens a
|
||||
//! submenu or the next menu, ← closes the submenu or opens the previous menu, ↩ / Space run the
|
||||
//! highlighted item, Esc closes.
|
||||
//!
|
||||
//! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too,
|
||||
//! so the mouse and the keyboard share one highlighted row like a native menu.
|
||||
|
||||
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
|
||||
use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
/// Height of a scroll-arrow strip.
|
||||
pub const ARROW: f32 = 16.0;
|
||||
/// Gap kept between a menu and the window edge.
|
||||
const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
/// One actionable row of a menu level: a command or a submenu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Row {
|
||||
/// The row's widget id (a submenu's popup id derives from it).
|
||||
pub widget: Id,
|
||||
pub enabled: bool,
|
||||
/// The command it runs; `None` for a submenu.
|
||||
pub command: Option<String>,
|
||||
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
/// Keyboard / highlight state of the open menu.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Nav {
|
||||
/// The open top-level menu (index into the menu bar).
|
||||
top: Option<usize>,
|
||||
/// Popup ids of the top-level menus, from the last frame.
|
||||
tops: Vec<Id>,
|
||||
/// The level the arrow keys move in (0 = the top-level menu).
|
||||
pub(crate) level: usize,
|
||||
/// Highlighted row per level.
|
||||
hi: Vec<Option<usize>>,
|
||||
/// This frame's rows per level (deeper levels are dropped when their parent redraws).
|
||||
pub(crate) rows: Vec<Vec<Row>>,
|
||||
/// Menu-root id per level (the owner of egui's `MenuState` for that level).
|
||||
roots: Vec<Id>,
|
||||
/// Bottom of the menu bar: where top-level menus hang from.
|
||||
pub bar_bottom: Option<f32>,
|
||||
/// The visible (scrolled) part of each level.
|
||||
pub(crate) views: Vec<Rect>,
|
||||
/// Scroll the highlighted row of `level` into view when it is next drawn.
|
||||
reveal: bool,
|
||||
/// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened
|
||||
/// submenu).
|
||||
first_at: Option<usize>,
|
||||
}
|
||||
|
||||
fn nav_id() -> Id {
|
||||
Id::new("pc-menu-nav")
|
||||
}
|
||||
|
||||
fn open_id() -> Id {
|
||||
Id::new("pc-menu-open")
|
||||
}
|
||||
|
||||
/// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.)
|
||||
pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
// Only while the menu bar is still drawn (Full Screen Mode hides it with a menu open).
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data(|d| d.get_temp::<u64>(open_id())).is_some_and(|seen| seen + 2 >= pass)
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
/// Last frame's state, reset when another top-level menu (or none) is open now.
|
||||
pub fn load(ctx: &egui::Context) -> Nav {
|
||||
let mut nav: Nav = ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default();
|
||||
let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id));
|
||||
if top != nav.top {
|
||||
nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() };
|
||||
}
|
||||
nav
|
||||
}
|
||||
|
||||
/// The state as the last frame left it (tests, inspection).
|
||||
pub fn current(ctx: &egui::Context) -> Nav {
|
||||
ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The highlighted row of `level`, if any.
|
||||
pub fn highlighted(&self, level: usize) -> Option<usize> {
|
||||
self.hi.get(level).copied().flatten()
|
||||
}
|
||||
|
||||
fn set_hi(&mut self, level: usize, row: Option<usize>) {
|
||||
if self.hi.len() <= level {
|
||||
self.hi.resize(level + 1, None);
|
||||
}
|
||||
self.hi[level] = row;
|
||||
}
|
||||
|
||||
/// Start drawing `level`: forget its rows from before, and every deeper level's.
|
||||
fn begin_level(&mut self, level: usize, root: Id) {
|
||||
self.rows.truncate(level);
|
||||
self.rows.resize(level + 1, Vec::new());
|
||||
self.roots.truncate(level);
|
||||
self.roots.resize(level + 1, root);
|
||||
self.views.truncate(level);
|
||||
self.views.resize(level + 1, Rect::NOTHING);
|
||||
}
|
||||
|
||||
/// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the
|
||||
/// pointer) is on it, and register it for the keys. `add` returns the row's response.
|
||||
pub fn row<R>(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R {
|
||||
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
|
||||
let lit = enabled && self.highlighted(level) == Some(i);
|
||||
let saved = ui.visuals().widgets.inactive;
|
||||
if lit {
|
||||
ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered;
|
||||
}
|
||||
let (r, out) = add(ui, self);
|
||||
ui.visuals_mut().widgets.inactive = saved;
|
||||
if let Some(rows) = self.rows.get_mut(level) {
|
||||
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect });
|
||||
self.after_row(level, i, lit, &r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls
|
||||
/// the highlighted row into view.
|
||||
fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) {
|
||||
let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO);
|
||||
if moved && r.hovered() && !lit {
|
||||
self.set_hi(level, Some(i));
|
||||
self.level = level;
|
||||
r.ctx.request_repaint();
|
||||
}
|
||||
if lit && self.reveal && level == self.level {
|
||||
r.scroll_to_me(None);
|
||||
self.reveal = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled(&self, level: usize) -> Vec<usize> {
|
||||
self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// ↑ / ↓: the next enabled row (wrapping), or the first / last one.
|
||||
fn step(&mut self, level: usize, down: bool) {
|
||||
let on = self.enabled(level);
|
||||
let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return };
|
||||
let next = match self.highlighted(level) {
|
||||
None => {
|
||||
if down {
|
||||
first
|
||||
} else {
|
||||
last
|
||||
}
|
||||
}
|
||||
Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first),
|
||||
Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last),
|
||||
};
|
||||
self.set_hi(level, Some(next));
|
||||
self.hi.truncate(level + 1);
|
||||
self.level = level;
|
||||
self.reveal = true;
|
||||
}
|
||||
|
||||
/// Open the highlighted submenu of `level` and move the keyboard into it.
|
||||
fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) {
|
||||
let Some(&root) = self.roots.get(level) else { return };
|
||||
let sub = SubMenu::id_from_widget_id(widget);
|
||||
// egui drops an open item it has never drawn: mark it shown so it survives to the next frame.
|
||||
MenuState::mark_shown(ctx, sub);
|
||||
MenuState::from_id(ctx, root, |s| s.open_item = Some(sub));
|
||||
self.level = level + 1;
|
||||
self.hi.truncate(level + 1);
|
||||
self.first_at = Some(level + 1);
|
||||
}
|
||||
|
||||
/// Handle this frame's navigation keys (after the menus were drawn). `tops` are the menu
|
||||
/// bar's popup ids; an activated command lands in `clicked`.
|
||||
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option<String>) {
|
||||
self.tops = tops.to_vec();
|
||||
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
|
||||
let level = self.level.min(self.rows.len().saturating_sub(1));
|
||||
self.level = level;
|
||||
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
|
||||
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
|
||||
let switch = |by: usize| {
|
||||
let n = tops.len();
|
||||
if let Some(&id) = tops.get((top + by) % n) {
|
||||
egui::Popup::open_id(ctx, id);
|
||||
}
|
||||
};
|
||||
if press(Key::Escape) {
|
||||
egui::Popup::close_all(ctx);
|
||||
} else if press(Key::ArrowDown) {
|
||||
self.step(level, true);
|
||||
} else if press(Key::ArrowUp) {
|
||||
self.step(level, false);
|
||||
} else if press(Key::ArrowRight) {
|
||||
match current {
|
||||
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
|
||||
_ => switch(1),
|
||||
}
|
||||
} else if press(Key::ArrowLeft) {
|
||||
if level > 0 {
|
||||
if let Some(&root) = self.roots.get(level - 1) {
|
||||
MenuState::from_id(ctx, root, |s| s.open_item = None);
|
||||
}
|
||||
self.hi.truncate(level);
|
||||
self.level = level - 1;
|
||||
} else {
|
||||
switch(tops.len() - 1);
|
||||
}
|
||||
} else if press(Key::Enter) || press(Key::Space) {
|
||||
match current {
|
||||
Some(Row { enabled: true, command: Some(c), .. }) => {
|
||||
*clicked = Some(c);
|
||||
egui::Popup::close_all(ctx);
|
||||
}
|
||||
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
pub fn store(self, ctx: &egui::Context) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
if self.top.is_some() {
|
||||
d.insert_temp(open_id(), pass);
|
||||
} else {
|
||||
d.remove::<u64>(open_id());
|
||||
}
|
||||
d.insert_temp(nav_id(), self);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
|
||||
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
|
||||
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
|
||||
}
|
||||
|
||||
/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with
|
||||
/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu.
|
||||
pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let level = depth.saturating_sub(1);
|
||||
nav.begin_level(level, find_menu_root(ui).id);
|
||||
let screen = ctx.content_rect();
|
||||
// The menu frame's margin and stroke around the rows.
|
||||
let frame = ui.spacing().menu_margin.sum().y + 2.0;
|
||||
// A top-level menu hangs below the menu bar; a submenu may slide up to fit the window.
|
||||
// (From the menu bar, not from where the popup is now: egui slides a popup that doesn't fit.)
|
||||
let top = nav.bar_bottom.unwrap_or(screen.top()) + EDGE;
|
||||
let room = if depth <= 1 { screen.bottom() - top - EDGE } else { screen.height() - 2.0 * EDGE } - frame;
|
||||
let room = room.max(4.0 * ARROW);
|
||||
let key = ui.id().with(("pc-menu-level", depth));
|
||||
// Rows' height from the last frame: does this level overflow?
|
||||
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
|
||||
let over = content.is_some_and(|h| h > room + 0.5);
|
||||
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
// Wheel, scroll bar and dragging the rows all scroll (#160).
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(if over { room - 2.0 * ARROW } else { room })
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, |ui| rows(ui, nav));
|
||||
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
|
||||
if let Some(v) = nav.views.get_mut(level) {
|
||||
*v = out.inner_rect;
|
||||
}
|
||||
if nav.first_at == Some(level) {
|
||||
nav.first_at = None;
|
||||
nav.step(level, true);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (Some(up), Some(down)) = (up, down) else { return };
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
let x = ui.min_rect().x_range();
|
||||
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
let offset = out.state.offset.y;
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut target = offset;
|
||||
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
|
||||
let strip = Rect::from_x_y_ranges(x, strip.y_range());
|
||||
let c = strip.center();
|
||||
let (h, w) = (4.5, 6.0);
|
||||
let tri = if dir < 0.0 {
|
||||
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
|
||||
} else {
|
||||
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_faint.gamma_multiply(0.5) }, egui::Stroke::NONE));
|
||||
let id = key.with(dir < 0.0);
|
||||
let r = ui.interact(strip, id, Sense::hover());
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }));
|
||||
if live && pointer_on(ui, strip) {
|
||||
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The wheel scrolls over the arrows too.
|
||||
if pointer_on(ui, strip) {
|
||||
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
if wheel != 0.0 {
|
||||
target = (target - wheel).clamp(0.0, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
if target != offset {
|
||||
let mut st = out.state;
|
||||
st.offset.y = target;
|
||||
st.store(&ctx, out.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "menu_nav_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Menus on small displays (#138): every row of the longest menus is reachable by keyboard, wheel
|
||||
//! and the scroll arrows, and the keyboard drives submenus and runs commands.
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
/// Window size in pixels and UI scale: the small displays of #138.
|
||||
const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.0), (1280.0, 720.0, 1.5), (1024.0, 600.0, 1.5)];
|
||||
/// The tallest menus.
|
||||
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.sync_views();
|
||||
app
|
||||
}
|
||||
|
||||
fn harness((w, h, scale): (f32, f32, f32)) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(w / scale, h / scale))
|
||||
.with_pixels_per_point(scale)
|
||||
.build_ui_state(|ui, app| crate::menus::menu_bar(app, ui), app());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none());
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'static, PhotocraftApp>, top: &str) {
|
||||
h.get_by_label(top).click();
|
||||
h.run_steps(4);
|
||||
assert!(Nav::current(&h.ctx).rows.first().is_some_and(|r| !r.is_empty()), "{top} did not open");
|
||||
}
|
||||
|
||||
/// Is row `i` of `level` entirely inside its level's visible (scrolled) area, on screen?
|
||||
fn visible(h: &Harness<'static, PhotocraftApp>, level: usize, i: usize) -> bool {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
let (Some(row), Some(view)) = (nav.rows.get(level).and_then(|r| r.get(i)), nav.views.get(level)) else { return false };
|
||||
view.expand(0.5).contains_rect(row.rect) && h.ctx.content_rect().expand(0.5).contains_rect(*view)
|
||||
}
|
||||
|
||||
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<Row> {
|
||||
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
|
||||
h.key_press(k);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_of_the_longest_menus_is_reachable_with_the_arrow_keys() {
|
||||
for display in DISPLAYS {
|
||||
for top in LONGEST {
|
||||
let mut h = harness(display);
|
||||
open(&mut h, top);
|
||||
let all = rows(&h, 0);
|
||||
let enabled: Vec<usize> = all.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect();
|
||||
assert!(enabled.len() > 5, "{top}: {} enabled rows", enabled.len());
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..enabled.len() {
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
let i = Nav::current(&h.ctx).highlighted(0).expect("a highlighted row");
|
||||
assert!(
|
||||
visible(&h, 0, i),
|
||||
"{display:?} {top}: highlighted row {i} ({:?}) is scrolled out of view",
|
||||
rows(&h, 0).get(i).map(|r| r.command.clone())
|
||||
);
|
||||
seen.push(i);
|
||||
}
|
||||
seen.sort_unstable();
|
||||
assert_eq!(seen, enabled, "{display:?} {top}: ↓ visits every enabled row once");
|
||||
// ↓ wraps to the first row (scrolling back up); ↑ wraps to the last.
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
assert_eq!(Nav::current(&h.ctx).highlighted(0), enabled.first().copied());
|
||||
assert!(visible(&h, 0, enabled[0]));
|
||||
key(&mut h, egui::Key::ArrowUp);
|
||||
let last = *enabled.last().unwrap();
|
||||
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(last));
|
||||
assert!(visible(&h, 0, last), "{display:?} {top}: the last row is reachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_displays_overflow_and_the_menu_stays_in_the_window() {
|
||||
// 1024 × 600 at 150 %: 683 × 400 points, far shorter than the Filter menu.
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
let all = rows(&h, 0);
|
||||
let last = all.len() - 1;
|
||||
assert!(!visible(&h, 0, last), "the Filter menu must overflow at 683 × 400 pt for this test to mean anything");
|
||||
let nav = Nav::current(&h.ctx);
|
||||
let screen = h.ctx.content_rect();
|
||||
assert!(nav.views[0].bottom() <= screen.bottom() && nav.views[0].top() >= screen.top());
|
||||
let bar = h.get_by_label("Filter").rect();
|
||||
assert!(nav.views[0].top() >= bar.bottom(), "the menu hangs below the menu bar instead of sliding over it: {:?} vs {bar:?}", nav.views[0]);
|
||||
// Scroll arrows show when the rows overflow.
|
||||
assert!(h.query_by_label("Scroll menu down").is_some() && h.query_by_label("Scroll menu up").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mouse_wheel_scrolls_a_long_menu() {
|
||||
for display in DISPLAYS {
|
||||
let mut h = harness(display);
|
||||
open(&mut h, "Filter");
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(view.center());
|
||||
h.run_steps(2);
|
||||
for _ in 0..60 {
|
||||
if visible(&h, 0, last) {
|
||||
break;
|
||||
}
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(visible(&h, 0, last), "{display:?}: the wheel reaches the last Filter row");
|
||||
assert!(rows(&h, 0).len() == last + 1, "the menu stays open while scrolling");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resting_on_the_scroll_arrows_scrolls() {
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
assert!(visible(&h, 0, 0) && !visible(&h, 0, last));
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(egui::pos2(view.center().x, view.bottom() + ARROW / 2.0));
|
||||
for _ in 0..240 {
|
||||
h.run_steps(1);
|
||||
if visible(&h, 0, last) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(visible(&h, 0, last), "resting on ▼ scrolls to the end");
|
||||
assert!(!visible(&h, 0, 0));
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(egui::pos2(view.center().x, view.top() - ARROW / 2.0));
|
||||
for _ in 0..240 {
|
||||
h.run_steps(1);
|
||||
if visible(&h, 0, 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(visible(&h, 0, 0), "resting on ▲ scrolls back to the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_open_and_close_submenus_and_enter_runs_a_command() {
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
// Walk down to the first submenu (Blur, Distort, …) and open it with →.
|
||||
for _ in 0..rows(&h, 0).len() {
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.is_none()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
key(&mut h, egui::Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
assert_eq!(nav.level, 1, "→ moves into the submenu");
|
||||
assert!(nav.rows.get(1).is_some_and(|r| !r.is_empty()), "the submenu is open");
|
||||
let first = nav.rows[1].iter().position(|r| r.enabled);
|
||||
assert_eq!(nav.highlighted(1), first, "its first enabled row is highlighted");
|
||||
assert!(visible(&h, 1, first.unwrap()));
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
assert_eq!(Nav::current(&h.ctx).level, 1);
|
||||
// ← closes it again.
|
||||
key(&mut h, egui::Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
assert_eq!((nav.level, nav.rows.len()), (0, 1), "← closes the submenu");
|
||||
// ↩ runs the highlighted command and closes the menu: Select › All.
|
||||
key(&mut h, egui::Key::Escape);
|
||||
open(&mut h, "Select");
|
||||
for _ in 0..rows(&h, 0).len() {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.as_deref() == Some("select.all")) {
|
||||
break;
|
||||
}
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
}
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_none());
|
||||
key(&mut h, egui::Key::Enter);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "↩ ran Select › All");
|
||||
assert!(!is_open(&h.ctx), "and closed the menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() {
|
||||
let mut h = harness((1280.0, 720.0, 1.0));
|
||||
assert!(!is_open(&h.ctx));
|
||||
open(&mut h, "File");
|
||||
assert!(is_open(&h.ctx), "shortcuts leave the keys to an open menu");
|
||||
key(&mut h, egui::Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_none(), "→ leaves File");
|
||||
assert!(h.query_by_label_contains("Undo").is_some(), "→ opens Edit");
|
||||
key(&mut h, egui::Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_some(), "← back to File");
|
||||
key(&mut h, egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_none(), "Esc closes the menu");
|
||||
assert!(!is_open(&h.ctx), "shortcuts work again");
|
||||
}
|
||||
+25
-15
@@ -698,6 +698,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let items = menu_items(app);
|
||||
let mut clicked: Option<String> = None;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let mut nav = crate::menu_nav::Nav::load(ui.ctx());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let v = &mut ui.style_mut().visuals;
|
||||
@@ -707,6 +708,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
v.widgets.hovered.bg_stroke = egui::Stroke::NONE;
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0);
|
||||
nav.bar_bottom = Some(ui.max_rect().bottom());
|
||||
let mut buttons = Vec::with_capacity(TOP_MENUS.len());
|
||||
for top in TOP_MENUS {
|
||||
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
@@ -715,13 +717,16 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if mine.is_empty() {
|
||||
ui.weak("(coming soon)");
|
||||
}
|
||||
render_level(ui, &mine, 1, &mut clicked);
|
||||
render_level(ui, &mine, 1, &mut clicked, &mut nav);
|
||||
});
|
||||
buttons.push(r.response);
|
||||
}
|
||||
switch_on_hover(ui.ctx(), &buttons);
|
||||
let tops: Vec<egui::Id> = buttons.iter().map(egui::Popup::default_response_id).collect();
|
||||
nav.keys(ui.ctx(), &tops, &mut clicked);
|
||||
});
|
||||
});
|
||||
nav.store(ui.ctx());
|
||||
if let Some(id) = clicked {
|
||||
let ctx = ui.ctx().clone();
|
||||
let _ = invoke(app, &ctx, &id, json!({}));
|
||||
@@ -743,6 +748,10 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
|
||||
// `Response::hovered` is false while the menu's popup layer is open. Hit-test the title
|
||||
// rect manually, but only when its own layer is top-most at the pointer.
|
||||
let Some(p) = ctx.pointer_hover_pos() else { return };
|
||||
// Only a moving pointer switches: a pointer resting on a title must not undo ← / → .
|
||||
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
|
||||
return;
|
||||
}
|
||||
if let Some(i) = buttons.iter().position(|b| pointer_reaches_title(ctx, b, p))
|
||||
&& i != open
|
||||
{
|
||||
@@ -751,19 +760,13 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
|
||||
// On short displays leave room for the menu bar and popup frame so the ScrollArea becomes
|
||||
// active before the popup reaches the bottom edge. Keep wheel/scrollbar input and also allow
|
||||
// direct dragging of the menu contents.
|
||||
let max_height = (ui.ctx().content_rect().height() - 56.0).max(96.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(max_height)
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, |ui| render_level_rows(ui, items, depth, clicked));
|
||||
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
|
||||
// Menu popups can be taller than the window: each level stays on screen and scrolls (wheel,
|
||||
// scroll arrows, keyboard), like a native menu on a small display.
|
||||
crate::menu_nav::level(ui, depth, nav, |ui, nav| render_level_rows(ui, items, depth, clicked, nav));
|
||||
}
|
||||
|
||||
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
|
||||
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
if t.pro {
|
||||
// Spectrum/macOS menus: blue highlight row with white text.
|
||||
@@ -798,7 +801,12 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
|
||||
if let Some(sc) = &it.shortcut {
|
||||
b = b.shortcut_text(crate::shortcuts::pretty(sc));
|
||||
}
|
||||
if ui.add_enabled(it.enabled, b).clicked() {
|
||||
let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| {
|
||||
let r = ui.add_enabled(it.enabled, b);
|
||||
let hit = r.clicked();
|
||||
(r, hit)
|
||||
});
|
||||
if hit {
|
||||
*clicked = Some(it.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
@@ -811,8 +819,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
|
||||
shown_subs.push(name);
|
||||
let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect();
|
||||
let any_enabled = child.iter().any(|c| c.enabled && c.label != "---");
|
||||
ui.add_enabled_ui(any_enabled || !child.is_empty(), |ui| {
|
||||
ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked));
|
||||
let enabled = any_enabled || !child.is_empty();
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
nav.row(ui, depth - 1, enabled, None, |ui, nav| (ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked, nav)).response, ()));
|
||||
});
|
||||
last_was_sep = false;
|
||||
}
|
||||
@@ -840,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);
|
||||
@@ -613,7 +619,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Tool::Move => hint(ui, "Drag to move the active layer"),
|
||||
Tool::Eyedropper => hint(ui, "Click to sample the foreground colour · ⌥-click for background"),
|
||||
Tool::Zoom => {
|
||||
hint(ui, "Click to zoom in · ⌥-click to zoom out");
|
||||
widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, "Scrubby Zoom");
|
||||
hint(ui, "Click to zoom in · ⌥-click to zoom out · drag right/left to zoom in/out");
|
||||
if widgets::secondary_button(ui, "Fit Screen", 0.0).clicked()
|
||||
&& let Some(i) = app.session.active_index()
|
||||
{
|
||||
@@ -637,6 +644,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 +775,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 +810,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 +1056,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;
|
||||
@@ -1105,6 +1117,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Docks zero the item spacing: keep the Opacity and Fill fields apart (#155).
|
||||
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, if t.pro { "Lock:" } else { "Lock" });
|
||||
if t.pro {
|
||||
@@ -1175,7 +1189,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);
|
||||
}
|
||||
@@ -1341,14 +1359,15 @@ fn layer_row(
|
||||
if eye_resp.clicked() {
|
||||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
|
||||
}
|
||||
let ts = if t.pro { 24.0 } else { 34.0 };
|
||||
// Everything but the indentation and the name, so a narrow panel squeezes the indentation first.
|
||||
let fixed = 6.0
|
||||
// Everything but the thumbnails, the indentation and the name, so a narrow panel squeezes
|
||||
// the indentation first, then the thumbnails (a layer with two masks has three).
|
||||
let others = 6.0
|
||||
+ 28.0
|
||||
+ if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 }
|
||||
+ if l.clipped { 12.0 } else { 0.0 }
|
||||
+ (ts + 6.0) * if l.mask.is_some() { 2.0 } else { 1.0 }
|
||||
+ crate::layer_row_ui::reserved_width(l);
|
||||
let ts = crate::mask_thumbs_ui::thumb_size(l, if t.pro { 24.0 } else { 34.0 }, rect.width() - others);
|
||||
let fixed = others + ts + 6.0 + crate::mask_thumbs_ui::width(l, ts);
|
||||
x += 28.0 + crate::layer_row_ui::indent(depth, rect.width(), fixed);
|
||||
let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions);
|
||||
if l.clipped {
|
||||
@@ -1358,22 +1377,9 @@ fn layer_row(
|
||||
let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary);
|
||||
x += ts + 6.0;
|
||||
let mut mask_rect = None;
|
||||
if let Some(m) = &l.mask {
|
||||
let mr = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
if ui.is_rect_visible(mr) {
|
||||
let tex = app.mask_thumb(ctx, doc, l.id, m);
|
||||
painter.image(tex, mr, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
painter.rect_stroke(
|
||||
mr,
|
||||
if t.pro { 0.0 } else { 4.0 },
|
||||
Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
mask_rect = Some(mr);
|
||||
x += ts + 6.0;
|
||||
}
|
||||
// Link chains and pixel / vector mask thumbnails (#153).
|
||||
let masks = crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions);
|
||||
let mask_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Pixel);
|
||||
// Photoshop frames the targeted thumbnail (pixels or mask) of the active layer with corner brackets.
|
||||
if row.primary {
|
||||
let target = if app.ui.mask_target { mask_rect } else { Some(thumb) };
|
||||
@@ -1419,15 +1425,26 @@ fn layer_row(
|
||||
(on_mask || thumb.expand(2.0).contains(p))
|
||||
.then(|| json!({"channel": if on_mask { "mask" } else { "transparency" }, "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)}))
|
||||
});
|
||||
// ⇧-click a mask thumbnail: disable / enable that mask.
|
||||
let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| {
|
||||
let kind = masks.hit(pos?)?;
|
||||
crate::mask_thumbs_ui::click_command(l, kind, m)
|
||||
});
|
||||
if let Some(p) = thumb_load {
|
||||
actions.push(("select.loadSelection".into(), p));
|
||||
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled {
|
||||
} else if let Some(cmd) = mask_toggle {
|
||||
actions.push(cmd);
|
||||
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked {
|
||||
let mode = select_mode(ui.input(|i| i.modifiers));
|
||||
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
|
||||
// Clicking a thumbnail picks what painting targets; adjustment/fill layers target their mask.
|
||||
let pos = resp.interact_pointer_pos();
|
||||
let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p));
|
||||
let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p));
|
||||
// The vector mask thumbnail picks the layer's path in the Paths panel.
|
||||
if pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector) {
|
||||
app.ui.selected_path = Some("layer".into());
|
||||
}
|
||||
let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
|
||||
if on_mask || (content_less && l.mask.is_some()) {
|
||||
actions.push(("ui.maskTarget".into(), json!(true)));
|
||||
@@ -1752,37 +1769,23 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let Some(id) = st.active_layer else { return };
|
||||
let Some(layer) = st.doc.layer(id).cloned() else { return };
|
||||
ui.horizontal(|ui| {
|
||||
let icon = match &layer.content {
|
||||
LayerContent::Adjustment(_) => "sliders-horizontal",
|
||||
LayerContent::Group(_) => "folder",
|
||||
LayerContent::Fill(_) => "paint-bucket",
|
||||
LayerContent::Text(_) => "type",
|
||||
LayerContent::Shape(_) => "pentagon",
|
||||
_ => "image",
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover());
|
||||
icons::paint(ui, r, icon, 15.0, t.icon);
|
||||
let kind = match &layer.content {
|
||||
LayerContent::Adjustment(a) => a.label().to_string(),
|
||||
LayerContent::Group(g) if g.artboard.is_some() => "Artboard".to_string(),
|
||||
other => format!("{} Layer", other.kind_name()),
|
||||
};
|
||||
ui.label(RichText::new(kind).color(t.text));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
// Header: kind icon, layer name and kind (#155); sections below draw their own separators.
|
||||
crate::props_layout::header(ui, &layer);
|
||||
let is_adjustment = matches!(layer.content, LayerContent::Adjustment(_));
|
||||
if is_adjustment || layer.artboard().is_some() || !t.pro {
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
adjustment_controls(app, ui, id, adj);
|
||||
} else if layer.artboard().is_some() {
|
||||
crate::artboard_ui::properties(app, ui, &layer);
|
||||
} else if t.pro {
|
||||
// Transform, Align, the kind's sections and Quick Actions.
|
||||
crate::layer_props_ui::properties(app, ui, &layer);
|
||||
} else {
|
||||
if t.pro {
|
||||
crate::layer_props_ui::properties(app, ui, &layer);
|
||||
} else {
|
||||
layer_controls(app, ui, &layer);
|
||||
}
|
||||
layer_controls(app, ui, &layer);
|
||||
if matches!(layer.content, LayerContent::Text(_)) {
|
||||
crate::type_tool::type_properties(app, ui);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Shared Properties-panel layout (#155): the layer header (kind icon + layer name), the one
|
||||
//! collapsible section header every section uses (separator, disclosure chevron, title), column
|
||||
//! widths that make field rows fill the panel, and the Quick Actions each layer kind offers.
|
||||
|
||||
use egui::{Rect, Sense, pos2, vec2};
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// Height of a section header row.
|
||||
pub const SECTION_H: f32 = 24.0;
|
||||
/// Width of the short label (or glyph) column in front of a field: "W", "tT", "VA"…
|
||||
pub const LABEL_W: f32 = 22.0;
|
||||
/// Horizontal gap between a label and its field.
|
||||
pub const LABEL_GAP: f32 = 4.0;
|
||||
/// Horizontal gap between two label+field columns.
|
||||
pub const COL_GAP: f32 = 8.0;
|
||||
|
||||
/// Icon for a layer kind (the same glyphs as the Layers panel's kind filter).
|
||||
pub fn kind_icon(content: &LayerContent) -> &'static str {
|
||||
match content {
|
||||
LayerContent::Adjustment(_) => "sliders-horizontal",
|
||||
LayerContent::Group(_) => "folder",
|
||||
LayerContent::Fill(_) => "paint-bucket",
|
||||
LayerContent::Text(_) => "type",
|
||||
LayerContent::Shape(_) => "pentagon",
|
||||
LayerContent::Smart(_) => "app-window",
|
||||
LayerContent::Raster(_) => "image",
|
||||
}
|
||||
}
|
||||
|
||||
/// "Type Layer", "Curves", "Artboard"…
|
||||
pub fn kind_label(layer: &Layer) -> String {
|
||||
match &layer.content {
|
||||
LayerContent::Adjustment(a) => a.label().to_string(),
|
||||
LayerContent::Group(g) if g.artboard.is_some() => "Artboard".to_string(),
|
||||
other => format!("{} Layer", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Header row: kind icon, the layer's name, and its kind right-aligned in the dim text colour.
|
||||
pub fn header(ui: &mut egui::Ui, layer: &Layer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
|
||||
let icon_r = Rect::from_center_size(pos2(r.left() + 10.0, r.center().y), vec2(20.0, 20.0));
|
||||
crate::icons::paint(ui, icon_r, kind_icon(&layer.content), 15.0, t.icon);
|
||||
let kind = kind_label(layer);
|
||||
let kind_g = ui.painter().layout_no_wrap(kind.clone(), egui::FontId::proportional(11.0), t.text_faint);
|
||||
let kind_w = kind_g.size().x;
|
||||
// The name takes what's left between the icon and the kind (elided when long).
|
||||
let name_x = r.left() + 26.0;
|
||||
let name_w = (r.right() - kind_w - 8.0 - name_x).max(0.0);
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(layer.name.clone(), theme::medium(12.5), t.text);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(name_w);
|
||||
let name_g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(pos2(name_x, r.center().y - name_g.size().y / 2.0), name_g, t.text);
|
||||
if r.width() > kind_w + 60.0 {
|
||||
ui.painter().galley(pos2(r.right() - kind_w - 2.0, r.center().y - kind_g.size().y / 2.0), kind_g, t.text_faint);
|
||||
}
|
||||
let name = layer.name.clone();
|
||||
resp.widget_info(move || egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{name} — {kind}")));
|
||||
}
|
||||
|
||||
/// A collapsible section: a separator, then a chevron + title row. Returns whether it's open.
|
||||
/// Every Properties section uses this, so headers look and behave alike.
|
||||
pub fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = egui::Id::new(("props-section", id));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
|
||||
ui.add_space(theme::ROW_GAP);
|
||||
widgets::hairline(ui);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
crate::icons::paint(
|
||||
ui,
|
||||
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
11.0,
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, theme::semibold(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
let label = title.to_string();
|
||||
resp.widget_info(move || egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &label));
|
||||
record(ui.ctx(), title, r);
|
||||
if open {
|
||||
ui.add_space(theme::ROW_GAP / 2.0);
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// Section headers drawn this pass, top to bottom (title, rect), for tests and `ui.inspect`.
|
||||
pub fn sections_drawn(ctx: &egui::Context) -> Vec<(String, Rect)> {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data(|d| d.get_temp::<(u64, Vec<(String, Rect)>)>(egui::Id::new("props-sections"))).filter(|(p, _)| *p + 1 >= pass).map(|(_, v)| v).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn record(ctx: &egui::Context, title: &str, r: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
let e = d.get_temp_mut_or_default::<(u64, Vec<(String, Rect)>)>(egui::Id::new("props-sections"));
|
||||
if e.0 != pass {
|
||||
*e = (pass, Vec::new());
|
||||
}
|
||||
e.1.push((title.to_string(), r));
|
||||
});
|
||||
}
|
||||
|
||||
/// Width of each field when `cols` label+field pairs (labels `label_w` wide) share the row.
|
||||
pub fn field_width(avail: f32, cols: usize, label_w: f32) -> f32 {
|
||||
let n = cols.max(1) as f32;
|
||||
((avail - n * (label_w + LABEL_GAP) - (n - 1.0) * COL_GAP) / n).max(36.0)
|
||||
}
|
||||
|
||||
/// The Quick Actions a layer kind offers, as (label, command id), Photoshop-style: pixel layers get
|
||||
/// subject selection, type layers its conversions, shapes and smart objects their own.
|
||||
pub fn quick_actions(content: &LayerContent) -> &'static [(&'static str, &'static str)] {
|
||||
match content {
|
||||
LayerContent::Raster(_) => &[("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")],
|
||||
LayerContent::Text(_) => &[
|
||||
("Convert to Shape", "type.convertToShape"),
|
||||
("Convert to Paragraph Text", "type.convertToParagraphText"),
|
||||
("Convert to Point Text", "type.convertToPointText"),
|
||||
("Create Warped Text", "type.warpText"),
|
||||
("Rasterize Type", "type.rasterizeTypeLayer"),
|
||||
],
|
||||
LayerContent::Shape(_) => &[("Rasterize Shape", "layer.rasterize.shape"), ("Convert to Smart Object", "layer.smartObjects.convertToSmartObject")],
|
||||
LayerContent::Smart(_) => &[("Edit Contents", "layer.smartObjects.editContents"), ("Rasterize Smart Object", "layer.rasterize.smartObject")],
|
||||
LayerContent::Group(_) => &[("Ungroup Layers", "layer.ungroupLayers"), ("Convert to Smart Object", "layer.smartObjects.convertToSmartObject")],
|
||||
LayerContent::Fill(_) => &[("Rasterize Fill Content", "layer.rasterize.fillContent")],
|
||||
LayerContent::Adjustment(_) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The Quick Actions shown right now: the kind's list, limited to commands that exist and are
|
||||
/// enabled (Convert to Paragraph Text only for point type, and vice versa).
|
||||
pub fn visible_quick_actions(app: &PhotocraftApp, content: &LayerContent) -> Vec<(&'static str, &'static str)> {
|
||||
quick_actions(content).iter().copied().filter(|(_, id)| crate::menus::is_live(id) && crate::menus::is_enabled(app, id)).collect()
|
||||
}
|
||||
|
||||
/// Quick Actions section body: full-width buttons, two per row when the panel is wide enough.
|
||||
/// Returns the command to run (through the menus, so ones with dialogs open them).
|
||||
pub fn quick_actions_ui(app: &PhotocraftApp, ui: &mut egui::Ui, content: &LayerContent) -> Option<&'static str> {
|
||||
let actions = visible_quick_actions(app, content);
|
||||
if actions.is_empty() || !section(ui, "quick", "Quick Actions") {
|
||||
return None;
|
||||
}
|
||||
let avail = ui.available_width();
|
||||
let cols = if avail >= 360.0 { 2 } else { 1 };
|
||||
let mut out = None;
|
||||
for row in actions.chunks(cols) {
|
||||
// A short last row (or a lone action) still spans the panel.
|
||||
let w = ((avail - (row.len() - 1) as f32 * COL_GAP) / row.len() as f32).floor();
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
for (label, id) in row {
|
||||
let resp = widgets::secondary_button(ui, label, w);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label));
|
||||
if resp.clicked() {
|
||||
out = Some(*id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::ROW_GAP);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "props_layout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
//! #155: Properties sections share one header style, Quick Actions fit the layer kind, controls
|
||||
//! fill the panel at every dock width, and the Layers panel's Opacity/Fill rows keep their gap.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// A document with one layer of each kind; returns the session and the ids by kind.
|
||||
fn kinds() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = |v: serde_json::Value| v["layer"].as_u64().unwrap();
|
||||
let pixel = id(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap());
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let shape = id(s.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 80, 40], "fill": "#ff0000"})).unwrap());
|
||||
let ty = id(s.execute("type.create", json!({"text": "Headline", "size": 48, "x": 40, "y": 200})).unwrap());
|
||||
(s, vec![("pixel", pixel), ("shape", shape), ("type", ty)])
|
||||
}
|
||||
|
||||
fn layer_of(app: &PhotocraftApp, id: u64) -> Layer {
|
||||
app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
/// Pro theme with only the Properties group expanded (it then fills the column).
|
||||
fn harness(session: photocraft_engine::Session, ppp: f32, dock_width: f32) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1600.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(session, crate::Services::default())
|
||||
});
|
||||
set(&mut h, json!({"dockWidth": dock_width, "dock": {"collapsed": ["color", "navigator", "history", "layers"]}}));
|
||||
h
|
||||
}
|
||||
|
||||
fn set(h: &mut Harness<'static, PhotocraftApp>, p: serde_json::Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = crate::control::ControlRequest::new("ui.set", p);
|
||||
crate::control::handle(h.state_mut(), &ctx, &req);
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
fn select(h: &mut Harness<'static, PhotocraftApp>, id: u64) {
|
||||
h.state_mut().run("layer.select", json!({"layer": id})).unwrap();
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
fn headers(h: &Harness<'_, PhotocraftApp>) -> Vec<(String, Rect)> {
|
||||
sections_drawn(&h.ctx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_actions_fit_the_layer_kind() {
|
||||
let ids = |c: &LayerContent| quick_actions(c).iter().map(|(_, id)| *id).collect::<Vec<_>>();
|
||||
let (s, layers) = kinds();
|
||||
let app = PhotocraftApp::new(s, crate::Services::default());
|
||||
for (kind, id) in layers {
|
||||
let l = layer_of(&app, id);
|
||||
let all = ids(&l.content);
|
||||
assert!(!all.is_empty(), "{kind}");
|
||||
match kind {
|
||||
"pixel" => assert!(all.contains(&"select.subject")),
|
||||
_ => assert!(!all.contains(&"select.subject"), "{kind}: Select Subject is for pixel layers"),
|
||||
}
|
||||
// Every listed command exists, except ones not implemented yet (filtered when shown).
|
||||
for id in &all {
|
||||
assert!(crate::menus::is_live(id) || *id == "layer.removeBackground", "{kind}: {id} is not a command");
|
||||
}
|
||||
}
|
||||
let ty = app.session.active().unwrap().doc.layers.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content.clone();
|
||||
assert_eq!(ids(&ty), ["type.convertToShape", "type.convertToParagraphText", "type.convertToPointText", "type.warpText", "type.rasterizeTypeLayer"]);
|
||||
assert!(ids(&LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_layer_sections_share_one_header_style_and_actions_fit() {
|
||||
let (s, layers) = kinds();
|
||||
let ty = layers.iter().find(|(k, _)| *k == "type").unwrap().1;
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
select(&mut h, ty);
|
||||
let hs = headers(&h);
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["Transform", "Align and Distribute", "Character", "Paragraph", "Type Options", "Quick Actions"]);
|
||||
let first = hs[0].1;
|
||||
for (n, r) in &hs {
|
||||
assert!((r.left() - first.left()).abs() < 0.5 && (r.width() - first.width()).abs() < 0.5, "{n}: {r:?} vs {first:?}");
|
||||
assert!((r.height() - SECTION_H).abs() < 0.5, "{n}: {r:?}");
|
||||
}
|
||||
// The header names the layer.
|
||||
assert!(h.query_by_value("Headline — Type Layer").is_some());
|
||||
// Only type-relevant Quick Actions (point type: Convert to Paragraph Text, not Point Text).
|
||||
let app = h.state();
|
||||
let shown: Vec<&str> = visible_quick_actions(app, &layer_of(app, ty).content).iter().map(|(l, _)| *l).collect();
|
||||
assert_eq!(shown, ["Convert to Shape", "Convert to Paragraph Text", "Create Warped Text", "Rasterize Type"]);
|
||||
for label in &shown {
|
||||
assert!(h.query_by_label(label).is_some(), "{label} button");
|
||||
}
|
||||
assert!(h.query_by_label("Select Subject").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_kinds_use_the_same_headers() {
|
||||
let (s, layers) = kinds();
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
for (kind, id) in layers {
|
||||
select(&mut h, id);
|
||||
let hs = headers(&h);
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names.first(), Some(&"Transform"), "{kind}: {names:?}");
|
||||
assert_eq!(names.last(), Some(&"Quick Actions"), "{kind}: {names:?}");
|
||||
if kind == "shape" {
|
||||
assert!(names.contains(&"Appearance") && names.contains(&"Shape"), "{names:?}");
|
||||
}
|
||||
let first = hs[0].1;
|
||||
assert!(hs.iter().all(|(_, r)| (r.left() - first.left()).abs() < 0.5 && (r.height() - SECTION_H).abs() < 0.5), "{kind}: {hs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_controls_fill_the_panel_at_every_width_and_scale() {
|
||||
for ppp in [1.0, 2.0] {
|
||||
for dw in [250.0, 380.0, 520.0] {
|
||||
let (s, layers) = kinds();
|
||||
let ty = layers.iter().find(|(k, _)| *k == "type").unwrap().1;
|
||||
let mut h = harness(s, ppp, dw);
|
||||
select(&mut h, ty);
|
||||
let hs = headers(&h);
|
||||
let header = hs.iter().find(|(n, _)| n == "Character").unwrap().1;
|
||||
let what = format!("{ppp}x @ {dw}");
|
||||
// Font family and style dropdowns span the section.
|
||||
let combos: Vec<Rect> = h
|
||||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0)
|
||||
.collect();
|
||||
assert!(combos.len() >= 2, "{what}: {combos:?}");
|
||||
for r in &combos[..2] {
|
||||
assert!((r.left() - header.left()).abs() <= 2.0 && (r.right() - header.right()).abs() <= 2.0, "{what}: {r:?} vs {header:?}");
|
||||
}
|
||||
// Number fields end at the section's right edge (the second column), never past it.
|
||||
let fields: Vec<Rect> = h
|
||||
.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0)
|
||||
.collect();
|
||||
assert!(fields.len() >= 6, "{what}: {} fields", fields.len());
|
||||
let right = fields.iter().map(|r| r.right()).fold(f32::MIN, f32::max);
|
||||
assert!(right <= header.right() + 0.5 && right >= header.right() - 30.0, "{what}: fields end at {right}, panel {}", header.right());
|
||||
// Transform's second column ends where Character's does (one right edge for the panel).
|
||||
let transform = hs[0].1;
|
||||
let t_right = h
|
||||
.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= transform.bottom() && r.bottom() <= header.top())
|
||||
.map(|r| r.right())
|
||||
.fold(f32::MIN, f32::max);
|
||||
// (Both rows have a unit suffix: compare with the size/leading row.)
|
||||
let top = fields.iter().map(|r| r.top()).fold(f32::MAX, f32::min);
|
||||
let lead = fields.iter().filter(|r| (r.top() - top).abs() < 0.5).map(|r| r.right()).fold(f32::MIN, f32::max);
|
||||
assert!((t_right - lead).abs() < 0.5, "{what}: Transform fields end at {t_right}, Character at {lead}");
|
||||
// Quick Actions buttons span the panel too.
|
||||
let qa = h.get_by_label("Rasterize Type").rect();
|
||||
assert!(qa.right() >= header.right() - 2.0 && qa.right() <= header.right() + 1.0, "{what}: {qa:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_opacity_and_fill_rows_keep_a_gap() {
|
||||
for dw in [250.0, 520.0] {
|
||||
let (s, layers) = kinds();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
});
|
||||
set(&mut h, json!({"dockWidth": dw}));
|
||||
select(&mut h, layers[0].1);
|
||||
let o = h.query_all_by_label("Opacity:").next().unwrap().rect();
|
||||
let f = h.query_all_by_label("Fill:").next().unwrap().rect();
|
||||
// Rows are 24 pt high: centres at least a row plus the gap apart.
|
||||
assert!(f.center().y - o.center().y >= 24.0 + crate::theme::ROW_GAP - 0.5, "{dw}: {o:?} {f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_width_divides_the_row() {
|
||||
for avail in [200.0, 300.0, 500.0] {
|
||||
let w = field_width(avail, 2, LABEL_W);
|
||||
assert!((2.0 * (LABEL_W + LABEL_GAP + w) + COL_GAP - avail).abs() < 1e-3);
|
||||
}
|
||||
assert_eq!(field_width(10.0, 3, LABEL_W), 36.0);
|
||||
assert_eq!(field_width(f32::NAN, 0, LABEL_W), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_and_paragraph_dock_panels_share_the_headers_without_type_options() {
|
||||
let (s, layers) = kinds();
|
||||
let (ty, px) = (layers.iter().find(|(k, _)| *k == "type").unwrap().1, layers[0].1);
|
||||
let mut h = harness(s, 1.0, 320.0);
|
||||
// Only the Window › Character group, expanded.
|
||||
set(&mut h, json!({"panels": {"character": true}, "dock": {"collapsed": ["color", "navigator", "history", "layers", "properties"]}}));
|
||||
select(&mut h, ty);
|
||||
for (tab, want) in [(0, "Character"), (1, "Paragraph")] {
|
||||
set(&mut h, json!({"dockTabs": {"character": tab}}));
|
||||
let hs = headers(&h);
|
||||
assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}");
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, [want], "tab {tab}");
|
||||
}
|
||||
// No type layer: the empty-state message, no sections.
|
||||
select(&mut h, px);
|
||||
assert!(headers(&h).is_empty());
|
||||
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -156,6 +156,10 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu.
|
||||
if crate::menu_nav::is_open(ctx) {
|
||||
return;
|
||||
}
|
||||
// Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim
|
||||
// keyboard input before the distortion-mode handler below runs. Give Liquify's local
|
||||
// shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits.
|
||||
@@ -196,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)) {
|
||||
@@ -259,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +378,9 @@ pub struct ToolOptions {
|
||||
pub bg_limits: String,
|
||||
pub bg_tolerance: f32,
|
||||
pub bg_protect_fg: bool,
|
||||
/// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle).
|
||||
#[serde(default = "yes")]
|
||||
pub zoom_scrubby: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -441,6 +448,7 @@ impl Default for ToolOptions {
|
||||
bg_limits: "contiguous".into(),
|
||||
bg_tolerance: 50.0,
|
||||
bg_protect_fg: false,
|
||||
zoom_scrubby: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,6 +518,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,236 @@
|
||||
//! ⇧ with painting tools and the Gradient tool (#178), as in Photoshop:
|
||||
//!
|
||||
//! - ⇧ while dragging a painting stroke keeps it on a straight line at 0°, 45° or 90° from where
|
||||
//! the constraint began (the stroke's start, or the point where ⇧ went down mid-stroke). The
|
||||
//! direction locks once the pointer has moved a few screen points.
|
||||
//! - ⇧-click after a stroke paints a straight line from the end of the previous stroke to the
|
||||
//! click. The line is part of the new stroke, so it is one `paint.stroke` and one undo step.
|
||||
//! - The Gradient tool's ⇧ snaps the gradient angle to 45° increments.
|
||||
//!
|
||||
//! Points go through the normal drag/`LiveStroke`/commit path, so the live preview, pen dynamics
|
||||
//! and Smoothing treat them like any other stroke points.
|
||||
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Screen points the pointer must travel before a ⇧-constrained stroke locks its direction.
|
||||
const LOCK_DISTANCE: f64 = 3.0;
|
||||
|
||||
/// A ⇧ constraint in progress: its origin and, once locked, the unit direction of the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Axis {
|
||||
pub origin: [f64; 2],
|
||||
pub dir: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
/// Tools whose drag ⇧ constrains: every painting tool and the Gradient tool.
|
||||
pub fn applies(tool: Tool) -> bool {
|
||||
tool.is_brushlike() || tool == Tool::Gradient
|
||||
}
|
||||
|
||||
/// Tools that ⇧-click connects to the previous stroke with a straight line.
|
||||
pub fn connects(tool: Tool) -> bool {
|
||||
tool.is_brushlike()
|
||||
}
|
||||
|
||||
/// The unit direction from `origin` towards `p`, snapped to the nearest multiple of `step`
|
||||
/// radians; None when the points coincide (or aren't finite).
|
||||
pub fn snapped_dir(origin: [f64; 2], p: [f64; 2], step: f64) -> Option<[f64; 2]> {
|
||||
let (dx, dy) = (p[0] - origin[0], p[1] - origin[1]);
|
||||
if !(dx.is_finite() && dy.is_finite()) || dx.hypot(dy) < 1e-9 || step <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let a = (dy.atan2(dx) / step).round() * step;
|
||||
Some([a.cos(), a.sin()])
|
||||
}
|
||||
|
||||
/// `p` projected onto the line through `origin` along unit `dir`.
|
||||
pub fn project(origin: [f64; 2], dir: [f64; 2], p: [f64; 2]) -> [f64; 2] {
|
||||
let t = (p[0] - origin[0]) * dir[0] + (p[1] - origin[1]) * dir[1];
|
||||
[origin[0] + dir[0] * t, origin[1] + dir[1] * t]
|
||||
}
|
||||
|
||||
/// `p` moved onto the nearest 45° ray from `origin` (the Gradient tool's ⇧).
|
||||
pub fn snap45(origin: [f64; 2], p: [f64; 2]) -> [f64; 2] {
|
||||
match snapped_dir(origin, p, std::f64::consts::FRAC_PI_4) {
|
||||
Some(d) => project(origin, d, p),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrain the pointer at `p` for a drag of `tool` that started at `start` and has reached
|
||||
/// `last`. `axis` carries the stroke's lock between events; `zoom` converts the lock distance
|
||||
/// to document pixels. Without ⇧ the point passes through and the lock is dropped.
|
||||
pub fn constrain(tool: Tool, axis: &mut Option<Axis>, start: [f64; 2], last: [f64; 2], p: [f64; 2], shift: bool, zoom: f32) -> [f64; 2] {
|
||||
if !shift || !applies(tool) {
|
||||
*axis = None;
|
||||
return p;
|
||||
}
|
||||
if tool == Tool::Gradient {
|
||||
return snap45(start, p);
|
||||
}
|
||||
let a = axis.get_or_insert(Axis { origin: last, dir: None });
|
||||
if a.dir.is_none() {
|
||||
let reach = LOCK_DISTANCE / f64::from(zoom.max(1e-3));
|
||||
if (p[0] - a.origin[0]).hypot(p[1] - a.origin[1]) < reach {
|
||||
return a.origin;
|
||||
}
|
||||
a.dir = snapped_dir(a.origin, p, std::f64::consts::FRAC_PI_4);
|
||||
}
|
||||
match a.dir {
|
||||
Some(d) => project(a.origin, d, p),
|
||||
None => a.origin,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use serde_json::json;
|
||||
|
||||
fn close(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_angles() {
|
||||
let o = [10.0, 10.0];
|
||||
// Gradient: 45° increments.
|
||||
assert!(close(snap45(o, [50.0, 13.0]), [50.0, 10.0]));
|
||||
assert!(close(snap45(o, [12.0, -40.0]), [10.0, -40.0]));
|
||||
let d = snap45(o, [40.0, 37.0]);
|
||||
assert!((d[0] - d[1]).abs() < 1e-9 && d[0] > 30.0, "45°: {d:?}");
|
||||
assert!(close(snap45(o, o), o));
|
||||
assert!(snap45(o, [f64::NAN, 1.0])[0].is_nan(), "non-finite input passes through");
|
||||
// Painting: the direction locks once the pointer has moved a few points.
|
||||
let mut axis = None;
|
||||
let p = |q, ax: &mut Option<Axis>| constrain(Tool::Brush, ax, o, o, q, true, 1.0);
|
||||
assert!(close(p([11.0, 11.0], &mut axis), o), "inside the lock distance");
|
||||
assert!(close(p([30.0, 12.0], &mut axis), [30.0, 10.0]), "horizontal");
|
||||
// Locked: wandering towards 45° stays on the horizontal line.
|
||||
assert!(close(p([60.0, 45.0], &mut axis), [60.0, 10.0]));
|
||||
// Releasing ⇧ frees the stroke and drops the lock.
|
||||
assert!(close(constrain(Tool::Brush, &mut axis, o, o, [70.0, 50.0], false, 1.0), [70.0, 50.0]) && axis.is_none());
|
||||
// Vertical and diagonal locks.
|
||||
let mut axis = None;
|
||||
assert!(close(p([9.0, 60.0], &mut axis), [10.0, 60.0]));
|
||||
let mut axis = None;
|
||||
let q = p([40.0, 43.0], &mut axis);
|
||||
assert!((q[0] - 10.0 - (q[1] - 10.0)).abs() < 1e-9);
|
||||
// Other tools pass through.
|
||||
assert!(close(constrain(Tool::Move, &mut None, o, o, [30.0, 12.0], true, 1.0), [30.0, 12.0]));
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 120, "background": "transparent"})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 4, "hardness": 1.0}})).unwrap();
|
||||
app.session.tools.brush.smoothing.amount = 0.0;
|
||||
app
|
||||
}
|
||||
|
||||
fn last_stroke_points(app: &PhotocraftApp) -> Vec<[f64; 2]> {
|
||||
let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).unwrap();
|
||||
p["points"].as_array().unwrap().iter().map(|q| [q[0].as_f64().unwrap(), q[1].as_f64().unwrap()]).collect()
|
||||
}
|
||||
|
||||
fn alpha(app: &PhotocraftApp, x: i32, y: i32) -> f32 {
|
||||
app.session.documents()[0].doc.layers[0].surface().unwrap().rgba(x, y)[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_paints_a_straight_constrained_stroke() {
|
||||
for tool in [Tool::Brush, Tool::Eraser] {
|
||||
let mut app = app();
|
||||
if tool == Tool::Eraser {
|
||||
app.run("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
}
|
||||
app.ui.tool = tool;
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 60.0, pressure: 1.0 }, shift);
|
||||
for (x, y) in [(30.0, 63.0), (50.0, 55.0), (80.0, 68.0), (100.0, 52.0)] {
|
||||
tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, shift);
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 105.0, y: 70.0 }, shift);
|
||||
let pts = last_stroke_points(&app);
|
||||
assert!(pts.len() >= 2 && pts.iter().all(|p| p[1] == 60.0), "{tool:?}: {pts:?}");
|
||||
assert_eq!(pts.last().unwrap()[0], 105.0);
|
||||
// The pixels follow the line: painted (or erased) along y = 60, untouched at y = 68.
|
||||
let on = alpha(&app, 70, 60);
|
||||
let off = alpha(&app, 80, 68);
|
||||
if tool == Tool::Brush {
|
||||
assert!(on > 0.9 && off == 0.0, "{on} {off}");
|
||||
} else {
|
||||
assert!(on < 0.1 && off > 0.9, "{on} {off}");
|
||||
}
|
||||
assert_eq!(app.session.documents()[0].history.past_len(), 1 + usize::from(tool == Tool::Eraser));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_draws_a_line_from_the_last_stroke_in_one_step() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let none = egui::Modifiers::NONE;
|
||||
// A first (free) stroke ending at (20, 20).
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, none);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 20.0, y: 20.0, pressure: 1.0 }, none);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, none);
|
||||
let before = app.session.documents()[0].history.past_len();
|
||||
// ⇧-click at (100, 100): a straight line from (20, 20).
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, shift);
|
||||
assert_eq!(last_stroke_points(&app), vec![[20.0, 20.0], [100.0, 100.0]]);
|
||||
assert_eq!(app.session.documents()[0].history.past_len(), before + 1, "one undo step per line");
|
||||
assert!(alpha(&app, 60, 60) > 0.9 && alpha(&app, 60, 70) == 0.0);
|
||||
// And the next ⇧-click continues from there.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 20.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 20.0 }, shift);
|
||||
assert_eq!(last_stroke_points(&app), vec![[100.0, 100.0], [100.0, 20.0]]);
|
||||
assert!(alpha(&app, 100, 60) > 0.9);
|
||||
// Undo removes just the last line.
|
||||
app.session.undo();
|
||||
assert!(alpha(&app, 100, 60) == 0.0 && alpha(&app, 60, 60) > 0.9);
|
||||
// The line start survives the undo (as in Photoshop). With smoothing on, the line still
|
||||
// runs from (100, 20) exactly to the click.
|
||||
app.session.tools.brush.smoothing.amount = 0.6;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 100.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 100.0 }, shift);
|
||||
assert!(alpha(&app, 20, 100) > 0.9 && alpha(&app, 40, 80) > 0.9 && alpha(&app, 90, 30) > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_without_a_previous_stroke_is_a_dab() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 50.0 }, shift);
|
||||
assert_eq!(last_stroke_points(&app), vec![[50.0, 50.0]]);
|
||||
// A new document doesn't connect to another document's stroke.
|
||||
app.run("file.new", json!({"width": 60, "height": 60, "background": "transparent"})).unwrap();
|
||||
tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 5.0, y: 5.0 }, shift);
|
||||
assert_eq!(last_stroke_points(&app), vec![[5.0, 5.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_gradient_snaps_to_45_degrees() {
|
||||
let mut app = app();
|
||||
app.ui.tool = Tool::Gradient;
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 52.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 85.0 }, shift);
|
||||
let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.gradient").map(|(_, p)| p.clone()).unwrap();
|
||||
let to = [p["to"][0].as_f64().unwrap(), p["to"][1].as_f64().unwrap()];
|
||||
assert!((to[0] - to[1]).abs() < 1e-9 && to[0] > 80.0, "45°: {to:?}");
|
||||
// Horizontal.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, shift);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 22.0 }, shift);
|
||||
let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.gradient").map(|(_, p)| p.clone()).unwrap();
|
||||
assert_eq!(p["to"], json!([90.0, 10.0]));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,68 +290,9 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
stack.extend(fallback.iter().cloned());
|
||||
fonts.families.insert(FontFamily::Name(fam.into()), stack);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
add_cjk_fallback(&mut fonts, &cjk_font_candidates());
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Name under which the OS Japanese/CJK font is registered.
|
||||
const CJK_FONT: &str = "system-cjk";
|
||||
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
|
||||
const CJK_MAX_BYTES: u64 = 128 << 20;
|
||||
|
||||
/// OS-installed fonts with Japanese coverage, most preferred first: (path, face index in a collection).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cjk_font_candidates() -> Vec<(std::path::PathBuf, u32)> {
|
||||
use std::path::PathBuf;
|
||||
let list: &[(&str, u32)] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", 0),
|
||||
("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", 0),
|
||||
("/Library/Fonts/Arial Unicode.ttf", 0),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||
]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&[
|
||||
("C:\\Windows\\Fonts\\YuGothM.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\YuGothR.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\meiryo.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\msgothic.ttc", 0),
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0),
|
||||
("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 0),
|
||||
("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", 0),
|
||||
("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 0),
|
||||
("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", 0),
|
||||
("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 0),
|
||||
("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", 0),
|
||||
]
|
||||
};
|
||||
list.iter().map(|(p, i)| (PathBuf::from(p), *i)).collect()
|
||||
}
|
||||
|
||||
/// Appends the first readable font from `candidates` to the end of every family's stack, so
|
||||
/// Latin text keeps using Inter / JetBrains Mono and only missing glyphs (Japanese) fall through.
|
||||
/// Without it egui draws CJK as tofu. Returns whether a font was added; none found is not an error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn add_cjk_fallback(fonts: &mut FontDefinitions, candidates: &[(std::path::PathBuf, u32)]) -> bool {
|
||||
for (path, index) in candidates {
|
||||
let Ok(meta) = std::fs::metadata(path) else { continue };
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > CJK_MAX_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { continue };
|
||||
let mut data = FontData::from_owned(bytes);
|
||||
data.index = *index;
|
||||
fonts.font_data.insert(CJK_FONT.to_owned(), Arc::new(data));
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push(CJK_FONT.to_owned());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
// Japanese / Chinese / Korean system fonts are registered on demand (cjk_fonts.rs).
|
||||
crate::cjk_fonts::install(ctx);
|
||||
}
|
||||
|
||||
pub fn medium(size: f32) -> FontId {
|
||||
@@ -434,6 +375,10 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Vertical gap between stacked control rows in panels (Properties fields, the Layers panel's
|
||||
/// Opacity and Fill rows); docks zero egui's item spacing, so rows add this themselves.
|
||||
pub const ROW_GAP: f32 = 4.0;
|
||||
|
||||
pub fn canvas_bg(t: &Tokens) -> Color32 {
|
||||
t.canvas
|
||||
}
|
||||
@@ -596,32 +541,3 @@ pub mod live {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod cjk_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fallback_is_appended_to_every_family_and_missing_files_are_skipped() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let font = dir.join("f.ttf");
|
||||
std::fs::write(&font, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||||
let empty = dir.join("empty.ttf");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let before: Vec<usize> = fonts.families.values().map(Vec::len).collect();
|
||||
assert!(add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0), (empty, 0), (dir.join("nope"), 0), (font, 0)]));
|
||||
assert!(fonts.font_data.contains_key(CJK_FONT));
|
||||
for (stack, n) in fonts.families.values().zip(before) {
|
||||
assert_eq!(stack.len(), n + 1);
|
||||
assert_eq!(stack.last().map(String::as_str), Some(CJK_FONT), "must be last so Latin keeps its own font");
|
||||
}
|
||||
// Nothing usable: untouched.
|
||||
let mut fonts = FontDefinitions::default();
|
||||
assert!(!add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0)]));
|
||||
assert!(!fonts.font_data.contains_key(CJK_FONT));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! On-canvas tool feedback: the selection tools' intent badge (#170) and outlines that stay
|
||||
//! visible on any image (#172).
|
||||
//!
|
||||
//! Photoshop marks the selection-tool cursor with a small `+` (add), `−` (subtract) or `×`
|
||||
//! (intersect) reflecting the effective mode: the options bar's mode, overridden by the held
|
||||
//! modifiers. Selection outlines and drag previews are drawn as black-and-white dashes (marching
|
||||
//! ants) so they read on light and dark pixels alike.
|
||||
|
||||
use egui::{Color32, Pos2, Shape, Stroke, pos2, vec2};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// The intent a selection tool's next gesture has, shown as a cursor badge (none for New).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Badge {
|
||||
Add,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
/// The badge for an engine selection mode name (`replace` has none).
|
||||
pub fn from_mode(mode: &str) -> Option<Badge> {
|
||||
match mode {
|
||||
"add" => Some(Badge::Add),
|
||||
"subtract" => Some(Badge::Subtract),
|
||||
"intersect" => Some(Badge::Intersect),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools whose cursor carries the selection-mode badge.
|
||||
pub fn is_selection_tool(tool: Tool) -> bool {
|
||||
matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand | Tool::QuickSelection | Tool::ObjectSelection)
|
||||
}
|
||||
|
||||
/// The effective selection mode of `tool` for the options-bar mode `bar` (0 New, 1 Add,
|
||||
/// 2 Subtract, 3 Intersect) and the held modifiers: exactly what the tool's gesture will use.
|
||||
pub fn selection_mode(tool: Tool, bar: u8, m: egui::Modifiers) -> &'static str {
|
||||
match tool {
|
||||
// Quick Selection always adds (⌥ subtracts), like Photoshop's after the first stroke.
|
||||
Tool::QuickSelection => {
|
||||
if m.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
"add"
|
||||
}
|
||||
}
|
||||
// Object Selection: ⌥ subtracts, ⇧ adds (see `retouch_ui::finish_object_selection`).
|
||||
Tool::ObjectSelection => {
|
||||
if m.alt {
|
||||
"subtract"
|
||||
} else if m.shift {
|
||||
"add"
|
||||
} else {
|
||||
"replace"
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if m.shift && m.alt {
|
||||
"intersect"
|
||||
} else if m.shift {
|
||||
"add"
|
||||
} else if m.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
["replace", "add", "subtract", "intersect"][bar.min(3) as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The badge the cursor of `tool` shows with modifiers `m` (None for non-selection tools or New).
|
||||
pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option<Badge> {
|
||||
if !is_selection_tool(tool) {
|
||||
return None;
|
||||
}
|
||||
Badge::from_mode(selection_mode(tool, app.ui.selection_mode, m))
|
||||
}
|
||||
|
||||
/// The badge's glyph as line segments around `c` (half-size `h`).
|
||||
pub fn badge_glyph(b: Badge, c: Pos2, h: f32) -> Vec<[Pos2; 2]> {
|
||||
match b {
|
||||
Badge::Add => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]],
|
||||
Badge::Subtract => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)]],
|
||||
Badge::Intersect => {
|
||||
let d = h * 0.8;
|
||||
vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the badge next to the cursor hotspot `p` (Photoshop puts it below-right of the
|
||||
/// crosshair; Quick Selection shows it inside the brush circle, at the centre).
|
||||
pub fn draw_badge(painter: &egui::Painter, p: Pos2, b: Badge, centred: bool) {
|
||||
// Right of the system arrow cursor: at 1× the arrow covers (11, 11), so the badge sat under it.
|
||||
let c = if centred { p } else { p + vec2(30.0, 11.0) };
|
||||
// White glyph with a dark halo: readable on any pixels.
|
||||
for (w, col) in [(3.0, Color32::from_black_alpha(200)), (1.2, Color32::WHITE)] {
|
||||
for seg in badge_glyph(b, c, 3.5) {
|
||||
painter.line_segment(seg, Stroke::new(w, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dash length of the marching ants, in screen points.
|
||||
pub const DASH: f32 = 4.0;
|
||||
|
||||
/// Marching ants along a polyline: a solid white line under black dashes, the dashes shifted by
|
||||
/// `phase` (animate it with time). Visible on any image, like Photoshop's selection edges.
|
||||
pub fn ants(points: &[Pos2], closed: bool, phase: f32) -> Vec<Shape> {
|
||||
let Some(&first) = points.first().filter(|_| points.len() >= 2) else { return Vec::new() };
|
||||
let mut pts = points.to_vec();
|
||||
if closed {
|
||||
pts.push(first);
|
||||
}
|
||||
let mut out = vec![Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))];
|
||||
let black = Stroke::new(1.0, Color32::BLACK);
|
||||
// Arc length so dashes run continuously across vertices.
|
||||
let mut along = -phase.rem_euclid(DASH * 2.0);
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let len = a.distance(b);
|
||||
if len < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let dir = (b - a) / len;
|
||||
// Position of this segment's start within the dash period.
|
||||
let mut t = -(along.rem_euclid(DASH * 2.0));
|
||||
while t < len {
|
||||
let (s0, s1) = (t.max(0.0), (t + DASH).min(len));
|
||||
if s1 > s0 {
|
||||
out.push(Shape::line_segment([a + dir * s0, a + dir * s1], black));
|
||||
}
|
||||
t += DASH * 2.0;
|
||||
}
|
||||
along += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Marching-ants phase for the frame time `time` (s), and keep the animation running.
|
||||
pub fn ants_phase(ctx: &egui::Context) -> f32 {
|
||||
let time = ctx.input(|i| i.time);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
((time * 10.0) % (DASH as f64 * 2.0)) as f32
|
||||
}
|
||||
|
||||
/// Draw animated marching ants along `points`.
|
||||
pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) {
|
||||
let phase = ants_phase(painter.ctx());
|
||||
painter.extend(ants(points, closed, phase));
|
||||
}
|
||||
|
||||
/// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth).
|
||||
pub fn ellipse_points(r: egui::Rect) -> Vec<Pos2> {
|
||||
let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0);
|
||||
let n = ((a.abs() + b.abs()) * 0.5).clamp(16.0, 256.0) as usize;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = i as f32 / n as f32 * std::f32::consts::TAU;
|
||||
pos2(c.x + a * t.cos(), c.y + b * t.sin())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A path preview in `color` with a contrasting dark halo, so a blue shape outline still shows
|
||||
/// on blue (or any) pixels: the shape tools' drag preview.
|
||||
pub fn contrast_path(points: Vec<Pos2>, closed: bool, color: Color32) -> Vec<Shape> {
|
||||
let make = |pts: Vec<Pos2>, s: Stroke| if closed { Shape::closed_line(pts, s) } else { Shape::line(pts, s) };
|
||||
vec![make(points.clone(), Stroke::new(3.0, Color32::from_black_alpha(150))), make(points, Stroke::new(1.0, color))]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strokes(shapes: &[Shape]) -> Vec<Color32> {
|
||||
shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::LineSegment { stroke, .. } => Some(stroke.color),
|
||||
Shape::Path(p) => match p.stroke.color {
|
||||
egui::epaint::ColorMode::Solid(c) => Some(c),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_follows_modifiers_and_the_options_bar() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let none = egui::Modifiers::NONE;
|
||||
let (shift, alt) = (egui::Modifiers::SHIFT, egui::Modifiers::ALT);
|
||||
let both = egui::Modifiers { shift: true, alt: true, ..Default::default() };
|
||||
for t in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::Lasso, Tool::PolygonLasso, Tool::MagicWand] {
|
||||
assert_eq!(badge(&app, t, none), None, "{t:?}: New selection has no badge");
|
||||
assert_eq!(badge(&app, t, shift), Some(Badge::Add));
|
||||
assert_eq!(badge(&app, t, alt), Some(Badge::Subtract));
|
||||
assert_eq!(badge(&app, t, both), Some(Badge::Intersect));
|
||||
}
|
||||
// The options bar's mode shows without any key held; keys still override it.
|
||||
app.ui.selection_mode = 2;
|
||||
assert_eq!(badge(&app, Tool::Lasso, none), Some(Badge::Subtract));
|
||||
assert_eq!(badge(&app, Tool::Lasso, shift), Some(Badge::Add));
|
||||
app.ui.selection_mode = 3;
|
||||
assert_eq!(badge(&app, Tool::RectMarquee, none), Some(Badge::Intersect));
|
||||
// Smart selection tools.
|
||||
assert_eq!(badge(&app, Tool::QuickSelection, none), Some(Badge::Add));
|
||||
assert_eq!(badge(&app, Tool::QuickSelection, alt), Some(Badge::Subtract));
|
||||
assert_eq!(badge(&app, Tool::ObjectSelection, none), None);
|
||||
assert_eq!(badge(&app, Tool::ObjectSelection, shift), Some(Badge::Add));
|
||||
// Other tools never show one.
|
||||
for t in [Tool::Brush, Tool::Move, Tool::Zoom, Tool::Crop] {
|
||||
assert_eq!(badge(&app, t, shift), None);
|
||||
}
|
||||
// Distinct glyphs: + has two strokes, − one, × two diagonals.
|
||||
let c = pos2(10.0, 10.0);
|
||||
assert_eq!(badge_glyph(Badge::Add, c, 3.0).len(), 2);
|
||||
assert_eq!(badge_glyph(Badge::Subtract, c, 3.0).len(), 1);
|
||||
assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ants_use_two_contrasting_colours_and_march() {
|
||||
let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)];
|
||||
let a = ants(&pts, true, 0.0);
|
||||
let cols = strokes(&a);
|
||||
assert!(cols.contains(&Color32::WHITE) && cols.contains(&Color32::BLACK), "{cols:?}");
|
||||
// Roughly half the length is black dashes: (40 + 30 + 50) / 8 ≈ 15 dashes.
|
||||
let dashes = cols.iter().filter(|c| **c == Color32::BLACK).count();
|
||||
assert!((13..=18).contains(&dashes), "{dashes}");
|
||||
// The phase moves the dashes (the ants march).
|
||||
let first = |s: &[Shape]| {
|
||||
s.iter().find_map(|s| match s {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == Color32::BLACK => Some(points[0]),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_ne!(first(&a), first(&ants(&pts, true, 2.0)));
|
||||
assert!(ants(&pts[..1], false, 0.0).is_empty());
|
||||
// Shape previews: the accent path over a dark halo.
|
||||
let s = contrast_path(pts.to_vec(), true, Color32::from_rgb(0, 120, 255));
|
||||
let cols = strokes(&s);
|
||||
assert_eq!(cols.len(), 2);
|
||||
assert!(cols[0].r() < 10 && cols[0].a() > 100 && cols[1] == Color32::from_rgb(0, 120, 255));
|
||||
// Ellipses close smoothly.
|
||||
let e = ellipse_points(egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)));
|
||||
assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+285
-167
@@ -565,10 +565,10 @@ pub fn styles(family: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Searchable font-family combo box.
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String) -> bool {
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32) -> bool {
|
||||
let mut changed = false;
|
||||
let search_id = ui.id().with("font-search");
|
||||
egui::ComboBox::from_id_salt("type-font").selected_text(current.as_str()).width(170.0).height(460.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
egui::ComboBox::from_id_salt("type-font").selected_text(current.as_str()).width(width).height(460.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(200.0));
|
||||
if !r.has_focus() && q.is_empty() {
|
||||
@@ -649,7 +649,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
None => (o.type_font.clone(), o.type_style.clone(), o.type_size),
|
||||
};
|
||||
let _ = crate::icons::button(ui, "text-cursor", 24.0, false, "Toggle text orientation");
|
||||
if font_picker(ui, &mut fam) {
|
||||
if font_picker(ui, &mut fam, 170.0) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
@@ -757,7 +757,7 @@ fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, ph
|
||||
|
||||
fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, r, icon, 12.0, t.text_dim);
|
||||
resp.on_hover_text(tip);
|
||||
}
|
||||
@@ -801,96 +801,156 @@ 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).
|
||||
/// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`.
|
||||
fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(w, 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, t.radius_sm, bg);
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
paint(ui, r, if on { t.text } else { t.text_dim });
|
||||
let tip = tip.to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
/// Width of each of `n` equal cells filling `avail` with 2 pt gaps.
|
||||
fn cell_width(avail: f32, n: usize) -> f32 {
|
||||
let n = n.max(1) as f32;
|
||||
((avail - (n - 1.0) * 2.0) / n).floor().max(20.0)
|
||||
}
|
||||
|
||||
/// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155).
|
||||
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
type_sections(app, ui, true, true);
|
||||
type_options(app, ui);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// The Character and/or Paragraph sections, with the shared collapsible headers and fields that
|
||||
/// fill the panel width (#155).
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
|
||||
use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section};
|
||||
use crate::theme::ROW_GAP;
|
||||
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| {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(title).font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
// Row helper: `ui.horizontal` with the column gap, then the shared row gap.
|
||||
let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
add(ui)
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
};
|
||||
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", "Character") {
|
||||
let full = ui.available_width();
|
||||
let w = field_width(full, 2, LABEL_W);
|
||||
let mut fam = c.font_family.clone();
|
||||
row(ui, &mut |ui| {
|
||||
if font_picker(ui, &mut fam, full) {
|
||||
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;
|
||||
row(ui, &mut |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, full) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |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}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |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();
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
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}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |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}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |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: label + a swatch filling the rest of the second column.
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
let g = ui.painter().layout_no_wrap("Color".into(), egui::FontId::proportional(12.0), t.text_dim);
|
||||
let lw = g.size().x.max(LABEL_W);
|
||||
let (lr, _) = ui.allocate_exact_size(egui::vec2(lw, 22.0), egui::Sense::hover());
|
||||
ui.painter().galley(egui::pos2(lr.left(), lr.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let sw = (LABEL_W + LABEL_GAP + w - lw - LABEL_GAP).max(24.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(sw, 22.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text("Text color");
|
||||
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: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
@@ -910,45 +970,43 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
("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);
|
||||
let cw = cell_width(full, toggles.len());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let clicked = toggle_cell(ui, cw, on, tip, |ui, r, col| {
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
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 clicked {
|
||||
apply(app, ui.ctx(), props);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
section(ui, "Paragraph");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
if paragraph && section(ui, "paragraph", "Paragraph") {
|
||||
use photocraft_doc::text::TextAlign as A;
|
||||
let full = ui.available_width();
|
||||
let w = field_width(full, 2, LABEL_W);
|
||||
let items = [
|
||||
("left", "Left align text", A::Left),
|
||||
("center", "Center text", A::Center),
|
||||
@@ -958,53 +1016,113 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
("justifyRight", "Justify last right", A::JustifyRight),
|
||||
("justifyAll", "Justify all", A::JustifyAll),
|
||||
];
|
||||
for (key, tip, a) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 24.0), egui::Sense::click());
|
||||
let on = para.align == a;
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
},
|
||||
);
|
||||
if on {
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside);
|
||||
let cw = cell_width(full, items.len());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, a) in items {
|
||||
let clicked = toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| {
|
||||
// The glyph keeps its size; the cell grows around it.
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0));
|
||||
align_glyph(ui.painter(), g, a, if para.align == a { col } else { t.icon });
|
||||
});
|
||||
if clicked {
|
||||
apply(app, ui.ctx(), json!({"align": key}));
|
||||
}
|
||||
}
|
||||
align_glyph(ui.painter(), r.shrink2(egui::vec2(7.0, 7.0)), a, if on { t.text } else { t.icon });
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), json!({"align": key}));
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"startIndent": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"endIndent": v}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"firstLineIndent": v}));
|
||||
}
|
||||
});
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceBefore": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceAfter": v}));
|
||||
}
|
||||
});
|
||||
let mut hy = para.hyphenate;
|
||||
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
|
||||
apply(app, ui.ctx(), json!({"hyphenate": hy}));
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type Options: anti-aliasing and orientation of the whole layer (the Type menu's commands).
|
||||
fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
use crate::props_layout::{COL_GAP, LABEL_GAP};
|
||||
let Some((layer, _)) = target(app) else { return };
|
||||
let Some((aa, vertical)) = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|st| text_layer(&st.doc, LayerId(layer)))
|
||||
.map(|t| (photocraft_engine::type_extra_cmds::aa_name(t.antialias).to_string(), t.orientation == photocraft_doc::text::Orientation::Vertical))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !crate::props_layout::section(ui, "typeOptions", "Type Options") {
|
||||
return;
|
||||
}
|
||||
let full = ui.available_width();
|
||||
let mut run: Option<String> = None;
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"startIndent": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"endIndent": v}));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = COL_GAP;
|
||||
// Anti-aliasing dropdown (aₐ glyph label), then the two orientation toggles.
|
||||
let orient_w = 2.0 * 28.0 + 2.0;
|
||||
let dd_w = (full - crate::props_layout::LABEL_W - LABEL_GAP - COL_GAP - orient_w).max(60.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "aa", crate::theme::semibold(11.0), t.text_dim);
|
||||
resp.on_hover_text("Anti-aliasing method");
|
||||
let mut cur = aa.clone();
|
||||
let opts: Vec<(String, &str)> = ["none", "sharp", "crisp", "strong", "smooth", "windowsLcd", "windows"]
|
||||
.into_iter()
|
||||
.filter_map(|k| photocraft_engine::commands::find(&format!("type.antiAlias.{k}")).map(|c| (k.to_string(), c.label)))
|
||||
.collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-aa", &mut cur, &opts, dd_w) {
|
||||
run = Some(format!("type.antiAlias.{cur}"));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, on) in [("horizontal", "Horizontal type", !vertical), ("vertical", "Vertical type", vertical)] {
|
||||
let clicked = toggle_cell(ui, 28.0, on, tip, |ui, r, col| {
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 12.0));
|
||||
let s = Stroke::new(1.3, col);
|
||||
if key == "horizontal" {
|
||||
ui.painter().line_segment([g.left_top(), g.right_top()], s);
|
||||
ui.painter().line_segment([g.center_top(), g.center_bottom()], s);
|
||||
} else {
|
||||
ui.painter().line_segment([g.left_top(), g.left_bottom()], s);
|
||||
ui.painter().line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
});
|
||||
if clicked && !on {
|
||||
run = Some(format!("type.orientation.{key}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"firstLineIndent": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceBefore": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"spaceAfter": v}));
|
||||
}
|
||||
});
|
||||
let mut hy = para.hyphenate;
|
||||
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
|
||||
apply(app, ui.ctx(), json!({"hyphenate": hy}));
|
||||
ui.add_space(crate::theme::ROW_GAP);
|
||||
if let Some(id) = run
|
||||
&& let Err(e) = app.run(&id, json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+151
-69
@@ -110,7 +110,8 @@ pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) {
|
||||
// Every tool's own shape is convex (custom shapes aren't, and draw no fill).
|
||||
painter.add(egui::Shape::convex_polygon(pts.clone(), fill, stroke));
|
||||
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, accent)));
|
||||
// The accent path over a dark halo: visible on any pixels (#172).
|
||||
painter.extend(crate::tool_feedback::contrast_path(pts, true, accent));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,42 +446,43 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut edit: Option<Value> = None;
|
||||
let key = |k: &str| format!("shape-{}-{k}", id.0);
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Appearance").font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Fill").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.fill.as_ref(), "Set shape fill type") {
|
||||
edit = Some(if c == "none" { json!({"fill": null}) } else { json!({"fill": c, "coalesce": key("fill")}) });
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Stroke").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), "Set shape stroke type") {
|
||||
edit = Some(if c == "none" {
|
||||
json!({"stroke": null})
|
||||
} else {
|
||||
json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")})
|
||||
});
|
||||
}
|
||||
let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width);
|
||||
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
|
||||
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
|
||||
}
|
||||
if sh.stroke.is_some() {
|
||||
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
|
||||
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
|
||||
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
|
||||
_ => "center",
|
||||
// The shared collapsible section headers (#155).
|
||||
if crate::props_layout::section(ui, "appearance", "Appearance") {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Fill").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.fill.as_ref(), "Set shape fill type") {
|
||||
edit = Some(if c == "none" { json!({"fill": null}) } else { json!({"fill": c, "coalesce": key("fill")}) });
|
||||
}
|
||||
.to_string();
|
||||
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
|
||||
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
|
||||
edit = Some(json!({"stroke": {"align": align}}));
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Stroke").color(t.text_dim).size(12.0));
|
||||
if let Some(c) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), "Set shape stroke type") {
|
||||
edit = Some(if c == "none" {
|
||||
json!({"stroke": null})
|
||||
} else {
|
||||
json!({"stroke": {"color": c, "width": sh.stroke.as_ref().map_or(3.0, |s| s.width)}, "coalesce": key("stroke")})
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(live) = &sh.live {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Shape").font(crate::theme::semibold(12.0)).color(t.text));
|
||||
let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width);
|
||||
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
|
||||
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
|
||||
}
|
||||
if sh.stroke.is_some() {
|
||||
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
|
||||
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
|
||||
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
|
||||
_ => "center",
|
||||
}
|
||||
.to_string();
|
||||
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
|
||||
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
|
||||
edit = Some(json!({"stroke": {"align": align}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(live) = &sh.live
|
||||
&& crate::props_layout::section(ui, "liveShape", "Shape")
|
||||
{
|
||||
let num = |ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str| -> bool {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(12.0));
|
||||
crate::widgets::value_field(ui, v, range, unit, 64.0).changed()
|
||||
@@ -540,6 +542,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
|
||||
fn thumb(ui: &egui::Ui, r: Rect, path: &Path, doc: &Document) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// At the document's aspect ratio, like the Channels thumbnails.
|
||||
let r = crate::channels_panel::fit_thumb(r, doc.size.width, doc.size.height).0;
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_gray(if t.pro { 222 } else { 240 }));
|
||||
let (w, h) = (doc.size.width.max(1) as f64, doc.size.height.max(1) as f64);
|
||||
let s = (r.width() as f64 / w).min(r.height() as f64 / h);
|
||||
@@ -554,6 +558,55 @@ fn thumb(ui: &egui::Ui, r: Rect, path: &Path, doc: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Paths panel row is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PathRow {
|
||||
Saved,
|
||||
Work,
|
||||
/// The selected layer's shape path or vector mask (temporary, italic).
|
||||
Layer,
|
||||
}
|
||||
|
||||
/// A Paths panel row.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathEntry {
|
||||
pub name: String,
|
||||
pub path: Path,
|
||||
pub kind: PathRow,
|
||||
}
|
||||
|
||||
/// The Paths panel's rows, top to bottom: saved paths, the work path, then the selected layer's
|
||||
/// shape path ("<Layer> Shape Path") or vector mask ("<Layer> Vector Mask"), like Photoshop.
|
||||
pub fn path_rows(doc: &Document, active: Option<photocraft_doc::LayerId>) -> Vec<PathEntry> {
|
||||
let mut rows: Vec<_> = doc.paths.iter().map(|p| PathEntry { name: p.name.clone(), path: p.path.clone(), kind: PathRow::Saved }).collect();
|
||||
if let Some(wp) = &doc.work_path {
|
||||
rows.push(PathEntry { name: "Work Path".into(), path: wp.clone(), kind: PathRow::Work });
|
||||
}
|
||||
if let Some(l) = active.and_then(|id| doc.layer(id)) {
|
||||
let layer_path = match &l.content {
|
||||
LayerContent::Shape(sh) => Some((format!("{} Shape Path", l.name), sh.path.clone())),
|
||||
_ => l.vector_mask.as_ref().map(|v| (format!("{} Vector Mask", l.name), v.path.clone())),
|
||||
};
|
||||
if let Some((name, path)) = layer_path {
|
||||
rows.push(PathEntry { name, path, kind: PathRow::Layer });
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn footer_id() -> egui::Id {
|
||||
egui::Id::new("paths-footer")
|
||||
}
|
||||
|
||||
fn ctx_data_footer(ctx: &egui::Context, r: Rect) {
|
||||
ctx.data_mut(|d| d.insert_temp(footer_id(), r));
|
||||
}
|
||||
|
||||
/// Where the Paths panel's button footer was drawn last frame.
|
||||
pub fn paths_footer(ctx: &egui::Context) -> Option<Rect> {
|
||||
ctx.data(|d| d.get_temp(footer_id()))
|
||||
}
|
||||
|
||||
pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
@@ -561,51 +614,79 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
};
|
||||
let doc = st.doc.clone();
|
||||
let mut rows: Vec<(String, Path, bool)> = doc.paths.iter().map(|p| (p.name.clone(), p.path.clone(), false)).collect();
|
||||
if let Some(wp) = &doc.work_path {
|
||||
rows.push(("Work Path".into(), wp.clone(), true));
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.label(egui::RichText::new("Draw with the Pen tool (P) or make a work path from a selection.").color(t.text_faint).size(11.5));
|
||||
}
|
||||
let rows = path_rows(&doc, st.active_layer);
|
||||
let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer);
|
||||
let mut action: Option<(&str, Value)> = None;
|
||||
for (name, path, work) in &rows {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
|
||||
let key = if *work { "work".to_string() } else { name.clone() };
|
||||
let sel = app.ui.selected_path.as_deref() == Some(key.as_str());
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
thumb(ui, Rect::from_min_size(pos2(r.left() + 8.0, r.top() + 4.0), vec2(28.0, 28.0)), path, &doc);
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.append(name, 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics: *work, ..Default::default() });
|
||||
let g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.selected_path = Some(key.clone());
|
||||
}
|
||||
// Double-clicking the Work Path saves it (Photoshop's "Save Path").
|
||||
if resp.double_clicked() && *work {
|
||||
let n = doc.paths.len() + 1;
|
||||
action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")})));
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
|
||||
let footer = 34.0;
|
||||
let fill = ui.available_height() > footer + 60.0;
|
||||
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
|
||||
egui::ScrollArea::vertical().id_salt("path-rows").max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(
|
||||
ui,
|
||||
|ui| {
|
||||
if rows.is_empty() {
|
||||
ui.label(egui::RichText::new("Draw with the Pen tool (P) or make a work path from a selection.").color(t.text_faint).size(11.5));
|
||||
}
|
||||
for PathEntry { name, path, kind } in &rows {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
|
||||
let key = match kind {
|
||||
PathRow::Work => "work".to_string(),
|
||||
PathRow::Layer => "layer".to_string(),
|
||||
PathRow::Saved => name.clone(),
|
||||
};
|
||||
// Rows are painted: name them for screen readers and UI tests.
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, true, name));
|
||||
let sel = app.ui.selected_path.as_deref() == Some(key.as_str());
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
thumb(ui, Rect::from_min_size(pos2(r.left() + 8.0, r.top() + 4.0), vec2(28.0, 28.0)), path, &doc);
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
// Temporary paths (the work path, the selected layer's shape path or vector mask) are italic.
|
||||
let italics = *kind != PathRow::Saved;
|
||||
job.append(name, 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() });
|
||||
let g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.selected_path = Some(key.clone());
|
||||
}
|
||||
// Double-clicking the Work Path saves it (Photoshop's "Save Path").
|
||||
if resp.double_clicked() && *kind == PathRow::Work {
|
||||
let n = doc.paths.len() + 1;
|
||||
action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")})));
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let sel = app.ui.selected_path.clone().unwrap_or_else(|| "work".into());
|
||||
ui.horizontal(|ui| {
|
||||
crate::widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
let sel = app.ui.selected_path.clone().filter(|s| s != "layer" || has_layer_path).unwrap_or_else(|| "work".into());
|
||||
let footer_rect = ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let fg = hex(app.session.tools.foreground);
|
||||
let items: [(&str, &str, &str, Value); 6] = [
|
||||
let mut n = doc.paths.len() + 1;
|
||||
while doc.paths.iter().any(|p| p.name == format!("Path {n}")) {
|
||||
n += 1;
|
||||
}
|
||||
let items: [(&str, &str, &str, Value); 7] = [
|
||||
("paint-bucket", "Fill path with foreground color", "path.fill", json!({"name": sel, "color": fg})),
|
||||
("circle", "Stroke path with brush", "path.stroke", json!({"name": sel, "tool": "brush"})),
|
||||
("square-dashed", "Load path as a selection", "path.toSelection", json!({"name": sel})),
|
||||
("spline", "Make work path from selection", "select.toWorkPath", json!({"tolerance": 2.0})),
|
||||
("square", "Add vector mask", "layer.vectorMask.fromPath", json!({"name": sel})),
|
||||
("square-plus", "Create new path", "path.set", json!({"name": format!("Path {n}"), "path": {"subpaths": []}})),
|
||||
("trash", "Delete path", "path.delete", json!({"name": sel})),
|
||||
];
|
||||
// The layer's own path: deleting it deletes the vector mask (a shape keeps its path).
|
||||
let shape = rows.iter().any(|r| r.kind == PathRow::Layer && r.name.ends_with(" Shape Path"));
|
||||
let delete_layer_path = sel == "layer" && !shape;
|
||||
let items = items.map(|(icon, tip, cmd, p)| {
|
||||
if cmd == "path.delete" && delete_layer_path { (icon, "Delete vector mask", "layer.vectorMask.delete", json!({})) } else { (icon, tip, cmd, p) }
|
||||
});
|
||||
for (icon, tip, cmd, p) in items {
|
||||
let icon = if crate::icons::exists(icon) { icon } else { "square" };
|
||||
if crate::icons::button(ui, icon, 24.0, false, tip).clicked() {
|
||||
@@ -613,6 +694,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx_data_footer(ui.ctx(), footer_rect.response.rect);
|
||||
if let Some((cmd, p)) = action
|
||||
&& let Err(e) = app.run(cmd, p)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Zoom tool drags (#171).
|
||||
//!
|
||||
//! With Scrubby Zoom on (the default, as in Photoshop) press-and-drag zooms continuously: right
|
||||
//! zooms in, left zooms out, anchored at the point where the button went down. With it off, the
|
||||
//! drag draws a rectangle and the release zooms to fit it. A click (no drag) still steps the zoom
|
||||
//! in, or out with ⌥ (handled by the canvas).
|
||||
|
||||
use egui::{Pos2, Rect, vec2};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ViewXform;
|
||||
use crate::paint_mouse::Buttons;
|
||||
use crate::state::View;
|
||||
|
||||
/// Zoom limits of the canvas (1 % … 6400 %).
|
||||
pub const MIN_ZOOM: f32 = 0.01;
|
||||
pub const MAX_ZOOM: f32 = 64.0;
|
||||
/// Horizontal drag (screen points) that doubles, or halves, the zoom.
|
||||
pub const DOUBLING: f32 = 100.0;
|
||||
|
||||
/// A Zoom-tool drag in progress.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ZoomDrag {
|
||||
/// Screen point where the button went down.
|
||||
anchor: Pos2,
|
||||
/// The document point under it when the drag began (it stays under the anchor).
|
||||
doc: [f64; 2],
|
||||
zoom0: f32,
|
||||
/// Scrubby Zoom off: a zoom rectangle, its current corner.
|
||||
rect_to: Option<Pos2>,
|
||||
}
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("pc-zoom-tool-drag")
|
||||
}
|
||||
|
||||
/// The scrubby zoom for a horizontal drag of `dx` points from a press at zoom `zoom0`.
|
||||
pub fn scrub_zoom(zoom0: f32, dx: f32) -> f32 {
|
||||
// ±40 doublings already span far more than the zoom range; it keeps the power finite.
|
||||
let e = if dx.is_finite() { (dx / DOUBLING).clamp(-40.0, 40.0) } else { 0.0 };
|
||||
let z = zoom0 * e.exp2();
|
||||
if z.is_finite() { z.clamp(MIN_ZOOM, MAX_ZOOM) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`.
|
||||
pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32) {
|
||||
view.zoom = zoom;
|
||||
let d = (screen - xf.rect.center()) / zoom;
|
||||
let dx = if xf.flip { -d.x } else { d.x };
|
||||
view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y];
|
||||
}
|
||||
|
||||
/// Route the canvas buttons for the Zoom tool. Returns true when it consumed the drag (the
|
||||
/// canvas then handles only the click).
|
||||
pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, pointer: Option<Pos2>) -> bool {
|
||||
let scrubby = app.ui.tool_options.zoom_scrubby;
|
||||
if b.started {
|
||||
let anchor = ctx.input(|i| i.pointer.press_origin()).filter(|p| xf.rect.contains(*p)).or(pointer);
|
||||
if let Some(anchor) = anchor {
|
||||
let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor) };
|
||||
ctx.data_mut(|d| d.insert_temp(id(), z));
|
||||
}
|
||||
}
|
||||
let Some(mut z) = ctx.data(|d| d.get_temp::<ZoomDrag>(id())) else { return b.started || b.dragged || b.stopped };
|
||||
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
|
||||
match z.rect_to.as_mut() {
|
||||
Some(to) => *to = p,
|
||||
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x)),
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(id(), z));
|
||||
}
|
||||
if b.stopped {
|
||||
ctx.data_mut(|d| d.remove::<ZoomDrag>(id()));
|
||||
if let Some(to) = z.rect_to {
|
||||
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom
|
||||
/// at the press point; ⌥ steps out).
|
||||
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) {
|
||||
if out || r.width() < 4.0 || r.height() < 4.0 {
|
||||
let doc = xf.to_doc(anchor);
|
||||
anchor_view(view, xf, anchor, doc, crate::canvas::zoom_step(view.zoom, if out { -1 } else { 1 }).clamp(MIN_ZOOM, MAX_ZOOM));
|
||||
return;
|
||||
}
|
||||
let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height());
|
||||
let zoom = (view.zoom * k).clamp(MIN_ZOOM, MAX_ZOOM);
|
||||
let c = xf.to_doc(r.center());
|
||||
anchor_view(view, xf, xf.rect.center(), c, zoom);
|
||||
}
|
||||
|
||||
/// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants.
|
||||
pub fn draw(ctx: &egui::Context, painter: &egui::Painter) {
|
||||
let Some(z) = ctx.data(|d| d.get_temp::<ZoomDrag>(id())) else { return };
|
||||
let Some(to) = z.rect_to else { return };
|
||||
let r = Rect::from_two_pos(z.anchor, to);
|
||||
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
|
||||
crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, PointerButton};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn scrub_zoom_is_continuous_and_clamped() {
|
||||
assert_eq!(scrub_zoom(1.0, 0.0), 1.0);
|
||||
assert!((scrub_zoom(1.0, DOUBLING) - 2.0).abs() < 1e-5);
|
||||
assert!((scrub_zoom(1.0, -DOUBLING) - 0.5).abs() < 1e-5);
|
||||
// Monotone: every point of drag to the right zooms in a little more.
|
||||
let zs: Vec<f32> = (0..50).map(|i| scrub_zoom(0.7, i as f32 * 3.0)).collect();
|
||||
assert!(zs.windows(2).all(|w| w[1] > w[0]));
|
||||
assert_eq!(scrub_zoom(1.0, 1e6), MAX_ZOOM);
|
||||
assert_eq!(scrub_zoom(1.0, -1e6), MIN_ZOOM);
|
||||
assert_eq!(scrub_zoom(2.0, f32::NAN), 2.0);
|
||||
}
|
||||
|
||||
fn harness(scrubby: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = false;
|
||||
app.ui.tool = crate::state::Tool::Zoom;
|
||||
app.ui.tool_options.zoom_scrubby = scrubby;
|
||||
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
// Fonts set up after the first frame only apply from the next one.
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn view_xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: false }
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) {
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubby_zoom_drags_continuously_about_the_press_point() {
|
||||
let mut h = harness(true);
|
||||
let r = h.state().last_canvas_rect;
|
||||
let p0 = r.center() + vec2(-150.0, 60.0);
|
||||
let doc0 = view_xf(&h).to_doc(p0);
|
||||
let z0 = h.state().current_zoom();
|
||||
h.event(Event::PointerMoved(p0));
|
||||
h.step();
|
||||
press(&mut h, p0, true);
|
||||
// Right: zooms in a little more each frame, the pressed document point stays put.
|
||||
let mut last = z0;
|
||||
for i in 1..=8 {
|
||||
h.event(Event::PointerMoved(p0 + vec2(i as f32 * 15.0, (i % 3) as f32)));
|
||||
h.step();
|
||||
let z = h.state().current_zoom();
|
||||
assert!(z > last, "frame {i}: {last} -> {z}");
|
||||
last = z;
|
||||
let s = view_xf(&h).to_screen(doc0[0] as f32, doc0[1] as f32);
|
||||
assert!(s.distance(p0) < 0.5, "anchor drifted to {s:?} (pressed at {p0:?})");
|
||||
}
|
||||
// Back left past the press point: zooms out below the start.
|
||||
h.event(Event::PointerMoved(p0 - vec2(60.0, 0.0)));
|
||||
h.step();
|
||||
let z = h.state().current_zoom();
|
||||
assert!(z < z0 && (z - scrub_zoom(z0, -60.0)).abs() < 1e-4, "{z0} -> {z}");
|
||||
press(&mut h, p0 - vec2(60.0, 0.0), false);
|
||||
h.run_steps(2);
|
||||
// Release keeps the zoom; nothing was painted or recorded.
|
||||
assert!((h.state().current_zoom() - z).abs() < 1e-5);
|
||||
assert_eq!(h.state().session.documents()[0].history.past_len(), 0);
|
||||
// A plain click still steps in.
|
||||
let zc = h.state().current_zoom();
|
||||
h.event(Event::PointerMoved(p0));
|
||||
h.step();
|
||||
press(&mut h, p0, true);
|
||||
press(&mut h, p0, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().current_zoom(), crate::canvas::zoom_step(zc, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_rectangle_without_scrubby_zoom() {
|
||||
let mut h = harness(false);
|
||||
let r = h.state().last_canvas_rect;
|
||||
let z0 = h.state().current_zoom();
|
||||
let a = r.center() - vec2(60.0, 40.0);
|
||||
let b = r.center() + vec2(60.0, 40.0);
|
||||
let mid = view_xf(&h).to_doc(r.center());
|
||||
h.event(Event::PointerMoved(a));
|
||||
h.step();
|
||||
press(&mut h, a, true);
|
||||
for t in [0.3, 0.7, 1.0] {
|
||||
h.event(Event::PointerMoved(a + (b - a) * t));
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.state().current_zoom(), z0, "no zoom while the rectangle is drawn");
|
||||
press(&mut h, b, false);
|
||||
h.run_steps(2);
|
||||
let z = h.state().current_zoom();
|
||||
let k = (r.width() / 120.0).min(r.height() / 80.0);
|
||||
assert!((z - z0 * k).abs() < z0 * k * 0.02, "{z0} * {k} vs {z}");
|
||||
// The rectangle's centre is now the view centre.
|
||||
let c = h.state().ui.views[0].center;
|
||||
assert!((c[0] as f64 - mid[0]).abs() < 1.0 && (c[1] as f64 - mid[1]).abs() < 1.0, "{c:?} vs {mid:?}");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given
|
||||
- `engine.commands`: list commands with enablement
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
|
||||
- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
|
||||
+10
-2
@@ -169,7 +169,7 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par
|
||||
- byte-exact round trips
|
||||
- malformed-input sweeps (truncate at every offset)
|
||||
- fuzz targets (`crates/*/fuzz`)
|
||||
- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, and `cargo xtask corpus --download` fetches PngSuite. Tests skip silently when a corpus is absent.
|
||||
- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, `cargo xtask corpus --download` fetches PngSuite, and `cargo xtask corpus --psd-tools` fetches the complete psd-tools test set (MIT, 309 PSD/PSB files at a pinned commit, sha256-verified) into `corpus/psd-tools`. Tests skip silently when a corpus is absent.
|
||||
- **Composite oracle:** a PSD's embedded merged image is compared with our compositor's output. The pass rate is tracked in the roadmap.
|
||||
- **UI:** unit tests for widgets and state, plus screenshot checks through the control channel.
|
||||
|
||||
@@ -229,7 +229,15 @@ so a following `ui.inspect` observes their effect.
|
||||
## Rendering fidelity (PSD oracle)
|
||||
|
||||
`cargo test --release -p photocraft-io --test corpus -- --nocapture` compares our composite of every
|
||||
corpus PSD with Photoshop's own merged image (PASS ≤ 2/255). To dig into one file:
|
||||
corpus PSD with Photoshop's own merged image (PASS ≤ 2/255) and checks that export → re-import renders
|
||||
the same, per source with its own floors: `corpus/psd` (enforced with `PHOTOCRAFT_CORPUS=1`) and the
|
||||
psd-tools set (`cargo xtask corpus --psd-tools`, enforced with `PHOTOCRAFT_PSDTOOLS_CORPUS=1`, which
|
||||
also runs a truncation/corruption sweep over every file that must never panic). A panic is reported
|
||||
as `CRASH` and fails an enforced run. Files without a real merged image (Maximize Compatibility off)
|
||||
are judged against their embedded thumbnail instead (`PASS (thumbnail)`, a strict low-resolution
|
||||
check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/corpus.rs` when they
|
||||
improve; never lower them. Synthetic reproductions of corpus findings live in
|
||||
`crates/io/tests/corpus_regressions.rs` (corpus files are never committed). To dig into one file:
|
||||
|
||||
```sh
|
||||
cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/<file>.psd 0 png /tmp/diff.png
|
||||
|
||||
+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`.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
3ae85219dadb3028dab6f4f8f72e6e1a6ca4b5d40aeede8abf87ec24020150fa 0layers.psb
|
||||
0711306e56002f193e33aefe4a1e9ce89ceafaea04da243593bc0c1e697c7d64 0layers.psd
|
||||
4f40d8f359571c86e485af6c711a52af90c4b8aac2ad84db6655cca75a61ce24 0layers_tblocks.psb
|
||||
407c1b0659fd6283f2833b9cf95c7ca3538c5cc61d226e63cebf90a4c1c9f665 0layers_tblocks.psd
|
||||
e0c9b37ffb5981aaedbeec2b2dfa3ad561b57e86e5e04758ed859ca819cd427c 16bit5x5.psb
|
||||
c78689ea7b576f23bbd8b6b4f4993a365266c3ffc9aaa02cc20bef4a36a9d7a0 16bit5x5.psd
|
||||
85317ccdb7ec11a51f1983ad2055544a490839c01f8b313db8070c1b0f0b83f4 1layer.psb
|
||||
c2b457581d549f4bea2e5c34a04b68b2917f640bce0e958b6bca9c65be75174b 1layer.psd
|
||||
ee06e0307b1d5966eccc84768c245183c9ad90dc8e0ab3a8113f75d407615d4d 1layer2.psd
|
||||
1434880f98b92d60193e6759859bc5ebe589e5deeed22246d0e37c85db58840f 2layer_8ele_tblocks.psd
|
||||
863c51eb4d4e72214a3cbdf06a0594c485e4262d899318b1ba914d4c848382ab 2layers.psb
|
||||
406ddf7cbf5a1065b992b9c0d2a59be6290a371b90f23b9a04c0e6bbc94beb0c 2layers.psd
|
||||
a31ee6a32a1e51ef2cae6e4c829ac328c560436306a5e7038c42c562ec241594 300dpi.psb
|
||||
613a5c22e0afdaed8fc3e2c11ab0f30267c515cecb27165833b0ed3833d0f51f 300dpi.psd
|
||||
232bde4537492a671d22b7ff7025a786bc8110677ce5add180103f977be4f5ee 32bit.psb
|
||||
78dd96ec74960a392db0a3bf4193478d62adfa5d93634ab7ce8a40e4468df5e1 32bit.psd
|
||||
cd506872a4b1dc48bf7f64e10d321b99ea950c557cd3a83e303288e8ef6bf481 32bit5x5.psb
|
||||
66a4260e6bb8a60deff94031a66fa502c74870d75a723b4180aef02c35a97d0e 32bit5x5.psd
|
||||
72ab9e417042a9258350326814ebbdeef84442ac3f453b5462f3d339adc29768 adjustment-fillers.psd
|
||||
6c866b95c1458b5a639249f379fad009e3dd6f8cfa2d3dfbbb431ee637c30390 adjustment-mask.psd
|
||||
39f55d8391a80bb768012738a37971a4c02ecffc5519ecab67ec89781e0a0af7 adjustments/adjustment_backdrop_test.psd
|
||||
7dc81242cc78a22cf11a9b587e335299235de37e29b869a7bbb37c6ad7bf29d8 adjustments/adjustment_clipping.psd
|
||||
55a3b828b486c77e1ba1c6ed5442ba2c1a157e0fc1020c5bf50d82e920221306 adjustments/adjustment_nested_composition_1.psd
|
||||
7e120732f1520095f5a0f112e2995d6da69f824013d10f800b630f13d1ccf018 adjustments/adjustment_nested_composition_2.psd
|
||||
0e9b3b8f029a9cdcea9e3e974fd45ad9c4eb78fd4617220f806fca72cc05e24c adjustments/adjustment_nested_composition_3.psd
|
||||
1577dded7ceed05fb786b775948501571cad06d90b20c61a5a0923067c501000 adjustments/adjustment_nested_composition_4.psd
|
||||
f150b3200c829edd0613e9be83c22c37c2d993a8ee9927be3400ed2c2b14e2aa adjustments/adjustment_nested_composition_5.psd
|
||||
12cbc4ca9a18cfae4a01dd207c5f44b135606e55354f162636be796ea0329992 adjustments/brightnesscontrast_cmyk.psd
|
||||
c21289d955a9b24418f26ae423dc69ab1d7431c734510391f99cfa563ddf4167 adjustments/brightnesscontrast_grayscale.psd
|
||||
b1844e3919ba6858361b696cb1d9232ef3a5b702f00b55c1c973f7f1c5433487 adjustments/brightnesscontrast_legacy_cmyk.psd
|
||||
d79c5b778d2b6e023c9b17fba58b0efa05d90c071f623d5b52a4bd8ad9d9fa82 adjustments/brightnesscontrast_legacy_grayscale.psd
|
||||
6c36993b8429316aeaa3b67af97d9517eec01f09e548bf5770a6d1643548aa68 adjustments/brightnesscontrast_legacy_rgb.psd
|
||||
bc2e1771b28268b101b6f50fe4f71444f54d170266de4b5bfb0244e47a9a8df2 adjustments/brightnesscontrast_rgb.psd
|
||||
3508445b94eddadd1f74245ccb316889cbbc3b37e40b983a3b2b5d53b5f56c4c adjustments/curves_cmyk.psd
|
||||
f716cdf162c2c49e41aab1393f0b6f15336a8f66e664936b0294dc997ce272d0 adjustments/curves_grayscale.psd
|
||||
ca20254c47a9fcc0029caa81f7ac7e197ff3657808332c22b75e23920fd040bb adjustments/curves_rgb.psd
|
||||
01147b29385829f34cb5c746d7d779fc82cff89fb92fc47b9652300526271f5a adjustments/exposure_grayscale.psd
|
||||
3e188d416aa2608fc22e34ef274787d58dd4172510760dcef3cbc8c470607379 adjustments/exposure_rgb.psd
|
||||
8e64b22972a81439c97ea2ec94d535e4c947c276c54ab701d7eee042de02efa0 adjustments/huesaturation_colorize_rgb.psd
|
||||
bacb269f9b1f75e54bc64f10cf33e5acf947389900b7ee6b6576b7cb332e4cdb adjustments/huesaturation_lightness_rgb.psd
|
||||
2c81966b5f43f48ea0596ee02b41a18eff3db92e3a9a54c83769e561f018f55d adjustments/huesaturation_rgb.psd
|
||||
389c6fe7d8dd6f73e4ea4039b8ee4b94cc9c20023ace6727aa76799a7897f409 adjustments/huesaturation_saturation_rgb.psd
|
||||
cf0b1d08cfc2f6f1c222009df6f15cad0f52fcb7b2bb3f461229a2b4349929b1 adjustments/invert_cmyk.psd
|
||||
b4660b0e3af58db0e593232f31c63b46c74557b8bcc4bf7cc8835d9930f1f26c adjustments/invert_grayscale.psd
|
||||
6a48998f4d0c970b973eab4d465997f8fe7183f1d757013089a3144c124152a4 adjustments/invert_rgb.psd
|
||||
b566d8e2db943fe666083d0765f9e31cfbc5cc33ac2793d5b94bf4d37caa944f adjustments/levels_cmyk.psd
|
||||
d5fa40edf2973be124d27cc485151d70a8c017d90ddcfd8b87c6d55b0ec17fb5 adjustments/levels_grayscale.psd
|
||||
7c6a8e94a97b66d554748ad09be068a6dcbe3cd8d56ea3809eb231a8168905df adjustments/levels_rgb.psd
|
||||
3f737af36486974475e28cc2fa747dcef9a993b20d357a48e14bb77a3e2d3b60 adjustments/posterize_16bits_cmyk.psd
|
||||
05f9af8ead05b0febbaf3286b6d8bf82d2e39a31fa0fdeac2885a22886156a26 adjustments/posterize_16bits_grayscale.psd
|
||||
f349d0e2e253e1d7b3dcd8645d555a1605b7e2d6bba1b99de164ab7853e4871c adjustments/posterize_16bits_rgb.psd
|
||||
24846e6fbd76b928cdf6833807345043261e3967ee58331d93e46ca79c1616d6 adjustments/posterize_cmyk.psd
|
||||
5c3219ce794da2c4c5d7dcf3e00f3152c07d0a2064d4104ba05767238889dd8a adjustments/posterize_grayscale.psd
|
||||
7e97af29a928e43e0c16baeb3b60483574774e2d7f23667ab07a3affeb83048a adjustments/posterize_rgb.psd
|
||||
b9edb0f14ee579357df5bb887bc10077c99b29febf132feb1897e9dd0a47ce8b adjustments/threshold_16bits_grayscale.psd
|
||||
db8191826e0ca11fdf0961abd9acfa9fbb31b3edca5fc7492f5c0b6766d13559 adjustments/threshold_16bits_rgb.psd
|
||||
5f0f902f8ca88e9b149d107c7d2099d367965c576e21f329d029553ae4d80ad0 adjustments/threshold_cmyk.psd
|
||||
ea435249c68711f8f68fe58cf7c7f38d7e4259d0c919638c2eb5034857f55671 adjustments/threshold_grayscale.psd
|
||||
d61e095b3bf816a39eac075243ca0cadeb1922411b14db13c3fda598b741c5f0 adjustments/threshold_rgb.psd
|
||||
433b583db7361a0be3dd6be74031288da16f1a01b4573c125e1bdcdc9697acf4 advanced-blending.psd
|
||||
10f0065dde46150f1adbc9acc3594b85f42a0e02323e87ca1a5ec80d908ccb13 artboard-bgcolor.psd
|
||||
86d3ccc572cfeec57b271467ba103fb9e32c4f33351276abbf4f962913072b81 artboard.psd
|
||||
391f4c009b9bd4cc5fdffb3799c3476f7d690874e6ca458463ab7fe3b5739aee background-red-opacity-80.psd
|
||||
3bf2015eeb25319fd365711f9b5ea52bf1099f7f767dd1bea47d08283cc0eaa8 blend-and-clipping.psd
|
||||
6032d73dd7a02f2a24d748fc74a79848279ccbe113c2fca143e653bfd5da6cc9 blend-modes/cmyk-blend-modes.psd
|
||||
3a234f7b9664c07e2cb554fb92f464987e299d56aca8713519689afc2b7cde60 blend-modes/color-burn.psd
|
||||
0b320de07d9285ce6d718f1f99d762c96c6216a9f13582f6f2cd5c8a1dae6408 blend-modes/color-dodge.psd
|
||||
8f9bc703572cadffcb2d0be9b3e54d98c26998008693bde6de42f6da542a00f2 blend-modes/color.psd
|
||||
83a49ce3fa585ec40dc6a89dc007546eb90a19c934ecb3c4ba4c6a0793f7b495 blend-modes/darken.psd
|
||||
8a1a674394804c907478f67d6a410e12d4d44833d7804c5cbb78ad6f835f33d5 blend-modes/darker-color.psd
|
||||
a4c341a91a074b97efb15463c9c1133d6827367b6985ee0c1eefcba8425eca41 blend-modes/difference.psd
|
||||
d100f77081f28a75678d6b626379d2327e64c2c04b08afe8a763f59f7dda6c1d blend-modes/dissolve.psd
|
||||
59cccb4099dea57d63733fd675536f0a85af59986093b625734734e82f86de1f blend-modes/divide.psd
|
||||
9f9e7f72b28ea2a1179051e55995485c073a63627d880ef453e5fc7e0ffb9dcc blend-modes/exclusion.psd
|
||||
28ac78b43922a11106a058a0c8368576083f4c57016e6e775bea3a9e9d2a046d blend-modes/gray-blend-modes.psd
|
||||
272d1f6e12ba9853451cdcec6585440320507ed2a37a1aee8ed37d19662ef103 blend-modes/group-divider-blend-mode.psd
|
||||
b45a66ad68a902a715c0db39eb1754f1c98b091245c50620eae800f3a2030d01 blend-modes/hard-light.psd
|
||||
574ac1260e30c3f8d6b2975ba98674b5e052f7838c3c0cd880299f8879ef7e74 blend-modes/hard-mix.psd
|
||||
e70d0ae7d708ef3b0bbb62ad03f0c3d00268a0d15c5a4bb0c0c2ccba0a78f924 blend-modes/hue.psd
|
||||
4fc0432c1a154cddc0a70f558281cf5034caf561d61b91a0409e6bdc89c613d8 blend-modes/lighten.psd
|
||||
5ff8eddc32b369cbe24470727b01560174bc1a31a48cd90da766c29c79533823 blend-modes/lighter-color.psd
|
||||
c9dab0d70793b22dbb73ebbdb71c25f771e483f50a0c55193f9ea6f544342558 blend-modes/linear-burn.psd
|
||||
1fd41e7c186f999d736dba2bd2d09624bcf307e15bd6dc03461b2194546ee06b blend-modes/linear-dodge.psd
|
||||
28ee78b412a63832317bdff2400844ab10a91cb4c4f28d52ef3695428a730b9e blend-modes/linear-light.psd
|
||||
bbb36bfc321189dde7212605681907bc627a38a7fc6a97d7a3bb90004173605a blend-modes/luminosity.psd
|
||||
f0e16879b2cd78e7fa859ea00a22abfc601cb84347d9f854e055d3ed0ffd59a5 blend-modes/multiply.psd
|
||||
ad44357d551d7f59d496843f2260bf0e9645fcf63068d3535432383a0299292d blend-modes/normal.psd
|
||||
21cc256ef3fa5806bfa45c7a69c92e2a2e78a03e9d12025ba8cfa76b07941362 blend-modes/overlay.psd
|
||||
b7b95e85a67d79c46e84757b0ddf84e5f0dd3229fce994507cabe176db6d82f0 blend-modes/pass-through.psd
|
||||
c7e2b0aa5b3ca3eaed159b3783a2958053a25b27edf0a06922a826e59e231d9b blend-modes/pin-light.psd
|
||||
3005b561711e23fb17681e36d49c5200b48a89eed8e87b7fffe6c5fce95d3b0e blend-modes/rgb-blend-modes.psd
|
||||
b60e973294894556eef9e0bb65798e6ddfee3d71bd0389fef4813692b0e415f2 blend-modes/saturation.psd
|
||||
2786c78df04ff6724a112ca3eb2a9ba7468a4dcb3d5e8ab804c8e13711556195 blend-modes/screen.psd
|
||||
3e68729590ac9e5031bd870301e7df2ada3490297d3d372d97015c09aa114a8c blend-modes/soft-light.psd
|
||||
863638e0769d2bab51ce97f19184063cde4457bc5a8fa4c9024311f6251d8def blend-modes/subtract.psd
|
||||
e350f2bf3f0b756a3ba61eb6e62f8ff747561da095ca09d54e212835ed23161e blend-modes/vivid-light.psd
|
||||
463ca1c2f93622285315ff6d69e7c2a38bf7f0143bae4676a89ccb924d596662 broken-groups.psb
|
||||
c76302640f11dca2a81d7ece60d5e4095bc62a442fc03e8ede78d1c1550a8d07 broken-groups.psd
|
||||
60c2fcfe7245bfa141b565b54cdaa36ee4ad56133a883260c663f857db935f15 clip-adjustment.psd
|
||||
92b54b07d584d65924cb2944bc44b0dac7ede671c3e667202fcf6d067837eccb clipping-mask.psb
|
||||
0a44262635e68fe2c6823eed6cb840ddc43b9d2620c8d6948e1aae81291ac55a clipping-mask.psd
|
||||
624fa86083a976f9bce2dcb73d063e8b1144233fff65396c7d79524ab9960772 clipping-mask2.psd
|
||||
27adc3c327ac4fb1cc069bf4dfee8d8ff70784180f3855bb0bf605709a91537a clipping-mask3.psd
|
||||
366f250a766fa5ff7198a6ff292138854666957bb58099bfebba4448fe96126f clipping-mask4.psd
|
||||
a80dd95e43e05ecb1dc2e59189a74ad90d7e4cd44a071e8477eb6b63edfe7c08 clipping-mask5.psd
|
||||
3f868555748e87a357890f2f20afa150f5820a460d73d3d0c1d2c52b14ebea0b cmyk-alpha-spot.psd
|
||||
f563bc6bc16595c837eece4c8f3cbe9d20acfd004d48163de95c76ec40a9ecae cmyk-gray-ramp.psd
|
||||
d5164efc0221f84cd3cc9573db3d8a4805691e04e67b738b4160c042a7b9fb9d cmyk-spot.psd
|
||||
579787b33f69062b1cec82c3ddeeff1da33303f3379c96f516fdd07eab834fc6 colormodes/100x20_1bit_bitmap_rle.psd
|
||||
edaf3b8cd5f621b4f35436778e91be2d487f76dc381caa1cc8bd17d325041f14 colormodes/20x5_1bit_bitmap.psd
|
||||
4b25f12981dd2d435ba2669e3e771198c76765d3934a6cf57c069e0ad009cf6e colormodes/4x4_16bit_cmyk.psd
|
||||
5ee15880f77c1a0e2566864d8770a26efe3aa261245e360267973ebdd542a49c colormodes/4x4_16bit_grayscale.psd
|
||||
35f1727b45b73eb6191a726937f336c8ef6515992bd4913c80e67f46dbc5f12b colormodes/4x4_16bit_lab.psd
|
||||
36730fdab702fbbde8f8b96451d1099186a4443d02180119542ed50aab34c88d colormodes/4x4_16bit_multichannel.psd
|
||||
5ac6e33ce952a5a6daf86144bedf849d60fd0008513f94c57fc1973960249a9a colormodes/4x4_16bit_rgb.psd
|
||||
56ec3967706373af835a319aa2ef6698351e07b8b495e5296ba4fe8848c2dd5f colormodes/4x4_1bit_bitmap.psd
|
||||
f46c66327138fd85986ad514b9002c2cf0f4d654223e2ef6652a907b110ab8cd colormodes/4x4_32bit_grayscale.psd
|
||||
7104010764a3223399c3c1c797d64502be4391d266645edf0e600b75ea77e1f5 colormodes/4x4_32bit_rgb.psd
|
||||
1b662e547d5edc6e10e9a84a03434988c6133795f8b416a03004afa9dd0334fa colormodes/4x4_8bit_cmyk.psd
|
||||
4169c367a7e4698f55955901849c38b8bd284dd61f8ddcbef8bafa70e54bb19b colormodes/4x4_8bit_duotone.psd
|
||||
2811614db8536c363ffd4b9c97baf8965b58fda5c894ffeffe1067d1287edacf colormodes/4x4_8bit_grayscale.psd
|
||||
1da88234b4c1d33ef2fed6a3a496f68e3c28c0bce420400917736e9b72f194cc colormodes/4x4_8bit_index_color.psd
|
||||
48e52730a29cf715f9df9be12d6730af9e8125d27c5293910fedd7d621cf03e9 colormodes/4x4_8bit_lab.psd
|
||||
b656bd96b10350fc6911afaabbc2fa6a48cf5c1ae59c1a8e2f952539bd4f1930 colormodes/4x4_8bit_rgb.psd
|
||||
1f87ed0f6bace7587c0ab3fb48ffe22a7796a42e11743b6a10c42a4d2bd56d0f colormodes/4x4_8bit_rgba.psd
|
||||
528599e9b1e9456b7e5764da2c75c6cb7ba976774b7d352caa34d34451775f27 colorprofiles/north_america_newspaper.psd
|
||||
96ab1a61b0067548a830d8c9a9b07309657c4052a252ae92fa15866357e273c4 descriptors/hsb-color-swatches.psd
|
||||
67986bbc829fd8d391dab43b471f7b7b8edf9c4635db5b6af4970ecabab6fabc descriptors/lab-color-swatches.psd
|
||||
eb0cab1dd8decb1be3c0ceb95eaf555cc4370a011ee25dc76fbb164483c2b55e descriptors/stroke-color-descriptors-gray.psd
|
||||
2d47b073029143e2224e303fad965973a70e526a8c88234d1fb9be116eaa3aa8 descriptors/stroke-color-descriptors-hsb-with-rgb-mode.psd
|
||||
9f472778a8dca363a3dcac7f8a5aab14d53a8a6c84ed6f7b12381c96b099cf09 descriptors/stroke-color-descriptors-lab.psd
|
||||
cc95a5bd2bdc6cf2de60ec1741acfa6672c5c4a12d6e750bde2e91fd6e5939a6 descriptors/stroke-color-descriptors-rgb.psd
|
||||
0dc6114f10b814368f2fac1708668777cf0ede0159ee0484f3b9f40c165b8691 effect-stroke-gradient.psd
|
||||
f1523bab5138726aa1f5ff89b3ca061175cc75e779148c2c750a914dd05e399f effects/antialiased-stroke-edge.psd
|
||||
496e91bd45d5ca9ed97e67d3f6c61cf88c8b169228e7b25e30b78c159c24714e effects/blend-modes.psd
|
||||
9ecadbb3216d4a5c2cd1df273b215635445afe7798dd0af204cf8d9d70f1eebc effects/center-stroke-sizes.psd
|
||||
f7c0631ead3370a97aa445f610665796d14475528acc1184e276dd5d3f47ca3e effects/double-stroke-effects.psd
|
||||
0dc5b4c928ecd890a93b6c887d14f709bda441a1982c9839b48f5c12e142261b effects/effect-enums.psd
|
||||
42815f35af3998fd2fa4785bca94bb79124063e228d43c1fd206f71cdc43de87 effects/effects-enabled.psd
|
||||
04fa5572072685682da74a90e5ab2228750f67f17cd43afb45ba0f9a27151da7 effects/feathered-stroke.psd
|
||||
678d9ca2357056ac84e3494d0965268e7c700fc656101efc863d43e57cca0239 effects/group-clips-child-stroke.psd
|
||||
62c699af1abd88422787aad0014e923b4ac1aa7e10d460f0ef485cb6310dd379 effects/group-stroke-off-canvas.psd
|
||||
341a20b123b85ca0a9bdae0df3d74d8831341371c774081a4638856a1821f7ea effects/inset-stroke-sizes.psd
|
||||
5be59daf7c5130513ab1c1e8900a9a317602434abd1ab46aaa9f4a98e5335d08 effects/outside-stroke.psd
|
||||
f0c53835a0c5a8279512497f3448f2cfaf6ec31831eaab160c32779ed6a93fd2 effects/shape-fx.psd
|
||||
d4db7556433ada2b7530de3e2a826327ec5497d77a2e2ce78dc48ccb980637c6 effects/shape-fx2.psd
|
||||
f9a513b4192b336b644caf93153f20ac30fef448298a7dd64f06f46728f770f6 effects/stroke-composite.psd
|
||||
bd6514a680c05740ec8392d7ca624a2a6a90e8bddfdb65a698fc184980344af1 effects/stroke-effect-transparent-shape.psd
|
||||
8481c35a8739c6d3177237194d1da1528bcfa70ed8831394176c6b2b21e11a1c effects/stroke-effects.psd
|
||||
b6fb31441e7c69d07045549495307864ff0fb2c21518aff0080b5effacd97af3 effects/stroke-without-vector-mask.psd
|
||||
ec878104e88476b0ff3ac05943d88a690eb19b1440c63585cf22ba50f0baccc8 empty-group.psd
|
||||
e63dd3689546f26113080a86a3ab6ad74c9200b44fa917c19bbaaab906692da5 empty-layer.psb
|
||||
f616f4984c6225c57751d8b6303368caaf6212a92070331ccce99f43352af0c0 empty-layer.psd
|
||||
40fb3984526471ff38b6836c647075802c66d2cded2e88d675898a5486317e4a fill_adjustments.psd
|
||||
88d1e42e5c70a6f3f99b57e1feb623e0c9460807fbb00cb80223e213415e0a1d gradient-fill.psb
|
||||
30d617696f4453b8f4fb6688af152aea30d8e5730f632105d3f8feecc6ecc150 gradient-fill.psd
|
||||
39af0f5d0ef8ca1fb31d2e4ebf75f5bc5d8a9a506ba3829b998575ff3375ba82 gradient-sizes.psd
|
||||
9c5e709a1659b9d65ebe5bec6719191bf7dedae72b224fbb63414225f812b018 gradient-styles.psd
|
||||
2fcaed63aec566c47872f8e84eb4a070b4e5b27d88bf8432dd1ec11fb418cb40 gradients/noise-gradient-cmyk.psd
|
||||
de9b1ea4b76ac5abadfddb794d98ac0189a46b9c575d5ad276d02306c9c6188e gradients/noise-gradient-grayscale.psd
|
||||
8e6a6b2594af88e7d8af0f48df40803634b1073239d823703a768c9ed7128940 gradients/noise-gradient-lab.psd
|
||||
83e205b5f380dd2623b166cb45f7f35765c3c93a787b242f0a9ea20cf9782bdf gradients/noise-gradient-rgb.psd
|
||||
99e7b4652b50e4b518e9f071bd630b3e73f3c874b5b0348db44fa911793ed1db gray0.psb
|
||||
f7160dd9dea521cb05e586dd62cff3f7a2429b070b2aba38cdc71b5522e9217c gray0.psd
|
||||
daf50e30e718c674f7c6e62ff88a098087ff27da632da6c3de87fcbcafeb45b2 gray1.psb
|
||||
a8af49b6771e85355195d5e379b80d1e44e21a3ff6a1cb7272949fba68fb0a09 gray1.psd
|
||||
b8e346ee1909c793c5d1be36b01dacad7152d3f40757b4271cfb869d4a5ba141 group-clipping/group-clipping.psd
|
||||
e5322646c91d54e068293e96580faebc3eae5d82d75ab3b38bd0e5c7cfb14ad2 group.psb
|
||||
1aaf572b69f0b0fa7c04b7b3a14c97e310a0c7bbdc06e733c9dd0d0cdcf7df1c group.psd
|
||||
1dccb23ffa47e59a548d96a893612e48d3f8f876ce3cc81779657ad19ae886a6 hidden-groups.psb
|
||||
57ebb0195f61309c9c73fddec6a9c44ce931ccb3f165f8a78c73861a1fe3862d hidden-groups.psd
|
||||
61504449562ab7e9f72c9182279385df4f7374c002c5443fdaf1db3bf8e738b9 hidden-layer.psb
|
||||
3aaadd20b5ff9d5e778239ee734c9c2bf57fc56bccc82c1b003c3719df46dba0 hidden-layer.psd
|
||||
d4b49e5bc0f514a32727b2067e460ca0b3592645cdff655bcaa4f319338ca384 history.psb
|
||||
b93a3fe87dc807596050f0ab696c7b7972171dda27430593ae50f8516141619a history.psd
|
||||
e554563c160386836cfd186ba7bd657f08c0c57e60fd7f1156fd3d9bf08de4ae issues/issue397.psd
|
||||
2ed49149deeffbccb89aebbf8c5a19dee12a864b17641c7bc03a89e957f3eaa8 layer-name-emoji.psd
|
||||
4a875e7a01d12346e1ee9a3e0c66e2e08b29c9786c3d966e39296c7488a79647 layer_comps.psb
|
||||
4179ed664df5e5b3473c62587039f6a373a0f1494f6bc490acec7e57493d1537 layer_comps.psd
|
||||
6ab41ce6e17b3287c703dc4cf4d898506ffc1bd9babf20d0f1de5ef6df1d2e1e layer_effects.psd
|
||||
807a1362da4d756f9096a75d9b0fb0ec46c3c5feeafe3ce96ccc66db7ba5a014 layer_mask_data.psb
|
||||
e30f5d2f51ef2dc5790b45b50ff3911ab7991ffdf63230561364a2574c1392aa layer_mask_data.psd
|
||||
e2d4a3c2162bb022c115ebeb51eeb5b9f22978e12e47f419aeeeac45f9e2be71 layer_params.psb
|
||||
23c2c5af17b56f682193cd6d1095ab57ea685eeaf4a9061399bf79d6a0a28b19 layer_params.psd
|
||||
8d995a89eee1b4b11e0639cb4546e46aff8c9fd168a7b7ddfb4abb9f4b75e806 layers-minimal/gradient-fill.psd
|
||||
c1f7bb729d8476f328388ed58fd9fadc72819426b23b45f7cbf9ece1b0e36865 layers-minimal/pattern-fill.psd
|
||||
5862b2f0cae962f73468cf32f4f1410fb785010201a074dba7102f83a15dfa00 layers-minimal/pixel-layer.psd
|
||||
53f4791a888269f835a65e30fd29d2cc775490c35cdefe16987129e00bbd9a18 layers-minimal/shape-layer.psd
|
||||
f451b816bb5292564eaad9a29eb7543c3b2ad6b5dc35d1babc0a4c799502ee01 layers-minimal/smartobject-layer.psd
|
||||
e22eebb32a29bf05072882b2de9f15132da70b90417784439798432a69ee6267 layers-minimal/solid-color-fill.psd
|
||||
b24e6f4c7277dd499ebb20c88ac06698f4563e75bfeb6aa6cb0d2466140fd81f layers-minimal/type-layer.psd
|
||||
70d843798d732f2a56f3dc8614d7c5e6c575b45328ece7e9fe61a340ef3b48ad layers/black-and-white.psd
|
||||
ba069aff12a110616c4d1ba7522f0cda8dc312fa4129f4cb8bdc21e30c805744 layers/brightness-contrast.psd
|
||||
974fe05663302be94379d72d08d1d1d6e7025ea7ddad012cd1cd81e0d3cb94d9 layers/channel-mixer.psd
|
||||
cc7fdeb95d6b06790f07d0564b8c5591b19af94839ed453e38517fea14d02cdf layers/color-balance.psd
|
||||
10d5b941563e3305c741e2c1725483546634349270ae0fc2bc49b2778c1b181f layers/color-lookup.psd
|
||||
8c343f76e9eb964425c5a6efdcb29829fc21f47ad06ac465a9acf1ed6036c0a9 layers/curves-with-vectormask.psd
|
||||
e34d8f3bf915d4e48527efd2c6231d39b0705943208391ec03571015aa37ac54 layers/curves.psd
|
||||
1eb4ffdab3c21acf455baf7084cfafefe816d628f4edc7d45206f3e0dffe183b layers/exposure.psd
|
||||
2bac8500879484584fae6af3d90c8c2a2b2f566d350971e79dfc5283c993e2ae layers/gradient-fill.psd
|
||||
8980760d269e95954e55c9142aca02fb21d27138b66d1e62f6921ab9bc131968 layers/gradient-map-v3-classic.psd
|
||||
38fdbffa9b6b0144aad4ddcd66333ed698e9091ec318a1516240e9a0b7b85bb2 layers/gradient-map-v3-linear.psd
|
||||
416a2db01f11356df8f3a0ce24cb66cc977c15124cf820bc9d2729a90987f488 layers/gradient-map-v3-perceptual.psd
|
||||
a6b436d69b68b76ceb0fc4f8d7f3d080823ce5591d1a71f30aa391a6a19f01bf layers/gradient-map.psd
|
||||
83b4847a919359154ed5c01baa39606f5de42abd30eff73bc101f979b3303fb3 layers/group.psd
|
||||
679b1b6468b1c68d65f698d2c4dbdb185232092d78a2f9f6e77c897f400c7111 layers/hue-saturation.psd
|
||||
a282a963588bb02022dc09fd1174b2d6afe0df1110953585df3bc046e5fccd5f layers/invert.psd
|
||||
f15bbf06fbd48fc3ad35a5ec266fe2927c39db2bf591fcad7186a4815e633920 layers/levels.psd
|
||||
078fd9ff7469953557201775726afce0b1de85e691bb8bb1d7f797d0dc26c907 layers/pattern-fill.psb
|
||||
f726a18877069161884ec933c5ea4962918f45de8317a47abe6bb9075baf059f layers/pattern-fill.psd
|
||||
80710c84f302f6fc873da8aeb4da99a08953dbfce0962ae70725542ad25f3fff layers/photo-filter.psd
|
||||
480743cc294c702c43c8ef80ac9d4bd9a52c2534a2ccec8708689eeef673964c layers/pixel-layer.psd
|
||||
4a0f93719c3c3c755f49c4e8f60b482f42da1085b40428e00c8f61f4e7d5caf7 layers/posterize.psd
|
||||
14f75f6cb941b81e57a64f03346c13beb731227b16fa3494d9b12e796fdcdbd7 layers/selective-color.psd
|
||||
23b3cc8dfcaca161443ab7345e49fdcd5494ceb864c16221b61ad99473a53830 layers/shape-layer.psd
|
||||
a34abf773a64f21b59d5a854f7d394e7ac41e402d0ce546ddd86d19c764a63a2 layers/smartobject-layer.psd
|
||||
d8229e39489dd992b1965593603c660353d4444f080f97a5233fdc1b1e14fcf2 layers/solid-color-fill.psd
|
||||
223d49f2caa50399c1525594d4a4da215143d24baac8d8b909e603579473cd22 layers/threshold.psd
|
||||
7a61ae63f2518e436320d48c9b6e4cd7b3df64f3d86dac23dd83055f12bc88ba layers/type-layer.psd
|
||||
a18173ed1f3fc01e392b410b0c745f8f7cb4cfcd3d74af80fb426faac015cc1e layers/vibrance.psd
|
||||
7f4240b160a541a0aded458ffc83aaeb9c8d59306342f6065ec9cc0d41be21ec mask-density-layermask.psd
|
||||
185ccf0a483da25af5c3a25942158d849f8bda24ab8be615088fab4f08d01730 mask-density-layervectormask.psd
|
||||
efcce23eeca979a32c9a954b76a9f2948c656b7c48ec1b86606525949796364c mask-density-vectormask.psd
|
||||
989e198abd0bc7e7573db1c5240ea713994113f03cb5a785be941679921561c2 mask-disabled.psd
|
||||
0fa601f84afa646472484a21556b6947f89773e9ce9fa172b96b4f37d8846963 mask-index.psd
|
||||
71bff054dbb4e1749192ba7d99034c689c0337c46c9fdcac86d63dbce9ca299b mask-parameters-no-real-channel.psd
|
||||
1e774b247a20457ad8f3aaa36314bab0d487ea88ec96cf8b9d992f5dbf0b8785 mask-vector-density.psd
|
||||
263ed5883f2af5a1ffb90562d155b2b56a1288b761b7a39d9ec40c3862fc9a9e mask.psb
|
||||
a6928c0e306022d474876e61f4b1dc5b7c1ed1e1cb422e6e72511da0a6aae90b mask.psd
|
||||
41779602a0840558846ac51197efea4205759c45327233ffa440b653c5e63a38 mask_parameters.psd
|
||||
1eb6407018962be307b9eebd41739e56828bfd0fd7073d2a41fb7dcdd537ed7d masks.psb
|
||||
8cd1a0d53274436e79662a96d0f8d020a73df0626cf7921429047a6fe7bf0d07 masks.psd
|
||||
cc6363e24089c2f8910ae44b8067825e2af5e1c43c5d8b3bc6323934fa3cfacd masks/2.psd
|
||||
2b99f27b5a6b3d1dc6a10baa526f804b642a1c7feaffc47a6014bf950d60d7e0 masks2.psb
|
||||
5f9e03f6f1c8ac04dd1e15f7253b489942c3958c00a0e4876b2f944f8ce31d67 masks2.psd
|
||||
00519ff3184b2f426258163b60437cc722dfdcd596a198f85ddd45c17b22fcd5 masks3.psd
|
||||
ed428417c175d15d135666b5896c3e280259f6d243515acc71d4f6c276729897 metadata.psb
|
||||
d279fb8ea593aa1e06b109eed3427594e536211d639299225ed0fbe3586bba6c metadata.psd
|
||||
801b1c14522974ec39099eec445aafab47fc89700b96f1f788466698a09e6bcc multichannel-pattern-fill.psd
|
||||
213f56c58b7b484c0cb7239873854ed2ec7863f350c2283d42c66929cdb54f50 note.psb
|
||||
0b343c50db1a572393ea6a41d48f1aec7fa8d20c862a1716985b73d1a20d8b5f note.psd
|
||||
b3568a8c6491d2da4b9e2aa8c6842b443c09b51aca6ffe44bcbfcc2ae17189ef opacity-fill.psd
|
||||
a9297d5920b230136e66b8cdc5b6f23b3780f55e2075010f938c9ca0ee7d0599 passthrough_blendmodes1.psd
|
||||
a13688cc0baab09d96d5d5878f01c00897cfdc6cf87de5cc5fcff24236ee0e52 passthrough_blendmodes2.psd
|
||||
ce32b24f793fd3c64c79c16b71888ce6e7ab0b3c398ba121e97379ca6cd64a87 passthrough_blendmodes3.psd
|
||||
59e55f9ca8d252980f600998b55ccf81b21d42bc43bf4daeaedd8b296d1a5be7 passthrough_clipping_mask_adjustment.psd
|
||||
27b434d10abcf08c7836ae0511437a3928cb9dd208e731c153d2d078508154e3 passthrough_clipping_mask_blendmode.psd
|
||||
f909304768db5243a833dfc270c78a6701ec36f73e7024b7544f026d15c610f7 passthrough_fill_adjustment.psd
|
||||
eeb3ca39ce23b40ac6e45ece19527f2da33b81597b4549c26a25a8b2e3b85780 passthrough_fill_blendmode.psd
|
||||
514910f20d2a2339a386c35299efe1922c8a44a23591834c997329f1409450f3 passthrough_opacity.psd
|
||||
d6e4db16b3916c26416231d21657bc679a7af3fe8009864bde4b1d177ceb9a01 passthrough_vector_mask.psd
|
||||
17200edea3e3e69f7b884111020a506abba897880479e69506f4355a51ce09e4 path-operations/combine-group.psd
|
||||
61513fbddb67c94d00f7c8a88191074ea9b259f2fd78a180945c49a1e1aa60e3 path-operations/combine.psd
|
||||
3ff08258a3b1710729606342cdf0f00e849e76333233b0a5340448f7a23814d7 path-operations/exclude-first.psd
|
||||
27f0f3899d176f43bd0231118a2f62cae566c593f7b86a75dee251e125b34115 path-operations/exclude-group.psd
|
||||
d50e60b672bfe21ac02ebefb446c12b6d5d5d15530560a785f50e4e8f6f66791 path-operations/exclude.psd
|
||||
79412dda0453661605b724cbcbef417200188ecb9f87e4778151e4b731b952ac path-operations/intersect-all.psd
|
||||
787a94b10c127efaab037c40b9c797af8d83e4ef7ab6846f730f5adf7addac5b path-operations/intersect-first.psd
|
||||
1f9d38f6cbb3e8bd5648c4acecf5b5a1b1c3f3f0ef94e925bf42d5c0b31d3c2f path-operations/intersect-group.psd
|
||||
3237bb98d4c07ea2e9e533795f515d8958158385872be7e2a4e0508ba53374be path-operations/subtract-all.psd
|
||||
81a1536a432907a6ab8c8a41d3ff061ecfe5e3686970ba2e3697d8e79f60e9b9 path-operations/subtract-first.psd
|
||||
126a90c715c831f41c0b1926819d20dcd6fae14625174e45aa72a2f30318dfd1 path-operations/subtract-group.psd
|
||||
83fdf32685b4d7c035099be3132bc57b687f44c662ccda5ddcb9efd9e2b8569c path-operations/subtract-second.psd
|
||||
8624d62d1d2a94eff2119a08621e02e0d8513db4da0b24e9939e2a9feffb5841 patterns.psd
|
||||
2445db54440058a7a67c807a5e7a0817e2ad9f5f09c300af137a5e0da02515c4 pen-text.psb
|
||||
83514b47cb106a79bc1d676dfd851be372d315f6ba209a943cb7add03f242aa4 pen-text.psd
|
||||
066eeb1bce9c123ffcb57f380d9a51e0687024fdd264904ed3a44c3d5666490c placedLayer.psb
|
||||
69ea01bf88cb85c48d3a78c3bb9e06ae141c9c9fc6a88267eae95c315e16180e placedLayer.psd
|
||||
596a8131e4d63f65a0cc2b6a513fadc06f844a25ce8714be397f506d23abc398 semi-transparent-layers.psb
|
||||
2d190efddf648408ad0115852f8a029eec44de10bd29f6ff222aaa574cc31bc1 semi-transparent-layers.psd
|
||||
bdac1216edac692b5941cd28d60a6ded9ca7b23b32e903af47066c3f9dce958f slices.psb
|
||||
dc7540774270ede4e5d8f76326a495854adb9b69a99c466c1f7d262b2b7f7484 slices.psd
|
||||
4dfea68f5d42bd663bb8e9a375a5eb19622fb06b340acc22c6de1536b878f9f3 smart-object-slice.psb
|
||||
12ef0c357ec24e094390619878fc3476cb7198c8e7690aceb3e18e0b51dc1b28 smart-object-slice.psd
|
||||
885b3481df602f877f363f45f7206c4095ad347ab01276843d7fb37901d1ec0b stroke-center-no-fill.psd
|
||||
9dd230f2ab0bd34a47da0cc4410890adfa650c509fa1f4c7f5624146c698f924 stroke-center.psd
|
||||
46cfa6e630f6badbdb92376620d481c2128a76439b50d652d00534e6c51d8ce3 stroke-outer-no-fill.psd
|
||||
6a61905b234c7447281214976878205a135d5900bbb81db0c6db7eda1187a877 stroke-outer.psd
|
||||
ab39632f4266a2bc72cfb676f06aa5fc60f1c4951fed2f9fd67165895e61890c stroke.psd
|
||||
d28350bc188655b1bfb4741877af946f07810f2eaa50c95d33939c210dd79e96 text.psb
|
||||
eb40ec54a5f270913463bcc847be65672ce6fff0d0d56288725f58e299e00f1c text.psd
|
||||
405f13bbbcc9001d8771f313d898ba683d79a7e79005650d8c1a017a4fb0ca2a third-party-psds/cactus_top.psd
|
||||
01cdafadb5828ac23dd9b5ed4d308c00509f36f64da5760002b7e7f7e3d235d9 transparency/clip-opacity.psd
|
||||
3b0273eaa87051c0482fca601ca9ba111595e74a77494771f911402f0c1da1c4 transparency/fill-opacity.psd
|
||||
3f2fa613f3bd4ba33038c5897c0ea1fee9afcd1de396fd4d961fee6215e3e3f8 transparency/knockout-deep-cyanbg.psd
|
||||
0cc7495b55f1b937f9d1c465e6d736f58233fbfbbea69b7eadefb49ac831ef8c transparency/knockout-deep-nested-pt.psd
|
||||
d206355323809135408357a715814112852fe52d55269a9e5feb681567dff6f8 transparency/knockout-deep-nested.psd
|
||||
fe1de1727f836bcaf86fd76845aae29e66a268abf4977b80571e30413a57deeb transparency/knockout-deep-nobg.psd
|
||||
3cd5136ed85f14ba251d00867fb3df76377ff007b36247fe4180eeb21f0cc9d9 transparency/knockout-deep-normal.psd
|
||||
c963f7026d2e9f304984a82103a4c0ce05393151890f2277f93dfa02a9b5789c transparency/knockout-deep-passthrough.psd
|
||||
4db674ff13022a3b688d0e23fc087f0978fab8f45a581d601ef050d05ae14189 transparency/knockout-isolated-groups.psd
|
||||
33bc40c3c70f813ba6c0d6ea5d8ea29b9d8a12ca27dabeb83726ff91a3c2e37c transparency/knockout-none-cyanbg.psd
|
||||
9755c4adda49116b7afac2280e9a481ec1420e65ecbd3de54736df0fb902aaf5 transparency/knockout-none-nested.psd
|
||||
ac034b07a50b3badb803053da33859c89d64a12f97adf7bfe5c139c254cbb3d8 transparency/knockout-none-normal.psd
|
||||
6c378b8aa2f93fcf9f58d58744cd7674f5bd76e6832b82f3988ca9a260619c86 transparency/knockout-none-passthrough.psd
|
||||
38aca2dd78bf0682450468363907a2389edd28c345bdf1de3125b8f46a56178f transparency/knockout-shallow-nested-pt.psd
|
||||
b94230c312c3caea229e13a27264fd9f89f719fa98de0cf87f539cfb8ab25dd6 transparency/knockout-shallow-nested.psd
|
||||
86d3c7fc67576dc3472a54a9c3478525881875948b5ca81aca0496d4b10fdc78 transparency/transparency-group.psd
|
||||
6710f99e5b32843cebc7a1a5d252b8d4156735cfe10f971bd76e860541bd7ede transparentbg-gimp.psb
|
||||
9c700d3e7ad0429ac3ea83a40bb02f364a3d4e7713e7d608e23a8e03014d3b9c transparentbg-gimp.psd
|
||||
f4c14e457a74dcfc979c0ac4e2b25378036cb983dda1ee273a3f5267405f24d1 transparentbg.psb
|
||||
d6b3ebbc2928e80d6cb9e53e2bfc935083a2f76def4325547c382c0141fda8bb transparentbg.psd
|
||||
62e9eec973d1ef9c61515ffe6eba7d6285e6943e0e1b1e01b125a6f2b3896346 unicode_pathname.psb
|
||||
9ce5876f237020b4a10607d60bef0d4c568d7d330ac91016778196762f429f42 unicode_pathname.psd
|
||||
4e6a91fd815b5f42aa5f1873979f668747594645a7845b700c01e6c53d03988d vector-mask-disabled.psd
|
||||
0596ec478f78842bdb471cffe0ceadae132904d5062efa3425721b0413122be9 vector-mask.psb
|
||||
e0dd21d3325a42cd81c6abc17a4fdc283101eca0ba306f852fe8bc167926d502 vector-mask.psd
|
||||
fc6c32967f6a62641fa02fa314fe24717de49b74da0e1367abdb51a4b55cd49f vector-mask2.psd
|
||||
344eb19a6ae1be6d5282eb0483a2db62c58ee5bddff8a3168dada53d391c6dbd vector-mask3.psd
|
||||
+10
-2
@@ -5,6 +5,8 @@
|
||||
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod psd_tools;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -18,8 +20,10 @@ commands:
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
corpus [--download] [--psd-tools [--update-manifest]]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite;
|
||||
--psd-tools fetches the psd-tools PSDs (MIT, pinned commit, sha256-verified)
|
||||
into corpus/psd-tools
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity Photoshop menu parity; rewrites docs/parity.md
|
||||
version [set X.Y.Z[-pre]]
|
||||
@@ -35,6 +39,7 @@ fn main() -> ExitCode {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") if rest.contains(&"--psd-tools") => psd_tools::fetch(rest.contains(&"--update-manifest")),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => cmd_parity(),
|
||||
@@ -214,6 +219,9 @@ Tests that use a corpus skip cleanly when it is absent.
|
||||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||
corpus/psd-tools/ the full psd-tools test set (MIT) at a pinned commit, verified
|
||||
against xtask/psd-tools-corpus.sha256. Fetch: cargo xtask corpus --psd-tools
|
||||
Run: PHOTOCRAFT_PSDTOOLS_CORPUS=1 cargo test --release -p photocraft-io --test corpus
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
corpus/exr/ OpenEXR sample images (optional)
|
||||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `cargo xtask corpus --psd-tools`: fetches the psd-tools test PSDs
|
||||
//! (MIT, https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) at a
|
||||
//! pinned commit into `corpus/psd-tools/` (gitignored, never committed) and
|
||||
//! verifies every file against `xtask/psd-tools-corpus.sha256`.
|
||||
//!
|
||||
//! Re-running is cheap: an already complete, verified copy is left alone.
|
||||
//! To move to a newer upstream commit, change `COMMIT`, delete
|
||||
//! `corpus/psd-tools`, run with `--psd-tools --update-manifest` and review the
|
||||
//! manifest diff (and the corpus floors in `crates/io/tests/corpus.rs`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{root, run, sha256};
|
||||
|
||||
/// psd-tools commit the corpus is pinned to (main, 2026-10-05).
|
||||
pub const COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2";
|
||||
/// Generic User-Agent: no personal details in requests.
|
||||
const USER_AGENT: &str = "Photocraft-dev";
|
||||
const MANIFEST: &str = "xtask/psd-tools-corpus.sha256";
|
||||
|
||||
/// `(sha256, relative path)` pairs from the manifest.
|
||||
fn manifest() -> Result<Vec<(String, String)>, String> {
|
||||
let path = root().join(MANIFEST);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let (hash, file) = l.split_once(" ").ok_or_else(|| format!("{MANIFEST}: bad line {l:?}"))?;
|
||||
if hash.len() != 64 || file.contains("..") || file.starts_with('/') {
|
||||
return Err(format!("{MANIFEST}: bad line {l:?}"));
|
||||
}
|
||||
Ok((hash.to_string(), file.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files in `dest` that are missing or do not match the manifest.
|
||||
fn mismatches(dest: &Path, files: &[(String, String)]) -> Vec<String> {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|(hash, file)| match std::fs::read(dest.join(file)) {
|
||||
Ok(bytes) if sha256::hex(&bytes) == *hash => None,
|
||||
Ok(_) => Some(format!("{file}: sha256 mismatch")),
|
||||
Err(_) => Some(format!("{file}: missing")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_psds(dir: &Path, base: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect_psds(&p, base, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) {
|
||||
out.push(p.strip_prefix(base).unwrap_or(&p).to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch(update_manifest: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
let dest = corpus.join("psd-tools");
|
||||
let files = if update_manifest { Vec::new() } else { manifest()? };
|
||||
if !update_manifest && dest.is_dir() && mismatches(&dest, &files).is_empty() {
|
||||
println!("psd-tools: {} files in {} already match {MANIFEST}", files.len(), dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
let staging = corpus.join(".psd-tools-download");
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?;
|
||||
let tgz = staging.join("psd-tools.tar.gz");
|
||||
let url = format!("https://codeload.github.com/psd-tools/psd-tools/tar.gz/{COMMIT}");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(&url);
|
||||
run(curl, &format!("curl {url}"))?;
|
||||
let top = format!("psd-tools-{COMMIT}");
|
||||
let mut tar = Command::new("tar");
|
||||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&staging).arg(format!("{top}/tests/psd_files")).arg(format!("{top}/LICENSE"));
|
||||
run(tar, "tar -xzf psd-tools.tar.gz")?;
|
||||
let extracted = staging.join(&top).join("tests/psd_files");
|
||||
let fresh = if update_manifest {
|
||||
let mut found = Vec::new();
|
||||
collect_psds(&extracted, &extracted, &mut found);
|
||||
found.sort();
|
||||
let mut lines = String::new();
|
||||
let mut out = Vec::new();
|
||||
for f in found {
|
||||
let rel = f.to_string_lossy().replace('\\', "/");
|
||||
let bytes = std::fs::read(extracted.join(&f)).map_err(|e| format!("read {rel}: {e}"))?;
|
||||
let hash = sha256::hex(&bytes);
|
||||
lines.push_str(&format!("{hash} {rel}\n"));
|
||||
out.push((hash, rel));
|
||||
}
|
||||
std::fs::write(root().join(MANIFEST), lines).map_err(|e| format!("write {MANIFEST}: {e}"))?;
|
||||
println!("psd-tools: wrote {} entries to {MANIFEST}", out.len());
|
||||
out
|
||||
} else {
|
||||
files
|
||||
};
|
||||
let bad = mismatches(&extracted, &fresh);
|
||||
if !bad.is_empty() {
|
||||
return Err(format!("psd-tools download does not match {MANIFEST} ({} problems): {}", bad.len(), bad.join(", ")));
|
||||
}
|
||||
// Keep only the verified PSD/PSB files (plus the licence), then swap into place.
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
for (_, file) in &fresh {
|
||||
let to = dest.join(file);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(extracted.join(file), &to).map_err(|e| format!("move {file}: {e}"))?;
|
||||
}
|
||||
std::fs::rename(staging.join(&top).join("LICENSE"), dest.join("LICENSE")).map_err(|e| format!("move LICENSE: {e}"))?;
|
||||
std::fs::write(
|
||||
dest.join("SOURCES.md"),
|
||||
format!(
|
||||
"# psd-tools test corpus\n\nThe PSD/PSB files of https://github.com/psd-tools/psd-tools/tree/{COMMIT}/tests/psd_files, \
|
||||
unmodified. MIT licence, Copyright (c) 2019 Kota Yamaguchi (see `LICENSE`). Fetched and sha256-verified by \
|
||||
`cargo xtask corpus --psd-tools` against `{MANIFEST}`. Gitignored; never commit these files.\n"
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("write SOURCES.md: {e}"))?;
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
println!("psd-tools: {} verified files in {} (commit {COMMIT})", fresh.len(), dest.display());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! SHA-256 (FIPS 180-4), for verifying downloaded test corpora without
|
||||
//! depending on `sha256sum`/`shasum` being installed.
|
||||
|
||||
const K: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74,
|
||||
0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d,
|
||||
0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e,
|
||||
0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
/// Lower-case hex SHA-256 of `data`.
|
||||
pub fn hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||
let mut msg = data.to_vec();
|
||||
let bits = (data.len() as u64).wrapping_mul(8);
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend_from_slice(&bits.to_be_bytes());
|
||||
for block in msg.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 64];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
|
||||
for i in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ (!e & g);
|
||||
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let t2 = s0.wrapping_add(maj);
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d.wrapping_add(t1);
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = t1.wrapping_add(t2);
|
||||
}
|
||||
for (x, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
|
||||
*x = x.wrapping_add(v);
|
||||
}
|
||||
}
|
||||
h.iter().map(|v| format!("{v:08x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn known_vectors() {
|
||||
assert_eq!(super::hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(super::hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let long = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
|
||||
assert_eq!(super::hex(long), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
|
||||
assert_eq!(super::hex(&[b'a'; 1000]), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user