Compare commits

...
Author SHA1 Message Date
Brandon Thomas 0f8ba488d3 Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) 2026-10-05 08:01:42 -04:00
Brandon Thomas 574d0d3d53 PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195) 2026-10-05 08:01:39 -04:00
Brandon Thomas a4380b2953 Properties: uniform sections, per-kind Quick Actions, full-width controls (#155) (#192) (#192) 2026-10-05 08:01:25 -04:00
Brandon ThomasandClaude Opus 5.5 8877a812f9 GPU compositor: page layer surfaces beyond 16384 px with a memory budget (#187) (#187)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:49:38 -04:00
Brandon ThomasandClaude Opus 5.5 b22deaa944 Layers mask thumbnails + chains; Channels/Paths temporary rows and footers (#153, #154) (#191)
* Layers rows: vector-mask thumbnail, link chains, red X on disabled masks (#153)

Each mask gets a chain (click toggles layer.layerMask.linked /
layer.vectorMask.linked) and a thumbnail; disabled masks are crossed out
and Shift-click toggles them. Vector masks render white/grey, cached and
pruned like the other row thumbnails. Thumbnails shrink on narrow rows so
the name keeps its minimum width.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Channels/Paths: temporary layer mask and layer path rows, aspect thumbnails, bottom footers (#154)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:45:12 -04:00
Brandon ThomasandClaude Opus 5.5 f5c6f4bb65 UI and Type tool CJK fallback: Japanese, Simplified/Traditional Chinese and Korean system fonts, lazily loaded in locale order (#149) (#185)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:23:03 -04:00
Brandon ThomasandClaude Opus 5.5 f857b4a31f Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91) (#183)
* Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)

Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.

Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.

Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test: clear texture deltas in transform gpu_ctx helper

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:50 -04:00
Brandon ThomasandClaude Opus 5.5 b9809ca63c Dock polish: Layers gets the height, Character/Paragraph group, eliding tab strips, reveal the active layer (#147, #150, #151, #152) (#181)
- #147: default-sized dock groups give way (down to compact heights) so the
  filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
  1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
  first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
  Character | Paragraph dock group below Properties instead of switching
  Properties; choosing the visible one again closes it; workspace presets and
  Reset close it. Saved layouts missing a group slot it below its default
  predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
  padding shrinks, then labels elide with '...', then overflowing tabs move
  into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
  Select commands, undo, automation) the Layers panel opens its closed parent
  groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
  tab rects never intersect the menu button in every theme at 1x/2x and
  180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
  doesn't scroll for visible rows); undo reveals. Adjustment-editor and
  shortcut tests no longer depend on default group heights.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:37 -04:00
Nigel Ihza Firdausy d94a8f617a fix(ui): avoid duplicate sliders for wide filter ranges (#179) 2026-10-05 07:22:22 -04:00
Nigel Ihza Firdausy 043e050237 fix(ui): pad Curves graph around endpoint handles (#177) 2026-10-05 07:22:07 -04:00
Nigel Ihza Firdausy d8cf255c47 fix(ui): sample eyedropper while dragging (#176) 2026-10-05 07:21:52 -04:00
Nigel Ihza Firdausy 107302f05c fix(ui): rerender Liquify proxy after undo (#175) 2026-10-05 07:21:37 -04:00
Brandon ThomasandClaude Opus 5.5 1c4cb8307c Brushes: Tilt Scale, Brush Projection, Spacing checkbox, section locks, Mixer Wetness/Mix; Brushes grid + drag and drop; options bar through tools.setBrush (#174)
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush

Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields

- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:23 -04:00
Brandon ThomasandClaude Opus 5.5 433b84fae7 Docs: where we stand and where we're going (roadmap, README, AGENTS.md) (#186)
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:14:53 -04:00
Brandon ThomasandClaude Opus 5.5 3389ba558b Menus scroll and navigate by keyboard on small displays; Color Range dialog on the no-GPU path (#145, #138) (#168)
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)

Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)

The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:25 -04:00
99 changed files with 10108 additions and 1324 deletions

No files matched your search

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