Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ecfd080416 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]>
2026-10-05 06:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 56080328ed 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]>
2026-10-05 06:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 10203e7a60 AGENTS.md: no Tauri, Electron or webview shells (#184)
The desktop app is native egui/eframe on wgpu and the web build is the same Rust compiled to
WebAssembly; state explicitly that Tauri (or any webview/JS UI framework) must never be added.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 05:41:36 -04:00
Brandon ThomasandClaude Opus 5.5 20783fdc70 Roadmap: honest parity assessment (2026-10-05) (#182)
parity.md's 625/625 measures menu wiring, not behaviour. Record a candid assessment after
0.2.0 and the first user feedback (feature surface ~55-70%, pro-switchable ~20-35%, area by area),
how to make parity measurable (workflow acceptance tests, real-file corpus, visual QA, user
reports), mark parity-estimate.md's ~82% as an upper bound, and update the current focus.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 05:20:57 -04:00
Brandon ThomasandClaude Opus 5.5 231bf5fe81 Flatpak single-file bundles as release artifacts (#163)
* Flatpak single-file bundle as a release artifact (#140)

packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml repackages the
release's Linux tarball (no Rust build, no network) on the freedesktop 26.08
runtime; packaging/linux/flatpak-bundle.sh builds it with flatpak-builder and
flatpak build-bundle and smoke-tests it. release.yml gains a Flatpak job per
arch that feeds the draft release and SHA256SUMS. The Flathub manifest moves to
26.08 with the same documented finish-args (portals + Pictures/Documents), and
packaging-lint checks the two manifests agree. Font scanning also looks in
/run/host/fonts and /run/host/user-fonts so host fonts show up in the sandbox.

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

* Docs: installing the Flatpak bundle (#140)

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

* Flatpak job: install the SVG pixbuf loader for appstream compose

Without librsvg2-common (absent on the x86_64 runner image) the host's
appstreamcli compose fails with file-read-error on the scalable icon.

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

* release.yml: the Flatpak job runs without the release environment

It only repackages the Linux job's tarball and signs nothing, so it doesn't need (and
shouldn't hold) the signing secrets while running third-party tooling.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 04:15:21 -04:00
Brandon ThomasandClaude Opus 5.5 4195f25f27 io tests: PSD type-layer bounds regression (#148) (#165)
Hand-built TySh/EngineData type layers as Photoshop writes them (point,
paragraph BoxBounds, scaled and rotated transforms, multi-line with explicit
leading, mixed sizes, 72/144/300 dpi) go through PSD export -> import and must
keep their full pixel extent and layer bounds, parse to the right pt size,
transform and shape, re-render with our engine onto the same pixels (bounds
within 1 px, IoU > 0.97) and composite to the file's merged image.

Investigation of #148: imported text bounds were already right. In the
designer QA PSD, 'Headline 105' carries a 300x200 vector mask added by the
generator (every i % 10 == 5 leaf), which clips the text at x = 685; with the
mask deleted the full line renders. H 29 px is the ink height of a 36 px line
without descenders, as Photoshop reports it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 04:15:08 -04:00
Nigel Ihza Firdausy 6293f51954 fix(ui): make tall menus scrollable on short displays (#160) 2026-10-05 04:14:53 -04:00
a202171891 fix(geom): saturate Rect::translate and inflate on i32 overflow (#159)
* fix(geom): saturate Rect::translate and inflate on i32 overflow

from_xywh already uses saturating_add. translate and inflate used
plain +/- and panicked in debug (or wrapped in release) when a layer
near the i32 edges was nudged off-canvas.

* fmt: Rect::translate/inflate at the workspace width (#159)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 04:14:40 -04:00
Brandon ThomasandClaude Opus 5.5 a6c5fa6732 Layers rows: name truncates before the fx/lock/link indicators; fx triangle; Collapse All Groups (#144, #143) (#167)
- Layers panel rows lay out their right-hand indicators first (lock, fx badge
  with an effects disclosure triangle, link, optional blend-mode label) and
  give the name the remaining width, cut with an ellipsis. The Studio kind
  sub-label and the Effects sub-rows truncate the same way; a narrow panel
  squeezes group indentation before the name. Rows leave room for the
  overlay scrollbar. Drawn rects are recorded for tests (layer_row_ui).
- New view-state command layer.setEffectsExpanded (DocState::fx_collapsed,
  no history step), driven by the fx triangle (Alt-click: all layers).
- Layers panel menu: Collapse All Groups (layer.setExpanded all=false).
  Group open/closed state is document data (PSD lsct, .pcraft).
- ui.set {dockWidth} sets the right dock width (250..520) for agents and
  snapshots.
- engine example designer_psd: ~140-layer designer PSD (4-deep groups, long,
  CJK/Greek/accented names, fx, clipping, masks, vector masks, locks, links,
  type/shape/fill/adjustment layers, hidden layers) for visual QA.
- Tests: name never intersects an indicator at 1x/1.5x/2x, 250/290/520 pt and
  in every theme; fx triangle hides/shows effect rows without selecting;
  Collapse All Groups from the panel menu, state survives PSD and .pcraft.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 03:38:47 -04:00
Brandon ThomasandClaude Opus 5.5 177620a58d Layers groups expand/collapse (#126); every displayed shortcut fires (#127) (#141)
* Layers panel: group disclosure triangles expand/collapse groups (#126)

Group rows get a disclosure triangle; click toggles the group, Alt-click sets
every group. Rows now list a group directly above its contents (the panel
used to reverse a pre-order walk, putting headers below their children) and
closed groups hide their contents (a kind filter still shows them).

The state is Group::expanded (PSD section divider open/closed folder,
.pcraft), toggled by the new layer.setExpanded command as a view change:
no history step, a clean document stays clean. document.inspect reports it.

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

* Shortcuts: every displayed shortcut fires, from wherever focus is (#127)

- The menus show the catalogue's Photoshop shortcuts, but only command and
  shell shortcuts were bound: Image Size (Cmd+Alt+I), Canvas Size
  (Cmd+Alt+C), Feather (Shift+F6), Layer > Quick Export / Export As,
  Window > Info (F8) and Actions (Alt+F9) showed a key that did nothing.
  The binding table (new shortcut_dispatch module) now merges the
  catalogue's shortcuts for live items, one owner per key.
- Any focused widget swallowed every shortcut (egui_wants_keyboard_input):
  after clicking into a value field (Layers Opacity/Fill, options bar
  Opacity/Flow/Smoothing), the Curves graph, or Tab, nothing fired until
  the canvas was clicked. Now a focused text field keeps typing and its
  editing keys (select all, clipboard, undo, navigation) while Cmd
  shortcuts and F-keys still fire, as in Photoshop; a focused non-text
  widget keeps only its navigation keys.
- A shortcut whose command is disabled says why on the status bar.
- Layers panel rows carry their layer name for accessibility (and tests).

Tests press every displayed menu / shell shortcut and every tool letter in
a whole-app kittest harness with a layered document (group, type layer,
selection) from four focus places, asserting each dispatches its item or
the item is greyed with a reason; plus real runs of the common ones.

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

* UI tests: scroll Layers rows into view under the fixed-height dock

The dock (#139) gives the Layers panel a fixed height that scrolls, so a
group's triangle or a row can sit below the fold. Tests now scroll the
node into view (accesskit ScrollIntoView) and click its real rect.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 02:28:57 -04:00
Brandon ThomasandClaude Opus 5.5 3c54849ba6 Type tool: caret/selection land on the glyph under the pointer; font size is live, one undo step, ~60x faster (#123, #124) (#142)
* Canvas: tool gestures start at the press origin, not where egui recognised the drag (#123)

A drag is only recognised once the pointer has moved past egui's click
distance; the Down event used the pointer position at that moment, so a
Type tool drag-selection's anchor landed a glyph or more away from where
the button went down. Adds a text-engine test that the caret geometry
(clusters, lines) sits on the rendered glyphs for tracking, horizontal
scale, mixed sizes and wrapped, centred paragraph text.

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

* Type tool: PSD type edits on the layout it shows; size edits are live, one step per drag, sized as shown (#123, #124)

- Clicking into a PSD type layer whose pixels are still Photoshop's
  re-renders it with our engine first (inside the edit session's history
  step; Cancel restores Photoshop's pixels), so caret and selection sit on
  what is shown. Hit-testing a type layer also accepts its shown pixels.
- type.edit / type.setStyle damage only the layer's old and new pixels, so
  the canvas recomposites that area instead of the whole document.
- Scrubbing a size (or any) type field coalesces per drag: live preview
  every frame, one history step per drag.
- Options bar and Properties show and edit font size and leading as the
  layer appears (size x transform scale, like Photoshop), and the options
  bar shows the size at the selection.
- examples/type_bench: 6000x4000, 50 type layers, size step went from
  297 ms to 4.6 ms (CPU canvas), 489 ms to 10 ms with drop shadows.

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

* Crop test: the frame starts at the press origin now (#123)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 01:54:15 -04:00
MikeeI 28a63fe27e fix(io): recognize reordered PhotoCraft archives (#137) 2026-10-05 01:37:25 -04:00
MikeeI 7862267069 fix(engine): preserve redo on unchanged bit depth (#133) 2026-10-05 01:37:12 -04:00
Brandon ThomasandClaude Opus 5.5 800f264ee3 Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush (#136)
* tools.setBrush: skip untouched tip/pattern bitmaps; enum variant changes replace

merge_brush no longer base64-round-trips sampled tips and pattern tiles the
patch doesn't name: a Brush Settings edit on a 2500 px imported tip goes from
121 ms to 0.68 ms. A patch switching an externally tagged enum's variant
(texture pattern tile -> procedural) now replaces it instead of merging into
an invalid two-variant object.

Refs #95

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

* Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush

- Sections split into brush_sections.rs: Brush Tip Shape with a tip grid of
  preset tips (relative-size thumbnails), Restore original size, Flip X/Y, a
  draggable angle/roundness ellipse with fields, Hardness (greyed for sampled
  tips), Spacing; Shape Dynamics, Scattering, Texture (pattern swatch, style,
  invert, scale, brightness, contrast, each tip, mode, depth, minimum depth,
  depth jitter), Dual Brush (mode, flip, tip grid, size, spacing, scatter,
  both axes, count), Color Dynamics, Transfer, Brush Pose (tilt now in ±100 %
  of the model's ±90°, was off by 90x), Build-up, Smoothing.
- Control pop-ups offer the direction controls only for Angle; Fade shows its
  step count; disabled sections show their options greyed; clicking a section
  name shows and turns it on.
- Every edit becomes one tools.setBrush with only the changed fields (bitmaps
  skipped unless changed), so the panel is journaled and drivable.
- brush_preview.rs: slot cache keyed on a cheap settings signature; the strip
  and thumbnails re-render only on change and no longer leak a texture per
  slider value.
- Brushes tab: size slider, search, collapsible groups (state in
  UiState.brushes_panel), tip thumbnail + stroke preview + name rows, new /
  delete / import buttons.

Refs #95

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 01:37:01 -04:00
Brandon ThomasandClaude Opus 5.5 b4b1aab4ad Dock: fixed-height panel groups with splitters, collapse, reorder and a remembered layout (#139)
Panel group heights no longer follow their content (#88): switching between a
pixel and an adjustment layer used to grow Properties and squeeze Layers.

- New `dock` module: `DockLayout` (order, heights, collapsed) in `UiState::dock`
  (serde default, settable with `ui.set {dock}`). The last expanded group fills
  the column; content taller than its group scrolls inside it.
- Drag the gap between groups to resize (min heights, clamped to the window);
  double-click a tab or use the panel menu (hamburger, now live) to collapse,
  move a group up/down, or close it; drag a tab strip to reorder (blocked by
  Lock Workspace).
- Layout saved with New Workspace, reset by Reset Workspace (default heights,
  order and tabs), and remembered across launches in `prefs.panelLayout` while
  Remember Workspace Changes is on.
- #129: the icon rail toggled panel visibility, so a stray click made a panel
  vanish. It now shows / expands / collapses a docked group and never hides it.
  Window > <panel> and Window > Color/Gradients/... expand a collapsed group
  instead of hiding it, so the menu always brings a panel back.

Tests: kittests for stable rects across layer kinds (Pro and Studio), splitter
drag + UI-state round trip + prefs persistence, reset/new workspace, double-click
collapse and strip-drag reorder (and lock), tiny windows, rail/Window menu
recovery, and a click-around test over layers, canvas, toolbar and options bar.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 01:36:48 -04:00
60 changed files with 6244 additions and 744 deletions

No files matched your search

+10 -1
View File
@@ -50,4 +50,13 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft' and m['command'] == 'photocraft'"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
+39 -1
View File
@@ -201,6 +201,44 @@ jobs:
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
web:
name: Web (wasm)
needs: version
@@ -236,7 +274,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
needs: [version, macos, windows, linux, flatpak, web]
runs-on: ubuntu-latest
environment: release
permissions:
+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
+8 -1
View File
@@ -257,7 +257,14 @@ cargo test --workspace # the test suite
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
```sh
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
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).
+182
View File
@@ -0,0 +1,182 @@
//! Generate a realistic "designer" PSD for Layers-panel QA (#143, #144): about 150 layers in
//! groups nested four deep, long and short names (Latin, accented, CJK), many layers with
//! effects, clipping masks, layer and vector masks, locks, links, smart objects, adjustment,
//! fill, shape and type layers, hidden layers and a mix of open and closed groups. It is built
//! through engine commands and saved through the real PSD exporter.
//!
//! ```sh
//! cargo run -p photocraft-engine --example designer_psd -- out.psd
//! ```
use photocraft_engine::Session;
use serde_json::{Value, json};
const NAMES: &[&str] = &[
"BG",
"a",
"Button / Primary / Hover state with a very long descriptive name that keeps going",
"背景のテクスチャ 夕焼け",
"Überschrift – Größe ½ (Entwurf)",
"Shadow copy 3",
"Rectangle 12",
"Photo — Model portrait, retouched final FINAL v7 (approved by client)",
"图层 12 副本",
"Icon",
"Highlight",
"Card / Product thumbnail / Hover",
"Ελληνικά κείμενο",
"Glow",
"Divider line",
"카드 배경",
];
struct Gen {
s: Session,
n: usize,
}
impl Gen {
fn run(&mut self, id: &str, p: Value) -> Value {
match self.s.execute(id, p.clone()) {
Ok(v) => v,
Err(e) => {
eprintln!("{id} {p}: {e}");
Value::Null
}
}
}
fn active(&self) -> u64 {
self.s.active().and_then(|d| d.active_layer).map_or(0, |l| l.0)
}
/// One leaf layer of a kind picked from the running counter; returns its id.
fn leaf(&mut self) -> u64 {
let i = self.n;
self.n += 1;
let name = format!("{} {}", NAMES[i % NAMES.len()], if i.is_multiple_of(3) { String::new() } else { format!("#{i}") }).trim().to_string();
let (x, y) = ((i * 97 % 1400) as i64, (i * 53 % 1000) as i64);
let colour = format!("#{:02x}{:02x}{:02x}", (i * 47 % 256), (i * 91 % 256), (i * 23 % 256));
let id = match i % 17 {
3 => {
let v = self.run("type.create", json!({"x": x, "y": y + 40, "text": format!("Headline {i}: Autumn collection"), "size": 36, "name": name}));
v.get("layer").and_then(Value::as_u64).unwrap_or_else(|| self.active())
}
7 => {
self.run("layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 150], [255, 255]]}));
let id = self.active();
self.run("layer.setProps", json!({"layer": id, "name": format!("Curves {i}")}));
id
}
11 => {
self.run("shape.create", json!({"kind": "roundedRect", "rect": [x, y, 220, 90], "fill": colour, "name": name}));
self.active()
}
14 => {
self.run("layer.newFillLayer.solidColor", json!({"color": colour}));
self.active()
}
_ => {
self.run("layer.new.layer", json!({"name": name}));
self.run("select.rect", json!({"x": x, "y": y, "width": 180 + i % 5 * 40, "height": 120 + i % 3 * 30}));
self.run("edit.fill", json!({"contents": "color", "color": colour}));
self.run("select.deselect", json!({}));
self.active()
}
};
if i % 4 == 1 {
self.run("layer.layerStyle.dropShadow", json!({"layer": id, "distance": 6, "size": 10}));
if i % 8 == 1 {
self.run("layer.select", json!({"layer": id}));
self.run("layer.layerStyle.stroke", json!({"size": 2, "color": "#ffffff", "add": true}));
}
}
if i % 6 == 2 {
self.run("layer.layerMask.revealAll", json!({"layer": id}));
}
if i % 10 == 5 {
self.run("layer.select", json!({"layer": id}));
let path = json!({"subpaths": [{"knots": [[x, y], [x + 300, y], [x + 300, y + 200], [x, y + 200]]}]});
self.run("layer.vectorMask.add", json!({"layer": id, "path": path}));
}
if i % 9 == 4 {
self.run("layer.setProps", json!({"layer": id, "locks": {"all": true}}));
} else if i % 9 == 6 {
self.run("layer.setProps", json!({"layer": id, "locks": {"position": true}}));
}
if i % 8 == 3 {
self.run("layer.setProps", json!({"layer": id, "visible": false}));
}
if i % 12 == 10 {
self.run("layer.setProps", json!({"layer": id, "blend": "Multiply"}));
}
if i % 13 == 6 {
self.run("layer.select", json!({"layer": id}));
self.run("layer.smartObjects.convertToSmartObject", json!({"layer": id}));
}
self.s.active().and_then(|d| d.active_layer).map_or(id, |l| l.0)
}
/// `count` items above the active layer, the item at `nest` (if any) a group built one
/// level deeper, then everything grouped as `name`. Returns the group's id.
fn group(&mut self, name: &str, depth: usize, count: usize, open: bool) -> u64 {
let mut ids = Vec::new();
for k in 0..count {
if depth < 3 && k == count / 2 {
let sub = format!("{name} › Level {} {}", depth + 2, ["Cards", "サブグループ", "Details", "Variants"][depth % 4]);
ids.push(self.group(&sub, depth + 1, count.saturating_sub(2).max(3), !self.n.is_multiple_of(3)));
} else {
let id = self.leaf();
// Clip every 7th layer to the one below it (inside the same group).
if self.n.is_multiple_of(7) && !ids.is_empty() {
self.run("layer.setProps", json!({"layer": id, "clipped": true}));
}
ids.push(id);
}
}
self.run("layer.select", json!({"layer": ids[0]}));
for id in &ids[1..] {
self.run("layer.select", json!({"layer": id, "mode": "toggle"}));
}
let g = self.run("layer.groupLayers", json!({"name": name}))["layer"].as_u64().unwrap_or_else(|| self.active());
if depth == 1 && self.n.is_multiple_of(2) {
self.run("layer.layerStyle.dropShadow", json!({"layer": g}));
}
self.run("layer.setExpanded", json!({"layer": g, "expanded": open}));
self.run("layer.select", json!({"layer": g}));
g
}
}
fn main() -> Result<(), String> {
let out = std::env::args().nth(1).unwrap_or_else(|| "designer.psd".into());
let mut g = Gen { s: Session::new(), n: 0 };
g.run("file.new", json!({"width": 1600, "height": 1200, "name": "designer"}));
let groups = [
("Footer", 6, false),
("Retouching — dodge & burn, frequency separation", 7, true),
("Typography / 本文", 6, false),
("Product Grid — Autumn/Winter 2026 campaign (final, do not edit)", 8, true),
("ヒーローセクション", 7, true),
("Navigation", 5, false),
("Header / Logo lockup", 6, true),
];
for (name, count, open) in groups {
g.group(name, 0, count, open);
}
for _ in 0..10 {
g.leaf();
}
// Two linked layers at the top.
let a = g.leaf();
let b = g.leaf();
g.run("layer.select", json!({"layer": a}));
g.run("layer.select", json!({"layer": b, "mode": "toggle"}));
g.run("layer.linkLayers", json!({}));
let doc = g.s.active().map(|d| (*d.doc).clone()).ok_or("no document")?;
let layers = doc.walk().len();
let bytes = photocraft_io::export(&doc, &out, &Default::default()).map_err(|e| e.to_string())?.bytes;
std::fs::write(&out, &bytes).map_err(|e| e.to_string())?;
println!("wrote {out}: {layers} layers, {} bytes", bytes.len());
Ok(())
}
+34 -2
View File
@@ -84,6 +84,11 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
/// Deep-merge `patch` into `base` (objects merge key by key; anything else replaces).
fn merge(base: &mut Value, patch: &Value) {
match (base, patch) {
// A different variant of an externally tagged enum (`{"tile":…}` → `{"procedural":…}`)
// replaces the old one instead of merging into a two-variant object.
(Value::Object(b), Value::Object(p)) if b.len() == 1 && p.len() == 1 && b.keys().next() != p.keys().next() => {
*b = p.clone();
}
(Value::Object(b), Value::Object(p)) => {
for (k, v) in p {
match b.get_mut(k) {
@@ -100,9 +105,36 @@ fn merge(base: &mut Value, patch: &Value) {
/// Apply a JSON brush patch onto a brush.
pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result<BrushSettings> {
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
// Bitmaps the patch doesn't touch (sampled tips, pattern tiles: up to megabytes of base64) skip
// the JSON round trip, so a Brush Settings slider on an imported tip stays cheap.
let touches = |path: &[&str]| {
let mut v = patch;
for k in path {
match v.get(k) {
Some(x) => v = x,
// A non-object on the way replaces the whole section.
None => return !v.is_object(),
}
}
true
};
let mut light = base.clone();
let tip = (!touches(&["tip"])).then(|| std::mem::take(&mut light.tip));
let dual_tip = (!touches(&["dualBrush", "tip"])).then(|| std::mem::take(&mut light.dual_brush.tip));
let pattern = (!touches(&["texture", "pattern"])).then(|| std::mem::take(&mut light.texture.pattern));
let mut v = serde_json::to_value(&light).map_err(|e| bad(cmd, e.to_string()))?;
merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
let mut out: BrushSettings = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
if let Some(t) = tip {
out.tip = t;
}
if let Some(t) = dual_tip {
out.dual_brush.tip = t;
}
if let Some(p) = pattern {
out.texture.pattern = p;
}
Ok(out)
}
fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPreset> {
+39
View File
@@ -176,6 +176,45 @@ fn set_brush_merges_fields() {
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
}
#[test]
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
let base = BrushSettings {
tip: TipShape::Sampled(tile(900, 700, 3)),
dual_brush: photocraft_paint::DualBrush { tip: TipShape::Sampled(tile(30, 20, 2)), ..Default::default() },
texture: photocraft_paint::Texture { pattern: photocraft_paint::Pattern::Tile(tile(64, 64, 1)), ..Default::default() },
..Default::default()
};
let cmd = "tools.setBrush";
// Untouched bitmaps survive any patch that doesn't name them.
for patch in [json!({"size": 12}), json!({"dualBrush": {"size": 9}}), json!({"texture": {"depth": 0.5}}), json!({})] {
let b = merge_brush(&base, &patch, cmd).unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip, &b.texture.pattern), (&base.tip, &base.dual_brush.tip, &base.texture.pattern), "{patch}");
}
let b = merge_brush(&base, &json!({"size": 12, "dualBrush": {"size": 9}}), cmd).unwrap();
assert_eq!((b.size, b.dual_brush.size), (12.0, 9.0));
// Named ones are replaced.
let b = merge_brush(
&base,
&json!({"tip": "round", "dualBrush": {"tip": "round"}, "texture": {"pattern": {"procedural": {"style": "dots", "size": 32, "seed": 2}}}}),
cmd,
)
.unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip), (&TipShape::Round, &TipShape::Round));
assert!(matches!(b.texture.pattern, photocraft_paint::Pattern::Procedural { size: 32, .. }));
// A section replaced by a non-object is an error, not a silently kept bitmap.
assert!(merge_brush(&base, &json!({"dualBrush": 3}), cmd).is_err());
assert!(merge_brush(&base, &json!(null), cmd).is_err());
// Speed: a panel edit on a big imported tip doesn't base64 the tip.
let big = BrushSettings { tip: TipShape::Sampled(tile(2500, 2500, 7)), ..Default::default() };
let t0 = std::time::Instant::now();
for i in 0..10 {
merge_brush(&big, &json!({"size": 10 + i}), cmd).unwrap();
}
let ms = t0.elapsed().as_secs_f64() * 100.0;
eprintln!("merge_brush on a 2500 px tip: {ms:.2} ms per edit");
}
#[test]
fn define_brush_from_selection() {
let mut s = session(40, 40);
+2
View File
@@ -988,6 +988,8 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::wia_cmds::specs());
v.extend(crate::variables_cmds::specs());
v.extend(crate::plugin_cmds::specs());
v.extend(crate::group_view_cmds::specs());
v.extend(crate::fx_view_cmds::specs());
v
}
+134
View File
@@ -0,0 +1,134 @@
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
//! clean document stays clean.
use photocraft_doc::LayerId;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const ID: &str = "layer.setEffectsExpanded";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn has_effects(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
}
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
}
}
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: show or hide a layer's effects list (no
/// `expanded`: toggle). With `all`, every layer with effects takes the new state (⌥-click).
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
let want = opt_bool(p, "expanded")?;
let all = opt_bool(p, "all")?.unwrap_or(false);
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let layer = match p.get("layer") {
None | Some(Value::Null) if all => None,
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
let current = match layer {
Some(id) => {
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if l.effects.items.is_empty() {
return Err(bad(format!("layer {} has no effects", id.0)));
}
!st.fx_collapsed.contains(&id)
}
None => with_fx.iter().any(|id| !st.fx_collapsed.contains(id)),
};
let expanded = want.unwrap_or(!current);
let targets = if all { with_fx } else { layer.into_iter().collect() };
// Forget ids no longer in the document so the list can't grow without bound.
let doc = st.doc.clone();
st.fx_collapsed.retain(|id| doc.layer(*id).is_some() && !targets.contains(id));
if !expanded {
st.fx_collapsed.extend(targets.iter().copied());
}
Ok(json!({"expanded": expanded, "layers": targets.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Effects",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
enabled: has_effects,
run: set_expanded,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
s.execute("layer.layerStyle.stroke", json!({})).unwrap();
(s, a, b)
}
fn open(s: &Session, id: u64) -> bool {
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
}
#[test]
fn toggles_one_layer_or_all_without_history() {
let (mut s, a, b) = session();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
let steps = s.active().unwrap().history.entries().len();
assert!(open(&s, a) && open(&s, b), "effects lists start open");
assert_eq!(s.execute(ID, json!({"layer": a})).unwrap()["expanded"], false);
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().history.entries().len(), steps, "no history step");
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
s.execute(ID, json!({"layer": a, "all": true})).unwrap();
assert!(open(&s, a) && open(&s, b), "toggling an open-and-closed set from a closed layer opens all");
s.execute(ID, json!({"all": true, "expanded": false})).unwrap();
assert!(!open(&s, a) && !open(&s, b));
s.execute(ID, json!({"layer": b, "expanded": true})).unwrap();
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().fx_collapsed.len(), 1, "no duplicates");
}
#[test]
fn bad_params_fail_gracefully() {
let (mut s, _, _) = session();
let bg = s.active().unwrap().doc.layers[0].id.0;
for p in [
json!({"layer": bg}),
json!({"layer": 9999}),
json!({"layer": "x"}),
json!({"layer": -1}),
json!({"expanded": "yes", "all": true}),
json!({"all": 3}),
] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute(ID, json!({})).is_err());
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no effects: disabled");
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Layers panel disclosure state: expanding and collapsing layer groups (#126).
//!
//! A group's open/closed state is document data ([`photocraft_doc::Group::expanded`], the PSD
//! section divider's open/closed folder type, saved in `.pcraft` too) but, as in Photoshop,
//! toggling it is a view change: no history step, and a clean document stays clean.
use std::sync::Arc;
use photocraft_doc::{Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const ID: &str = "layer.setExpanded";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn has_group(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| l.is_group()) { Ok(()) } else { Err("the document has no layer groups".into()) }
}
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
}
}
fn set_all(layers: &mut [Layer], expanded: bool) -> usize {
let mut n = 0;
for l in layers {
if let LayerContent::Group(g) = &mut l.content {
g.expanded = expanded;
n += 1 + set_all(&mut g.children, expanded);
}
}
n
}
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: open or close a group (no `expanded`: toggle).
/// With `all`, every group in the document takes the new state (Photoshop's ⌥-click on a
/// disclosure triangle); without a layer, `all` toggles from "any group open".
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
let want = opt_bool(p, "expanded")?;
let all = opt_bool(p, "all")?.unwrap_or(false);
let st = s.active().ok_or(EngineError::NoDocument)?;
let layer = match p.get("layer") {
None | Some(Value::Null) if all => None,
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let current = match layer {
Some(id) => match &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content {
LayerContent::Group(g) => g.expanded,
_ => return Err(bad(format!("layer {} is not a group", id.0))),
},
None => st.doc.walk().iter().any(|(_, _, l)| matches!(&l.content, LayerContent::Group(g) if g.expanded)),
};
let expanded = want.unwrap_or(!current);
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
// Copy-on-write: history snapshots keep their own state (layers share tiles, so this is cheap).
let doc = Arc::make_mut(&mut st.doc);
let changed = if all {
set_all(&mut doc.layers, expanded)
} else {
let id = layer.ok_or_else(|| bad("no layer"))?;
match doc.layer_mut(id).map(|l| &mut l.content) {
Some(LayerContent::Group(g)) => {
g.expanded = expanded;
1
}
_ => return Err(EngineError::NoLayer(id)),
}
};
Ok(json!({"expanded": expanded, "groups": changed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Group",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every group; not an undo step)"##,
enabled: has_group,
run: set_expanded,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
(s, inner, outer)
}
fn expanded(s: &Session, id: u64) -> bool {
matches!(&s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Group(g) if g.expanded)
}
#[test]
fn toggles_one_group_without_history_or_dirtying() {
let (mut s, inner, outer) = session();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
let undo_steps = s.active().unwrap().history.entries().len();
assert!(expanded(&s, inner) && expanded(&s, outer));
assert_eq!(s.execute(ID, json!({"layer": inner})).unwrap()["expanded"], false);
assert!(!expanded(&s, inner) && expanded(&s, outer));
assert_eq!(s.active().unwrap().history.entries().len(), undo_steps, "no history step");
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
s.execute(ID, json!({"layer": inner, "expanded": true})).unwrap();
assert!(expanded(&s, inner));
// The inspect JSON reports it.
s.execute(ID, json!({"layer": outer, "expanded": false})).unwrap();
assert_eq!(s.execute("document.inspect", json!({})).unwrap()["layers"][0]["expanded"], false);
}
#[test]
fn all_sets_every_group() {
let (mut s, inner, outer) = session();
s.execute(ID, json!({"layer": outer, "all": true})).unwrap();
assert!(!expanded(&s, inner) && !expanded(&s, outer));
assert_eq!(s.execute(ID, json!({"all": true})).unwrap()["groups"], 2);
assert!(expanded(&s, inner) && expanded(&s, outer));
}
#[test]
fn bad_params_fail_gracefully() {
let (mut s, _, _) = session();
let bg = s.active().unwrap().doc.layers[0].id.0;
for p in [
json!({"layer": bg}),
json!({"layer": 9999}),
json!({"layer": "x"}),
json!({"layer": -1}),
json!({"expanded": "yes", "all": true}),
json!({"all": 3}),
] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute(ID, json!({})).is_err());
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no groups: disabled");
}
}
+10 -3
View File
@@ -314,10 +314,12 @@ fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value> {
/// Image → Mode → 8/16/32 Bits/Channel.
fn convert_depth(s: &mut Session, depth: SampleType) -> Result<Value> {
let current = s.active().ok_or(EngineError::NoDocument)?.doc.depth;
if current == depth {
// History::record clears redo even when an edit closure leaves the document unchanged.
return Ok(Value::Null);
}
s.edit("Bit Depth", |doc, _| {
if doc.depth == depth {
return Ok(());
}
for_each_surface(&mut doc.layers, true, &mut |surf, _| {
let f = surf.format().with_sample(depth);
*surf = surf.convert(f);
@@ -550,6 +552,11 @@ mod tests {
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U8));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s).depth, SampleType::F32);
let revision = s.active().unwrap().revision;
s.execute("image.mode.bits32", json!({})).unwrap();
assert_eq!(s.active().unwrap().revision, revision, "an unchanged bit depth must preserve the history branch");
assert!(s.redo(), "selecting the current depth must not discard the undone conversion");
assert_eq!(doc(&s).depth, SampleType::U8);
}
#[test]
+1
View File
@@ -85,6 +85,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
}
+6
View File
@@ -30,7 +30,9 @@ pub mod file_cmds;
pub mod filters;
pub mod filters_ext;
mod frame_cmds;
pub mod fx_view_cmds;
pub mod gallery_cmds;
pub mod group_view_cmds;
pub mod image_cmds;
pub mod inspect;
pub mod layer_menu_cmds;
@@ -143,6 +145,9 @@ pub struct DocState {
pub channel_view: channel_cmds::ChannelView,
/// Select › Isolate Layers: the Layers panel lists only these layers (empty = off; view state).
pub isolated_layers: Vec<LayerId>,
/// Layers panel: layers whose effects list is collapsed under their row (the fx triangle;
/// view state, not history). Effects lists start open.
pub fx_collapsed: Vec<LayerId>,
}
impl DocState {
@@ -161,6 +166,7 @@ impl DocState {
coalesce: None,
channel_view: Default::default(),
isolated_layers: Vec::new(),
fx_collapsed: Vec::new(),
}
}
/// The selected layers in bottom-to-top document order, always including the active layer.
+4
View File
@@ -722,6 +722,10 @@ pub struct Preferences {
pub workspaces: BTreeMap<String, Value>,
/// Window › Workspace › Lock Workspace: panels can't be moved.
pub workspace_locked: bool,
/// The live panel layout (dock group heights, order, collapsed groups, open panels), saved
/// as the user changes it and restored at launch when Workspace › Remember Workspace
/// Changes is on. JSON owned by the shell.
pub panel_layout: Value,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}
+30 -3
View File
@@ -358,16 +358,24 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) {
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result<R>) -> Result<R> {
let id = layer_id(s, p)?;
s.edit(label, |doc, _| {
let (r, damage) = s.edit(label, |doc, _| {
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Text(t) = &mut l.content else {
return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name())));
};
let before = t.cache.as_ref().map(|c| c.tile_bounds());
let r = f(t, &snapshot)?;
refresh(&snapshot, t);
Ok(r)
})
// Only this layer's pixels changed: the canvas recomposites their old and new area
// instead of the whole document (#124). Unknown old pixels mean a full refresh.
let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b));
Ok((r, damage))
})?;
if let Some(st) = s.active_mut() {
st.last_damage = damage;
}
Ok(r)
}
fn layer_name(text: &str) -> String {
@@ -756,6 +764,25 @@ mod tests {
assert!(!faces["faces"].as_array().unwrap().is_empty());
}
/// #124: a type edit damages only the layer's old and new pixels (the canvas recomposites
/// that, not the document), and a drag's coalesced steps are one history step.
#[test]
fn type_edits_damage_only_the_layer_and_coalesce() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 3000, "height": 2000})).unwrap();
let id = s.execute("type.create", json!({"x": 100, "y": 100, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap();
let steps = s.active().unwrap().history.entries().len();
for k in 0..5 {
s.execute("type.setStyle", json!({"layer": id, "size": 30 + k * 10, "coalesce": "drag"})).unwrap();
let d = s.active().unwrap().last_damage.expect("damage rect");
let r = text_layer(&s, id).cache.unwrap().content_bounds();
assert!(d.intersect(&r) == r && d.width() <= 512 && d.height() <= 512, "{d:?} {r:?}");
}
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
assert!(s.undo());
assert_eq!(text_layer(&s, id).runs[0].style.size_pt, 20.0);
}
#[test]
fn style_range_on_empty_text() {
let mut t = TextLayer::default();
+12 -5
View File
@@ -91,13 +91,20 @@ impl Default for LoadOptions {
}
}
/// `true` if `bytes` look like a `.pcraft` ZIP bundle (a ZIP whose first
/// entry is `manifest.json`, which is how we write them).
/// `true` when ZIP bytes contain a `manifest.json` entry.
///
/// Bundles written here place the manifest first; re-zipped bundles may reorder entries.
pub fn is_pcraft(bytes: &[u8]) -> bool {
bytes.len() > 30 && bytes.starts_with(b"PK\x03\x04") && {
let n = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
bytes.get(30..30 + n) == Some(b"manifest.json")
if !bytes.starts_with(b"PK\x03\x04") {
return false;
}
if bytes.len() >= 30 {
let name_len = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
if bytes.get(30..30 + name_len) == Some(b"manifest.json") {
return true;
}
}
zip::ZipReader::new(bytes).is_ok_and(|archive| archive.find("manifest.json").is_some())
}
/// Save to an in-memory ZIP bundle (one-shot; use [`PcraftWriter`] for
+29 -11
View File
@@ -126,10 +126,10 @@ pub struct ZipReader<'a> {
}
fn u16_at(b: &[u8], o: usize) -> Result<u16, FormatError> {
b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).ok_or_else(|| FormatError::corrupt("truncated zip"))
o.checked_add(2).and_then(|end| b.get(o..end)).map(|s| u16::from_le_bytes([s[0], s[1]])).ok_or_else(|| FormatError::corrupt("truncated zip"))
}
fn u32_at(b: &[u8], o: usize) -> Result<u32, FormatError> {
b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])).ok_or_else(|| FormatError::corrupt("truncated zip"))
o.checked_add(4).and_then(|end| b.get(o..end)).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])).ok_or_else(|| FormatError::corrupt("truncated zip"))
}
impl<'a> ZipReader<'a> {
@@ -146,12 +146,11 @@ impl<'a> ZipReader<'a> {
let count = u16_at(bytes, eocd + 10)? as usize;
let cd_size = u32_at(bytes, eocd + 12)? as usize;
let cd_off = u32_at(bytes, eocd + 16)? as usize;
if cd_off.checked_add(cd_size).is_none_or(|e| e > eocd) {
return Err(FormatError::corrupt("zip central directory out of range"));
}
let cd_end = cd_off.checked_add(cd_size).filter(|end| *end <= eocd).ok_or_else(|| FormatError::corrupt("zip central directory out of range"))?;
let mut entries = Vec::with_capacity(count.min(bytes.len() / 46));
let mut p = cd_off;
for _ in 0..count {
let header_end = p.checked_add(46).filter(|end| *end <= cd_end).ok_or_else(|| FormatError::corrupt("truncated zip central directory entry"))?;
if u32_at(bytes, p)? != CENTRAL_SIG {
return Err(FormatError::corrupt("bad zip central directory entry"));
}
@@ -164,23 +163,36 @@ impl<'a> ZipReader<'a> {
let elen = u16_at(bytes, p + 30)? as usize;
let clen = u16_at(bytes, p + 32)? as usize;
let local = u32_at(bytes, p + 42)? as usize;
let name = bytes.get(p + 46..p + 46 + nlen).ok_or_else(|| FormatError::corrupt("truncated zip name"))?;
let name_end = header_end.checked_add(nlen).filter(|end| *end <= cd_end).ok_or_else(|| FormatError::corrupt("zip entry name out of range"))?;
let name = bytes.get(header_end..name_end).ok_or_else(|| FormatError::corrupt("truncated zip name"))?;
let name = String::from_utf8_lossy(name).into_owned();
if flags & 1 != 0 {
return Err(FormatError::Unsupported("encrypted zip entries".into()));
}
// Bound each entry by the declared central-directory extent before advancing.
let entry_end = name_end
.checked_add(elen)
.and_then(|end| end.checked_add(clen))
.filter(|end| *end <= cd_end)
.ok_or_else(|| FormatError::corrupt("zip central directory entry out of range"))?;
// Local header gives the real data offset.
if u32_at(bytes, local)? != LOCAL_SIG {
return Err(FormatError::corrupt("bad zip local header"));
}
let lnlen = u16_at(bytes, local + 26)? as usize;
let lelen = u16_at(bytes, local + 28)? as usize;
let data_offset = local + 30 + lnlen + lelen;
if data_offset.checked_add(compressed).is_none_or(|e| e > bytes.len()) {
let local_name_len = local.checked_add(26).ok_or_else(|| FormatError::corrupt("zip local header offset overflow"))?;
let local_extra_len = local.checked_add(28).ok_or_else(|| FormatError::corrupt("zip local header offset overflow"))?;
let lnlen = u16_at(bytes, local_name_len)? as usize;
let lelen = u16_at(bytes, local_extra_len)? as usize;
let data_offset = local
.checked_add(30)
.and_then(|offset| offset.checked_add(lnlen))
.and_then(|offset| offset.checked_add(lelen))
.ok_or_else(|| FormatError::corrupt("zip data offset overflow"))?;
if data_offset.checked_add(compressed).is_none_or(|end| end > bytes.len()) {
return Err(FormatError::corrupt(format!("zip entry `{name}` out of range")));
}
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
p += 46 + nlen + elen + clen;
p = entry_end;
}
Ok(ZipReader { bytes, entries })
}
@@ -258,4 +270,10 @@ mod tests {
let r = ZipReader::new(&bytes).unwrap();
assert!(r.read_by_name("a", 100).is_err());
}
#[test]
fn offset_access_rejects_overflow() {
assert!(u16_at(&[], usize::MAX).is_err());
assert!(u32_at(&[], usize::MAX - 3).is_err());
}
}
+15 -2
View File
@@ -84,10 +84,10 @@ impl Rect {
Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1))
}
pub fn translate(&self, dx: i32, dy: i32) -> Rect {
Rect::new(self.x0 + dx, self.y0 + dy, self.x1 + dx, self.y1 + dy)
Rect::new(self.x0.saturating_add(dx), self.y0.saturating_add(dy), self.x1.saturating_add(dx), self.y1.saturating_add(dy))
}
pub fn inflate(&self, d: i32) -> Rect {
Rect::new(self.x0 - d, self.y0 - d, self.x1 + d, self.y1 + d)
Rect::new(self.x0.saturating_sub(d), self.y0.saturating_sub(d), self.x1.saturating_add(d), self.y1.saturating_add(d))
}
/// All tiles overlapping this rect.
pub fn tiles(&self) -> impl Iterator<Item = TileCoord> + use<> {
@@ -242,4 +242,17 @@ mod tests {
// translate first, then scale
assert_eq!(t.apply(Point::new(1.0, 0.0)), Point::new(22.0, 0.0));
}
#[test]
fn translate_and_inflate_saturate_on_overflow() {
let r = Rect::new(i32::MAX - 10, i32::MIN + 10, i32::MAX, i32::MIN + 20);
let t = r.translate(100, -100);
assert_eq!(t.x0, i32::MAX);
assert_eq!(t.x1, i32::MAX);
assert_eq!(t.y0, i32::MIN);
assert_eq!(t.y1, i32::MIN);
let grown = Rect::new(i32::MAX - 5, i32::MIN + 5, i32::MAX - 1, i32::MIN + 10).inflate(10);
assert_eq!(grown.x1, i32::MAX);
assert_eq!(grown.y0, i32::MIN);
}
}
+6 -1
View File
@@ -108,7 +108,8 @@ pub fn is_psd(bytes: &[u8]) -> bool {
/// Imports a file. PSD/PSB and camera raws are detected by magic; everything
/// else is decoded with `photocraft-codecs`.
pub fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
if photocraft_format::is_pcraft(bytes) {
// A declared native extension must reach its loader so malformed bundles retain format errors.
if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) {
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
}
if is_psd(bytes) {
@@ -127,6 +128,10 @@ fn extension(name_or_ext: &str) -> String {
name_or_ext.rsplit(['.', '/', '\\']).next().unwrap_or(name_or_ext).to_ascii_lowercase()
}
fn has_extension(name: &str, expected: &str) -> bool {
name.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).is_some_and(|(base, ext)| !base.is_empty() && ext.eq_ignore_ascii_case(expected))
}
/// Exports `doc` to the format named by `name_or_ext` (a file name, path or
/// bare extension).
pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result<ExportResult, IoError> {
+18
View File
@@ -216,4 +216,22 @@ fn synthetic_pcraft_roundtrip_keeps_everything() {
let c = &fresh.layer_comps[0];
assert_eq!(c.states.len(), fresh.layer_count());
assert!(c.states.iter().all(|s| fresh.layer(s.layer).is_some()));
// Native identity is independent of ZIP entry order and the filename extension.
let zip = photocraft_format::zip::ZipReader::new(&bytes).unwrap();
let mut reordered = photocraft_format::zip::ZipWriter::new();
reordered.add("extra.txt", b"not the manifest").unwrap();
for entry in &zip.entries {
reordered.add(&entry.name, &zip.read(entry, bytes.len()).unwrap()).unwrap();
}
let reordered = reordered.finish().unwrap();
for name in ["x.PCRAFT", "no-extension"] {
let imported = import(name, &reordered).unwrap().document;
assert_eq!(imported.layer_comps, d.layer_comps);
assert_eq!(imported.layers[1].artboard(), d.layers[1].artboard());
}
let png = export(&d, "x.png", &ExportOptions::default()).unwrap().bytes;
assert!(import("pcraft", &png).is_ok());
assert!(matches!(import("broken.pcraft", b"not a native bundle"), Err(photocraft_io::IoError::Pcraft(_))));
}
+302
View File
@@ -0,0 +1,302 @@
//! Imported type-layer geometry (#148): PSD type layers built the way Photoshop writes them (a
//! `TySh` with the text-space → document transform, `EngineData` with `StyleRun`/`ParagraphRun`
//! sizes in text-space units, `BoxBounds` for paragraph text, `bounds`/`boundingBox` and the
//! trailing integer bounds) must import with the right model, so that
//! - the layer's pixels (Photoshop's cache) keep their full extent: nothing is clipped,
//! - re-rendering the imported model with our engine lands on the same pixels (no transform
//! scale applied twice or not at all, no 72-vs-document-dpi mix-up, no box height used as a
//! clip for point text), and
//! - the document composite matches the file's merged composite.
//!
//! Fonts: the bundled Inter (deterministic), so our render is the "Photoshop" render here.
mod common;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::TextShape;
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
use photocraft_geom::{Affine, Rect};
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D};
use photocraft_raster::Surface;
use photocraft_text::engine_data::Value as E;
use photocraft_text::psd::{TySh, write_tysh};
/// One character run: (UTF-16 length, font size in text-space units, explicit leading).
struct Run {
len: usize,
size: f64,
leading: Option<f64>,
}
fn real(v: f64) -> E {
E::Real(v)
}
fn style(size: f64, leading: Option<f64>) -> E {
E::Dict(vec![
("Font".into(), E::Int(0)),
("FontSize".into(), real(size)),
("AutoLeading".into(), E::Bool(leading.is_none())),
("Leading".into(), real(leading.unwrap_or(0.0))),
("FillColor".into(), E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![real(1.0), real(0.0), real(0.0), real(0.0)]))])),
])
}
/// Photoshop-shaped EngineData for `text` (paragraphs separated by `\r`).
fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>) -> E {
let ed_text = format!("{text}\r");
let n16 = ed_text.encode_utf16().count() as i64;
let mut sruns = Vec::new();
let mut slens = Vec::new();
for (i, r) in runs.iter().enumerate() {
sruns.push(E::Dict(vec![("StyleSheet".into(), E::Dict(vec![("StyleSheetData".into(), style(r.size, r.leading))]))]));
slens.push(E::Int(r.len as i64 + i64::from(i + 1 == runs.len())));
}
let para = E::Dict(vec![("Justification".into(), E::Int(0)), ("AutoLeading".into(), real(1.2))]);
let mut photoshop = vec![("ShapeType".into(), E::Int(i64::from(box_bounds.is_some())))];
match box_bounds {
Some(b) => photoshop.push(("BoxBounds".into(), E::Array(b.iter().map(|v| real(*v)).collect()))),
None => photoshop.push(("PointBase".into(), E::Array(vec![real(0.0), real(0.0)]))),
}
let shape = E::Dict(vec![
("ShapeType".into(), E::Int(i64::from(box_bounds.is_some()))),
("Cookie".into(), E::Dict(vec![("Photoshop".into(), E::Dict(photoshop))])),
]);
let resources = E::Dict(vec![
("FontSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Inter-Regular".into())), ("Script".into(), E::Int(0))])])),
("StyleSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("StyleSheetData".into(), style(12.0, None))])])),
("ParagraphSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("Properties".into(), para.clone())])])),
]);
E::Dict(vec![
(
"EngineDict".into(),
E::Dict(vec![
("Editor".into(), E::Dict(vec![("Text".into(), E::String(ed_text))])),
(
"ParagraphRun".into(),
E::Dict(vec![
("RunArray".into(), E::Array(vec![E::Dict(vec![("ParagraphSheet".into(), E::Dict(vec![("Properties".into(), para)]))])])),
("RunLengthArray".into(), E::Array(vec![E::Int(n16)])),
]),
),
("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(sruns)), ("RunLengthArray".into(), E::Array(slens))])),
("Rendered".into(), E::Dict(vec![("Shapes".into(), E::Dict(vec![("Children".into(), E::Array(vec![shape]))]))])),
]),
),
("ResourceDict".into(), resources),
])
}
/// A `TySh` block as Photoshop writes it.
fn tysh(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transform: Affine, ink: [f64; 4]) -> Vec<u8> {
let rect = |cls: &str| {
D::Descriptor(
Descriptor::new(cls)
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
)
};
let desc = Descriptor::new("TxLr")
.with("Txt ", D::Text(UnicodeString::new_nul(text)))
.with("textGridding", D::Enumerated { type_id: Id::new("textGridding"), value: Id::new("None") })
.with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") })
.with("AntA", D::Enumerated { type_id: Id::new("Annt"), value: Id::new("AnSm") })
.with("bounds", rect("bounds"))
.with("boundingBox", rect("boundingBox"))
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds))));
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
}
struct Case {
name: &'static str,
dpi: f32,
text: &'static str,
runs: Vec<Run>,
box_bounds: Option<[f64; 4]>,
transform: Affine,
/// Expected Character-panel size of the first run, in points.
size_pt: f32,
}
fn cases() -> Vec<Case> {
let r = |len: usize, size: f64| Run { len, size, leading: None };
let t = |x: f64, y: f64| Affine::translate(x, y);
// Photoshop keeps the font size in text space and the scale in the transform.
let scaled = |s: f64, x: f64, y: f64| Affine { m: [s, 0.0, 0.0, s, x, y] };
let rotated = |deg: f64, s: f64, x: f64, y: f64| {
let (sin, cos) = deg.to_radians().sin_cos();
Affine { m: [s * cos, s * sin, -s * sin, s * cos, x, y] }
};
vec![
Case {
name: "point 36pt @72",
dpi: 72.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 36.0)],
box_bounds: None,
transform: t(40.0, 80.0),
size_pt: 36.0,
},
Case {
name: "point 36pt @300",
dpi: 300.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 150.0)],
box_bounds: None,
transform: t(40.0, 200.0),
size_pt: 36.0,
},
Case {
name: "point 12×3 scaled",
dpi: 72.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 12.0)],
box_bounds: None,
transform: scaled(3.0, 40.0, 80.0),
size_pt: 12.0,
},
Case {
name: "point rotated",
dpi: 72.0,
text: "Autumn collection",
runs: vec![r(17, 18.0)],
box_bounds: None,
transform: rotated(-20.0, 2.0, 60.0, 300.0),
size_pt: 18.0,
},
Case {
name: "point multi-line",
dpi: 72.0,
text: "Headline\rAutumn collection\rgy",
runs: vec![Run { len: 29, size: 36.0, leading: Some(50.0) }],
box_bounds: None,
transform: t(40.0, 60.0),
size_pt: 36.0,
},
Case {
name: "point mixed sizes",
dpi: 72.0,
text: "Big small Big",
runs: vec![r(4, 60.0), r(6, 14.0), r(3, 60.0)],
box_bounds: None,
transform: t(30.0, 120.0),
size_pt: 60.0,
},
Case {
name: "paragraph box",
dpi: 72.0,
text: "Paragraph text that wraps over several lines in its box",
runs: vec![r(55, 24.0)],
box_bounds: Some([0.0, 0.0, 260.0, 200.0]),
transform: t(20.0, 20.0),
size_pt: 24.0,
},
Case {
name: "paragraph box scaled",
dpi: 144.0,
text: "Paragraph text that wraps over several lines in its box",
runs: vec![r(55, 20.0)],
box_bounds: Some([0.0, 0.0, 200.0, 150.0]),
transform: scaled(1.5, 20.0, 20.0),
size_pt: 10.0,
},
]
}
fn ink_alpha(s: &Surface, r: Rect) -> Vec<f32> {
let n = s.channels();
s.read_region(r).chunks_exact(n).map(|p| p[n - 1]).collect()
}
fn iou(a: &Surface, b: &Surface) -> f32 {
let u = a.content_bounds().union(&b.content_bounds());
let (x, y) = (ink_alpha(a, u), ink_alpha(b, u));
let inter: f32 = x.iter().zip(&y).map(|(p, q)| p.min(*q)).sum();
let union: f32 = x.iter().zip(&y).map(|(p, q)| p.max(*q)).sum();
if union > 0.0 { inter / union } else { 1.0 }
}
fn close(a: Rect, b: Rect, tol: i32) -> bool {
(a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol
}
/// Builds a one-layer document whose type layer carries `data` as its `TySh` and "Photoshop's"
/// pixels (our render of the parsed model), exports it to PSD and imports it back.
fn import_case(c: &Case) -> (Rect, TextLayer, Document, Vec<[f32; 4]>) {
let (w, h) = (900, 700);
let mut doc = Document::new("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = c.dpi;
// Photoshop's text `bounds` are the logical bounds in text space; measure them like it does.
let probe = photocraft_text::psd::text_layer_from_tysh(&tysh(c.text, &c.runs, c.box_bounds, c.transform, [0.0; 4]), c.dpi).unwrap();
let mut engine = photocraft_text::TextEngine::new();
let ink = engine.layout(&probe, c.dpi).bounds().unwrap_or([0.0; 4]).map(f64::from);
let data = tysh(c.text, &c.runs, c.box_bounds, c.transform, ink);
let mut t = photocraft_text::psd::text_layer_from_tysh(&data, c.dpi).unwrap();
engine.render_layer(&mut t, c.dpi, doc.pixel_format());
let drawn = t.cache.as_ref().unwrap().content_bounds();
t.psd_raw = Some(std::sync::Arc::new(data));
doc.layers.push(Layer::new(c.name, LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
let merged = photocraft_io::merged_composite(&file).unwrap();
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("{}: not a type layer", c.name) };
(drawn, bt.clone(), back, merged)
}
#[test]
fn imported_type_layers_keep_their_full_bounds() {
for c in cases() {
let (drawn, t, doc, merged) = import_case(&c);
assert!(!drawn.is_empty(), "{}: nothing drawn", c.name);
// Model: size in points at the document resolution, transform and shape as written.
assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt);
assert_eq!(t.transform, c.transform, "{}", c.name);
match (c.box_bounds, t.shape) {
(None, TextShape::Point) => {}
(Some(b), TextShape::Box { x, y, width, height }) => {
assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name)
}
(want, got) => panic!("{}: shape {got:?}, want box {want:?}", c.name),
}
// The imported pixels (what the canvas shows, and what Properties measures) are the
// whole of Photoshop's rendering.
let cache = t.cache.as_ref().unwrap();
assert_eq!(cache.content_bounds(), drawn, "{}: imported pixels clipped", c.name);
let layer_bounds = photocraft_compose::layer_bounds(&doc.layers[0], doc.bounds());
assert_eq!(layer_bounds, drawn, "{}: layer bounds", c.name);
// Re-rendering the imported model (what the type tool does on click) draws the same
// text in the same place: nothing clipped, no scale or dpi drift.
let mut engine = photocraft_text::TextEngine::new();
let (_, ours) = engine.render(&t, doc.resolution_dpi, doc.pixel_format());
let ours_rect = ours.surface.content_bounds();
assert!(close(ours_rect, drawn, 1), "{}: re-render {ours_rect:?} vs file {drawn:?}", c.name);
let o = iou(&ours.surface, cache);
assert!(o > 0.97, "{}: re-render overlap {o}", c.name);
// And the document composite is the file's merged composite.
let px = photocraft_compose::flatten(&doc).px;
let worst = common::max_diff(&px, &merged);
assert!(worst <= 2.0 / 255.0, "{}: composite differs from the merged image by {worst}", c.name);
}
}
#[test]
fn point_text_height_follows_the_type_size() {
// 36 pt at 72 dpi: caps and ascenders of "Headline" are about 0.73 em tall; a line with a
// descender spans about 0.95 em. Properties' H is the pixel height of the layer.
let c = &cases()[0];
let (drawn, ..) = import_case(c);
assert!((24..=30).contains(&drawn.height()), "no-descender line: H {}", drawn.height());
let c = Case { text: "Headline: Autumn typography", runs: vec![Run { len: 27, size: 36.0, leading: None }], ..cases().remove(0) };
let (drawn, ..) = import_case(&c);
assert!((32..=38).contains(&drawn.height()), "line with descenders: H {}", drawn.height());
// Same text at 300 dpi: 36 pt = 150 px.
let c = Case { dpi: 300.0, runs: vec![Run { len: 27, size: 150.0, leading: None }], ..c };
let (drawn300, ..) = import_case(&c);
let ratio = drawn300.height() as f32 / drawn.height() as f32;
assert!((ratio - 300.0 / 72.0).abs() < 0.2, "300 dpi H {} vs 72 dpi H {}", drawn300.height(), drawn.height());
}
+22
View File
@@ -340,6 +340,10 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
} else {
v.push(PathBuf::from("/usr/share/fonts"));
v.push(PathBuf::from("/usr/local/share/fonts"));
// Inside a Flatpak sandbox the host's system and per-user fonts are mounted here
// (`/usr/share/fonts` is the runtime's own small set). Missing dirs are skipped.
v.push(PathBuf::from("/run/host/fonts"));
v.push(PathBuf::from("/run/host/user-fonts"));
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
v.push(PathBuf::from(d).join("fonts"));
}
@@ -350,3 +354,21 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
}
v
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#[test]
#[cfg(target_os = "linux")]
fn scans_flatpak_host_fonts() {
let dirs = super::system_font_dirs();
assert!(dirs.iter().any(|d| d.ends_with("run/host/fonts")));
assert!(dirs.iter().any(|d| d.ends_with("run/host/user-fonts")));
}
#[test]
fn missing_font_dirs_are_skipped() {
let mut files = Vec::new();
super::collect_font_files(std::path::Path::new("/nonexistent/photocraft/fonts"), 0, &mut files);
assert!(files.is_empty());
}
}
+44
View File
@@ -485,3 +485,47 @@ fn engine_data_template_without_engine_dict() {
assert!(matches!(text, Some(E::String(s)) if s == "Hi\r"), "{text:?}");
}
}
/// #123: the caret geometry (clusters, lines) sits on the rendered glyphs, so a click on a glyph
/// lands next to it: its left part before it, its right part after it. Plain text, tracking,
/// horizontal scale, mixed sizes, and centred, wrapped paragraph text.
#[test]
fn clusters_sit_on_rendered_glyphs() {
let mut e = TextEngine::new();
let big = CharStyle { size_pt: 40.0, ..Default::default() };
let small = CharStyle { size_pt: 18.0, ..Default::default() };
let mixed =
TextLayer { text: "HOHOHO".into(), runs: vec![TextRun { len: 3, style: big.clone() }, TextRun { len: 3, style: small.clone() }], ..Default::default() };
let boxed = with_para(
TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 150.0, height: 200.0 }, ..styled("HOH HOH HOH HOH", small.clone()) },
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
);
let cases = [
("plain", styled("HOHOH", big.clone())),
("tracking", styled("HOHOH", CharStyle { tracking: 300.0, ..big.clone() })),
("hscale", styled("HOHOH", CharStyle { horizontal_scale: 1.6, ..big.clone() })),
("mixed", mixed),
("box", boxed),
];
for (name, t) in cases {
let t = TextLayer { transform: Affine::translate(7.0, 60.0), ..t };
let (l, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
assert!(l.lines.len() >= if name == "box" { 2 } else { 1 }, "{name}");
for c in l.clusters.iter().filter(|c| !t.text[c.range.clone()].trim().is_empty()) {
let ln = &l.lines[c.line];
// Ink under the cluster's middle, between the line's ascent and descent.
let col = photocraft_geom::Rect::new(
(c.x + c.advance * 0.3 + 7.0).floor() as i32,
(ln.baseline - ln.ascent + 60.0).floor() as i32,
(c.x + c.advance * 0.55 + 7.0).ceil() as i32,
(ln.baseline + ln.descent + 60.0).ceil() as i32,
);
assert!(alpha_sum(&r.surface, col) > 1.0, "{name}: no ink under cluster {:?} ({col:?})", c.range);
let y = ln.baseline - ln.ascent * 0.4;
assert_eq!(l.hit_test(c.x + c.advance * 0.2, y), c.range.start, "{name}: left part of {:?}", c.range);
assert_eq!(l.hit_test(c.x + c.advance * 0.8, y), c.range.end, "{name}: right part of {:?}", c.range);
let (cx, top, bottom) = l.caret(c.range.start);
assert!((cx - c.x).abs() < 1e-3 && top < y && bottom > y, "{name}: caret at {:?}", c.range);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Font-size drag latency (#124): a large document with many type layers, one layer's size
//! changed step by step the way the options bar / Properties drag does it (one `type.setStyle`
//! per frame, all sharing one coalesce key), each followed by the CPU canvas refresh the app does
//! for it (`canvas::ensure_texture`, the path without a GPU or when the GPU compositor falls back).
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example type_bench -- [--size 6000x4000] [--layers 50] [--steps 30] [--effects]
//! ```
use std::time::Instant;
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::json;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn median(mut v: Vec<f64>) -> f64 {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) =
arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::<u32>().ok()?, b.parse::<u32>().ok()?)))).unwrap_or((6000, 4000));
let n: usize = arg(&args, "--layers").and_then(|s| s.parse().ok()).unwrap_or(50);
let steps: usize = arg(&args, "--steps").and_then(|s| s.parse().ok()).unwrap_or(30);
let effects = args.iter().any(|a| a == "--effects");
// `--no-coalesce`: one history step per step, as the options bar did before #124.
let coalesce = !args.iter().any(|a| a == "--no-coalesce");
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": w, "height": h})).expect("new document");
let t0 = Instant::now();
let mut ids = Vec::new();
for i in 0..n {
let (x, y) = (40 + (i % 5) as u32 * (w / 5), 120 + (i / 5) as u32 * (h / (n as u32 / 5 + 1)).max(60));
let r = app
.run("type.create", json!({"x": x, "y": y, "text": format!("Layer {i}: the quick brown fox jumps"), "size": 24 + (i % 5) * 6}))
.expect("type.create");
ids.push(r["layer"].as_u64().unwrap_or(0));
if effects {
let _ = app.run("layer.layerStyle.dropShadow", json!({"layer": r["layer"], "distance": 8, "size": 10}));
}
}
eprintln!("{w}×{h}, {n} type layers created in {:.0} ms", t0.elapsed().as_secs_f64() * 1e3);
app.sync_views();
let ctx = egui::Context::default();
let t0 = Instant::now();
let _ = photocraft_ui_egui::canvas::ensure_texture(&mut app, &ctx, 0);
eprintln!("first canvas composite: {:.0} ms", t0.elapsed().as_secs_f64() * 1e3);
let id = ids.get(n / 2).copied().unwrap_or(0);
let (mut cmd, mut canvas, mut total) = (Vec::new(), Vec::new(), Vec::new());
for s in 0..steps {
let t0 = Instant::now();
app.run("type.setStyle", {
let mut p = json!({"layer": id, "size": 30.0 + s as f32 * 0.5});
if coalesce {
p["coalesce"] = json!("bench-size-drag");
}
p
})
.expect("type.setStyle");
let t1 = Instant::now();
let _ = photocraft_ui_egui::canvas::ensure_texture(&mut app, &ctx, 0);
let t2 = Instant::now();
cmd.push((t1 - t0).as_secs_f64() * 1e3);
canvas.push((t2 - t1).as_secs_f64() * 1e3);
total.push((t2 - t0).as_secs_f64() * 1e3);
}
// Undo steps back to the size before the drag.
let size = |app: &PhotocraftApp| match app.session.active().and_then(|s| s.doc.layer(photocraft_doc::LayerId(id))).map(|l| &l.content) {
Some(photocraft_doc::LayerContent::Text(t)) => t.size_pt,
_ => 0.0,
};
let start = 24.0 + ((n / 2) % 5) as f32 * 6.0;
let mut undos = 0;
while size(&app) != start && undos < steps + 1 && app.session.undo() {
undos += 1;
}
println!(
"size step (median of {steps}): command {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms; undo steps for the drag: {undos}",
median(cmd),
median(canvas),
median(total),
);
}
+325 -427
View File
@@ -1,18 +1,28 @@
//! Window › Brush Settings (F5): Photoshop's floating brush panel. Section list with enable boxes
//! on the left, the selected section's controls on the right, and a live stroke preview below.
//! A second tab lists the brush presets. Edits go straight to the session brush (tool state, like
//! the options bar); presets are applied through `tools.setBrush`.
use std::sync::Arc;
//! Window › Brush Settings (F5) and Window › Brushes: Photoshop's floating brush panel.
//!
//! Brush Settings: the section list with enable boxes on the left (clicking a name shows and turns
//! on that section), the selected section's controls on the right (see [`crate::brush_sections`]),
//! and a live stroke preview strip below, rendered with the real brush engine and cached on the
//! settings (it re-renders only when they change). Brushes: the presets in collapsible groups with
//! tip thumbnails and stroke previews, a size slider and a search field.
//!
//! 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.
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, Control, Dynamic, MaskMode, Pattern, PatternStyle, StrokePoint};
use serde_json::json;
use photocraft_engine::paint::{self, TipShape};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::{PhotocraftApp, icons, widgets};
pub use crate::brush_preview::{preview_pixels, preview_sig};
pub use crate::brush_sections::section_body;
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
pub const SECTIONS: [(&str, bool); 13] = [
("Brush Tip Shape", false),
@@ -30,7 +40,21 @@ pub const SECTIONS: [(&str, bool); 13] = [
("Protect Texture", true),
];
/// The enable flag behind section `i` (None for Brush Tip Shape).
/// Panel width and the stroke strip's size.
const WIDTH: f32 = 540.0;
const STRIP: (u32, u32) = (488, 76);
/// 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")]
pub struct BrushesPanelState {
/// Names of collapsed groups.
pub collapsed: Vec<String>,
/// Case-insensitive name filter.
pub filter: String,
}
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
Some(match i {
1 => &mut b.shape_dynamics.enabled,
@@ -43,324 +67,68 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
8 => &mut b.noise,
9 => &mut b.wet_edges,
10 => &mut b.build_up,
11 => return None,
12 => &mut b.protect_texture,
_ => return None,
})
}
const CONTROLS: [(Control, &str); 8] = [
(Control::Off, "Off"),
(Control::Fade, "Fade"),
(Control::PenPressure, "Pen Pressure"),
(Control::PenTilt, "Pen Tilt"),
(Control::StylusWheel, "Stylus Wheel"),
(Control::Rotation, "Rotation"),
(Control::InitialDirection, "Initial Direction"),
(Control::Direction, "Direction"),
];
const MASK_MODES: [(MaskMode, &str); 10] = [
(MaskMode::Multiply, "Multiply"),
(MaskMode::Subtract, "Subtract"),
(MaskMode::Darken, "Darken"),
(MaskMode::Overlay, "Overlay"),
(MaskMode::ColorDodge, "Color Dodge"),
(MaskMode::ColorBurn, "Color Burn"),
(MaskMode::LinearBurn, "Linear Burn"),
(MaskMode::HardMix, "Hard Mix"),
(MaskMode::LinearHeight, "Linear Height"),
(MaskMode::Height, "Height"),
];
/// Percent slider over a 0..`max` fraction.
fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32, max: f32) -> bool {
let mut p = *v * 100.0;
let changed = widgets::slider_row(ui, label, &mut p, 0.0..=max * 100.0, "%", None).changed();
if changed {
*v = (p / 100.0).clamp(0.0, max);
/// 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;
}
changed
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)
}
fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str) -> bool {
widgets::slider_row(ui, label, v, range, suffix, None).changed()
}
/// A jitter slider plus its Control dropdown (and the Fade step count when fading).
fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32, minimum: Option<&str>) {
let t = Tokens::get(ui.ctx());
pct(ui, label, &mut d.jitter, max);
ui.horizontal(|ui| {
ui.label(RichText::new("Control:").color(t.text_dim));
widgets::dropdown(ui, id, &mut d.control, &CONTROLS, 130.0);
if d.control == Control::Fade {
let mut steps = d.fade_steps as f32;
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 52.0).changed() {
d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32;
/// 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))
}
}
});
if let Some(m) = minimum {
pct(ui, m, &mut d.minimum, 1.0);
}
ui.add_space(4.0);
}
/// Size: a value field over a logarithmic slider (small sizes get most of the travel).
fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::value_field(ui, size, 1.0..=max, "px", 74.0);
});
});
let mut lv = size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=max.ln(), None).changed() {
*size = lv.exp().round().clamp(1.0, max);
}
ui.add_space(4.0);
}
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings) {
size_row(ui, "Size", &mut b.size, 5000.0);
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut b.flip_x, "Flip X");
widgets::checkbox(ui, &mut b.flip_y, "Flip Y");
});
num(ui, "Angle", &mut b.angle, -180.0..=180.0, "°");
pct(ui, "Roundness", &mut b.roundness, 1.0);
if matches!(b.tip, paint::TipShape::Round) {
pct(ui, "Hardness", &mut b.hardness, 1.0);
}
let mut spacing_on = b.spacing > 0.0;
widgets::checkbox(ui, &mut spacing_on, "Spacing");
if spacing_on {
pct(ui, "Spacing", &mut b.spacing, 10.0);
b.spacing = b.spacing.max(0.01);
}
}
fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tx = &mut b.texture;
let t = Tokens::get(ui.ctx());
if let Pattern::Procedural { style, size, .. } = &mut tx.pattern {
ui.horizontal(|ui| {
ui.label(RichText::new("Pattern").color(t.text_dim));
let opts = [(PatternStyle::Paper, "Paper"), (PatternStyle::Canvas, "Canvas"), (PatternStyle::Noise, "Noise"), (PatternStyle::Dots, "Dots")];
widgets::dropdown(ui, "brush-pattern", style, &opts, 110.0);
});
let mut s = *size as f32;
if num(ui, "Pattern Size", &mut s, 16.0..=1024.0, "px") {
*size = s.round() as u32;
}
} else {
ui.label(RichText::new("Custom pattern").color(t.text_faint));
}
widgets::checkbox(ui, &mut tx.invert, "Invert");
pct(ui, "Scale", &mut tx.scale, 10.0);
let mut br = tx.brightness * 150.0;
if num(ui, "Brightness", &mut br, -150.0..=150.0, "") {
tx.brightness = br / 150.0;
}
let mut ct = tx.contrast * 50.0;
if num(ui, "Contrast", &mut ct, -50.0..=100.0, "") {
tx.contrast = ct / 50.0;
}
widgets::checkbox(ui, &mut tx.each_tip, "Texture Each Tip");
ui.horizontal(|ui| {
ui.label(RichText::new("Mode").color(t.text_dim));
widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0);
});
pct(ui, "Depth", &mut tx.depth, 1.0);
if tx.each_tip {
dynamic(ui, "brush-depth-ctl", "Depth Jitter", &mut tx.depth_jitter, 1.0, Some("Minimum Depth"));
}
}
fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
let d = &mut b.dual_brush;
ui.horizontal(|ui| {
ui.label(RichText::new("Mode").color(t.text_dim));
widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 130.0);
widgets::checkbox(ui, &mut d.flip, "Flip");
});
size_row(ui, "Size", &mut d.size, 2500.0);
pct(ui, "Hardness", &mut d.hardness, 1.0);
pct(ui, "Spacing", &mut d.spacing, 10.0);
pct(ui, "Scatter", &mut d.scatter, 10.0);
widgets::checkbox(ui, &mut d.both_axes, "Both Axes");
let mut c = d.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
d.count = c.round() as u32;
}
}
fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let c = &mut b.color_dynamics;
widgets::checkbox(ui, &mut c.per_tip, "Apply Per Tip");
dynamic(ui, "brush-fgbg-ctl", "Foreground/Background Jitter", &mut c.fg_bg, 1.0, None);
pct(ui, "Hue Jitter", &mut c.hue_jitter, 1.0);
pct(ui, "Saturation Jitter", &mut c.saturation_jitter, 1.0);
pct(ui, "Brightness Jitter", &mut c.brightness_jitter, 1.0);
let mut p = c.purity * 100.0;
if num(ui, "Purity", &mut p, -100.0..=100.0, "%") {
c.purity = p / 100.0;
}
}
fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) {
let p = &mut b.pose;
let mut tx = p.tilt_x * 100.0;
if num(ui, "Tilt X", &mut tx, -100.0..=100.0, "%") {
p.tilt_x = tx / 100.0;
}
let mut ty = p.tilt_y * 100.0;
if num(ui, "Tilt Y", &mut ty, -100.0..=100.0, "%") {
p.tilt_y = ty / 100.0;
}
widgets::checkbox(ui, &mut p.override_tilt, "Override Tilt");
num(ui, "Rotation", &mut p.rotation, 0.0..=360.0, "°");
widgets::checkbox(ui, &mut p.override_rotation, "Override Rotation");
pct(ui, "Pressure", &mut p.pressure, 1.0);
widgets::checkbox(ui, &mut p.override_pressure, "Override Pressure");
}
fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) {
let s = &mut b.smoothing;
pct(ui, "Smoothing", &mut s.amount, 1.0);
widgets::checkbox(ui, &mut s.pulled_string, "Pulled String Mode");
widgets::checkbox(ui, &mut s.catch_up, "Stroke Catch-up");
widgets::checkbox(ui, &mut s.catch_up_on_end, "Catch-up on Stroke End");
widgets::checkbox(ui, &mut s.adjust_for_zoom, "Adjust for Zoom");
}
/// Controls for section `i`.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize) {
let t = Tokens::get(ui.ctx());
let note = |ui: &mut egui::Ui, s: &str| {
ui.label(RichText::new(s).color(t.text_faint));
};
match i {
0 => tip_shape(ui, b),
1 => {
let sd = &mut b.shape_dynamics;
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"));
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None);
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"));
widgets::checkbox(ui, &mut sd.flip_x_jitter, "Flip X Jitter");
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
}
2 => {
let sc = &mut b.scattering;
widgets::checkbox(ui, &mut sc.both_axes, "Both Axes");
dynamic(ui, "brush-scatter-ctl", "Scatter", &mut sc.scatter, 10.0, None);
let mut c = sc.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
sc.count = c.round() as u32;
}
dynamic(ui, "brush-count-ctl", "Count Jitter", &mut sc.count_jitter, 1.0, None);
}
3 => texture(ui, b),
4 => dual_brush(ui, b),
5 => color_dynamics(ui, b),
6 => {
let tr = &mut b.transfer;
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"));
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"));
}
7 => pose(ui, b),
8 => note(ui, "Adds grain to the soft edges of the brush tip."),
9 => note(ui, "Watercolour look: lighter interior, darker rims."),
10 => {
note(ui, "Airbrush: keeps painting while the pointer rests.");
num(ui, "Rate", &mut b.build_up_rate, 1.0..=200.0, "/s");
}
11 => smoothing(ui, b),
_ => note(ui, "Keeps the current texture when switching presets."),
}
}
/// A stroke rendered with the brush (scaled down to fit), as RGBA8, for previews and preset rows.
pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec<u8> {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let mut brush = b.clone();
let k = ((h as f32 * 0.55) / brush.size.max(1.0)).min(1.0);
brush.size = (brush.size * k).max(1.0);
brush.dual_brush.size = (brush.dual_brush.size * k).max(1.0);
brush.color = color;
brush.erase = false;
brush.seed = 7;
// Photoshop's preview: an S-curve with pressure tapering in and out.
let n = 64;
let points: Vec<StrokePoint> = (0..=n)
.map(|i| {
let s = i as f64 / n as f64;
let x = 10.0 + s * (w as f64 - 20.0);
let y = h as f64 / 2.0 - (s * std::f64::consts::TAU).sin() * h as f64 * 0.22;
StrokePoint::new(x, y, (s * std::f64::consts::PI).sin().max(0.05) as f32)
})
.collect();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = photocraft_raster::Surface::new(fmt);
paint::apply_stroke(&mut s, &paint::Stroke { brush, points }, None, false);
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut px = vec![[0.0f32; 4]; (w * h) as usize];
s.read_rgba_into(r, &mut px);
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
}
/// Cheap signature of everything that changes a brush's preview. Sampled tips and pattern tiles
/// contribute their size and a strided sample instead of every pixel, so imported presets with
/// big tips don't serialise megabytes per frame.
pub fn preview_sig(b: &BrushSettings) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
fn tile(h: &mut DefaultHasher, t: &paint::GrayTile) {
(t.width, t.height).hash(h);
let step = (t.data.len() / 4096).max(1);
t.data.iter().step_by(step).for_each(|v| v.hash(h));
}
fn tip(h: &mut DefaultHasher, t: &paint::TipShape) {
match t {
paint::TipShape::Round => 0u8.hash(h),
paint::TipShape::Sampled(g) => tile(h, g),
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
let mut h = DefaultHasher::new();
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
tip(&mut h, &b.tip);
let d = &b.dual_brush;
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
tip(&mut h, &d.tip);
let t = &b.texture;
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
match &t.pattern {
Pattern::Tile(g) => tile(&mut h, g),
p => js(&mut h, p),
}
h.finish()
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
}
/// Texture for a brush preview, cached on [`preview_sig`].
fn preview_texture(ui: &egui::Ui, b: &BrushSettings, w: u32, h: u32, color: Color32) -> Arc<egui::TextureHandle> {
let id = egui::Id::new(("brush-preview", preview_sig(b), w, h, color));
if let Some(tex) = ui.data(|d| d.get_temp::<Arc<egui::TextureHandle>>(id)) {
return tex;
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
if before == after {
return;
}
let patch = brush_patch(before, after);
if patch.as_object().is_some_and(Map::is_empty) {
return;
}
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
app.ui.status = e;
}
let c = color.to_normalized_gamma_f32();
let px = preview_pixels(b, w, h, [c[0], c[1], c[2], 1.0]);
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px);
let tex = Arc::new(ui.ctx().load_texture("brush-preview", img, egui::TextureOptions::LINEAR));
ui.data_mut(|d| d.insert_temp(id, tex.clone()));
tex
}
/// Group label for presets saved without a group.
@@ -379,49 +147,135 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// Does the current brush match `preset` (everything but colour and size)? 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
}
/// A fresh "Brush N" name.
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) {
if let Err(e) = app.run(id, p) {
app.ui.status = e;
}
}
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(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Import Brushes…").on_hover_text("Load Photoshop brushes (.abr)").clicked() {
app.open_dialog_file();
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);
// The current brush matches a preset when everything but its colour and size agrees.
// Cheap fields first: the full comparison clones the preset (and its tip).
let same = |a: &BrushSettings, b: &BrushSettings| {
a.hardness == b.hardness
&& a.spacing == b.spacing
&& a.tip == b.tip
&& BrushSettings { color: b.color, size: b.size, background: b.background, ..a.clone() } == *b
};
let brush = &app.session.tools.brush;
let mut clicked = None;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
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 {
ui.add_space(4.0);
ui.label(RichText::new(&group).font(theme::semibold(12.0)).color(t.text));
ui.add_space(2.0);
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(), 40.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
if !ui.is_rect_visible(r) {
continue;
}
if same(&p.brush, brush) {
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 tex = preview_texture(ui, &p.brush, 180, 36, t.text);
let ir = egui::Rect::from_min_size(r.left_top() + vec2(4.0, 2.0), vec2(180.0, 36.0));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
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(ir.right() + 10.0, r.center().y),
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),
@@ -431,13 +285,137 @@ fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
clicked = Some(p.name.clone());
}
}
ui.add_space(2.0);
}
});
if let Some(name) = clicked
&& let Err(e) = app.session.execute("tools.setBrush", json!({"preset": name}))
{
app.ui.status = e.to_string();
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.
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
ui.set_width(150.0);
ui.spacing_mut().item_spacing.y = 1.0;
for (i, (name, has_box)) in SECTIONS.iter().enumerate() {
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click());
if sel {
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 mut x = r.left() + 6.0;
if *has_box && let Some(flag) = section_flag(b, i) {
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5));
ui.painter().line_segment([a, m], Stroke::new(1.6, Color32::WHITE));
ui.painter().line_segment([m, c], Stroke::new(1.6, Color32::WHITE));
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
if box_resp.clicked() {
*flag = !*flag;
} else if resp.clicked() {
*flag = true;
}
x += 20.0;
} else if i > 0 {
x += 20.0;
}
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() {
app.ui.brush_section = i;
}
if i == 0 {
ui.add_space(4.0);
}
}
});
}
fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let section = app.ui.brush_section.min(SECTIONS.len() - 1);
ui.horizontal_top(|ui| {
section_list(app, ui, &mut b);
widgets::vline(ui, 482.0);
ui.vertical(|ui| {
ui.set_width(WIDTH - 190.0);
ui.label(RichText::new(SECTIONS[section].0).font(theme::semibold(12.5)).color(t.text));
ui.add_space(6.0);
let on = section_flag(&mut b, section).is_none_or(|f| *f);
egui::ScrollArea::vertical().id_salt(("brush-section", section)).max_height(450.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(WIDTH - 204.0);
// A section that's off shows its options greyed out (Photoshop).
ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets));
});
});
});
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(STRIP.0 as f32, STRIP.1 as f32), Sense::hover());
ui.painter().rect_filled(r, t.radius_sm, t.field);
let tex = brush_preview::stroke_texture(ui.ctx(), "settings-strip", &b, STRIP.0, STRIP.1, t.text);
ui.painter().image(tex.id(), r, full_uv(), Color32::WHITE);
ui.vertical(|ui| {
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from these settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
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() };
}
});
});
commit(app, &before, &b);
}
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
@@ -458,9 +436,9 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
.title_bar(false)
.resizable(false)
.frame(frame)
.default_pos(egui::pos2((canvas.right() - 500.0).max(canvas.left() + 8.0), canvas.top() + 40.0))
.default_pos(egui::pos2((canvas.right() - WIDTH - 30.0).max(canvas.left() + 8.0), canvas.top() + 24.0))
.show(ctx, |ui| {
ui.set_width(470.0);
ui.set_width(WIDTH);
ui.horizontal(|ui| {
let mut tab = app.ui.brush_tab;
for (i, name) in ["Brush Settings", "Brushes"].iter().enumerate() {
@@ -479,65 +457,9 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(6.0);
if app.ui.brush_tab == 1 {
presets_tab(app, ui);
return;
}
let mut b = app.session.tools.brush.clone();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(150.0);
ui.spacing_mut().item_spacing.y = 1.0;
for (i, (name, has_box)) in SECTIONS.iter().enumerate() {
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 21.0), Sense::click());
if sel {
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 mut x = r.left() + 6.0;
if *has_box && let Some(flag) = section_flag(&mut b, i) {
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(12.0, 12.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
ui.painter().text(br.center(), egui::Align2::CENTER_CENTER, "✓", egui::FontId::proportional(10.0), Color32::WHITE);
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
if box_resp.clicked() {
*flag = !*flag;
}
x += 18.0;
} else if i > 0 {
x += 18.0;
}
let color = if sel { t.text } else { t.text_dim };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, theme::medium(12.0), color);
if resp.clicked() {
app.ui.brush_section = i;
}
}
});
widgets::vline(ui, 350.0);
ui.vertical(|ui| {
ui.set_width(290.0);
ui.label(RichText::new(SECTIONS[app.ui.brush_section.min(SECTIONS.len() - 1)].0).font(theme::semibold(12.5)).color(t.text));
ui.add_space(4.0);
egui::ScrollArea::vertical().id_salt("brush-section").max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
section_body(ui, &mut b, app.ui.brush_section);
});
});
});
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
let tex = preview_texture(ui, &b, 450, 72, t.text);
let (r, _) = ui.allocate_exact_size(vec2(450.0, 72.0), Sense::hover());
ui.painter().rect_filled(r, t.radius_sm, t.field);
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
if b != app.session.tools.brush {
app.session.tools.brush = b;
brushes_tab(app, ui);
} else {
settings_tab(app, ui);
}
});
if !open {
@@ -546,29 +468,5 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
for (i, (_, has_box)) in SECTIONS.iter().enumerate() {
assert_eq!(section_flag(&mut b, i).is_some(), *has_box, "{i}");
}
*section_flag(&mut b, 3).unwrap() = true;
assert!(b.texture.enabled);
}
#[test]
fn preview_draws_a_stroke_that_fits() {
let mut b = BrushSettings { size: 400.0, ..Default::default() };
let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px.len(), 120 * 40 * 4);
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count();
assert!(covered > 200 && covered < 120 * 40 / 2, "{covered}");
// Dynamics change the preview.
b.scattering.enabled = true;
b.scattering.scatter.jitter = 3.0;
assert_ne!(preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]), px);
}
}
#[path = "brush_panel_tests.rs"]
mod tests;
+272
View File
@@ -0,0 +1,272 @@
use egui::vec2;
use egui_kittest::Harness;
use photocraft_engine::paint::{Control, GrayTile, MaskMode, Pattern, PatternStyle, TipShape};
use super::*;
fn app() -> PhotocraftApp {
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
}
fn last_journal(app: &PhotocraftApp) -> Option<(String, Value)> {
app.session.journal.last().cloned()
}
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
for (i, (_, has_box)) in SECTIONS.iter().enumerate() {
assert_eq!(section_flag(&mut b, i).is_some(), *has_box, "{i}");
}
*section_flag(&mut b, 3).unwrap() = true;
assert!(b.texture.enabled);
}
/// What each section's controls edit, set to non-default values.
fn edit_section(i: usize, b: &mut BrushSettings) {
let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum };
match i {
0 => {
b.size = 87.0;
b.flip_x = true;
b.flip_y = true;
b.angle = -35.0;
b.roundness = 0.4;
b.hardness = 0.25;
b.spacing = 0.6;
}
1 => {
let sd = &mut b.shape_dynamics;
sd.enabled = true;
sd.size = dynamic(0.3, Control::Fade, 0.2);
sd.angle = dynamic(0.1, Control::Direction, 0.0);
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
sd.flip_x_jitter = true;
sd.flip_y_jitter = true;
}
2 => {
let sc = &mut b.scattering;
sc.enabled = true;
sc.scatter = dynamic(2.5, Control::PenPressure, 0.0);
sc.both_axes = true;
sc.count = 4;
sc.count_jitter = dynamic(0.4, Control::StylusWheel, 0.0);
}
3 => {
let tx = &mut b.texture;
tx.enabled = true;
tx.pattern = Pattern::Procedural { style: PatternStyle::Canvas, size: 200, seed: 1 };
tx.invert = true;
tx.scale = 1.5;
tx.brightness = -0.2;
tx.contrast = 0.6;
tx.each_tip = true;
tx.mode = MaskMode::HardMix;
tx.depth = 0.7;
tx.depth_jitter = dynamic(0.3, Control::Rotation, 0.1);
}
4 => {
let d = &mut b.dual_brush;
d.enabled = true;
d.mode = MaskMode::ColorBurn;
d.flip = true;
d.tip = TipShape::Sampled(GrayTile::from_fn(8, 6, |x, y| ((x + y) % 2) as f32));
d.size = 33.0;
d.hardness = 0.5;
d.spacing = 0.8;
d.scatter = 1.2;
d.both_axes = true;
d.count = 3;
}
5 => {
let c = &mut b.color_dynamics;
c.enabled = true;
c.per_tip = false;
c.fg_bg = dynamic(0.6, Control::Fade, 0.0);
c.hue_jitter = 0.1;
c.saturation_jitter = 0.2;
c.brightness_jitter = 0.3;
c.purity = -0.4;
}
6 => {
b.transfer.enabled = true;
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
}
7 => {
let p = &mut b.pose;
p.enabled = true;
p.tilt_x = 45.0;
p.tilt_y = -27.0;
p.override_tilt = true;
p.rotation = 120.0;
p.override_rotation = true;
p.pressure = 0.4;
p.override_pressure = true;
}
8 => b.noise = true,
9 => b.wet_edges = true,
10 => {
b.build_up = true;
b.build_up_rate = 55.0;
}
11 => {
let s = &mut b.smoothing;
s.amount = 0.45;
s.pulled_string = true;
s.catch_up = false;
s.catch_up_on_end = false;
s.adjust_for_zoom = false;
}
_ => b.protect_texture = true,
}
}
#[test]
fn every_section_round_trips_through_set_brush() {
for (i, (name, _)) in SECTIONS.iter().enumerate() {
let mut app = app();
let before = app.session.tools.brush.clone();
let mut after = before.clone();
edit_section(i, &mut after);
assert_ne!(before, after, "section {i} edits nothing");
commit(&mut app, &before, &after);
assert_eq!(app.session.tools.brush, after, "section {i} ({name})");
// Through the command, with only the changed fields.
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}");
// 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}");
// Replaying the journaled call onto a fresh session gives the same brush (drivable).
let mut s = photocraft_engine::Session::new();
s.execute(&id, p).unwrap();
assert_eq!(s.tools.brush, after, "replay of section {i}");
}
}
#[test]
fn patches_are_minimal_and_skip_unchanged_bitmaps() {
let tip = TipShape::Sampled(GrayTile::from_fn(400, 300, |x, y| ((x * y) % 7) as f32 / 7.0));
let old = BrushSettings {
tip: tip.clone(),
texture: paint::Texture { pattern: Pattern::Tile(GrayTile::from_fn(64, 64, |x, _| x as f32 / 64.0)), ..Default::default() },
..Default::default()
};
let new = BrushSettings { size: 44.0, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"size": 44.0}));
let new = BrushSettings { shape_dynamics: paint::ShapeDynamics { enabled: true, ..Default::default() }, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"shapeDynamics": {"enabled": true}}));
assert_eq!(brush_patch(&old, &old), json!({}));
// A changed tip is sent whole.
let new = BrushSettings { tip: TipShape::Round, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"tip": "round"}));
// Applying any of these through the engine gives back `new`.
let mut app = app();
app.session.tools.brush = old.clone();
let new = BrushSettings { size: 9.0, roundness: 0.5, ..old.clone() };
commit(&mut app, &old, &new);
assert_eq!(app.session.tools.brush, new);
// No change, no command.
let n = app.session.journal.len();
commit(&mut app, &new, &new);
assert_eq!(app.session.journal.len(), n);
}
#[test]
fn bad_patches_fail_without_touching_the_brush() {
let mut app = app();
let before = app.session.tools.brush.clone();
for bad in [
json!({"brush": {"size": "huge"}}),
json!({"brush": {"shapeDynamics": {"size": {"control": "telepathy"}}}}),
json!({"brush": {"tip": {"sampled": {"width": 0}}}}),
] {
assert!(app.run("tools.setBrush", bad).is_err());
assert_eq!(app.session.tools.brush, before);
}
}
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.panels.brush_settings = true;
app.ui.brush_tab = tab;
app.ui.brush_section = section;
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
window(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
#[test]
fn preview_strip_re_renders_only_when_the_brush_changes() {
let mut h = harness(0, 0);
let ctx = h.ctx.clone();
let n0 = brush_preview::render_count(&ctx);
assert!(n0 >= 1, "the strip rendered");
h.run_steps(6);
assert_eq!(brush_preview::render_count(&ctx), n0, "idle frames re-rendered a preview");
// Change the brush through the engine: the strip renders once more, then stays cached.
h.state_mut().run("tools.setBrush", json!({"brush": {"scattering": {"enabled": true, "scatter": {"jitter": 2.0}}}})).unwrap();
h.run_steps(5);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
// Colour and the jitter seed of strokes aren't preview inputs: no re-render.
h.state_mut().session.tools.brush.color = [1.0, 0.0, 0.0, 1.0];
h.run_steps(3);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
}
#[test]
fn every_section_draws_without_issuing_commands() {
for tab in 0..2 {
for section in 0..SECTIONS.len() {
let mut h = harness(tab, section);
// A brush with every section on and a sampled tip.
let mut b = BrushSettings::default();
for i in 0..SECTIONS.len() {
edit_section(i, &mut b);
}
b.tip = TipShape::Sampled(GrayTile::from_fn(50, 40, |x, y| ((x ^ y) & 1) as f32));
h.state_mut().session.tools.brush = b.clone();
let n = h.state().session.journal.len();
h.run_steps(4);
// Merely showing the panel must not rewrite the brush (no rounding drift, no commands).
assert_eq!(h.state().session.journal.len(), n, "tab {tab} section {section}");
assert_eq!(h.state().session.tools.brush, b, "tab {tab} section {section}");
}
}
}
#[test]
fn brushes_panel_groups_collapse_and_filter() {
let mut h = harness(1, 0);
let groups = grouped_presets(&h.state().session.tools.presets);
assert!(groups.len() >= 2, "{groups:?}");
let ctx = h.ctx.clone();
let shown = |h: &mut Harness<'static, PhotocraftApp>| {
h.run_steps(2);
brush_preview::with_cache(&ctx, |c| c.len())
};
let all = shown(&mut h);
// Collapse every group: no more previews are needed, and the old ones are kept (cached).
h.state_mut().ui.brushes_panel.collapsed = groups.iter().map(|(g, _)| g.clone()).collect();
let n = brush_preview::render_count(&ctx);
assert_eq!(shown(&mut h), all);
assert_eq!(brush_preview::render_count(&ctx), n);
// A filter shows matching presets even in collapsed groups.
let first = h.state().session.tools.presets[0].name.clone();
h.state_mut().ui.brushes_panel.filter = first.to_uppercase();
h.run_steps(2);
assert!(brush_preview::render_count(&ctx) >= n);
}
+333
View File
@@ -0,0 +1,333 @@
//! Brush previews for the Brush Settings and Brushes panels: the S-curve stroke strip and tip
//! thumbnails, rendered with the real brush engine and cached per slot on a cheap signature of the
//! settings, so a preview re-renders only when something that changes it changes.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use egui::Color32;
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, Pattern, StrokePoint, TipShape};
/// Values cached per named slot, each tagged with the signature it was rendered for. A slot holds
/// one value, so dragging a slider replaces the slot's texture instead of piling up new ones.
#[derive(Clone)]
pub struct SigCache<T> {
slots: HashMap<String, (u64, T)>,
/// Number of renders so far (tests and the perf overlay).
pub renders: u64,
}
impl<T> Default for SigCache<T> {
fn default() -> Self {
Self { slots: HashMap::new(), renders: 0 }
}
}
impl<T: Clone> SigCache<T> {
/// The value for `slot`, rendered by `render` only when the slot is empty or `sig` changed.
pub fn get(&mut self, slot: &str, sig: u64, render: impl FnOnce() -> T) -> T {
if let Some((s, v)) = self.slots.get(slot)
&& *s == sig
{
return v.clone();
}
self.renders += 1;
let v = render();
self.slots.insert(slot.to_string(), (sig, v.clone()));
v
}
/// Drop slots not in `keep` (presets deleted from the session).
pub fn retain(&mut self, keep: impl Fn(&str) -> bool) {
self.slots.retain(|k, _| keep(k));
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
/// Size plus a strided sample of a bitmap: imported tips can be millions of pixels.
fn tile_sig(h: &mut DefaultHasher, t: &paint::GrayTile) {
(t.width, t.height, t.data.len()).hash(h);
let step = (t.data.len() / 4096).max(1);
t.data.iter().step_by(step).for_each(|v| v.hash(h));
}
fn tip_shape_sig(h: &mut DefaultHasher, t: &TipShape) {
match t {
TipShape::Round => 0u8.hash(h),
TipShape::Sampled(g) => tile_sig(h, g),
}
}
/// Cheap signature of everything that changes a brush's stroke preview.
pub fn preview_sig(b: &BrushSettings) -> u64 {
let mut h = DefaultHasher::new();
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
tip_shape_sig(&mut h, &b.tip);
let d = &b.dual_brush;
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
tip_shape_sig(&mut h, &d.tip);
let t = &b.texture;
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
match &t.pattern {
Pattern::Tile(g) => tile_sig(&mut h, g),
p => js(&mut h, p),
}
h.finish()
}
/// Signature of a tip thumbnail's inputs.
pub fn tip_sig(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool)) -> u64 {
let mut h = DefaultHasher::new();
tip_shape_sig(&mut h, tip);
(hardness.to_bits(), angle.to_bits(), roundness.to_bits(), flip).hash(&mut h);
h.finish()
}
/// A stroke rendered with the brush (scaled down to fit), as straight RGBA8: Photoshop's preview
/// S-curve with pressure tapering in and out.
pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec<u8> {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let mut brush = b.clone();
let k = ((h as f32 * 0.55) / brush.size.max(1.0)).min(1.0);
brush.size = (brush.size * k).max(1.0);
brush.dual_brush.size = (brush.dual_brush.size * k).max(1.0);
brush.color = color;
brush.erase = false;
brush.seed = 7;
// Build-up adds dabs over (simulated) time: the preview has none.
brush.build_up = false;
let n = 64;
let points: Vec<StrokePoint> = (0..=n)
.map(|i| {
let s = i as f64 / n as f64;
let x = 10.0 + s * (w as f64 - 20.0);
let y = h as f64 / 2.0 - (s * std::f64::consts::TAU).sin() * h as f64 * 0.22;
StrokePoint::new(x, y, (s * std::f64::consts::PI).sin().max(0.05) as f32)
})
.collect();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = photocraft_raster::Surface::new(fmt);
paint::apply_stroke(&mut s, &paint::Stroke { brush, points }, None, false);
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut px = vec![[0.0f32; 4]; (w * h) as usize];
s.read_rgba_into(r, &mut px);
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
}
/// Box-downsample a tip bitmap so its larger side is at most `max` (cheap bilinear lookups after).
fn shrink(g: &paint::GrayTile, max: u32) -> (u32, u32, Vec<f32>) {
let (w, h) = (g.width.max(1), g.height.max(1));
if !g.is_valid() {
return (1, 1, vec![0.0]);
}
let f = (w.max(h) as f32 / max.max(1) as f32).max(1.0);
let (nw, nh) = (((w as f32 / f).ceil() as u32).max(1), ((h as f32 / f).ceil() as u32).max(1));
let mut sum = vec![0.0f32; (nw * nh) as usize];
let mut cnt = vec![0u32; (nw * nh) as usize];
for y in 0..h {
let ny = ((y as f32 / f) as u32).min(nh - 1);
for x in 0..w {
let nx = ((x as f32 / f) as u32).min(nw - 1);
let i = (ny * nw + nx) as usize;
sum[i] += f32::from(g.data[(y * w + x) as usize]) / 65535.0;
cnt[i] += 1;
}
}
(nw, nh, sum.iter().zip(&cnt).map(|(s, c)| if *c > 0 { s / *c as f32 } else { 0.0 }).collect())
}
/// Coverage (0..1) of a brush tip drawn into an `n × n` cell, with the tip's angle, roundness,
/// flips and (for computed tips) hardness: the Brush Tip Shape grid and Brushes panel thumbnails.
pub fn tip_alpha(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool), n: u32) -> Vec<f32> {
let n = n.clamp(2, 512);
let c = n as f32 / 2.0;
let r = c - 1.0;
let (sin, cos) = angle.to_radians().sin_cos();
let round = roundness.clamp(0.01, 1.0);
let sampled = match tip {
TipShape::Sampled(g) => Some(shrink(g, n * 2)),
TipShape::Round => None,
};
let mut out = vec![0.0f32; (n * n) as usize];
// 2×2 supersampling keeps small thumbnails smooth.
for y in 0..n {
for x in 0..n {
let mut acc = 0.0;
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let (dx, dy) = (x as f32 + sx - c, c - (y as f32 + sy));
// Into tip space: undo the counter-clockwise angle, then the roundness squash.
let mut u = dx * cos + dy * sin;
let mut v = (-dx * sin + dy * cos) / round;
if flip.0 {
u = -u;
}
if flip.1 {
v = -v;
}
acc += match &sampled {
None => paint::dab_coverage(u.hypot(v), r, hardness),
Some((tw, th, data)) => {
// The tip's larger side spans the cell diameter.
let k = (*tw).max(*th) as f32 / (2.0 * r);
let tx = u * k + *tw as f32 / 2.0 - 0.5;
let ty = -v * k + *th as f32 / 2.0 - 0.5;
bilinear(data, *tw, *th, tx, ty)
}
};
}
out[(y * n + x) as usize] = (acc / 4.0).clamp(0.0, 1.0);
}
}
out
}
fn bilinear(data: &[f32], w: u32, h: u32, x: f32, y: f32) -> f32 {
if !(x > -1.0 && y > -1.0 && x < w as f32 && y < h as f32) {
return 0.0;
}
let at = |xi: i32, yi: i32| -> f32 {
if xi < 0 || yi < 0 || xi >= w as i32 || yi >= h as i32 { 0.0 } else { data.get((yi as u32 * w + xi as u32) as usize).copied().unwrap_or(0.0) }
};
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let (xi, yi) = (x0 as i32, y0 as i32);
let top = at(xi, yi) * (1.0 - fx) + at(xi + 1, yi) * fx;
let bottom = at(xi, yi + 1) * (1.0 - fx) + at(xi + 1, yi + 1) * fx;
top * (1.0 - fy) + bottom * fy
}
fn upload(ctx: &egui::Context, name: &str, w: u32, h: u32, rgba: &[u8]) -> egui::TextureHandle {
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba);
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
}
fn cache_id() -> egui::Id {
egui::Id::new("brush-preview-cache")
}
/// Run `f` on the context's preview cache. The cache is taken out of egui's memory while `f` runs,
/// because uploading a texture needs the context (which `data_mut` holds locked).
pub fn with_cache<R>(ctx: &egui::Context, f: impl FnOnce(&mut SigCache<egui::TextureHandle>) -> R) -> R {
let mut cache = ctx.data_mut(|d| std::mem::take(d.get_temp_mut_or_default::<SigCache<egui::TextureHandle>>(cache_id())));
let r = f(&mut cache);
ctx.data_mut(|d| d.insert_temp(cache_id(), cache));
r
}
/// Renders done by the preview cache so far.
pub fn render_count(ctx: &egui::Context) -> u64 {
with_cache(ctx, |c| c.renders)
}
/// The stroke preview of `b` for `slot`, re-rendered only when the brush or size changes.
pub fn stroke_texture(ctx: &egui::Context, slot: &str, b: &BrushSettings, w: u32, h: u32, color: Color32) -> egui::TextureHandle {
let mut hs = DefaultHasher::new();
(preview_sig(b), w, h, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let c = color.to_normalized_gamma_f32();
upload(ctx, slot, w, h, &preview_pixels(b, w, h, [c[0], c[1], c[2], 1.0]))
})
})
}
/// A tip thumbnail for `slot` (`n × n`; `shape` is hardness, angle, roundness), re-rendered only
/// when the tip changes.
pub fn tip_texture(ctx: &egui::Context, slot: &str, tip: &TipShape, shape: (f32, f32, f32), n: u32, color: Color32) -> egui::TextureHandle {
let (hardness, angle, roundness) = shape;
let mut hs = DefaultHasher::new();
(tip_sig(tip, hardness, angle, roundness, (false, false)), n, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let a = tip_alpha(tip, hardness, angle, roundness, (false, false), n);
let px: Vec<u8> = a.iter().flat_map(|v| [color.r(), color.g(), color.b(), (v * 255.0).round() as u8]).collect();
upload(ctx, slot, n, n, &px)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sig_cache_renders_only_on_change() {
let mut c: SigCache<u32> = SigCache::default();
let mut b = BrushSettings::default();
let mut calls = 0;
for _ in 0..5 {
c.get("strip", preview_sig(&b), || {
calls += 1;
calls
});
}
assert_eq!((calls, c.renders), (1, 1));
b.shape_dynamics.size.jitter = 0.5;
assert_eq!(c.get("strip", preview_sig(&b), || 99), 99);
assert_eq!(c.get("strip", preview_sig(&b), || 100), 99);
assert_eq!((c.renders, c.len()), (2, 1));
// Colour and the session's foreground don't matter; the brush's own fields do.
let base = preview_sig(&BrushSettings::default());
assert_eq!(base, preview_sig(&BrushSettings { color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }));
assert_ne!(base, preview_sig(&BrushSettings { texture: paint::Texture { enabled: true, ..Default::default() }, ..Default::default() }));
c.retain(|_| false);
assert!(c.is_empty());
}
#[test]
fn preview_draws_a_stroke_that_fits() {
let mut b = BrushSettings { size: 400.0, ..Default::default() };
let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px.len(), 120 * 40 * 4);
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count();
assert!(covered > 200 && covered < 120 * 40 / 2, "{covered}");
// Dynamics change the preview.
b.scattering.enabled = true;
b.scattering.scatter.jitter = 3.0;
assert_ne!(preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]), px);
// The Brush Settings strip with several sections on stays cheap to re-render.
b.shape_dynamics.enabled = true;
b.shape_dynamics.size.jitter = 0.4;
b.texture.enabled = true;
b.dual_brush.enabled = true;
let t0 = std::time::Instant::now();
preview_pixels(&b, 488, 76, [1.0; 4]);
eprintln!("strip render: {:.2} ms", t0.elapsed().as_secs_f64() * 1e3);
}
#[test]
fn tip_thumbnails_follow_shape_angle_and_roundness() {
let n = 32;
let at = |a: &[f32], x: u32, y: u32| a[(y * n + x) as usize];
let hard = tip_alpha(&TipShape::Round, 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&hard, 16, 16) > 0.99 && at(&hard, 0, 0) < 0.01 && at(&hard, 29, 16) > 0.9);
let soft = tip_alpha(&TipShape::Round, 0.0, 0.0, 1.0, (false, false), n);
assert!(at(&soft, 26, 16) < at(&hard, 26, 16));
// 25 % roundness: flat ellipse, wide along x; rotated 90°, tall along y.
let flat = tip_alpha(&TipShape::Round, 1.0, 0.0, 0.25, (false, false), n);
assert!(at(&flat, 28, 16) > 0.9 && at(&flat, 16, 4) < 0.01);
let tall = tip_alpha(&TipShape::Round, 1.0, 90.0, 0.25, (false, false), n);
assert!(at(&tall, 16, 4) > 0.9 && at(&tall, 28, 16) < 0.01);
// A sampled tip: a 300×100 bar fills the cell's width, centred.
let bar = paint::GrayTile::from_fn(300, 100, |_, _| 1.0);
let s = tip_alpha(&TipShape::Sampled(bar), 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&s, 3, 16) > 0.9 && at(&s, 16, 2) < 0.01);
// Degenerate input never panics.
let empty = paint::GrayTile { width: 0, height: 0, data: vec![] };
assert!(tip_alpha(&TipShape::Sampled(empty), 1.0, 0.0, 0.0, (true, true), 0).iter().all(|v| *v == 0.0));
}
}
+606
View File
@@ -0,0 +1,606 @@
//! The Brush Settings panel's section bodies (Photoshop's right-hand side): Brush Tip Shape with
//! its tip grid and angle/roundness widget, Shape Dynamics, Scattering, Texture, Dual Brush, Color
//! Dynamics, Transfer, Brush Pose, Build-up and Smoothing. Each edits a working copy of the brush;
//! the panel turns the difference into one `tools.setBrush` call.
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, BrushPreset, Control, Dynamic, MaskMode, Pattern, PatternStyle, TipShape};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::widgets;
pub const CONTROLS: [(Control, &str); 8] = [
(Control::Off, "Off"),
(Control::Fade, "Fade"),
(Control::PenPressure, "Pen Pressure"),
(Control::PenTilt, "Pen Tilt"),
(Control::StylusWheel, "Stylus Wheel"),
(Control::Rotation, "Rotation"),
(Control::InitialDirection, "Initial Direction"),
(Control::Direction, "Direction"),
];
pub const MASK_MODES: [(MaskMode, &str); 10] = [
(MaskMode::Multiply, "Multiply"),
(MaskMode::Subtract, "Subtract"),
(MaskMode::Darken, "Darken"),
(MaskMode::Overlay, "Overlay"),
(MaskMode::ColorDodge, "Color Dodge"),
(MaskMode::ColorBurn, "Color Burn"),
(MaskMode::LinearBurn, "Linear Burn"),
(MaskMode::HardMix, "Hard Mix"),
(MaskMode::LinearHeight, "Linear Height"),
(MaskMode::Height, "Height"),
];
const PATTERNS: [(PatternStyle, &str); 4] =
[(PatternStyle::Paper, "Paper"), (PatternStyle::Canvas, "Canvas"), (PatternStyle::Noise, "Noise"), (PatternStyle::Dots, "Dots")];
/// Percent slider over a 0..`max` fraction.
fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32, max: f32) -> bool {
signed_pct(ui, label, v, 0.0, max)
}
/// Percent slider over a `min..max` fraction.
fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) -> bool {
let mut p = (*v * 100.0).round();
let changed = widgets::slider_row(ui, label, &mut p, min * 100.0..=max * 100.0, "%", None).changed();
if changed {
*v = (p.round() / 100.0).clamp(min, max);
}
changed
}
fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str) -> bool {
let mut x = *v;
let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed();
if changed {
*v = x.round().clamp(*range.start(), *range.end());
}
changed
}
fn dim(ui: &mut egui::Ui, s: &str) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(s).color(t.text_dim));
}
/// `Control: [Off ▾] [25]`: the controller pop-up plus Fade's step count. `angle` offers the
/// direction controls (Photoshop only has them for Angle Jitter).
fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) {
ui.horizontal(|ui| {
dim(ui, "Control:");
let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 };
widgets::dropdown(ui, id, &mut d.control, &CONTROLS[..n], 130.0);
if d.control == Control::Fade {
let mut steps = d.fade_steps as f32;
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).on_hover_text("Fade steps").changed() {
d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32;
}
}
});
ui.add_space(2.0);
}
/// A jitter slider, its Control row and (optionally) the Minimum slider: one Photoshop block.
fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32, minimum: Option<&str>, angle: bool) {
pct(ui, label, &mut d.jitter, max);
control_row(ui, id, d, angle);
if let Some(m) = minimum {
pct(ui, m, &mut d.minimum, 1.0);
}
ui.add_space(6.0);
}
/// Size: a value field over a logarithmic slider (small sizes get most of the travel).
/// `restore` adds Photoshop's "Restore original size" button (sampled tips).
fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: Option<f32>) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut s = *size;
if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).changed() {
*size = s.round().clamp(1.0, max);
}
if let Some(orig) = restore
&& crate::icons::button(ui, "undo-2", 22.0, false, "Restore original size").clicked()
{
*size = orig.clamp(1.0, max);
}
});
});
let mut lv = size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=max.ln(), None).changed() {
*size = lv.exp().round().clamp(1.0, max);
}
ui.add_space(4.0);
}
/// Which part of the angle/roundness widget a drag grabbed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EllipseGrab {
Angle,
Roundness,
}
/// Outer radius of the angle/roundness widget's ellipse.
const ELLIPSE_R: f32 = 34.0;
/// Screen positions (relative to the widget centre) of the two roundness handles.
pub fn roundness_handles(angle: f32, roundness: f32) -> [egui::Vec2; 2] {
let (s, c) = angle.to_radians().sin_cos();
// The minor axis is the major axis turned 90° counter-clockwise; screen y points down.
let minor = vec2(-s, -c) * ELLIPSE_R * roundness.clamp(0.0, 1.0);
[minor, -minor]
}
/// What a press at `rel` (relative to the centre) grabs: a roundness handle within 7 px, else the angle.
pub fn ellipse_grab(rel: egui::Vec2, angle: f32, roundness: f32) -> EllipseGrab {
if roundness_handles(angle, roundness).iter().any(|h| (*h - rel).length() <= 7.0) { EllipseGrab::Roundness } else { EllipseGrab::Angle }
}
/// The new (angle, roundness) for a drag to `rel`: the angle points at the pointer (degrees,
/// counter-clockwise, -180..180); the roundness is the pointer's distance along the minor axis.
pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f32) -> (f32, f32) {
match grab {
EllipseGrab::Angle if rel.length() > 1.0 => ((-rel.y).atan2(rel.x).to_degrees().round(), roundness),
EllipseGrab::Angle => (angle, roundness),
EllipseGrab::Roundness => {
let (s, c) = angle.to_radians().sin_cos();
let along = (rel.x * -s + rel.y * -c).abs();
(angle, (along / ELLIPSE_R).clamp(0.01, 1.0))
}
}
}
/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash.
fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag());
let c = rect.center();
let grab_id = resp.id.with("grab");
let mut changed = false;
if resp.drag_started()
&& let Some(p) = resp.interact_pointer_pos()
{
let g = ellipse_grab(p - c, *angle, *roundness);
ui.data_mut(|d| d.insert_temp(grab_id, g == EllipseGrab::Roundness));
}
if (resp.dragged() || resp.clicked())
&& let Some(p) = resp.interact_pointer_pos()
{
let grab = if resp.clicked() {
ellipse_grab(p - c, *angle, *roundness)
} else if ui.data(|d| d.get_temp::<bool>(grab_id)).unwrap_or(false) {
EllipseGrab::Roundness
} else {
EllipseGrab::Angle
};
let (a, r) = ellipse_drag(grab, p - c, *angle, *roundness);
if a != *angle || (r - *roundness).abs() > 1e-4 {
*angle = a;
*roundness = r;
changed = true;
}
}
let p = ui.painter();
p.circle_filled(c, 40.0, t.field);
p.circle_stroke(c, 40.0, Stroke::new(1.0, t.field_border));
p.line_segment([c - vec2(40.0, 0.0), c + vec2(40.0, 0.0)], Stroke::new(1.0, t.separator));
p.line_segment([c - vec2(0.0, 40.0), c + vec2(0.0, 40.0)], Stroke::new(1.0, t.separator));
let (s, co) = angle.to_radians().sin_cos();
let major = vec2(co, -s);
let minor = vec2(-s, -co);
let rr = roundness.clamp(0.01, 1.0);
let pts: Vec<egui::Pos2> = (0..48)
.map(|i| {
let a = i as f32 / 48.0 * std::f32::consts::TAU;
c + major * a.cos() * ELLIPSE_R + minor * a.sin() * ELLIPSE_R * rr
})
.collect();
p.add(egui::Shape::convex_polygon(pts, t.text.gamma_multiply(0.18), Stroke::new(1.5, t.text)));
// Arrow along the major axis.
let tip = c + major * (ELLIPSE_R + 5.0);
p.line_segment([c, tip], Stroke::new(1.5, t.accent));
let back = tip - major * 7.0;
p.add(egui::Shape::convex_polygon(vec![tip, back + minor * 4.0, back - minor * 4.0], t.accent, Stroke::NONE));
for h in roundness_handles(*angle, *roundness) {
p.circle_filled(c + h, 4.0, Color32::WHITE);
p.circle_stroke(c + h, 4.0, Stroke::new(1.0, t.accent));
}
let _ = resp.on_hover_text("Drag the arrow to set the angle, the dots to set the roundness");
changed
}
/// The tip a preset contributes to a tip grid: (shape, size, hardness, angle, roundness, spacing).
pub fn preset_tip(p: &BrushPreset) -> (&TipShape, f32, f32, f32, f32, f32) {
let b = &p.brush;
(&b.tip, b.size, b.hardness, b.angle, b.roundness, b.spacing)
}
/// Thumbnails show relative size: small tips are drawn smaller (down to 30 %), 40 px and up fill the cell.
pub fn thumb_scale(size: f32) -> f32 {
(size / 40.0).clamp(0.3, 1.0)
}
/// Does `b`'s tip look like `p`'s (same bitmap or round hardness, and size)?
fn same_tip(tip: &TipShape, size: f32, hardness: f32, p: &BrushPreset) -> bool {
let (pt, ps, ph, ..) = preset_tip(p);
(size - ps).abs() < 0.5
&& match (tip, pt) {
(TipShape::Round, TipShape::Round) => (hardness - ph).abs() < 0.01,
(TipShape::Sampled(a), TipShape::Sampled(b)) => a.width == b.width && a.height == b.height && a.data == b.data,
_ => false,
}
}
/// Photoshop's scrolling grid of preset tips with their sizes. Returns the clicked preset index.
fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], selected: impl Fn(&BrushPreset) -> bool) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut clicked = None;
let frame = egui::Frame::NONE.fill(t.field).stroke(Stroke::new(1.0, t.field_border)).corner_radius(t.radius_sm as u8).inner_margin(egui::Margin::same(3));
frame.show(ui, |ui| {
egui::ScrollArea::vertical().id_salt(id).max_height(104.0).auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
for (i, p) in presets.iter().enumerate() {
let (cell, resp) = ui.allocate_exact_size(vec2(44.0, 50.0), Sense::click());
if !ui.is_rect_visible(cell) {
continue;
}
if selected(p) {
ui.painter().rect_filled(cell, 3.0, t.accent_soft);
ui.painter().rect_stroke(cell, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(cell, 3.0, t.hover);
}
let (tip, size, hard, angle, round, _) = preset_tip(p);
let tex = brush_preview::tip_texture(ui.ctx(), &format!("{id}:{}", p.name), tip, (hard, angle, round), 34, t.text);
let ir = egui::Rect::from_center_size(pos2(cell.center().x, cell.top() + 20.0), vec2(34.0, 34.0) * thumb_scale(size));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().text(
pos2(cell.center().x, cell.bottom() - 6.0),
egui::Align2::CENTER_CENTER,
format!("{}", size.round() as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
if resp.on_hover_text(&p.name).clicked() {
clicked = Some(i);
}
}
});
});
});
ui.add_space(6.0);
clicked
}
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) {
let (tip, size, hardness) = (b.tip.clone(), b.size, b.hardness);
if let Some(p) = tip_grid(ui, "brush-tip-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
// Picking a tip changes the tip only: the dynamics sections stay (Photoshop).
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
b.tip = pt.clone();
b.size = ps;
b.hardness = ph;
b.angle = pa;
b.roundness = pr;
b.spacing = psp;
}
let orig = match &b.tip {
TipShape::Sampled(g) => Some(g.width.max(g.height) as f32),
TipShape::Round => None,
};
size_row(ui, "Size", &mut b.size, 5000.0, orig);
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut b.flip_x, "Flip X");
ui.add_space(8.0);
widgets::checkbox(ui, &mut b.flip_y, "Flip Y");
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ellipse_widget(ui, &mut b.angle, &mut b.roundness);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, "Angle:"));
let mut a = b.angle;
if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() {
b.angle = a.round().clamp(-180.0, 180.0);
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, "Roundness:"));
let mut r = (b.roundness * 100.0).round();
if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() {
b.roundness = (r.round() / 100.0).clamp(0.01, 1.0);
}
});
});
});
ui.add_space(6.0);
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);
}
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);
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| {
widgets::checkbox(ui, &mut sd.flip_x_jitter, "Flip X Jitter");
ui.add_space(8.0);
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
});
}
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
let sc = &mut b.scattering;
pct(ui, "Scatter", &mut sc.scatter.jitter, 10.0);
widgets::checkbox(ui, &mut sc.both_axes, "Both Axes");
ui.add_space(2.0);
control_row(ui, "brush-scatter-ctl", &mut sc.scatter, false);
ui.add_space(6.0);
let mut c = sc.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
sc.count = c as u32;
}
dynamic(ui, "brush-count-ctl", "Count Jitter", &mut sc.count_jitter, 1.0, None, false);
}
/// The pattern swatch: the (adjusted) texture as the stroke sees it.
fn pattern_swatch(ui: &mut egui::Ui, tx: &paint::Texture) {
let t = Tokens::get(ui.ctx());
let n = 56u32;
let (rect, _) = ui.allocate_exact_size(vec2(n as f32, n as f32), Sense::hover());
let sig = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_vec(&(
match &tx.pattern {
Pattern::Tile(g) => {
(g.width, g.height, g.data.len() as u32, g.data.iter().step_by((g.data.len() / 512).max(1)).map(|v| *v as u32).sum::<u32>())
}
Pattern::Procedural { style, size, seed } => (*style as u32, *size, *seed, 0),
},
tx.invert,
tx.brightness,
tx.contrast,
))
.unwrap_or_default()
.hash(&mut h);
h.finish()
};
let tex = brush_preview::with_cache(ui.ctx(), |c| {
c.get("texture-swatch", sig, || {
let img = match &tx.pattern {
Pattern::Procedural { style, size, seed } => paint::procedural::pattern(*style, (*size).clamp(4, 1024), *seed),
Pattern::Tile(g) if g.is_valid() => paint::tile::PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() },
Pattern::Tile(_) => paint::tile::PatternImage { width: 1, height: 1, data: vec![0.5] },
};
let (br, ct) = (tx.brightness.clamp(-1.0, 1.0), tx.contrast.clamp(-1.0, 1.0));
let px: Vec<u8> = (0..n * n)
.flat_map(|i| {
let v = img.sample_wrap((i % n) as f32, (i / n) as f32);
let mut x = ((v - 0.5) * (1.0 + ct) + 0.5 + br).clamp(0.0, 1.0);
if tx.invert {
x = 1.0 - x;
}
let g = (x * 255.0).round() as u8;
[g, g, g, 255]
})
.collect();
let img = egui::ColorImage::from_rgba_unmultiplied([n as usize, n as usize], &px);
ui.ctx().load_texture("texture-swatch", img, egui::TextureOptions::LINEAR)
})
});
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
}
fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tx = &mut b.texture;
ui.horizontal(|ui| {
pattern_swatch(ui, tx);
ui.add_space(8.0);
ui.vertical(|ui| {
let mut style = match &tx.pattern {
Pattern::Procedural { style, .. } => Some(*style),
Pattern::Tile(_) => None,
};
let mut opts: Vec<(Option<PatternStyle>, &str)> = PATTERNS.iter().map(|(s, l)| (Some(*s), *l)).collect();
if style.is_none() {
opts.push((None, "Custom"));
}
if widgets::dropdown(ui, "brush-pattern", &mut style, &opts, 110.0)
&& let Some(s) = style
{
let size = match &tx.pattern {
Pattern::Procedural { size, .. } => *size,
Pattern::Tile(_) => 128,
};
tx.pattern = Pattern::Procedural { style: s, size, seed: 1 };
}
ui.add_space(4.0);
widgets::checkbox(ui, &mut tx.invert, "Invert");
});
});
ui.add_space(6.0);
if let Pattern::Procedural { size, .. } = &mut tx.pattern {
let mut s = *size as f32;
if num(ui, "Pattern Size", &mut s, 16.0..=1024.0, "px") {
*size = s as u32;
}
}
pct(ui, "Scale", &mut tx.scale, 10.0);
let mut br = (tx.brightness * 150.0).round();
if num(ui, "Brightness", &mut br, -150.0..=150.0, "") {
tx.brightness = br / 150.0;
}
// Photoshop's -50..100 contrast onto the model's -1..1.
let mut ct = if tx.contrast < 0.0 { tx.contrast * 50.0 } else { tx.contrast * 100.0 }.round();
if num(ui, "Contrast", &mut ct, -50.0..=100.0, "") {
tx.contrast = if ct < 0.0 { ct / 50.0 } else { ct / 100.0 };
}
widgets::checkbox(ui, &mut tx.each_tip, "Texture Each Tip");
ui.add_space(4.0);
ui.horizontal(|ui| {
dim(ui, "Mode:");
widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0);
});
ui.add_space(4.0);
pct(ui, "Depth", &mut tx.depth, 1.0);
let each = tx.each_tip;
ui.add_enabled_ui(each, |ui| {
pct(ui, "Minimum Depth", &mut tx.depth_jitter.minimum, 1.0);
pct(ui, "Depth Jitter", &mut tx.depth_jitter.jitter, 1.0);
control_row(ui, "brush-depth-ctl", &mut tx.depth_jitter, false);
});
}
fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) {
let d = &mut b.dual_brush;
ui.horizontal(|ui| {
dim(ui, "Mode:");
widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 120.0);
ui.add_space(8.0);
widgets::checkbox(ui, &mut d.flip, "Flip");
});
ui.add_space(4.0);
let (tip, size, hardness) = (d.tip.clone(), d.size, d.hardness);
if let Some(p) = tip_grid(ui, "brush-dual-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
d.tip = pt.clone();
d.size = ps;
d.hardness = ph;
d.angle = pa;
d.roundness = pr;
d.spacing = psp;
}
let orig = match &d.tip {
TipShape::Sampled(g) => Some(g.width.max(g.height) as f32),
TipShape::Round => None,
};
size_row(ui, "Size", &mut d.size, 2500.0, orig);
signed_pct(ui, "Spacing", &mut d.spacing, 0.01, 10.0);
pct(ui, "Scatter", &mut d.scatter, 10.0);
widgets::checkbox(ui, &mut d.both_axes, "Both Axes");
ui.add_space(4.0);
let mut c = d.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
d.count = c as u32;
}
}
fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let c = &mut b.color_dynamics;
widgets::checkbox(ui, &mut c.per_tip, "Apply Per Tip");
ui.add_space(6.0);
dynamic(ui, "brush-fgbg-ctl", "Foreground/Background Jitter", &mut c.fg_bg, 1.0, None, false);
let mut h = (c.hue_jitter * 100.0).round();
if widgets::slider_row(ui, "Hue Jitter", &mut h, 0.0..=100.0, "%", Some(&widgets::hue_stops())).changed() {
c.hue_jitter = (h.round() / 100.0).clamp(0.0, 1.0);
}
pct(ui, "Saturation Jitter", &mut c.saturation_jitter, 1.0);
pct(ui, "Brightness Jitter", &mut c.brightness_jitter, 1.0);
signed_pct(ui, "Purity", &mut c.purity, -1.0, 1.0);
}
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);
}
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
fn tilt(ui: &mut egui::Ui, label: &str, deg: &mut f32) {
let mut v = (*deg / 90.0).clamp(-1.0, 1.0);
if signed_pct(ui, label, &mut v, -1.0, 1.0) {
*deg = v * 90.0;
}
}
fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) {
let p = &mut b.pose;
tilt(ui, "Tilt X", &mut p.tilt_x);
tilt(ui, "Tilt Y", &mut p.tilt_y);
widgets::checkbox(ui, &mut p.override_tilt, "Override Tilt");
ui.add_space(8.0);
num(ui, "Rotation", &mut p.rotation, 0.0..=360.0, "°");
widgets::checkbox(ui, &mut p.override_rotation, "Override Rotation");
ui.add_space(8.0);
pct(ui, "Pressure", &mut p.pressure, 1.0);
widgets::checkbox(ui, &mut p.override_pressure, "Override Pressure");
}
fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) {
let s = &mut b.smoothing;
pct(ui, "Smoothing", &mut s.amount, 1.0);
widgets::checkbox(ui, &mut s.pulled_string, "Pulled String Mode");
widgets::checkbox(ui, &mut s.catch_up, "Stroke Catch-up");
widgets::checkbox(ui, &mut s.catch_up_on_end, "Catch-up on Stroke End");
widgets::checkbox(ui, &mut s.adjust_for_zoom, "Adjust for Zoom");
}
/// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset]) {
let t = Tokens::get(ui.ctx());
let note = |ui: &mut egui::Ui, s: &str| {
ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5)));
};
match i {
0 => tip_shape(ui, b, presets),
1 => shape_dynamics(ui, b),
2 => scattering(ui, b),
3 => texture(ui, b),
4 => dual_brush(ui, b, presets),
5 => color_dynamics(ui, b),
6 => transfer(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."),
10 => {
note(ui, "Airbrush: keeps depositing paint while the pointer rests.");
ui.add_space(6.0);
num(ui, "Rate", &mut b.build_up_rate, 1.0..=200.0, "/s");
}
11 => smoothing(ui, b),
_ => note(ui, "Keeps the current pattern and scale when you switch to another textured brush preset."),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipse_widget_drags_angle_and_roundness() {
// Pointing straight up is 90° (counter-clockwise from +x; screen y is down).
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(0.0, -20.0), 0.0, 1.0), (90.0, 1.0));
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(-20.0, 0.0), 0.0, 1.0).0.abs(), 180.0);
// The centre itself doesn't change the angle.
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(0.2, 0.1), 30.0, 0.5), (30.0, 0.5));
// At angle 0 the minor axis is vertical: half the radius up is 50 % roundness.
let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(0.0, -ELLIPSE_R / 2.0), 0.0, 1.0);
assert!((r - 0.5).abs() < 1e-4);
// Handles sit on the minor axis and are what a press there grabs.
let [h, _] = roundness_handles(0.0, 0.5);
assert!((h - vec2(0.0, -ELLIPSE_R / 2.0)).length() < 1e-4);
assert_eq!(ellipse_grab(h, 0.0, 0.5), EllipseGrab::Roundness);
assert_eq!(ellipse_grab(vec2(ELLIPSE_R, 0.0), 0.0, 0.5), EllipseGrab::Angle);
// Rotated 90°: the minor axis is horizontal.
let [h, _] = roundness_handles(90.0, 1.0);
assert!((h.x.abs() - ELLIPSE_R).abs() < 1e-3 && h.y.abs() < 1e-3, "{h:?}");
let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(500.0, 0.0), 90.0, 0.2);
assert_eq!(r, 1.0);
}
}
+3 -1
View File
@@ -1133,8 +1133,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// (Preferences › Tools, `paint_mouse`).
crate::paint_mouse::sync_tool_smoothing(app);
let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
// 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
&& let Some(p) = response.interact_pointer_pos()
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
{
if tool == Tool::Move && app.ui.transform.is_none() {
begin_transform_controls_at(app, &ctx, &xf, p);
+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");
}
}
+11
View File
@@ -135,6 +135,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
Err(e) => return err(e),
}
}
// Dock group order, heights and collapsed groups (see `dock::DockLayout`).
if let Some(d) = p.get("dock") {
match serde_json::from_value(d.clone()) {
Ok(v) => app.ui.dock = v,
Err(e) => return err(e),
}
}
// Right dock width in points (clamped to the dock's 250..=520 range), applied next frame.
if let Some(w) = p.get("dockWidth").and_then(Value::as_f64) {
crate::panels::request_dock_width(ctx, w as f32);
}
if let Some(i) = app.session.active_index() {
if let Some(z) = p.get("zoom").and_then(Value::as_f64) {
app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0);
+3 -2
View File
@@ -400,8 +400,9 @@ mod tests {
h.step();
}
let drawn = h.state().ui.crop_rect.unwrap();
// egui starts the drag at the first move past its threshold (220, 170).
assert_eq!((drawn[2] - drawn[0], drawn[3] - drawn[1]), (20.0, 10.0), "40x20 screen px at 200%");
// The frame starts where the button went down (200, 150), not at the first move past
// egui's drag threshold (#123).
assert_eq!((drawn[2] - drawn[0], drawn[3] - drawn[1]), (30.0, 20.0), "60x40 screen px at 200%");
h.event(space(true));
h.step();
for p in [pos2(280.0, 200.0), pos2(300.0, 210.0)] {
+477
View File
@@ -0,0 +1,477 @@
//! Right-dock layout (#88): panel groups with fixed heights, like Photoshop's dock columns.
//!
//! A group's height never follows its content: content taller than the group scrolls inside
//! it. The last expanded group (Layers by default) fills what the others leave. Drag the gap
//! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a
//! group to its tab strip, and drag a tab strip to move the group up or down the column
//! (unless Window › Workspace › Lock Workspace is on).
//!
//! The layout is [`DockLayout`] in `UiState::dock` (serialisable, drivable with `ui.set`), saved
//! with Window › Workspace › New Workspace…, reset by Reset Workspace, and remembered across
//! launches in the preferences (`panelLayout`) while Remember Workspace Changes is on.
use std::collections::BTreeMap;
use egui::{Rect, Sense, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::state::{DockTabs, Panels};
use crate::theme::Tokens;
use crate::widgets;
/// A dock panel group (one tab strip).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Group {
/// Color | Swatches | Gradients | Patterns.
Color,
/// Properties | Adjustments.
Properties,
/// Navigator | Histogram | Info.
Navigator,
/// History | Actions | Layer Comps.
History,
/// Layers | Channels | Paths.
Layers,
}
impl Group {
/// Photoshop Essentials order, top to bottom.
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
pub fn key(self) -> &'static str {
match self {
Group::Color => "color",
Group::Properties => "properties",
Group::Navigator => "navigator",
Group::History => "history",
Group::Layers => "layers",
}
}
/// Height (points, tab strip included) a group gets until the user resizes it.
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::Layers => 320.0,
}
}
/// The smallest height an expanded group can be dragged or squeezed to.
pub fn min_height(self) -> f32 {
match self {
Group::Layers => 140.0,
_ => 80.0,
}
}
pub fn tabs(self, pro: bool) -> &'static [&'static str] {
match self {
// Photoshop Essentials: Color | Swatches | Gradients | Patterns.
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
Group::Properties => &["Properties", "Adjustments"],
Group::Navigator => &["Navigator", "Histogram", "Info"],
Group::History => &["History", "Actions", "Layer Comps"],
Group::Layers => &["Layers", "Channels", "Paths"],
}
}
/// Tabs that lay out their own scrolling list and footer (they fill the group).
pub fn scrolls_itself(self, tab: usize) -> bool {
matches!((self, tab), (Group::Layers, 0) | (Group::History, 0))
}
fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize {
match self {
Group::Color => &mut tabs.color,
Group::Properties => &mut tabs.properties,
Group::Navigator => &mut tabs.navigator,
Group::History => &mut tabs.history,
Group::Layers => &mut tabs.layers,
}
}
/// The group for a `panels` / `dockTabs` key ("color", "properties", …).
pub fn from_key(key: &str) -> Option<Group> {
Group::ALL.into_iter().find(|g| g.key() == key)
}
pub fn shown(self, panels: &Panels) -> bool {
match self {
Group::Color => panels.color,
Group::Properties => panels.properties,
Group::Navigator => panels.navigator,
Group::History => panels.history,
Group::Layers => panels.layers,
}
}
fn shown_mut(self, panels: &mut Panels) -> &mut bool {
match self {
Group::Color => &mut panels.color,
Group::Properties => &mut panels.properties,
Group::Navigator => &mut panels.navigator,
Group::History => &mut panels.history,
Group::Layers => &mut panels.layers,
}
}
}
/// Order, heights and collapsed state of the dock groups.
#[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.
pub order: Vec<Group>,
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
pub heights: BTreeMap<Group, f32>,
/// Groups collapsed to their tab strip.
pub collapsed: Vec<Group>,
}
/// Gap between groups; it is also the splitter's grab area.
pub const GAP: f32 = 6.0;
/// Upper bound on a stored height (guards against absurd values from `ui.set`).
const MAX_HEIGHT: f32 = 4000.0;
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()) {
if !out.contains(g) {
out.push(*g);
}
}
out
}
pub fn is_collapsed(&self, g: Group) -> bool {
self.collapsed.contains(&g)
}
pub fn set_collapsed(&mut self, g: Group, on: bool) {
self.collapsed.retain(|c| *c != g);
if on {
self.collapsed.push(g);
}
}
/// The stored (or default) height, sanitised.
pub fn height(&self, g: Group) -> f32 {
match self.heights.get(&g) {
Some(h) if h.is_finite() => h.clamp(g.min_height(), MAX_HEIGHT),
_ => g.default_height(),
}
}
/// Move `g` so it is drawn just before `before` (or last when `None`).
pub fn move_group(&mut self, g: Group, before: Option<Group>) {
if before == Some(g) {
return;
}
let mut order = self.order();
order.retain(|x| *x != g);
let at = before.and_then(|b| order.iter().position(|x| *x == b)).unwrap_or(order.len());
order.insert(at, g);
self.order = order;
}
/// 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.
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));
let mut hs: Vec<f32> = shown
.iter()
.enumerate()
.map(|(i, g)| {
if self.is_collapsed(*g) {
strip
} else if Some(i) == filler {
0.0
} else {
self.height(*g)
}
})
.collect();
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 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.
for i in (0..f).rev() {
if deficit <= 0.0 {
break;
}
let Some(g) = shown.get(i) else { continue };
if self.is_collapsed(*g) {
continue;
}
if let Some(h) = hs.get_mut(i) {
let give = (*h - g.min_height()).max(0.0).min(deficit);
*h -= give;
deficit -= give;
}
}
let rest = avail - hs.iter().sum::<f32>() - gaps;
if let Some(h) = hs.get_mut(f) {
*h = rest.max(min_fill);
}
}
shown.iter().copied().zip(hs).collect()
}
}
/// Show `g` and expand it (Window › <panel>, the icon rail): a panel asked for is always
/// brought back, whatever state it was left in (#129).
pub fn reveal(app: &mut PhotocraftApp, g: Group) {
*g.shown_mut(&mut app.ui.panels) = true;
app.ui.dock.set_collapsed(g, false);
}
/// Icon rail click: a hidden group is shown, a collapsed one expanded and an expanded one
/// collapsed to its tab strip. A docked group is never hidden from the rail (it used to
/// toggle visibility, so one stray click made a panel vanish: #129); `docked` is false for
/// Studio's floating Properties card, which the rail shows and hides.
pub fn rail_click(app: &mut PhotocraftApp, g: Group, docked: bool) {
if !g.shown(&app.ui.panels) {
reveal(app, g);
} else if !docked {
*g.shown_mut(&mut app.ui.panels) = false;
} else {
let collapse = !app.ui.dock.is_collapsed(g);
app.ui.dock.set_collapsed(g, collapse);
}
}
/// Per-group interactions collected while drawing, applied afterwards.
enum Action {
ToggleCollapse(Group),
Close(Group),
Move(Group, Option<Group>),
}
/// Rects of the groups drawn last frame (screen points), for tests and automation.
pub fn last_rects(ctx: &egui::Context) -> Vec<(Group, Rect)> {
ctx.data(|d| d.get_temp::<Vec<(Group, Rect)>>(rects_id())).unwrap_or_default()
}
fn rects_id() -> egui::Id {
egui::Id::new("dock-group-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)) {
let t = Tokens::get(ui.ctx());
let strip = if t.pro { 28.0 } else { 40.0 };
let order: Vec<Group> = app.ui.dock.order().into_iter().filter(|g| shown.contains(g)).collect();
let area = ui.available_rect_before_wrap();
let heights = app.ui.dock.heights_for(&order, area.height(), strip);
let locked = app.session.prefs().workspace_locked;
let rects = rects_after_layout(&heights, area);
let mut actions: Vec<Action> = Vec::new();
let mut dragging: Option<Group> = None;
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));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
child.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 };
let before = *g.tab_mut(&mut app.ui.dock_tabs);
let mut sel = before;
let tabs = g.tabs(t.pro);
let resp = widgets::card_ex(&mut child, g.key(), tabs, &mut sel, collapsed, |ui, tab| {
let inner = ui.available_height().max(0.0);
if g.scrolls_itself(tab) {
ui.set_min_height(inner);
body(app, ui, g, tab);
} else {
egui::ScrollArea::vertical()
.id_salt(("dock-scroll", g.key(), tab))
.max_height(inner)
.auto_shrink([false, false])
.show(ui, |ui| body(app, ui, g, tab));
}
});
// 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));
}
if resp.strip.double_clicked() || resp.tab_double_clicked {
actions.push(Action::ToggleCollapse(g));
}
if !locked && resp.strip.dragged() {
dragging = Some(g);
}
if !locked
&& resp.strip.drag_stopped()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
actions.push(Action::Move(g, drop_before(&order, &rects, g, p.y)));
}
if dragging == Some(g) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
egui::Popup::menu(&resp.menu).show(|ui| {
ui.set_min_width(170.0);
if tabs.get(sel) == Some(&"Layers") {
crate::layer_row_ui::panel_menu(app, ui);
ui.separator();
}
if ui.button(if collapsed { "Expand Panel Group" } else { "Collapse Panel Group" }).clicked() {
actions.push(Action::ToggleCollapse(g));
ui.close();
}
let pos = order.iter().position(|x| *x == g).unwrap_or(0);
if ui.add_enabled(!locked && pos > 0, egui::Button::new("Move Group Up")).clicked() {
actions.push(Action::Move(g, order.get(pos.saturating_sub(1)).copied()));
ui.close();
}
if ui.add_enabled(!locked && pos + 1 < order.len(), egui::Button::new("Move Group Down")).clicked() {
actions.push(Action::Move(g, order.get(pos + 2).copied()));
ui.close();
}
ui.separator();
if ui.button("Close Tab Group").clicked() {
actions.push(Action::Close(g));
ui.close();
}
});
// Splitter in the gap below this group: resizes it against the next expanded group.
if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) {
let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0));
let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag());
if sresp.hovered() || sresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
ui.painter().line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(0.7)));
}
if sresp.dragged() {
resize(&mut app.ui.dock, &heights, i, sresp.drag_delta().y);
}
}
}
// Drop indicator while a group is dragged by its tab strip.
if let (Some(g), Some(p)) = (dragging, ui.ctx().pointer_interact_pos()) {
let before = drop_before(&order, &rects, g, p.y);
let line_y = match before.and_then(|b| rects.iter().find(|(x, _)| *x == b)) {
Some((_, r)) => r.top() - GAP / 2.0,
None => rects.last().map_or(area.top(), |(_, r)| r.bottom() + GAP / 2.0),
};
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.advance_cursor_after_rect(area);
for a in actions {
match a {
Action::ToggleCollapse(g) => {
let on = !app.ui.dock.is_collapsed(g);
app.ui.dock.set_collapsed(g, on);
}
Action::Close(g) => *g.shown_mut(&mut app.ui.panels) = false,
Action::Move(g, before) => {
if before != Some(g) {
app.ui.dock.move_group(g, before);
}
}
}
}
}
fn rects_after_layout(heights: &[(Group, f32)], area: Rect) -> Vec<(Group, Rect)> {
let mut y = area.top();
heights
.iter()
.map(|(g, h)| {
let r = Rect::from_min_size(pos2(area.left(), y), vec2(area.width(), h.max(0.0)));
y = r.bottom() + GAP;
(*g, r)
})
.collect()
}
/// The group the dragged one lands before when released at `y` (`None` = last).
fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32) -> Option<Group> {
let hit = rects.iter().find(|(_, r)| y < r.center().y).map(|(g, _)| *g);
match hit {
// Dropping onto itself, or just below itself, keeps the place.
Some(g) if g == dragged => Some(dragged),
Some(g) => {
let after_self = order.iter().position(|x| *x == dragged).zip(order.iter().position(|x| *x == g)).is_some_and(|(a, b)| b == a + 1);
if after_self { Some(dragged) } else { Some(g) }
}
None => None,
}
}
/// Splitter `i` (below group `i`) dragged by `dy`: group `i` grows or shrinks against the next
/// expanded group (or the filler, which absorbs the difference).
fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) {
if !dy.is_finite() || dy == 0.0 {
return;
}
let Some(&(g, h)) = heights.get(i) else { return };
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 };
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 {
layout.heights.insert(n, (nh - (new_h - h)).max(n.min_height()));
}
}
/// What `prefs.panelLayout` holds: the live layout and open panels.
fn snapshot(app: &PhotocraftApp) -> Value {
json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock})
}
/// Remember the layout in the preferences once the user lets go of the mouse (Workspace ›
/// Remember Workspace Changes). Cheap: a small JSON compare per frame.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !app.session.prefs().workspace.remember_workspace_changes || ctx.input(|i| i.pointer.any_down()) {
return;
}
let now = snapshot(app);
if app.session.prefs().panel_layout != now {
app.session.prefs.edit(|p| p.panel_layout = now);
}
}
/// Restore the remembered layout at launch. Unreadable parts keep their defaults.
pub fn restore(app: &mut PhotocraftApp) {
if !app.session.prefs().workspace.remember_workspace_changes {
return;
}
let saved = app.session.prefs().panel_layout.clone();
apply(app, &saved);
if let Some(ws) = saved.get("workspace").and_then(Value::as_str) {
app.ui.workspace = ws.to_string();
}
}
/// Apply the `panels`, `dockTabs` and `dock` parts of a saved layout (a workspace or
/// `panelLayout`). Missing or invalid parts are left alone.
pub fn apply(app: &mut PhotocraftApp, v: &Value) {
if let Some(p) = v.get("panels").and_then(|p| serde_json::from_value(p.clone()).ok()) {
app.ui.panels = p;
}
if let Some(t) = v.get("dockTabs").and_then(|t| serde_json::from_value(t.clone()).ok()) {
app.ui.dock_tabs = t;
}
if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) {
app.ui.dock = d;
}
}
#[cfg(test)]
#[path = "dock_tests.rs"]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
//! Dock layout tests (#88): fixed group heights, splitters, collapse, reorder, persistence.
use egui::{Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::Harness;
use serde_json::json;
use super::*;
use crate::theme::ThemeKind;
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);
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:?}");
// 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);
assert_eq!(hs[0].1, 28.0);
assert_eq!(hs[1].1, Group::Properties.default_height());
// Layers collapsed: Properties becomes the filler.
l.set_collapsed(Group::Layers, true);
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!(hs[2].1, 28.0);
assert!((hs[1].1 - (800.0 - 56.0 - 2.0 * GAP)).abs() < 1e-3);
}
#[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);
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());
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}");
}
}
assert!(l.heights_for(&[], 500.0, 28.0).is_empty());
}
#[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]);
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
l.heights.insert(Group::Color, bad);
let h = l.height(Group::Color);
assert!(h.is_finite() && h >= Group::Color.min_height() && h <= MAX_HEIGHT, "{bad} -> {h}");
}
// 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]);
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();
assert_eq!(ui.dock, DockLayout::default());
}
#[test]
fn move_group_reorders() {
let mut l = DockLayout::default();
l.move_group(Group::Layers, Some(Group::Color));
assert_eq!(l.order()[0], Group::Layers);
l.move_group(Group::Layers, None);
assert_eq!(l.order().last(), Some(&Group::Layers));
l.move_group(Group::Color, Some(Group::Color));
assert_eq!(l.order()[0], Group::Color);
}
fn app_with_layers() -> (PhotocraftApp, photocraft_doc::LayerId, photocraft_doc::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!({})).unwrap();
let pixel = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap();
let adj = app.session.active().unwrap().active_layer.unwrap();
assert_ne!(pixel, adj);
app.sync_views();
(app, pixel, adj)
}
fn harness(app: PhotocraftApp, size: egui::Vec2, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
// 60 fps steps, so two clicks a frame apart count as a double-click.
let mut h = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !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
}
fn rect_of(h: &Harness<'static, PhotocraftApp>, g: Group) -> Rect {
last_rects(&h.ctx).into_iter().find(|(x, _)| *x == g).map(|(_, r)| r).unwrap_or_else(|| panic!("{g:?} not drawn"))
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2) {
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=6 {
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn switching_layer_kinds_keeps_the_layers_panel_still() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, pixel, adj) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), theme);
let rects = |h: &Harness<'static, PhotocraftApp>| last_rects(&h.ctx);
let with_adj = rects(&h);
assert!(with_adj.iter().any(|(g, _)| *g == Group::Layers));
h.state_mut().run("layer.select", json!({"layer": pixel.0})).unwrap();
h.run_steps(4);
assert_eq!(rects(&h), with_adj, "{theme:?}: selecting a pixel layer moved the dock groups");
h.state_mut().run("layer.select", json!({"layer": adj.0})).unwrap();
h.run_steps(4);
assert_eq!(rects(&h), with_adj, "{theme:?}: selecting an adjustment layer moved the dock groups");
// Properties ↔ Adjustments tabs don't move anything either.
h.state_mut().ui.dock_tabs.properties = 1;
h.run_steps(3);
assert_eq!(rects(&h), with_adj);
}
}
#[test]
fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
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 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_eq!(layers2.bottom(), layers.bottom());
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
// Dragging past the minimum stops at it.
let split = Pos2::new(props2.center().x, props2.bottom() + GAP / 2.0);
drag(&mut h, split, split - vec2(0.0, 600.0));
assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height());
let from = Pos2::new(split.x, rect_of(&h, Group::Properties).bottom() + GAP / 2.0);
drag(&mut h, from, split);
assert!((h.state().ui.dock.heights[&Group::Properties] - stored).abs() < 2.0);
// Save and restore the UI state (what `ui.inspect` / `ui.set` and workspaces carry).
let saved = serde_json::to_value(&h.state().ui).unwrap();
let (mut app2, _, _) = app_with_layers();
app2.ui = serde_json::from_value(saved).unwrap();
let h2 = harness(app2, vec2(1200.0, 800.0), ThemeKind::ProMedium);
assert_eq!(last_rects(&h2.ctx), last_rects(&h.ctx));
// Remembered in the preferences once the mouse is up, and restored at the next launch.
let prefs = h.state().session.prefs_to_json();
let mut s2 = photocraft_engine::Session::new();
s2.load_prefs_json(&prefs).unwrap();
let mut app3 = PhotocraftApp::new(s2, crate::Services::default());
restore(&mut app3);
assert_eq!(app3.ui.dock, h.state().ui.dock);
// …unless Remember Workspace Changes is off.
let mut s3 = photocraft_engine::Session::new();
s3.load_prefs_json(&prefs).unwrap();
s3.prefs.edit(|p| p.workspace.remember_workspace_changes = false);
let mut app4 = PhotocraftApp::new(s3, crate::Services::default());
restore(&mut app4);
assert_eq!(app4.ui.dock, DockLayout::default());
}
#[test]
fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let ctx = h.ctx.clone();
let default = last_rects(&h.ctx);
h.state_mut().ui.dock.heights.insert(Group::Properties, 120.0);
h.state_mut().ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Tall Layers"})).unwrap();
let mine = h.state().ui.dock.clone();
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.essentials", json!({})).unwrap();
assert_eq!(h.state().ui.dock, DockLayout::default());
h.state_mut().ui.dock.heights.insert(Group::Color, 300.0);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
h.run_steps(3);
assert_eq!(last_rects(&h.ctx), default);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.select", json!({"name": "Tall Layers"})).unwrap();
assert_eq!(h.state().ui.dock, mine);
}
#[test]
fn double_clicking_a_tab_collapses_and_dragging_a_strip_reorders() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let color = rect_of(&h, Group::Color);
let tab = color.left_top() + vec2(20.0, 13.0);
h.event(egui::Event::PointerMoved(tab));
h.run_steps(1);
for _ in 0..2 {
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert!(h.state().ui.dock.is_collapsed(Group::Color));
assert!(rect_of(&h, Group::Color).height() < 40.0);
h.state_mut().ui.dock.set_collapsed(Group::Color, false);
h.run_steps(3);
// Drag the Layers tab strip (right of its tabs) above Color.
let layers = rect_of(&h, Group::Layers);
let strip = Pos2::new(layers.right() - 60.0, layers.top() + 13.0);
let to = rect_of(&h, Group::Color).left_top() + vec2(120.0, 10.0);
drag(&mut h, strip, to);
assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers));
// A locked workspace keeps the order.
h.state_mut().session.prefs.edit(|p| p.workspace_locked = true);
let layers = rect_of(&h, Group::Layers);
drag(&mut h, Pos2::new(layers.right() - 60.0, layers.top() + 13.0), Pos2::new(layers.right() - 60.0, 790.0));
assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers));
}
#[test]
fn tiny_windows_do_not_panic() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
for (w, ht) in [(40.0, 30.0), (300.0, 80.0), (600.0, 200.0), (2000.0, 120.0)] {
let (mut app, _, _) = app_with_layers();
app.ui.panels.history = true;
app.ui.panels.navigator = true;
let mut h = harness(app, vec2(w, ht), theme);
for (_, r) in last_rects(&h.ctx) {
assert!(r.height() >= 0.0 && r.is_finite());
}
// Drag a splitter way off-screen.
if let Some((_, r)) = last_rects(&h.ctx).first().copied() {
let p = Pos2::new(r.center().x, r.bottom() + GAP / 2.0);
drag(&mut h, p, p + vec2(0.0, 5000.0));
drag(&mut h, p, p - vec2(0.0, 5000.0));
}
}
}
}
#[test]
fn the_rail_and_window_menu_never_lose_a_panel() {
let (mut app, _, _) = app_with_layers();
let ctx = egui::Context::default();
// The rail collapses and expands a docked group; it never hides it (#129).
rail_click(&mut app, Group::Layers, true);
assert!(app.ui.panels.layers && app.ui.dock.is_collapsed(Group::Layers));
rail_click(&mut app, Group::Layers, true);
assert!(app.ui.panels.layers && !app.ui.dock.is_collapsed(Group::Layers));
// Window › Layers on a collapsed group expands it instead of hiding it.
app.ui.dock.set_collapsed(Group::Layers, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.layers", json!({})).unwrap();
assert!(app.ui.panels.layers && !app.ui.dock.is_collapsed(Group::Layers));
app.ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(&mut app, &ctx, "window.toggle.color", json!({})).unwrap();
assert!(app.ui.panels.color && !app.ui.dock.is_collapsed(Group::Color));
app.ui.dock.set_collapsed(Group::Color, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.gradients", json!({})).unwrap();
assert!(app.ui.panels.color && !app.ui.dock.is_collapsed(Group::Color) && app.ui.dock_tabs.color == 2);
// Hidden panels come back from the Window menu, expanded, on their tab.
app.ui.panels.history = false;
app.ui.dock.set_collapsed(Group::History, true);
crate::menus::invoke(&mut app, &ctx, "window.panel.actions", json!({})).unwrap();
assert!(app.ui.panels.history && !app.ui.dock.is_collapsed(Group::History) && app.ui.dock_tabs.history == 1);
// Reset Workspace brings back everything the preset shows.
app.ui.panels = crate::state::Panels { layers: false, properties: false, color: false, ..Default::default() };
app.ui.dock_tabs.layers = 2;
app.ui.dock.set_collapsed(Group::Properties, true);
crate::menus::invoke(&mut app, &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
assert!(app.ui.panels.layers && app.ui.panels.properties && app.ui.panels.color);
assert_eq!(app.ui.dock, DockLayout::default());
assert_eq!(app.ui.dock_tabs.layers, 0);
}
/// #129: clicking around the whole UI (layers, canvas, tools, options bar) never switches,
/// hides or moves a dock panel.
#[test]
fn clicking_around_the_ui_keeps_the_panels_put() {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let (app, _, _) = app_with_layers();
app
});
h.run_steps(8);
let panels = h.state().ui.panels.clone();
let tabs = h.state().ui.dock_tabs;
let rects = last_rects(&h.ctx);
assert!(rects.iter().any(|(g, _)| *g == Group::Layers), "{rects:?}");
let layers = rect_of(&h, Group::Layers);
let canvas = h.state().last_canvas_rect;
let mut points: Vec<Pos2> = Vec::new();
// Layer rows (eyes, thumbnails, names) and the Layers footer.
for dy in [118.0, 140.0, 150.0, 170.0, 182.0, 200.0] {
for x in [layers.left() + 22.0, layers.left() + 50.0, layers.center().x] {
points.push(Pos2::new(x, layers.top() + dy));
}
}
// The canvas, the toolbar column and the options bar.
points.extend([canvas.center(), canvas.left_top() + vec2(40.0, 40.0), canvas.right_bottom() - vec2(30.0, 30.0)]);
for y in (110..460).step_by(33) {
points.push(Pos2::new(20.0, y as f32));
}
for x in (40..1100).step_by(70) {
points.push(Pos2::new(x as f32, 50.0));
}
for p in points {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(2);
h.key_press(egui::Key::Escape);
h.run_steps(2);
// Popups and dialogs opened by a click are fine; panels changing aren't.
let app = h.state();
assert_eq!(app.ui.panels.layers, panels.layers, "click at {p:?} hid/showed Layers");
assert_eq!(app.ui.panels.properties, panels.properties, "click at {p:?} hid/showed Properties");
assert_eq!(app.ui.panels.color, panels.color, "click at {p:?} hid/showed Color");
assert_eq!(app.ui.dock_tabs, tabs, "click at {p:?} switched a dock tab");
assert!(app.ui.dock.collapsed.is_empty(), "click at {p:?} collapsed a group");
if app.ui.dialogs.is_empty() && app.ui.shell.dialog.is_none() {
assert_eq!(last_rects(&h.ctx), rects, "click at {p:?} moved the dock groups");
}
h.state_mut().ui.dialogs.clear();
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Layers panel row: the right-hand indicators and the name between them and the thumbnails
//! (#144).
//!
//! The indicators (lock, fx badge with its effects triangle, link, the blend-mode label) are
//! laid out first, from the row's right edge inward; the name gets the width that is left and
//! is cut with an ellipsis, so a long name never runs under an indicator at any panel width or
//! UI scale. The blend-mode label is optional: it is dropped before the name gets too narrow.
//! The rects drawn each frame are recorded ([`recorded`]) for tests and automation.
use egui::{Align2, FontId, Galley, Painter, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_color::BlendMode;
use photocraft_doc::Layer;
use serde_json::{Value, json};
use std::sync::Arc;
use crate::icons;
use crate::theme::{self, Tokens};
/// Space kept free at a row's right edge: the overlay scrollbar sits there.
pub const RIGHT_PAD: f32 = 10.0;
/// Gap between indicators, and between the name and the first indicator.
const GAP: f32 = 4.0;
/// Width of an icon indicator (lock, link).
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;
/// A right-hand row indicator.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Indicator {
Lock,
FxTriangle,
Fx,
Link,
Blend,
}
/// Where one row's name and indicators went this frame (screen points).
#[derive(Clone, Debug)]
pub struct RowRects {
pub layer: u64,
pub row: Rect,
/// The name as painted (after truncation); `None` when there was no room for it.
pub name: Option<Rect>,
pub indicators: Vec<(Indicator, Rect)>,
}
/// Lay out `items` (right to left, with their widths; `Blend` is optional) inside `row`,
/// leaving the name the span from `name_left`. Returns the indicator rects and the name's
/// right limit.
pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(Indicator, Rect)>, f32) {
let mut right = row.right() - RIGHT_PAD;
let mut out = Vec::with_capacity(items.len());
for &(kind, w) in items {
let w = w.max(0.0);
// The fx badge hugs its triangle; everything else keeps a gap.
let gap = if kind == Indicator::Fx && out.last().is_some_and(|(k, _)| *k == Indicator::FxTriangle) { 1.0 } else { GAP };
let left = right - w;
if kind == Indicator::Blend && left - GAP - name_left < MIN_NAME_W {
continue;
}
out.push((kind, Rect::from_min_max(pos2(left, row.top()), pos2(right, row.bottom()))));
right = left - gap;
}
let name_right = out.last().map_or(right, |(_, r)| r.left() - GAP);
(out, name_right)
}
/// Width the always-shown indicators of `l` take at the row's right (blend label excluded).
pub fn reserved_width(l: &Layer) -> f32 {
let mut w = RIGHT_PAD;
if l.locks.transparency || l.locks.position || l.locks.all {
w += ICON_W + GAP;
}
if !l.effects.items.is_empty() {
w += TRIANGLE_W + 1.0 + 13.0 + GAP;
}
if l.link_group.is_some() {
w += ICON_W + GAP;
}
w
}
/// Group indentation for a row at `depth`: 14 pt a level, squeezed when the panel is so narrow
/// that it would leave the name less than [`MIN_NAME_W`] (`fixed` = everything else in the row).
pub fn indent(depth: usize, row_width: f32, fixed: f32) -> f32 {
(depth as f32 * 14.0).min((row_width - fixed - MIN_NAME_W).max(0.0))
}
/// A single-line galley cut with "…" to `max_width` (nothing when there's no room).
pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color32, italics: bool, max_width: f32) -> Option<Arc<Galley>> {
if max_width < 8.0 {
return None;
}
let mut job = egui::text::LayoutJob::default();
job.append(text, 0.0, egui::TextFormat { font_id: font, color, italics, ..Default::default() });
job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
Some(painter.layout_job(job))
}
/// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the
/// name's right limit, the indicator rects, and whether the triangle was clicked this frame (so
/// the row doesn't also treat the click as a selection).
pub fn indicators(
ui: &egui::Ui,
painter: &Painter,
row: Rect,
name_left: f32,
l: &Layer,
fx_open: bool,
actions: &mut Vec<(String, Value)>,
) -> (f32, Vec<(Indicator, Rect)>, bool) {
let t = Tokens::get(ui.ctx());
let locked = l.locks.transparency || l.locks.position || l.locks.all;
let fx_font = theme::semibold(11.0);
let blend_font = FontId::proportional(10.5);
let has_fx = !l.effects.items.is_empty();
let show_blend = l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough;
let fx_galley = has_fx.then(|| painter.layout_no_wrap("fx".into(), fx_font, t.text_dim));
let blend_galley = show_blend.then(|| painter.layout_no_wrap(l.blend.label().into(), blend_font, t.text_faint));
let mut items = Vec::new();
if locked {
items.push((Indicator::Lock, ICON_W));
}
if let Some(g) = &fx_galley {
items.push((Indicator::FxTriangle, TRIANGLE_W));
items.push((Indicator::Fx, g.size().x));
}
if l.link_group.is_some() {
items.push((Indicator::Link, ICON_W));
}
if let Some(g) = &blend_galley {
items.push((Indicator::Blend, g.size().x));
}
let (rects, name_right) = layout(row, name_left, &items);
let mut clicked = false;
let cy = row.center().y;
for &(kind, r) in &rects {
match kind {
Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link", 12.0, t.text_faint),
Indicator::Fx => {
if let Some(g) = fx_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim);
}
}
Indicator::Blend => {
if let Some(g) = blend_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_faint);
}
}
Indicator::FxTriangle => {
let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click());
paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon });
if resp.clicked() {
clicked = true;
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setEffectsExpanded".into(), json!({"layer": l.id.0, "expanded": !fx_open, "all": all})));
}
let (verb, name) = (if fx_open { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if fx_open {
"Hide the layer's effects (⌥-click: all layers)"
} else {
"Show the layer's effects (⌥-click: all layers)"
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} effects {name}")));
}
}
}
(name_right, rects, clicked)
}
/// A small disclosure triangle: pointing down when open, right when closed.
pub fn paint_triangle(painter: &Painter, c: Pos2, open: bool, color: egui::Color32) {
let s = 3.0;
let pts = if open {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
painter.add(Shape::convex_polygon(pts, color, Stroke::NONE));
}
/// Paint `text` left-aligned at `x`, vertically centred on `cy`, cut to end before `right`.
/// Returns the painted rect.
pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: FontId, color: egui::Color32) -> Option<Rect> {
let g = truncated(painter, text, font, color, false, right - x)?;
let r = Align2::LEFT_CENTER.anchor_size(pos2(x, cy), g.size());
painter.galley(r.min, g, color);
Some(r)
}
/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout).
pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) {
let can = app.session.is_enabled("layer.setExpanded");
if ui.add_enabled(can, egui::Button::new("Collapse All Groups")).clicked() {
if let Err(e) = app.run("layer.setExpanded", json!({"all": true, "expanded": false})) {
app.ui.status = e;
app.ui.status_error = true;
}
ui.close();
}
}
fn rects_id() -> egui::Id {
egui::Id::new("layer-row-rects")
}
/// Start a frame's record (the Layers panel calls it before drawing its rows).
pub fn begin(ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(rects_id(), Vec::<RowRects>::new()));
}
pub fn record(ctx: &egui::Context, r: RowRects) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Vec<RowRects>>(rects_id()).push(r));
}
/// The rows the Layers panel drew last frame.
pub fn recorded(ctx: &egui::Context) -> Vec<RowRects> {
ctx.data(|d| d.get_temp::<Vec<RowRects>>(rects_id())).unwrap_or_default()
}
#[cfg(test)]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
//! #144: the name never runs under the right-hand indicators; the fx triangle; #143: Collapse
//! All Groups.
use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::{Indicator, RowRects, layout, recorded};
use crate::PhotocraftApp;
const LONG: &str = "Button / Primary / Hover state — a very long descriptive layer name 背景のテクスチャ that keeps going";
/// Long-named layers with every indicator, nested four groups deep.
fn busy() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let mut prev = None;
for depth in 0..4 {
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();
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();
s.execute("layer.setProps", json!({"layer": b, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap();
s.execute("layer.linkLayers", json!({})).unwrap();
if let Some(p) = prev {
s.execute("layer.select", json!({"layer": p, "mode": "toggle"})).unwrap();
}
let g = s.execute("layer.groupLayers", json!({"name": format!("{LONG} group {depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": g})).unwrap();
prev = Some(g);
}
s
}
fn harness(session: photocraft_engine::Session, ppp: f32, theme: &str, 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": theme, "dockWidth": dock_width, "dock": {"collapsed": ["color", "properties", "history", "navigator"]}}),
);
crate::control::handle(h.state_mut(), &ctx, &req);
h.run_steps(8);
h
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.intersects(b) && a.intersect(b).area() > 0.0
}
fn check(rows: &[RowRects], what: &str) {
assert!(rows.len() >= 8, "{what}: rows drawn ({})", rows.len());
let mut with_fx = 0;
for r in rows {
let name = r.name.unwrap_or_else(|| panic!("{what}: layer {} has no room for its name", r.layer));
assert!(name.right() <= r.row.right(), "{what}: name inside its row");
for (k, ind) in &r.indicators {
assert!(!overlaps(name, *ind), "{what}: layer {} name {name:?} overlaps {k:?} {ind:?}", r.layer);
assert!(ind.right() <= r.row.right() - super::RIGHT_PAD + 0.01, "{what}: {k:?} clear of the scrollbar");
}
for (i, (ka, a)) in r.indicators.iter().enumerate() {
for (kb, b) in &r.indicators[i + 1..] {
assert!(!overlaps(*a, *b), "{what}: {ka:?} overlaps {kb:?}");
}
}
with_fx += r.indicators.iter().any(|(k, _)| *k == Indicator::Fx) as usize;
}
assert!(with_fx >= 4, "{what}: fx badges drawn ({with_fx})");
}
#[test]
fn layout_gives_the_name_what_the_indicators_leave() {
let row = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 32.0));
let items = [(Indicator::Lock, 14.0), (Indicator::FxTriangle, 10.0), (Indicator::Fx, 12.0), (Indicator::Link, 14.0), (Indicator::Blend, 40.0)];
let (rects, right) = layout(row, 100.0, &items);
assert_eq!(rects.len(), 5);
assert!(rects.iter().all(|(_, r)| r.left() > right && r.right() <= 300.0 - super::RIGHT_PAD));
// Narrow: the optional blend label goes first; the rest stay.
let (rects, right) = layout(row, 200.0, &items);
assert_eq!(rects.iter().map(|(k, _)| *k).collect::<Vec<_>>(), [Indicator::Lock, Indicator::FxTriangle, Indicator::Fx, Indicator::Link]);
assert!(right < rects[3].1.left());
}
#[test]
fn long_names_never_run_under_the_indicators() {
for ppp in [1.0, 1.5, 2.0] {
for width in [250.0, 290.0, 520.0] {
let h = harness(busy(), ppp, "promedium", width);
check(&recorded(&h.ctx), &format!("@{ppp}x {width}pt"));
}
}
for theme in ["pro", "studio", "studiolight", "classic"] {
let h = harness(busy(), 1.0, theme, 250.0);
check(&recorded(&h.ctx), theme);
}
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn the_fx_triangle_hides_and_shows_the_effects_rows() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
let top = h.state().session.active().unwrap().doc.layers.last().unwrap().clone();
let rows = |h: &Harness<'_, PhotocraftApp>| recorded(&h.ctx).iter().map(|r| r.row.top()).collect::<Vec<_>>();
let before = rows(&h);
let label = format!("Collapse effects {}", top.name);
let p = h.get_by_label(&label).rect().center();
let active = h.state().session.active().unwrap().active_layer;
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.contains(&top.id));
assert_eq!(h.state().session.active().unwrap().active_layer, active, "the triangle doesn't select");
// Its Effects / Drop Shadow sub-rows are gone, so the next layer row moved up.
let after = rows(&h);
assert!(after[1] < before[1], "rows moved up: {:?} -> {:?}", &before[..2], &after[..2]);
let p = h.get_by_label(&format!("Expand effects {}", top.name)).rect().center();
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.is_empty());
}
fn groups_open(s: &photocraft_engine::Session) -> Vec<bool> {
s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| if let LayerContent::Group(g) = &l.content { Some(g.expanded) } else { None }).collect()
}
#[test]
fn collapse_all_groups_from_the_panel_menu_and_it_is_saved() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
assert!(groups_open(&h.state().session).iter().all(|o| *o));
let menu = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("layers group").1;
// The hamburger sits at the right end of the group's tab strip.
click(&mut h, pos2(menu.right() - 14.0, menu.top() + 14.0));
let item = h.get_by_label("Collapse All Groups").rect().center();
click(&mut h, item);
let open = groups_open(&h.state().session);
assert_eq!(open.len(), 4);
assert!(open.iter().all(|o| !o), "every group closed: {open:?}");
// Collapsed state is document data: it survives a PSD and a .pcraft round trip.
let doc = (*h.state().session.active().unwrap().doc).clone();
for name in ["c.psd", "c.pcraft"] {
let bytes = photocraft_io::export(&doc, name, &Default::default()).unwrap().bytes;
let back = photocraft_io::import(name, &bytes).unwrap().document;
let mut s = photocraft_engine::Session::new();
s.add_document(back, None);
assert!(groups_open(&s).iter().all(|o| !o), "{name}: groups stay closed");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Layers panel tree: the rows in display order (top of the stack first, each group directly
//! above its contents) and the groups' disclosure triangles (#126).
//!
//! Clicking a triangle opens or closes the group; ⌥-click sets every group in the document to
//! the new state (Photoshop). The state is the group's `expanded` flag, document data saved in
//! `.pcraft` and as the PSD section divider's open/closed folder type, toggled through
//! `layer.setExpanded` (a view change: no history step).
use egui::{Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_doc::{Document, Layer, LayerContent};
use serde_json::{Value, json};
use crate::theme::Tokens;
/// Width the disclosure triangle takes in a group row.
pub const TRIANGLE_W: f32 = 14.0;
/// Rows as the Layers panel lists them: topmost layer first, a group directly above its
/// children, and the contents of closed groups left out (unless `show_all`, used while the
/// panel is filtered by kind so matches inside closed groups still show).
pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> {
fn rec<'a>(layers: &'a [Layer], depth: usize, show_all: bool, out: &mut Vec<(usize, &'a Layer)>) {
for l in layers.iter().rev() {
out.push((depth, l));
if let LayerContent::Group(g) = &l.content
&& (g.expanded || show_all)
{
rec(&g.children, depth + 1, show_all, out);
}
}
}
let mut out = Vec::new();
rec(&doc.layers, 0, show_all, &mut out);
out
}
/// The hit area of a group row's triangle starting at `x`.
pub fn triangle_rect(row: Rect, x: f32) -> Rect {
Rect::from_min_size(pos2(x - 2.0, row.top()), vec2(TRIANGLE_W + 2.0, row.height()))
}
/// Paint and handle a group row's disclosure triangle at `*x` (advancing it). Returns true when
/// it was clicked this frame, so the row doesn't also treat the click as a selection.
pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: &mut Vec<(String, Value)>) -> bool {
let LayerContent::Group(g) = &l.content else { return false };
let hit = triangle_rect(row, *x);
let resp = ui.interact(hit, ui.id().with(("disclosure", l.id.0)), Sense::click());
let t = Tokens::get(ui.ctx());
let c = pos2(*x + TRIANGLE_W / 2.0 - 1.0, row.center().y);
let s = 3.5;
let pts = if g.expanded {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
let color = if resp.hovered() { t.text } else { t.icon };
ui.painter().add(Shape::convex_polygon(pts, color, Stroke::NONE));
*x += TRIANGLE_W;
let clicked = resp.clicked();
if clicked {
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setExpanded".into(), json!({"layer": l.id.0, "expanded": !g.expanded, "all": all})));
}
let (verb, name) = (if g.expanded { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if g.expanded { "Collapse group (⌥-click: all groups)" } else { "Expand group (⌥-click: all groups)" });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} group {name}")));
clicked
}
#[cfg(test)]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
//! #126: group disclosure triangles in the real Layers panel (whole app, real pointer input).
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::{Document, LayerContent, LayerId};
use serde_json::json;
use super::display_rows;
use crate::PhotocraftApp;
/// Background, then `outer { in-outer, inner { deep } }` with a pixel layer above it all.
fn layered() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layer": inner})).unwrap();
// `layer.new.layer` lands above the active layer, inside `outer`.
s.execute("layer.new.layer", json!({"name": "in-outer"})).unwrap();
s.execute("layer.select", json!({"layer": outer})).unwrap();
s.execute("layer.new.layer", json!({"name": "top"})).unwrap();
s
}
fn harness_with(session: photocraft_engine::Session, ppp: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.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())
});
h.run_steps(8);
h
}
fn group(h: &Harness<'_, PhotocraftApp>, name: &str) -> (LayerId, bool) {
let doc = &h.state().session.active().unwrap().doc;
doc.walk()
.into_iter()
.find_map(|(_, _, l)| match &l.content {
LayerContent::Group(g) if l.name == name => Some((l.id, g.expanded)),
_ => None,
})
.unwrap_or_else(|| panic!("no group {name}"))
}
/// The on-screen centre of a group's triangle, scrolled into view in the Layers panel first (the
/// dock gives Layers a fixed height, so rows can sit below its fold). `None` when the row isn't
/// listed (inside a closed group).
fn triangle(h: &mut Harness<'_, PhotocraftApp>, name: &str) -> Option<Pos2> {
let verb = if group(h, name).1 { "Collapse" } else { "Expand" };
let label = format!("{verb} group {name}");
h.query_by_label(&label)?.scroll_to_me();
h.run_steps(4);
h.query_by_label(&label).map(|n| n.rect().center())
}
/// A real click (press and release on separate frames), modifiers held throughout.
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.hover_at(at);
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(3);
}
#[test]
fn rows_list_groups_above_their_contents_and_hide_closed_ones() {
let s = layered();
let doc: &Document = &s.active().unwrap().doc;
let names = |doc: &Document, all| display_rows(doc, all).iter().map(|(d, l)| format!("{d}:{}", l.name)).collect::<Vec<_>>();
assert_eq!(names(doc, false), ["0:top", "0:outer", "1:in-outer", "1:inner", "2:deep", "0:Background"]);
let mut closed = doc.clone();
let id = closed.walk().into_iter().find(|(_, _, l)| l.name == "inner").unwrap().2.id;
if let Some(LayerContent::Group(g)) = closed.layer_mut(id).map(|l| &mut l.content) {
g.expanded = false;
}
assert_eq!(names(&closed, false), ["0:top", "0:outer", "1:in-outer", "1:inner", "0:Background"]);
assert_eq!(names(&closed, true).len(), 6, "a kind filter still finds layers in closed groups");
}
#[test]
fn clicking_a_triangle_toggles_its_group_at_every_ui_scale() {
for ppp in [1.0, 1.5, 2.0] {
let mut h = harness_with(layered(), ppp);
let active = h.state().session.active().unwrap().active_layer;
let undo = h.state().session.active().unwrap().history.entries().len();
let p = triangle(&mut h, "inner").expect("inner's triangle shows");
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "inner").1, "@{ppp}x: click collapses");
assert!(group(&h, "outer").1, "@{ppp}x: only that group");
// The click opened/closed the group; it didn't select it or start a drag-reorder.
assert_eq!(h.state().session.active().unwrap().active_layer, active, "@{ppp}x: selection unchanged");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), undo, "@{ppp}x: not an undo step");
let p = triangle(&mut h, "inner").expect("still shows, now pointing right");
click(&mut h, p, Modifiers::NONE);
assert!(group(&h, "inner").1, "@{ppp}x: click expands again");
// Nested: closing the outer group hides the inner group's row (and so its triangle).
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "outer").1);
assert!(triangle(&mut h, "inner").is_none(), "@{ppp}x: inner row hidden in a closed group");
}
}
#[test]
fn alt_click_toggles_every_group() {
let mut h = harness_with(layered(), 1.0);
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::ALT);
assert!(!group(&h, "outer").1 && !group(&h, "inner").1, "⌥-click closes all groups");
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::ALT);
assert!(group(&h, "outer").1 && group(&h, "inner").1, "⌥-click opens all groups");
assert!(triangle(&mut h, "inner").is_some());
}
#[test]
fn state_survives_psd_export_and_import_and_imported_groups_toggle() {
let mut h = harness_with(layered(), 2.0);
let p = triangle(&mut h, "inner").unwrap();
click(&mut h, p, Modifiers::NONE);
let doc = (*h.state().session.active().unwrap().doc).clone();
let bytes = photocraft_io::export(&doc, "groups.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("groups.psd", &bytes).unwrap().document;
let mut s = photocraft_engine::Session::new();
s.add_document(back, None);
let mut h = harness_with(s, 2.0);
assert!(group(&h, "outer").1 && !group(&h, "inner").1, "open/closed state round-trips through PSD");
// A PSD-imported group toggles like any other.
let p = triangle(&mut h, "inner").expect("imported group has a triangle");
click(&mut h, p, Modifiers::NONE);
assert!(group(&h, "inner").1);
let p = triangle(&mut h, "outer").unwrap();
click(&mut h, p, Modifiers::NONE);
assert!(!group(&h, "outer").1);
}
+7
View File
@@ -15,6 +15,8 @@ pub mod adjust_ui;
pub mod analysis_ui;
pub mod artboard_ui;
pub mod brush_panel;
pub mod brush_preview;
pub mod brush_sections;
pub mod camera_raw_ui;
pub mod canvas;
pub mod channel_view;
@@ -29,6 +31,7 @@ pub mod dialogs;
pub mod discard_ui;
pub mod distort_ui;
pub mod doc_props_ui;
pub mod dock;
pub mod enable_rules;
pub mod eraser_ui;
pub mod export_dialog;
@@ -41,10 +44,13 @@ mod icon_data;
pub mod icons;
pub mod layer_menu_ui;
pub mod layer_props_ui;
pub mod layer_row_ui;
pub mod layer_style;
pub mod layer_tree_ui;
pub mod links;
pub mod liquify_ui;
pub mod menu_catalog;
pub mod menu_nav;
pub mod menus;
pub mod new_doc_ui;
pub mod notices;
@@ -62,6 +68,7 @@ pub mod proxy;
pub mod puppet_ui;
pub mod retouch_ui;
pub mod rulers;
pub mod shortcut_dispatch;
pub mod shortcuts;
mod sizing;
pub mod slice_ui;
+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");
}
+62 -11
View File
@@ -60,7 +60,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
];
/// Photoshop's Window › <panel> ids for the panels the shell already has, as `window.toggle.*`.
fn panel_alias(id: &str) -> Option<&'static str> {
pub(crate) fn panel_alias(id: &str) -> Option<&'static str> {
Some(match id.strip_prefix("window.panel.")? {
"layers" => "window.toggle.layers",
"history" => "window.toggle.history",
@@ -372,6 +372,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
Ok(json!({"dialog": crate::adjust_dialog::open(app, a).ok_or("no document")?}))
}
t if t.starts_with("window.toggle.") => {
// A shown but collapsed dock group is expanded rather than hidden (#129).
if let Some(g) = crate::dock::Group::from_key(&t["window.toggle.".len()..]).filter(|g| g.shown(&app.ui.panels) && app.ui.dock.is_collapsed(*g)) {
crate::dock::reveal(app, g);
return Ok(Value::Null);
}
let p = &mut app.ui.panels;
let slot = match &t["window.toggle.".len()..] {
"layers" => &mut p.layers,
@@ -385,6 +390,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
_ => return Err(format!("unknown panel in {t}")),
};
*slot = !*slot;
if let Some(g) = crate::dock::Group::from_key(&t["window.toggle.".len()..]).filter(|g| g.shown(&app.ui.panels)) {
crate::dock::reveal(app, g);
}
Ok(Value::Null)
}
// Layer › Layer Style › <effect>… opens the Layer Style dialog on that effect.
@@ -690,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;
@@ -699,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();
@@ -707,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!({}));
@@ -735,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
{
@@ -743,14 +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>) {
// Menu popups can be taller than the viewport. Keep each level on-screen and scroll it,
// matching egui's own bounded-popup pattern used by ComboBox.
let max_height = (ui.ctx().content_rect().height() - 32.0).max(120.0);
egui::ScrollArea::vertical().id_salt(("menu-level", depth)).max_height(max_height).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.
@@ -785,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();
}
@@ -798,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;
}
@@ -812,6 +834,9 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
if crate::workspace_ui::apply_custom(app) {
return;
}
// Presets use the default group order, heights and tabs (Reset brings everything back).
app.ui.dock = Default::default();
app.ui.dock_tabs = Default::default();
let p = &mut app.ui.panels;
let (nav, color, layers, history, props) = match app.ui.workspace.as_str() {
"Photography" => (true, false, true, true, true),
@@ -949,6 +974,32 @@ mod tests {
assert!(max_bottom <= viewport.bottom() + 1.0, "menu popup overflowed viewport: {max_bottom} > {}", viewport.bottom());
}
#[test]
fn tall_menu_scrolls_with_mouse_wheel_on_short_display() {
use egui_kittest::{Harness, kittest::Queryable};
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut harness = Harness::builder().with_size(egui::vec2(900.0, 220.0)).build_ui_state(|ui, app| menu_bar(app, ui), app);
PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
harness.run_steps(3);
harness.get_by_label("Filter").click();
harness.run_steps(3);
let item = harness.get_by_label_contains("Filter Gallery…");
let before = item.rect().top();
harness.hover_at(item.rect().center());
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -80.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
harness.run_steps(4);
let after = harness.get_by_label_contains("Filter Gallery…").rect().top();
assert!(after < before - 1.0, "mouse wheel should move tall menu content: {before} -> {after}");
}
#[test]
fn window_panel_and_workspace_ids_drive_the_shell() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+122 -127
View File
@@ -716,7 +716,7 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if t.pro {
dock_panels(app, ui, &p, &t);
}
// Narrow icon rail (always visible) that toggles panels.
// Narrow icon rail (always visible): shows, expands or collapses panel groups.
let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) };
egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))).show(
ui,
@@ -724,23 +724,20 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let r = ui.max_rect();
ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator));
ui.spacing_mut().item_spacing.y = 4.0;
let entries: [(&str, &str, bool); 5] = [
("sliders-horizontal", "Properties", p.properties),
("navigation", "Navigator", p.navigator),
("palette", "Color & Swatches", p.color),
("layers", "Layers", p.layers),
("clock", "History", p.history),
use crate::dock::Group;
let entries: [(&str, &str, Group); 5] = [
("sliders-horizontal", "Properties", Group::Properties),
("navigation", "Navigator", Group::Navigator),
("palette", "Color & Swatches", Group::Color),
("layers", "Layers", Group::Layers),
("clock", "History", Group::History),
];
for (icon, name, on) in entries {
for (icon, name, g) in entries {
// Studio floats Properties outside the dock.
let docked = t.pro || g != Group::Properties;
let on = g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g));
if icons::rail_button(ui, icon, rb, on, name).clicked() {
let panels = &mut app.ui.panels;
match name {
"Properties" => panels.properties = !panels.properties,
"Navigator" => panels.navigator = !panels.navigator,
"Color & Swatches" => panels.color = !panels.color,
"Layers" => panels.layers = !panels.layers,
_ => panels.history = !panels.history,
}
crate::dock::rail_click(app, g, docked);
}
}
},
@@ -748,87 +745,72 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if !t.pro {
dock_panels(app, ui, &p, &t);
}
crate::dock::persist(app, ui.ctx());
}
/// The right dock's width range (points).
const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
fn dock_width_id() -> egui::Id {
egui::Id::new("dock-width-request")
}
/// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range.
pub fn request_dock_width(ctx: &egui::Context, w: f32) {
let w = if w.is_finite() { w.clamp(*DOCK_WIDTH.start(), *DOCK_WIDTH.end()) } else { *DOCK_WIDTH.start() };
ctx.data_mut(|d| d.insert_temp(dock_width_id(), w));
}
fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Panels, t: &Tokens) {
let p = p.clone();
if !(p.layers || p.history || p.color || p.navigator || (t.pro && p.properties)) {
use crate::dock::Group;
// Floating in Studio, Properties docks only in Pro (Photoshop).
let shown: Vec<Group> = [
(Group::Color, p.color),
(Group::Properties, t.pro && p.properties),
(Group::Navigator, p.navigator),
(Group::History, p.history),
(Group::Layers, p.layers),
]
.into_iter()
.filter_map(|(g, on)| on.then_some(g))
.collect();
if shown.is_empty() {
return;
}
let margin = if t.pro { 2 } else { 8 };
egui::Panel::right("dock")
.resizable(true)
.default_size(if t.pro { 290.0 } else { 300.0 })
.size_range(250.0..=520.0)
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin)))
.show(ui, |ui| {
let draw = |app: &mut PhotocraftApp, ui: &mut egui::Ui| {
ui.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 };
// Photoshop Essentials order: Color, Properties, (Navigator, History), Layers last and filling.
if p.color {
let mut sel = app.ui.dock_tabs.color;
// Photoshop Essentials: Color | Swatches | Gradients | Patterns.
let tabs: &[&str] = if t.pro { &["Color", "Swatches", "Gradients", "Patterns"] } else { &["Swatches", "Color", "Gradients", "Patterns"] };
let pro = t.pro;
widgets::card(ui, "color", tabs, &mut sel, |ui, tab| match (pro, tab) {
(_, 2) => crate::preset_panels::gradients_panel(app, ui),
(_, 3) => crate::preset_panels::patterns_panel(app, ui),
(true, 0) => color_field(app, ui),
(true, _) => swatches(app, ui),
(false, 0) => swatches(app, ui),
(false, _) => color_picker(app, ui),
});
app.ui.dock_tabs.color = sel;
}
if t.pro && p.properties {
let mut sel = app.ui.dock_tabs.properties;
// Leave Layers at least ~240 px; Properties scrolls within what remains.
let reserve = if p.layers { 240.0 } else { 40.0 };
let max_h = (ui.available_height() - reserve - 40.0).max(70.0);
widgets::card(ui, "properties", &["Properties", "Adjustments"], &mut sel, |ui, tab| {
egui::ScrollArea::vertical()
.id_salt("props-scroll")
.max_height(max_h)
.auto_shrink([false, true])
.show(ui, |ui| if tab == 0 { properties_body(app, ui) } else { adjustments_grid(app, ui) });
});
app.ui.dock_tabs.properties = sel;
}
if p.navigator {
let mut sel = app.ui.dock_tabs.navigator;
widgets::card(ui, "navigator", &["Navigator", "Histogram", "Info"], &mut sel, |ui, tab| match tab {
0 => navigator(app, ui),
1 => crate::tone::histogram_panel(app, ui),
_ => info_panel(app, ui),
});
app.ui.dock_tabs.navigator = sel;
}
if p.history {
let mut sel = app.ui.dock_tabs.history;
widgets::card(ui, "history", &["History", "Actions", "Layer Comps"], &mut sel, |ui, tab| match tab {
0 => history(app, ui),
1 => crate::actions::panel(app, ui),
_ => crate::comps_ui::panel(app, ui),
});
app.ui.dock_tabs.history = sel;
}
if p.layers {
let mut sel = app.ui.dock_tabs.layers;
widgets::card(ui, "layers", &["Layers", "Channels", "Paths"], &mut sel, |ui, tab| match tab {
0 => layers(app, ui),
1 => channels(app, ui),
_ => crate::vector_ui::paths_panel(app, ui),
});
app.ui.dock_tabs.layers = sel;
}
};
if t.pro {
// Fixed panels on top, Layers takes the rest (it scrolls internally).
draw(app, ui);
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| draw(app, ui));
}
});
let mut panel = egui::Panel::right("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::<f32>(dock_width_id())) {
panel = panel.exact_size(w);
}
panel.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))).show(ui, |ui| {
// Groups keep their heights whatever they show (#88): see `dock`.
crate::dock::show(app, ui, &shown, dock_body);
});
}
/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller.
fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) {
use crate::dock::Group;
let pro = Tokens::get(ui.ctx()).pro;
match (group, tab) {
(Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui),
(Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui),
(Group::Color, 0) if pro => color_field(app, ui),
(Group::Color, _) if pro => swatches(app, ui),
(Group::Color, 0) => swatches(app, ui),
(Group::Color, _) => color_picker(app, ui),
(Group::Properties, 0) => properties_body(app, ui),
(Group::Properties, _) => adjustments_grid(app, ui),
(Group::Navigator, 0) => navigator(app, ui),
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
(Group::Navigator, _) => info_panel(app, ui),
(Group::History, 0) => history(app, ui),
(Group::History, 1) => crate::actions::panel(app, ui),
(Group::History, _) => crate::comps_ui::panel(app, ui),
(Group::Layers, 0) => layers(app, ui),
(Group::Layers, 1) => channels(app, ui),
(Group::Layers, _) => crate::vector_ui::paths_panel(app, ui),
}
}
/// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size.
@@ -1159,10 +1141,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(2.0);
}
let rows = doc.walk();
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
let rows = crate::layer_tree_ui::display_rows(&doc, !app.ui.layer_filter.is_empty());
let ctx = ui.ctx().clone();
let footer = 38.0;
let fill = t.pro && ui.available_height() > footer + 60.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("layer-rows")
@@ -1171,7 +1154,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.auto_shrink([false, !fill])
.show(ui, |ui| {
let filter = app.ui.layer_filter.clone();
for (_, depth, l) in rows.iter().rev() {
let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default();
crate::layer_row_ui::begin(ui.ctx());
for &(depth, l) in &rows {
// Select › Isolate Layers.
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
continue;
@@ -1190,11 +1175,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 };
layer_row(app, &ctx, ui, &doc, l, *depth, row, &mut actions);
if !l.effects.items.is_empty() {
effect_rows(app, ui, l, *depth);
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
effect_rows(app, ui, l, depth);
}
crate::smart_ui::filter_rows(app, ui, l, *depth, &mut actions);
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
}
});
// End any layer drag after every row has had a chance to accept the drop.
@@ -1331,6 +1316,8 @@ fn layer_row(
let row_h = if t.pro { 32.0 } else { 46.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
layer_drag_and_drop(ctx, ui, l, rect, &resp, actions);
// Rows are painted: name them for screen readers and UI tests.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name));
let painter = ui.painter_at(rect.expand(1.0));
if t.pro {
if selected {
@@ -1354,12 +1341,20 @@ fn layer_row(
if eye_resp.clicked() {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
}
x += 28.0 + depth as f32 * 14.0;
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
+ 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);
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 {
painter.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, "↳", egui::FontId::proportional(13.0), t.text_faint);
x += 12.0;
}
let ts = if t.pro { 24.0 } else { 34.0 };
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;
@@ -1392,39 +1387,30 @@ fn layer_row(
}
}
}
// Right-hand indicators first; the name gets what is left and ends in "…" (#144).
let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id));
let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions);
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
let italic = !t.pro && l.name == "Background" && l.locks.transparency;
let mut job = egui::text::LayoutJob::default();
job.append(&l.name, 0.0, egui::TextFormat { font_id: font, color: name_color, italics: italic, ..Default::default() });
let galley = painter.layout_job(job);
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 });
painter.galley(text_pos, galley, name_color);
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {
let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 });
let r = Rect::from_min_size(text_pos, galley.size());
painter.galley(text_pos, galley, name_color);
r
});
if !is_pixel {
let sub = match &l.content {
LayerContent::Adjustment(a) => a.label().to_string(),
LayerContent::Group(g) => format!("Group · {} layers", g.children.len()),
other => other.kind_name().to_string(),
};
painter.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, egui::FontId::proportional(11.0), t.text_faint);
}
if !l.effects.items.is_empty() {
painter.text(pos2(rect.right() - 34.0, rect.center().y), Align2::RIGHT_CENTER, "fx", theme::semibold(11.0), t.text_dim);
}
if l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough {
painter.text(pos2(rect.right() - 30.0, rect.center().y), Align2::RIGHT_CENTER, l.blend.label(), egui::FontId::proportional(10.5), t.text_faint);
}
let locked = l.locks.transparency || l.locks.position || l.locks.all;
if locked {
icons::paint(ui, Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(14.0, 14.0)), "lock", 12.0, t.text_faint);
}
if l.link_group.is_some() {
let x = rect.right() - if locked { 30.0 } else { 14.0 };
icons::paint(ui, Rect::from_center_size(pos2(x, rect.center().y), vec2(14.0, 14.0)), "link", 12.0, t.text_faint);
crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint);
}
crate::layer_row_ui::record(ctx, crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, name: name_rect, indicators });
// ⌘-click a layer or mask thumbnail loads its transparency / mask as a selection (⇧ add,
// ⌥ subtract, ⇧⌥ intersect) instead of changing the layer selection.
let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| {
@@ -1435,7 +1421,7 @@ fn layer_row(
});
if let Some(p) = thumb_load {
actions.push(("select.loadSelection".into(), p));
} else if resp.clicked() && !eye_resp.clicked() {
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled {
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.
@@ -1455,7 +1441,7 @@ fn layer_row(
ctx.data_mut(|d| d.insert_temp(rename_id, l.name.clone()));
}
if let Some(mut text) = ctx.data(|d| d.get_temp::<String>(rename_id)) {
let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(rect.right() - 36.0, rect.center().y + 11.0));
let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(name_right.max(x + 40.0), rect.center().y + 11.0));
let te = ui.put(edit_rect, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(12.5)));
te.request_focus();
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
@@ -1564,10 +1550,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let mut target: Option<isize> = None;
let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true)));
// Leave room for Layers below (the dock gives Layers a height budget).
let reserve = if app.ui.panels.layers { 250.0 } else { 0.0 };
let max_h = (ui.available_height() - reserve - 40.0).clamp(60.0, 360.0);
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
// The dock gives History a fixed height: the rows scroll above the footer.
let footer = if t.pro { 34.0 } else { 0.0 };
let max_h = (ui.available_height() - footer).max(40.0);
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
for (i, (e, is_redo)) in all.enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if i == current {
@@ -2125,7 +2111,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
if i == 0 {
icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim);
}
ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint });
let right = rect.right() - crate::layer_row_ui::RIGHT_PAD;
crate::layer_row_ui::label(
ui.painter(),
x,
rect.center().y,
right,
&name,
egui::FontId::proportional(11.5),
if on { t.text_dim } else { t.text_faint },
);
if resp.double_clicked() {
crate::layer_style::open(app, kind);
}
+1
View File
@@ -101,6 +101,7 @@ pub fn load(app: &mut PhotocraftApp) {
{
app.ui.status = format!("Preferences were reset: {e}");
}
crate::dock::restore(app);
app.prefs_rt.saved_rev = app.session.prefs.rev();
if app.session.prefs().file_handling.recover_on_launch
&& let Some(recover) = app.services.recover.as_mut()
+3 -1
View File
@@ -83,11 +83,13 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
}
let want = params.get("show").and_then(Value::as_bool);
if let Some(tab) = color_tab(id) {
let showing = app.ui.panels.color && app.ui.dock_tabs.color == tab;
// A collapsed Color group counts as not showing: the item expands it (#129).
let showing = app.ui.panels.color && app.ui.dock_tabs.color == tab && !app.ui.dock.is_collapsed(crate::dock::Group::Color);
let show = want.unwrap_or(!showing);
app.ui.panels.color = show || (app.ui.panels.color && app.ui.dock_tabs.color != tab);
if show {
app.ui.dock_tabs.color = tab;
crate::dock::reveal(app, crate::dock::Group::Color);
}
return Some(Ok(json!({"visible": show})));
}
+224
View File
@@ -0,0 +1,224 @@
//! Keyboard shortcut table and dispatch (#127): every shortcut the menus display fires.
//!
//! The menus show Photoshop's default shortcuts from [`crate::menu_catalog::CATALOG`], while
//! commands also declare their own (`CommandSpec::shortcut`, [`crate::menus::UI_COMMANDS`]).
//! The binding table merges all three, with Edit › Keyboard Shortcuts overrides on top, so a
//! shortcut printed next to a live menu item always reaches that item. A shortcut whose command
//! is disabled in the current state reports why on the status bar instead of doing nothing
//! silently, as the greyed menu item shows.
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::json;
use crate::PhotocraftApp;
use crate::shortcuts::{consume, parse};
/// What a dispatched shortcut did (kept for tests and `ui.inspect`-style debugging).
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Ran,
Disabled(String),
Failed(String),
}
fn log_id() -> egui::Id {
egui::Id::new("pc-shortcut-log")
}
fn dry_run_id() -> egui::Id {
egui::Id::new("pc-shortcut-dry-run")
}
/// Tests: resolve and check shortcuts without running them (File › Exit, Open, browser links…).
pub fn set_dry_run(ctx: &egui::Context, on: bool) {
ctx.data_mut(|d| d.insert_temp(dry_run_id(), on));
}
/// The shortcuts dispatched since the last call (command id and outcome), oldest first.
pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> {
ctx.data_mut(|d| d.remove_temp::<Vec<(String, Outcome)>>(log_id())).unwrap_or_default()
}
/// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command:
/// command and shell shortcuts, then the menu catalogue's for live items without their own,
/// then Edit › Keyboard Shortcuts assignments to any other menu item.
pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> {
let prefs = app.session.prefs();
let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, prefs.shortcut(id, *sc)));
let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut)));
let own: std::collections::HashSet<&str> = crate::menus::UI_COMMANDS
.iter()
.map(|c| c.0)
.chain(photocraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some() || prefs.shortcuts.contains_key(c.id)).map(|c| c.id))
.collect();
let catalog = crate::menu_catalog::CATALOG
.iter()
.filter(|(_, _, sc, id)| sc.is_some() && !own.contains(id) && crate::menus::is_live(id))
.map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc)));
let overrides = prefs
.shortcuts
.iter()
.filter(|(id, sc)| !sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()))
.map(|(id, sc)| (id.as_str(), Some(sc.as_str())));
let mut all: Vec<(String, KeyboardShortcut)> = Vec::new();
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides) {
let Some(sc) = sc.and_then(parse) else { continue };
// Plain X and D are the colour keys (handled with the tool keys).
if sc.modifiers == Modifiers::NONE && matches!(sc.logical_key, Key::X | Key::D) {
continue;
}
if !all.iter().any(|(_, b)| *b == sc) {
all.push((id.to_string(), sc));
}
}
all.sort_by_key(|(_, sc)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8));
all
}
/// Where keyboard focus is: nowhere (the canvas), on a widget (a slider, the Curves graph, a
/// button reached with Tab), or in a text field.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
None,
Widget,
Text,
}
impl Focus {
pub fn of(ctx: &egui::Context) -> Focus {
if ctx.text_edit_focused() {
Focus::Text
} else if ctx.egui_wants_keyboard_input() {
Focus::Widget
} else {
Focus::None
}
}
/// May `sc` fire with this focus? A focused widget keeps its navigation keys (arrows, ↩,
/// Space, Delete…); a text field keeps everything but ⌘ shortcuts (minus its own editing
/// ones: select all, clipboard, undo) and the function keys.
pub fn allows(self, sc: &KeyboardShortcut) -> bool {
let k = sc.logical_key;
let function = matches!(
k,
Key::F1
| Key::F2
| Key::F3
| Key::F4
| Key::F5
| Key::F6
| Key::F7
| Key::F8
| Key::F9
| Key::F10
| Key::F11
| Key::F12
| Key::F13
| Key::F14
| Key::F15
);
let navigation = matches!(
k,
Key::ArrowLeft
| Key::ArrowRight
| Key::ArrowUp
| Key::ArrowDown
| Key::Enter
| Key::Space
| Key::Tab
| Key::Escape
| Key::Backspace
| Key::Delete
| Key::Home
| Key::End
| Key::PageUp
| Key::PageDown
);
match self {
Focus::None => true,
Focus::Widget => sc.modifiers.command || !navigation,
Focus::Text => {
let text_edit = !sc.modifiers.alt && matches!(k, Key::A | Key::C | Key::X | Key::V | Key::Z | Key::Y) || navigation;
function || (sc.modifiers.command && !text_edit)
}
}
}
}
/// The command whose shortcut was pressed this frame (its key press consumed), if any.
/// While typing (inline type), clipboard and select-all shortcuts belong to the text.
pub fn pressed_command(app: &PhotocraftApp, ctx: &egui::Context, focus: Focus, editing: bool) -> Option<String> {
// Building the table walks the registry: only do it when a key went down.
if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) {
return None;
}
for (id, sc) in bindings(app) {
if !focus.allows(&sc) {
continue;
}
if editing && matches!(id.as_str(), "edit.copy" | "edit.cut" | "edit.paste" | "edit.copyMerged" | "select.all" | "edit.pasteSpecial.pasteInPlace") {
continue;
}
if consume(ctx, &sc) {
return Some(id);
}
}
None
}
/// Why `id` can't run now (the engine's reason when it has one).
pub fn disabled_reason(app: &PhotocraftApp, id: &str) -> String {
photocraft_engine::commands::find(id)
.and_then(|c| (c.enabled)(&app.session).err())
.unwrap_or_else(|| if app.session.active().is_none() { "no document open".into() } else { "not available in the current state".into() })
}
fn label(id: &str) -> String {
crate::menu_catalog::CATALOG
.iter()
.find(|c| c.3 == id)
.map(|c| c.1)
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1))
.or_else(|| photocraft_engine::commands::find(id).map(|c| c.label))
.unwrap_or(id)
.trim_end_matches('…')
.to_string()
}
/// Run a shortcut's command like its menu item (dialogs included), or say why it can't run.
pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
let outcome = if !crate::menus::is_enabled(app, id) {
let why = disabled_reason(app, id);
app.ui.status = format!("{} is not available: {why}", label(id));
app.ui.status_error = true;
Outcome::Disabled(why)
} else if ctx.data(|d| d.get_temp::<bool>(dry_run_id())).unwrap_or(false) {
Outcome::Ran
} else {
let r = if crate::adjust_dialog::has_dialog(id) {
crate::adjust_dialog::open(app, id);
Ok(serde_json::Value::Null)
} else {
crate::menus::invoke(app, ctx, id, json!({}))
};
match r {
Ok(_) => Outcome::Ran,
Err(e) => {
app.ui.status = e.clone();
app.ui.status_error = true;
Outcome::Failed(e)
}
}
};
ctx.data_mut(|d| {
let log = d.get_temp_mut_or_default::<Vec<(String, Outcome)>>(log_id());
if log.len() >= 64 {
log.remove(0);
}
log.push((id.to_string(), outcome));
});
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,296 @@
//! #127: every shortcut the UI displays (menu items, shell commands, tool tooltips) dispatches its
//! command, or the item is visibly disabled with a reason — through the whole app, with a
//! realistic layered document, from the places focus usually is.
use egui::accesskit::Role;
use egui::{Key, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::{Focus, Outcome, bindings, set_dry_run, take_log};
use crate::PhotocraftApp;
use crate::shortcuts::parse;
use crate::state::Tool;
/// Background, a filled pixel layer "paint" (active, with a selection), a type layer and a group
/// holding a layer: what a real layout PSD looks like.
fn realistic() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 240, "height": 160})).unwrap();
s.execute("layer.new.layer", json!({"name": "in-group"})).unwrap();
s.execute("layer.groupLayers", json!({"name": "group"})).unwrap();
s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello"})).unwrap();
let paint = s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 60})).unwrap();
s.execute("edit.fill", json!({"color": "#3070c0"})).unwrap();
s.execute("layer.select", json!({"layer": paint})).unwrap();
s
}
fn services() -> crate::Services {
// Saving is available in the app (an exporter is installed); nothing is written here.
crate::Services { export: Some(Box::new(|_, _, _| Err("tests don't write files".into()))), ..Default::default() }
}
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(realistic(), services())
});
h.run_steps(8);
h
}
/// The platform's modifiers for a shortcut's: `Cmd` is ⌘ on the Mac and Ctrl elsewhere, as
/// egui-winit reports them (`command` plus the physical key).
fn platform(m: Modifiers) -> Modifiers {
let mut out = Modifiers { alt: m.alt, shift: m.shift, ctrl: m.ctrl, ..Default::default() };
if m.command {
out.command = true;
if cfg!(target_os = "macos") {
out.mac_cmd = true;
} else {
out.ctrl = true;
}
}
out
}
fn press(h: &mut Harness<'_, PhotocraftApp>, sc: &str) {
let sc = parse(sc).unwrap_or_else(|| panic!("unparsable shortcut {sc}"));
let m = platform(sc.modifiers);
h.event(egui::Event::ModifiersChanged(m));
h.event(egui::Event::Key { key: sc.logical_key, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.event(egui::Event::Key { key: sc.logical_key, physical_key: None, pressed: false, repeat: false, modifiers: m });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn active_name(h: &Harness<'_, PhotocraftApp>) -> String {
let st = h.state().session.active().unwrap();
st.active_layer.and_then(|id| st.doc.layer(id)).map(|l| l.name.clone()).unwrap_or_default()
}
/// Where focus is when the shortcut is pressed.
#[derive(Clone, Copy, Debug)]
enum Place {
/// The pointer over the canvas, nothing focused.
Canvas,
/// Just after clicking the "paint" row in the Layers panel.
LayersRow,
/// Just after clicking into the Layers panel's Opacity field (a text field now).
OpacityField,
/// A non-text widget focused (Tab from nothing focuses the first one, as egui does).
FocusedWidget,
}
fn put_focus(h: &mut Harness<'_, PhotocraftApp>, place: Place) {
match place {
Place::Canvas => {
let c = h.state().last_canvas_rect.center();
h.hover_at(c);
h.run_steps(2);
}
Place::LayersRow => {
h.get_by_role_and_label(Role::Button, "paint").scroll_to_me();
h.run_steps(4);
let row = h.get_by_role_and_label(Role::Button, "paint").rect();
click(h, row.center() + vec2(row.width() / 4.0, 0.0));
assert_eq!(active_name(h), "paint");
}
Place::OpacityField => {
// The Layers panel's Opacity field: the DragValue in the right dock reading 100.
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)
.min_by(|a, b| a.center().y.total_cmp(&b.center().y))
.expect("Layers opacity field")
.center();
click(h, at);
assert_eq!(Focus::of(&h.ctx), Focus::Text, "clicking a value field edits it");
}
Place::FocusedWidget => {
h.event(egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(Focus::of(&h.ctx), Focus::Widget, "Tab focused a widget");
}
}
take_log(&h.ctx);
}
/// Is `bound` the same action as the menu item `shown`? (Window › Layers is `window.panel.layers`
/// in the catalogue and `window.toggle.layers` in the shell.)
fn same(shown: &str, bound: &str) -> bool {
shown == bound || crate::menus::panel_alias(shown) == Some(bound) || crate::menus::panel_alias(bound) == Some(shown)
}
/// Every shortcut shown in the menus (and the shell's unlisted ones), with whether it is live.
fn displayed(app: &PhotocraftApp) -> Vec<(String, String, bool)> {
let mut v: Vec<(String, String, bool)> = crate::menus::menu_items(app)
.into_iter()
.filter(|i| i.id != "---")
.filter_map(|i| Some((i.id.clone(), i.shortcut?, crate::menus::is_live(&i.id))))
.collect();
for (id, _, menu, sc) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc
&& menu.is_empty()
{
v.push((id.to_string(), sc.to_string(), true));
}
}
v
}
#[test]
fn every_displayed_shortcut_is_bound_to_its_item() {
let h = harness();
let app = h.state();
let table = bindings(app);
let mut broken = Vec::new();
for (id, sc, live) in displayed(app) {
let parsed = parse(&sc).unwrap_or_else(|| panic!("{id}: unparsable {sc}"));
let owner = table.iter().find(|(_, b)| *b == parsed).map(|(o, _)| o.clone());
match owner {
Some(o) if same(&id, &o) => {}
_ if !live => {
// Not implemented yet: shown greyed (the parity report tracks these).
let item = crate::menus::menu_items(app).into_iter().find(|i| i.id == id);
assert!(item.is_none_or(|i| !i.enabled), "{id} isn't live but shows enabled");
}
o => broken.push(format!("{id} shows {sc} but it is bound to {o:?}")),
}
}
assert!(broken.is_empty(), "{} displayed shortcuts don't reach their item:\n{}", broken.len(), broken.join("\n"));
}
/// Press every displayed shortcut from each place focus usually is: each dispatches its command,
/// or the menu shows it disabled and the press reports why. (Dry run: File › Exit, Open, Help
/// links… resolve and check enablement without running.)
#[test]
fn every_displayed_shortcut_dispatches_from_where_focus_usually_is() {
let mut failures = Vec::new();
for place in [Place::Canvas, Place::LayersRow, Place::FocusedWidget, Place::OpacityField] {
let mut h = harness();
set_dry_run(&h.ctx, true);
put_focus(&mut h, place);
let focus = Focus::of(&h.ctx);
let items = crate::menus::menu_items(h.state());
for (id, sc, live) in displayed(h.state()) {
if !live {
continue;
}
let parsed = parse(&sc).unwrap();
press(&mut h, &sc);
let log = take_log(&h.ctx);
if !focus.allows(&parsed) {
// A focused text field keeps typing / editing keys: nothing may fire.
if !log.is_empty() {
failures.push(format!("{place:?}: {sc} ({id}) fired {log:?} into a focused field"));
}
continue;
}
let enabled = items.iter().find(|i| i.id == id).map_or_else(|| crate::menus::is_enabled(h.state(), &id), |i| i.enabled);
match log.as_slice() {
[(bound, Outcome::Ran)] if same(&id, bound) && enabled => {}
[(bound, Outcome::Disabled(why))] if same(&id, bound) && !enabled && !why.is_empty() => {}
other => failures.push(format!("{place:?}: {sc} ({id}, menu enabled: {enabled}) -> {other:?}")),
}
}
// Tool letters from the tools panel tooltips ("Brush Tool (B)").
if !matches!(focus, Focus::Text) {
for t in Tool::ALL.into_iter().filter(|t| t.key() != '\0') {
h.state_mut().ui.tool = if t.key() == 'H' { Tool::Move } else { Tool::Hand };
press(&mut h, &t.key().to_string());
if h.state().ui.tool.key() != t.key() {
failures.push(format!("{place:?}: tool key {} left {:?}", t.key(), h.state().ui.tool));
}
}
}
}
assert!(failures.is_empty(), "{} shortcut failures:\n{}", failures.len(), failures.join("\n"));
}
/// The common shortcuts really run (not a dry run) in the layered document after clicking around
/// the panels: the user report was "shortcut keys don't do anything".
#[test]
fn common_shortcuts_work_after_clicking_panels() {
for place in [Place::Canvas, Place::LayersRow, Place::FocusedWidget, Place::OpacityField] {
let mut h = harness();
put_focus(&mut h, place);
let count = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.walk().len();
let n = count(&h);
press(&mut h, "Cmd+J");
assert_eq!(count(&h), n + 1, "{place:?}: ⌘J duplicates the layer");
// In a focused text field ⌘Z / ⇧⌘Z undo the typing, as in Photoshop.
let text = matches!(place, Place::OpacityField);
if !text {
press(&mut h, "Cmd+Z");
assert_eq!(count(&h), n, "{place:?}: ⌘Z undoes");
press(&mut h, "Cmd+Shift+Z");
assert_eq!(count(&h), n + 1, "{place:?}: ⇧⌘Z redoes");
press(&mut h, "Cmd+Z");
}
press(&mut h, "Cmd+D");
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "{place:?}: ⌘D deselects");
press(&mut h, "Cmd+Shift+D");
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "{place:?}: ⇧⌘D reselects");
let z = h.state().current_zoom();
press(&mut h, "Cmd+=");
assert!(h.state().current_zoom() > z, "{place:?}: ⌘+ zooms in");
let rulers = h.state().ui.extras.rulers;
press(&mut h, "Cmd+R");
assert_ne!(h.state().ui.extras.rulers, rulers, "{place:?}: ⌘R toggles rulers");
let layers = h.state().ui.panels.layers;
press(&mut h, "F7");
assert_ne!(h.state().ui.panels.layers, layers, "{place:?}: F7 toggles Layers");
press(&mut h, "F7");
// Dialogs: Image Size (⌥⌘I, shown in the menu but unbound before #127) and Levels (⌘L).
for (sc, what) in [("Cmd+Alt+I", "Image Size"), ("Cmd+Alt+C", "Canvas Size"), ("Cmd+L", "Levels"), ("Cmd+M", "Curves")] {
press(&mut h, sc);
assert_eq!(h.state().ui.dialogs.len(), 1, "{place:?}: {sc} opens {what}");
h.state_mut().ui.dialogs.clear();
h.run_steps(2);
}
if !text {
press(&mut h, "B");
assert_eq!(h.state().ui.tool, Tool::Brush, "{place:?}: B picks the Brush");
press(&mut h, "V");
assert_eq!(h.state().ui.tool, Tool::Move, "{place:?}: V picks Move");
// The colour keys from the tools panel tooltips: X swaps, D resets.
let fg = h.state().session.tools.foreground;
press(&mut h, "X");
assert_eq!(h.state().session.tools.background, fg, "{place:?}: X swaps colours");
press(&mut h, "D");
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "{place:?}: D resets colours");
}
press(&mut h, "Cmd+G");
let group = h.state().session.active().unwrap().active_layer.and_then(|id| h.state().session.active().unwrap().doc.layer(id).cloned());
assert!(group.is_some_and(|l| matches!(l.content, LayerContent::Group(_))), "{place:?}: ⌘G groups");
}
}
#[test]
fn a_disabled_shortcut_says_why() {
let mut h = harness();
put_focus(&mut h, Place::Canvas);
// Merge Down on the bottom layer: Photoshop greys it; the key press reports the reason.
let bg = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().session.select_layer(bg).unwrap();
h.run_steps(2);
press(&mut h, "Cmd+E");
let log = take_log(&h.ctx);
assert!(matches!(log.as_slice(), [(id, Outcome::Disabled(_) | Outcome::Failed(_))] if id == "layer.mergeLayers"), "{log:?}");
assert!(!h.state().ui.status.is_empty() && h.state().ui.status_error, "the status bar says why");
}
+23 -41
View File
@@ -156,19 +156,35 @@ 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.
if app.distort.liquify.is_some() && !ctx.text_edit_focused() {
crate::liquify_ui::keys(app, ctx);
}
if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() || app.discard.is_some() {
// Dialogs and focused sliders keep canvas zoom; a focused text field keeps its keys.
if !ctx.text_edit_focused() {
use crate::shortcut_dispatch::{Focus, dispatch, pressed_command};
let focus = Focus::of(ctx);
// Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a
// focused text field keeps its keys.
let liquify_focus = app.distort.liquify.is_some() && focus != Focus::None;
if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() {
if focus != Focus::Text {
nav_keys(app, ctx);
}
return;
}
if focus == Focus::Text {
// A focused field keeps its typing and editing keys; menu shortcuts (⌘J, ⌘S, F7…) still
// fire, as in Photoshop.
if let Some(id) = pressed_command(app, ctx, focus, false) {
dispatch(app, ctx, &id);
}
return;
}
// Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels.
if crate::distort_ui::keys(app, ctx) {
return;
@@ -197,44 +213,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
// Inline type editing eats text and navigation keys; ⌘-shortcuts still reach the menus.
let editing = crate::type_tool::handle_keys(app, ctx);
// Registry + UI command shortcuts, most-modifiers first so ⇧⌘Z wins over ⌘Z.
// Edit › Keyboard Shortcuts overrides replace the defaults (and can bind any menu item).
let prefs = app.session.prefs();
let mut all: Vec<(String, KeyboardShortcut)> = crate::menus::UI_COMMANDS
.iter()
.filter_map(|(id, _, _, sc)| Some((id.to_string(), parse(prefs.shortcut(id, *sc)?)?)))
.chain(photocraft_engine::command_specs().iter().filter_map(|c| Some((c.id.to_string(), parse(prefs.shortcut(c.id, c.shortcut)?)?))))
.chain(
prefs
.shortcuts
.iter()
.filter(|(id, sc)| {
!sc.is_empty() && photocraft_engine::commands::find(id).is_none() && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str())
})
.filter_map(|(id, sc)| Some((id.clone(), parse(sc)?))),
)
.filter(|(_, sc)| sc.modifiers != Modifiers::NONE || !matches!(sc.logical_key, Key::X | Key::D))
.collect();
all.sort_by_key(|(_, sc)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8));
for (id, sc) in all {
// While typing, clipboard/select-all shortcuts belong to the text, not the pixels.
if editing && matches!(id.as_str(), "edit.copy" | "edit.cut" | "edit.paste" | "edit.copyMerged" | "select.all" | "edit.pasteSpecial.pasteInPlace") {
continue;
}
if consume(ctx, &sc) {
if crate::menus::is_enabled(app, &id) {
let r = if crate::adjust_dialog::has_dialog(&id) {
crate::adjust_dialog::open(app, &id);
Ok(serde_json::Value::Null)
} else {
crate::menus::invoke(app, ctx, &id, json!({}))
};
if let Err(e) = r {
app.ui.status = e;
}
}
return;
}
// Registry, UI and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]).
if let Some(id) = pressed_command(app, ctx, focus, editing) {
dispatch(app, ctx, &id);
return;
}
if editing {
return;
+8
View File
@@ -587,11 +587,17 @@ pub struct UiState {
pub workspace: String,
pub palette_open: bool,
pub dock_tabs: DockTabs,
/// Right-dock group order, heights and collapsed groups (see `dock`).
#[serde(default)]
pub dock: crate::dock::DockLayout,
/// Brush Settings: selected section (0 = Brush Tip Shape) and tab (0 settings, 1 Brushes).
#[serde(default)]
pub brush_section: usize,
#[serde(default)]
pub brush_tab: usize,
/// Brushes panel: collapsed groups and the search filter.
#[serde(default)]
pub brushes_panel: crate::brush_panel::BrushesPanelState,
/// Marquee options-bar mode: 0 new, 1 add, 2 subtract, 3 intersect (modifier keys override).
#[serde(default)]
pub selection_mode: u8,
@@ -651,8 +657,10 @@ impl Default for UiState {
workspace: "Essentials".into(),
palette_open: false,
dock_tabs: DockTabs::default(),
dock: Default::default(),
brush_section: 0,
brush_tab: 0,
brushes_panel: Default::default(),
selection_mode: 0,
tool_options: ToolOptions::default(),
polygon: Vec::new(),
+90 -32
View File
@@ -62,12 +62,42 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
ids.reverse(); // walk() is bottom-up; hit the topmost first
ids.into_iter().find(|id| {
let Some((l, aff, _)) = layout(app, *id) else { return false };
// The pixels shown count too: a PSD's type keeps Photoshop's rendering until edited,
// which can sit off our layout when fonts are substituted (#123).
let shown = text_layer(&doc, *id).and_then(|t| t.cache.as_ref()).map(|c| c.content_bounds()).is_some_and(|r| {
let s = f64::from(slop);
!r.is_empty() && x >= f64::from(r.x0) - s && x <= f64::from(r.x1) + s && y >= f64::from(r.y0) - s && y <= f64::from(r.y1) + s
});
let Some((l, aff, _)) = layout(app, *id) else { return shown };
let (tx, ty) = to_text(&aff, x, y);
l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop)
shown || l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop)
})
}
/// True when the layer's pixels are what our engine draws for it. A PSD's type layer keeps
/// Photoshop's pixels until it is edited, and with substituted fonts or a different line layout
/// they sit somewhere else than the glyphs the caret and selection are placed on.
fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool {
let Some(cache) = &t.cache else { return false };
let Ok(mut eng) = photocraft_text::shared().lock() else { return true };
let ours = eng.render(t, doc.resolution_dpi, doc.pixel_format()).1.surface.content_bounds();
let have = cache.content_bounds();
[(ours.x0, have.x0), (ours.y0, have.y0), (ours.x1, have.x1), (ours.y1, have.y1)].iter().all(|(a, b)| (a - b).abs() <= 1)
}
/// Start editing an existing type layer. Like Photoshop, editing shows the text as the type
/// engine lays it out, so a PSD layer is re-rendered first (inside the edit session's history
/// step, so Cancel brings Photoshop's pixels back).
fn begin_edit(app: &mut PhotocraftApp, id: LayerId, key: &str) {
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
if let Some(t) = text_layer(&doc, id)
&& !shows_own_layout(&doc, t)
{
let _ = app.run("type.edit", json!({"layer": id.0, "coalesce": key}));
}
}
fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize {
let Some((l, aff, text)) = layout(app, id) else { return 0 };
let (tx, ty) = to_text(&aff, x, y);
@@ -99,8 +129,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo
}
if let Some(id) = hit_layer(app, x, y) {
let _ = app.session.select_layer(id);
let key = session_key(app);
begin_edit(app, id, &key);
let off = hit_offset(app, id, x, y);
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true, preedit: None });
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, dragging: true, preedit: None });
return true;
}
false
@@ -566,17 +598,35 @@ fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> {
text_layer(&st.doc, id).map(|_| (id.0, None))
}
/// Scale of the target layer's transform. Like Photoshop, sizes show and edit as the layer
/// appears: a 12 pt layer scaled 200% (common in PSDs) reads 24 pt.
fn shown_scale(app: &PhotocraftApp) -> f32 {
let Some((id, _)) = target(app) else { return 1.0 };
let Some(t) = app.session.active().and_then(|st| text_layer(&st.doc, LayerId(id))) else { return 1.0 };
let m = t.transform.m;
let k = (m[0] * m[3] - m[1] * m[2]).abs().sqrt() as f32;
if k.is_finite() && k > 1e-3 { k } else { 1.0 }
}
/// Coalesce key while a field is being scrubbed: every step of one drag shares one history step
/// (Photoshop's single step per scrub, #124). Unique per press, so two drags are two steps.
fn drag_key(ctx: &egui::Context) -> Option<String> {
let id = ctx.dragged_id()?;
let t = ctx.input(|i| i.pointer.press_start_time())?;
Some(format!("type-drag-{}-{}", id.value(), t.to_bits()))
}
/// Apply character/paragraph properties to the target (selection, else whole layer) and remember
/// them as tool defaults.
fn apply(app: &mut PhotocraftApp, props: serde_json::Value) {
fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) {
let Some((layer, range)) = target(app) else { return };
let mut p = props;
p["layer"] = json!(layer);
if let Some(r) = range {
p["range"] = json!(r);
}
if let Some(ed) = &app.ui.text_edit {
p["coalesce"] = json!(ed.session);
if let Some(key) = app.ui.text_edit.as_ref().map(|ed| ed.session.clone()).or_else(|| drag_key(ctx)) {
p["coalesce"] = json!(key);
}
let _ = app.run("type.setStyle", p);
}
@@ -589,7 +639,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let st = app.session.active()?;
let tl = text_layer(&st.doc, LayerId(id))?;
let run = tl.char_runs().into_iter().next().map(|r| r.style);
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), tl.size_pt, tl.color))
// The size at the selection (else the first run), as the layer shows it (#124).
let size = styles_at(app).map_or(tl.size_pt, |(c, _)| c.size_pt) * shown_scale(app);
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), size, tl.color))
});
let o = app.ui.tool_options.clone();
let (mut fam, mut style, mut size) = match &shown {
@@ -602,19 +654,20 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let st = styles(&fam);
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
app.ui.tool_options.type_style = style.clone();
apply(app, json!({"font": fam, "fontStyle": style}));
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
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, "type-style", &mut style, &opts_ref, 110.0) {
app.ui.tool_options.type_style = style.clone();
apply(app, json!({"fontStyle": style}));
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
crate::icons::paint(ui, r, "type", 13.0, t.icon);
if crate::widgets::value_field(ui, &mut size, 1.0..=1296.0, "pt", 66.0).changed() {
app.ui.tool_options.type_size = size;
apply(app, json!({"size": size}));
let k = shown_scale(app);
apply(app, ui.ctx(), json!({"size": size / k}));
}
let mut aa = o.type_aa.clone();
let aa_opts = [
@@ -641,7 +694,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
{
if crate::icons::button(ui, icon, 24.0, o.type_align == align, tip).clicked() {
app.ui.tool_options.type_align = align.into();
apply(app, json!({"align": align}));
apply(app, ui.ctx(), json!({"align": align}));
}
}
ui.spacing_mut().item_spacing.x = 8.0;
@@ -663,7 +716,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut col = Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]));
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
apply(app, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
if app.ui.text_edit.is_some() {
@@ -764,7 +817,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
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, json!({"font": fam, "fontStyle": style}));
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
});
ui.horizontal(|ui| {
@@ -772,18 +825,19 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
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, json!({"fontStyle": style}));
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
});
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt, 0.1..=1296.0, "pt", w) {
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, json!({"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));
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, json!({"leading": v}));
apply(app, ui.ctx(), json!({"leading": v / k}));
}
});
ui.horizontal(|ui| {
@@ -801,23 +855,23 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
w,
) {
apply(app, json!({"kerning": k}));
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, json!({"tracking": v}));
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, json!({"verticalScale": v}));
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, json!({"horizontalScale": v}));
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, json!({"baselineShift": v}));
apply(app, ui.ctx(), json!({"baselineShift": v}));
}
// Colour chip.
ui.add_space(8.0);
@@ -830,7 +884,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
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, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
});
@@ -887,7 +941,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
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, props);
apply(app, ui.ctx(), props);
}
}
});
@@ -923,34 +977,34 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
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, json!({"align": key}));
apply(app, ui.ctx(), json!({"align": key}));
}
}
});
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, json!({"startIndent": v}));
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, json!({"endIndent": v}));
apply(app, ui.ctx(), json!({"endIndent": v}));
}
});
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, json!({"firstLineIndent": v}));
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, json!({"spaceBefore": v}));
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, json!({"spaceAfter": v}));
apply(app, ui.ctx(), json!({"spaceAfter": v}));
}
});
let mut hy = para.hyphenate;
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
apply(app, json!({"hyphenate": hy}));
apply(app, ui.ctx(), json!({"hyphenate": hy}));
}
}
@@ -963,6 +1017,10 @@ pub fn cancel(app: &mut PhotocraftApp) {
}
}
#[cfg(test)]
#[path = "type_tool_tests.rs"]
mod canvas_tests;
#[cfg(test)]
mod tests {
use super::*;
+290
View File
@@ -0,0 +1,290 @@
//! Type tool through the real canvas (#123, #124): clicks and drags at known glyph positions
//! place the caret and selection on the glyph under the pointer at any zoom, HiDPI scale, pan,
//! view flip and layer transform; size edits apply at layer and selection scope, for native and
//! PSD-round-tripped text, as one history step per drag.
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::{LayerContent, LayerId, TextLayer};
use photocraft_geom::{Affine, Point};
use serde_json::json;
use super::{layout, text_layer};
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
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 new_app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 1600, "height": 900})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.tool = crate::state::Tool::Type;
app
}
fn xf(app: &PhotocraftApp) -> ViewXform {
let v = &app.ui.views[0];
ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }
}
/// Screen position of a text-space point of layer `id`.
fn screen(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, x: f32, y: f32) -> Pos2 {
let app = h.state_mut();
let (_, aff, _) = layout(app, id).unwrap();
let p = aff.apply(Point::new(f64::from(x), f64::from(y)));
xf(app).to_screen(p.x as f32, p.y as f32)
}
/// A point inside glyph `i` (character index): `f` of the way along its advance, in the upper
/// half of its line.
fn glyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 {
let (l, _, text) = layout(h.state_mut(), id).unwrap();
let b = text.char_indices().nth(i).unwrap().0;
let c = l.clusters.iter().find(|c| c.range.start == b).unwrap().clone();
let ln = &l.lines[c.line];
screen(h, id, c.x + c.advance * f, ln.baseline - ln.ascent * 0.35)
}
fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool) {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE });
h.run_steps(1);
}
fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
press(h, p, true);
press(h, p, false);
h.run_steps(1);
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.run_steps(1);
press(h, a, true);
for k in 1..=6 {
h.hover_at(a + (b - a) * (k as f32 / 6.0));
h.run_steps(1);
}
press(h, b, false);
h.run_steps(1);
}
fn selection(h: &Harness<'static, PhotocraftApp>) -> (usize, usize) {
let Some(e) = h.state().ui.text_edit.clone() else { return (usize::MAX, usize::MAX) };
(e.anchor, e.caret)
}
/// Every combination the issue lists that this canvas has: zoom 25–200 %, HiDPI 1×/2×, a panned
/// view, View › Flip Horizontal, and point / paragraph text with identity or rotated+scaled layer
/// transforms. Clicks land before/after the glyph under the pointer; drags select from the glyph
/// pressed (not where egui recognised the drag) to the glyph released over.
#[test]
fn clicks_and_drags_land_on_the_glyph_under_the_pointer() {
let rotated = Affine { m: [1.4 * 0.94, 1.4 * 0.342, -1.4 * 0.342, 1.4 * 0.94, 500.0, 420.0] };
for ppp in [1.0, 2.0] {
for zoom in [0.25f32, 0.5, 1.0, 2.0] {
for (flip, pan) in [(false, [0.0, 0.0]), (true, [37.0, -21.0])] {
for (shape, tf) in [("point", None), ("box", None), ("point", Some(rotated)), ("box", Some(rotated))] {
let mut app = new_app();
let size = 60.0 / zoom;
let mut p = json!({"text": "HOHOHO HOHO", "size": size, "x": 300, "y": 420});
if shape == "box" {
p["box"] = json!([250.0, 250.0, size * 6.0, size * 4.0]);
}
let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap());
if let Some(a) = tf {
app.run("type.edit", json!({"layer": id.0, "transform": a.m})).unwrap();
}
app.ui.view.flip_horizontal = flip;
let mut h = harness(ppp, app);
// Centre the view on the text's middle, then pan.
let (l, aff, _) = layout(h.state_mut(), id).unwrap();
let b = l.bounds().unwrap();
let m = aff.apply(Point::new(f64::from(b[0] + b[2]) / 2.0, f64::from(b[1] + b[3]) / 2.0));
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.center, v.fit_pending) = (zoom, [m.x as f32 + pan[0], m.y as f32 + pan[1]], false);
h.run_steps(2);
let ctx = format!("ppp {ppp} zoom {zoom} flip {flip} {shape} transformed {}", tf.is_some());
let rect = xf(h.state()).rect;
for (i, f) in [(0, 0.2), (2, 0.8), (4, 0.3), (8, 0.7)] {
let p = glyph(&mut h, id, i, f);
assert!(rect.contains(p), "{ctx}: glyph {i} off screen at {p:?}");
}
// Click into the layer: caret before glyph 2 (left part), then after glyph 4.
let p = glyph(&mut h, id, 2, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (2, 2), "{ctx}: click on the left of glyph 2");
let p = glyph(&mut h, id, 4, 0.8);
click(&mut h, p);
assert_eq!(selection(&h), (5, 5), "{ctx}: click on the right of glyph 4");
// Drag from glyph 1 (left part) to glyph 8 (right part): characters 1..9.
let (a, b) = (glyph(&mut h, id, 1, 0.25), glyph(&mut h, id, 8, 0.75));
drag(&mut h, a, b);
assert_eq!(selection(&h), (1, 9), "{ctx}: drag selection");
// And backwards, starting inside the egui drag threshold of a glyph edge.
let (a, b) = (glyph(&mut h, id, 5, 0.85), glyph(&mut h, id, 3, 0.15));
drag(&mut h, a, b);
assert_eq!(selection(&h), (6, 3), "{ctx}: backwards drag selection");
}
}
}
}
}
fn text(app: &PhotocraftApp, id: LayerId) -> TextLayer {
text_layer(&app.session.active().unwrap().doc, id).unwrap().clone()
}
/// A PSD type layer keeps Photoshop's pixels until it is edited. Clicking into it re-renders it
/// with our engine first (so the caret sits on what is shown), inside the edit session's single
/// history step; Cancel brings Photoshop's pixels back.
#[test]
fn editing_psd_type_shows_our_layout_and_cancel_restores_it() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "HOHOHO", "size": 120, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
// Stand-in for Photoshop's rendering: the same text drawn somewhere else.
let moved = {
let mut t = text(&app, id);
t.transform = Affine::translate(330.0, 380.0);
photocraft_text::shared().lock().unwrap().render(&t, 72.0, photocraft_color::PixelFormat::RGBA8).1.surface
};
let st = app.session.active_mut().unwrap();
let mut doc = (*st.doc).clone();
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
t.cache = Some(moved.clone());
}
st.doc = std::sync::Arc::new(doc);
st.revision += 1;
let steps = app.session.active().unwrap().history.entries().len();
let mut h = harness(1.0, app);
let p = glyph(&mut h, id, 2, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (2, 2));
let ours = text(h.state(), id).cache.unwrap().content_bounds();
assert_ne!(ours, moved.content_bounds(), "re-rendered where the caret is");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1);
super::insert(h.state_mut(), "x");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one step for the session");
super::cancel(h.state_mut());
assert_eq!(text(h.state(), id).cache.unwrap().content_bounds(), moved.content_bounds());
assert_eq!(text(h.state(), id).text, "HOHOHO");
// A native layer shows our layout already: clicking into it records nothing.
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "HOHOHO", "size": 120, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let steps = app.session.active().unwrap().history.entries().len();
let mut h = harness(1.0, app);
let p = glyph(&mut h, id, 3, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (3, 3));
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps);
}
fn size_at(app: &PhotocraftApp, id: LayerId, ci: usize) -> f32 {
let t = text(app, id);
let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b);
let mut at = 0;
for r in t.char_runs() {
if b < at + r.len {
return r.style.size_pt;
}
at += r.len;
}
0.0
}
/// Font size from the options bar / Properties: the whole layer when no characters are selected,
/// only the selection when editing with one, as the layer appears (transform scale included), and
/// for type that went through a PSD file.
#[test]
fn size_applies_at_layer_and_selection_scope() {
for psd in [false, true] {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
app.run("type.edit", json!({"layer": id.0, "transform": [2.0, 0.0, 0.0, 2.0, 300.0, 420.0]})).unwrap();
let id = if psd {
let doc = (*app.session.active().unwrap().doc).clone();
let out = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap();
let back = photocraft_io::import("t.psd", &out.bytes).unwrap().document;
app.session.add_document(back, None);
app.sync_views();
let d = &app.session.active().unwrap().doc;
let id = d.walk().into_iter().find(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).unwrap();
let _ = app.session.select_layer(id);
id
} else {
id
};
let ctx = egui::Context::default();
assert!((super::shown_scale(&app) - 2.0).abs() < 1e-4, "psd {psd}");
let w0 = text(&app, id).cache.unwrap().content_bounds().width();
// Layer scope: 60 pt as shown = 30 pt in the layer's own space.
let k = super::shown_scale(&app);
super::apply(&mut app, &ctx, json!({"size": 60.0 / k}));
assert_eq!((size_at(&app, id, 0), size_at(&app, id, 10)), (30.0, 30.0), "psd {psd}");
let w1 = text(&app, id).cache.unwrap().content_bounds().width();
assert!(w1 as f32 > w0 as f32 * 1.4, "psd {psd}: the pixels grow ({w0} → {w1})");
// Selection scope: "world" only.
app.ui.text_edit =
Some(crate::state::TextEdit { layer: id.0, caret: 11, anchor: 6, session: "s".into(), created: false, dragging: false, preedit: None });
super::apply(&mut app, &ctx, json!({"size": 10.0}));
assert_eq!((size_at(&app, id, 0), size_at(&app, id, 5), size_at(&app, id, 6), size_at(&app, id, 10)), (30.0, 30.0, 10.0, 10.0), "psd {psd}");
}
}
/// Scrubbing the options bar's size field previews live (the size changes every frame) and is
/// one undo step per drag; each step only damages the type layer's area, not the whole canvas.
#[test]
fn size_drag_is_live_and_one_history_step_per_drag() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
ui.horizontal(|ui| super::options_bar(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
let steps = h.state().session.active().unwrap().history.entries().len();
let mut last = 20.0;
for d in 0..2 {
let field = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("size field").rect().center();
h.hover_at(field);
h.run_steps(1);
press(&mut h, field, true);
for k in 1..=5 {
h.hover_at(field + vec2(8.0 * k as f32, 0.0));
h.run_steps(1);
let now = size_at(h.state(), id, 0);
assert!(now > last, "drag {d} step {k}: live size {last} → {now}");
last = now;
let dmg = h.state().session.active().unwrap().last_damage.expect("damage rect, not a full refresh");
assert!(!dmg.is_empty() && dmg.width() * dmg.height() <= 512 * 512, "{dmg:?}");
}
press(&mut h, field + vec2(40.0, 0.0), false);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + d + 1, "one step per drag");
}
assert!(h.state_mut().session.undo());
assert!(h.state_mut().session.undo());
assert_eq!(size_at(h.state(), id, 0), 20.0);
}
+10 -3
View File
@@ -453,15 +453,22 @@ fn flag_param(p: &Value, cur: bool) -> bool {
fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result<Value, String> {
if let Some((panel, tab)) = panel_tab(app, id) {
let group = crate::dock::Group::from_key(panel);
let collapsed = group.is_some_and(|g| app.ui.dock.is_collapsed(g));
let (vis, cur) = panel_state(app, panel);
// Like Photoshop: choosing a visible panel's menu item again hides it.
if *vis && *cur == tab && !id.starts_with("type.panels.") {
// Like Photoshop: choosing a visible panel's menu item again hides it. A collapsed
// group is expanded instead, so the menu item always brings the panel back (#129).
if *vis && *cur == tab && !collapsed && !id.starts_with("type.panels.") {
*vis = false;
} else {
*vis = true;
*cur = tab;
}
return Ok(json!({"panel": panel, "tab": tab, "visible": *vis}));
let visible = *vis;
if let Some(g) = group.filter(|_| visible) {
crate::dock::reveal(app, g);
}
return Ok(json!({"panel": panel, "tab": tab, "visible": visible}));
}
let o = &mut app.ui.view;
if let Some(k) = id.strip_prefix("view.show.") {
+68 -30
View File
@@ -22,48 +22,82 @@ pub fn surface(ui: &Ui, rect: Rect, fill: Color32, raised: bool) {
/// A dock card: rounded container with a header of pill tabs and optional trailing actions.
/// Returns the index of the selected tab.
pub fn card(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) {
let _ = card_ex(ui, id, tabs, selected, false, body);
}
/// What happened on a card's tab strip this frame (see [`card_ex`]).
pub struct CardResponse {
/// The tab strip background: drag to move the group, double-click to collapse it.
pub strip: Response,
/// The panel menu button (hamburger in Pro, ellipsis in Studio).
pub menu: Response,
/// A tab was double-clicked (Photoshop collapses the group).
pub tab_double_clicked: bool,
}
/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions.
pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse {
let t = Tokens::get(ui.ctx());
if t.pro {
return pro_panel(ui, id, tabs, selected, body);
return pro_panel(ui, id, tabs, selected, collapsed, body);
}
let frame = egui::Frame::NONE
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
.corner_radius(CornerRadius::same(t.radius as u8))
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 10 });
frame.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, name) in tabs.iter().enumerate() {
if pill_tab(ui, name, *selected == i).clicked() {
*selected = i;
}
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: if collapsed { 6 } else { 10 } });
let out = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
// 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;
if !collapsed {
ui.add_space(6.0);
body(ui, *selected);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let _ = crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)));
});
});
ui.add_space(6.0);
body(ui, *selected);
});
CardResponse { strip, menu, tab_double_clicked: double }
})
.inner;
ui.add_space(6.0);
out
}
/// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu.
fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) {
fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse {
let t = Tokens::get(ui.ctx());
let width = ui.available_width();
// Tab strip.
// Tab strip. Its background senses drags (move the group) and double-clicks (collapse);
// the tabs, registered after it, keep their clicks.
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.tab_strip);
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;
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() {
@@ -77,6 +111,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i
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;
}
@@ -91,15 +126,18 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: i
ui.painter().line_segment([pos2(menu.center().x - 5.0, y), pos2(menu.center().x + 5.0, y)], Stroke::new(1.0, c));
}
// Body.
egui::Frame::NONE
.fill(t.card)
.corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 })
.inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(width - 16.0);
body(ui, *selected);
});
if !collapsed {
egui::Frame::NONE
.fill(t.card)
.corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 })
.inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.set_width(width - 16.0);
body(ui, *selected);
});
}
ui.add_space(2.0);
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: double }
}
pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
+4 -7
View File
@@ -174,7 +174,7 @@ fn new_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> {
return Err(format!("\"{name}\" is a built-in workspace"));
}
let prefs = app.session.prefs().clone();
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs});
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock});
if p.get("keyboardShortcuts").and_then(Value::as_bool) == Some(true) {
ws["shortcuts"] = json!(prefs.shortcuts);
}
@@ -221,12 +221,9 @@ fn select_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String>
/// Apply the current workspace when it is a saved one. Returns false for the built-in presets.
pub fn apply_custom(app: &mut PhotocraftApp) -> bool {
let Some(ws) = app.session.prefs().workspaces.get(&app.ui.workspace).cloned() else { return false };
if let Ok(p) = serde_json::from_value(ws["panels"].clone()) {
app.ui.panels = p;
}
if let Ok(t) = serde_json::from_value(ws["dockTabs"].clone()) {
app.ui.dock_tabs = t;
}
// Workspaces saved before the dock layout existed get the default heights.
app.ui.dock = Default::default();
crate::dock::apply(app, &ws);
let (sc, menus, toolbar) = (ws.get("shortcuts").cloned(), ws.get("menus").cloned(), ws.get("toolbar").cloned());
if sc.is_some() || menus.is_some() || toolbar.is_some() {
app.session.prefs.edit(|p| {
+2 -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?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes)
- `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.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
@@ -61,6 +61,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag |
| `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel |
| `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` |
| `layer.setExpanded` | `{"layer":id?,"expanded":bool?,"all":bool?}`: open/close a group in the Layers panel (no `expanded`: toggle; `all`: every group). A view change saved with the document (PSD open/closed folder), not an undo step; `document.inspect` reports `expanded` on groups |
| `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` |
| `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` |
| `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` |
+32 -8
View File
@@ -44,8 +44,8 @@ in the dialog.
| macOS 11+ (universal: Apple silicon + Intel) | `photocraft-<v>-macos-universal.dmg`, `photocraft-cli-<v>-macos-universal.zip` | `macos-15` |
| Windows 10+ x64 | `photocraft-<v>-windows-x64.msi`, `photocraft-<v>-windows-x64-portable.zip` | `windows-latest` |
| Windows 10+ x86 (32-bit) | `photocraft-<v>-windows-x86.msi`, `photocraft-<v>-windows-x86-portable.zip` | `windows-latest` |
| Linux x86_64 | `photocraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `photocraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux x86_64 | `photocraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz,flatpak}` | `ubuntu-22.04` (Flatpak: `ubuntu-24.04`) |
| Linux aarch64 | `photocraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz,flatpak}` | `ubuntu-22.04-arm` (Flatpak: `ubuntu-24.04-arm`) |
| Web | `photocraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
Every binary reports its version, the commit and the build date: `photocraft --version`,
@@ -128,11 +128,34 @@ Why these formats:
(`nfpm.yaml`). It doesn't need cargo-deb and cargo-generate-rpm metadata in our crates, and
it can build both formats on Ubuntu.
- **.tar.gz** is for people who manage their own `/opt` or `~/.local`.
- **Flatpak** is the route into Flathub, GNOME Software and KDE Discover.
`packaging/linux/flatpak/ai.storyteller.photocraft.yml` is ready for a Flathub submission.
CI only validates it. A real build needs vendored crate sources (`cargo-sources.json` from
`flatpak-cargo-generator.py`) and adds 20+ minutes per architecture. Build it by hand with
the commands in the manifest's header.
- **Flatpak bundle** (`.flatpak`) is a single file that installs into Flatpak, sandboxed and
updatable by installing a newer bundle, for people who prefer Flatpak to AppImage.
`packaging/linux/flatpak-bundle.sh` repackages the job's `.tar.gz` with
`packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml` on the freedesktop 26.08
runtime. It does no Rust build and needs no network inside `flatpak-builder`, so the bundle
holds the same binaries as the other formats. A separate `flatpak` job per architecture
(`ubuntu-24.04` and `ubuntu-24.04-arm`, for flatpak-builder 1.4) downloads the Linux job's
artifact, runs the script, installs the bundle and runs `photocraft-cli --version` inside the
sandbox as a smoke test. The bundle names Flathub as its runtime repo, so users install
it with:
```sh
flatpak install --user photocraft-<v>-linux-x86_64.flatpak # pulls org.freedesktop.Platform//26.08 from Flathub if missing
flatpak run ai.storyteller.photocraft
```
Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11
shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every
other file goes through the file-chooser and document portals. There's no network, so the
`--control` server is only reachable from inside the sandbox until the user runs
`flatpak override --user --share=network ai.storyteller.photocraft`. Host fonts are read from
`/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
`flatpak-builder`): `packaging/linux/package.sh --formats tar && packaging/linux/flatpak-bundle.sh`.
- **Flathub**: `packaging/linux/flatpak/ai.storyteller.photocraft.yml` builds from source and
is ready for a Flathub submission (it keeps the same runtime and `finish-args` as the bundle
manifest, which packaging-lint checks). CI doesn't build it. A real build needs vendored crate
sources (`cargo-sources.json` from `flatpak-cargo-generator.py`) and adds 20+ minutes per
architecture. Build it by hand with the commands in the manifest's header.
All Linux binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**: Ubuntu 22.04+,
Debian 12+, Fedora 36+, RHEL 10, openSUSE Tumbleweed. They link only glibc and libgcc_s. X11,
@@ -192,4 +215,5 @@ The outputs are committed, so packaging never needs those tools.
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons. It runs actionlint, shellcheck, a PowerShell parse, xmllint,
`desktop-file-validate`, `appstreamcli validate`, and a YAML check of the Flatpak manifest.
`desktop-file-validate`, `appstreamcli validate`, and a YAML check that the two Flatpak
manifests agree on the runtime and permissions.
+51 -8
View File
@@ -1,12 +1,14 @@
# Roadmap
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-01.
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-05.
**Parity metric:** `cargo xtask parity` measures how much of Photoshop's menu tree is live and
writes [`parity.md`](parity.md). It is the headline number for "how close are we", next to the PSD
composite oracle and the test count. For the weighted feature-parity estimate (~82% overall) and the
remaining Opus 5.5 effort (~25–40 wall-clock hours to a 95%, ships-for-most state), see
[`parity-estimate.md`](parity-estimate.md).
**Parity metrics.** `cargo xtask parity` measures how much of Photoshop's menu tree is *wired to a
command* and writes [`parity.md`](parity.md). It does **not** measure whether those commands behave
like Photoshop, feel right, or survive real files. Read it next to the PSD composite oracle, the
test count, and the [honest parity assessment](#honest-parity-assessment-2026-10-05) below, which
is the reference answer to "how close are we really". The weighted feature estimate in
[`parity-estimate.md`](parity-estimate.md) (~82%) counts feature surface and overstates user-facing
readiness; treat it as an upper bound.
| M | Status | Where we are |
|---|---|---|
@@ -26,6 +28,45 @@ remaining Opus 5.5 effort (~25–40 wall-clock hours to a 95%, ships-for-most st
**Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md).
## 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.
| 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. |
**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).
**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.
## Current focus (infrastructure before the long tail)
Landed on 2026-10-01:
@@ -36,8 +77,10 @@ Landed on 2026-10-01:
(`docs/releasing.md`).
Next:
1. **First signed release**: push to `release`, verify notarization and the installers, and fix CI
(`docs/releasing.md`). Windows code-signing material still needs to be obtained.
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.
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`.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Repackage a PhotoCraft Linux tarball as a single-file Flatpak bundle:
#
# $DIST/photocraft-<version>-linux-<arch>.flatpak
#
# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
#
# TARBALL defaults to $DIST/photocraft-<version>-linux-<arch>.tar.gz from package.sh. The bundle
# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
# `photocraft-cli --version` is run inside the sandbox as a smoke test.
# Manifest: packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.photocraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
TARBALL=""
while [ $# -gt 0 ]; do
case "$1" in
--no-test) TEST=0; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="photocraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
echo "==> PhotoCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")"
WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[ -x "$WORK/stage/bin/photocraft" ] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
# --disable-rofiles-fuse: no FUSE in containers and CI runners.
flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
--default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml"
OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [ "$TEST" = 1 ]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=photocraft-cli "$APP_ID" --version
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps'
fi
echo "==> done"
ls -lh "$OUT"
@@ -0,0 +1,50 @@
# Flatpak manifest for the single-file bundle attached to every GitHub release
# (photocraft-<version>-linux-<arch>.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
#
# It repackages the release's own Linux tarball (photocraft-<version>-linux-<arch>.tar.gz,
# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no
# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes,
# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball
# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11,
# Wayland, xkbcommon and Vulkan at runtime; the freedesktop runtime has all of them, and the
# GPU driver comes from its org.freedesktop.Platform.GL extension.
#
# ai.storyteller.photocraft.yml (same directory) is the from-source manifest for Flathub. Keep
# runtime-version and finish-args identical in both (packaging-lint checks this).
id: ai.storyteller.photocraft
runtime: org.freedesktop.Platform
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: photocraft
finish-args:
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
# Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
# the sandbox; `flatpak override --user --share=network ai.storyteller.photocraft` exposes it to
# host agents) and home/host filesystem access.
modules:
- name: photocraft
buildsystem: simple
build-commands:
- install -Dm755 bin/photocraft /app/bin/photocraft
- install -Dm755 bin/photocraft-cli /app/bin/photocraft-cli
- install -Dm644 share/applications/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop
# .pcraft, .psb and .qoi; the runtime's shared-mime-info already knows PSD and the rest.
- install -Dm644 share/mime/packages/ai.storyteller.photocraft.xml /app/share/mime/packages/ai.storyteller.photocraft.xml
# Already has the release's version and date substituted by package.sh.
- install -Dm644 share/metainfo/ai.storyteller.photocraft.metainfo.xml /app/share/metainfo/ai.storyteller.photocraft.metainfo.xml
- mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/photocraft /app/share/doc/
sources:
- type: dir
path: stage
@@ -8,22 +8,32 @@
# flatpak run ai.storyteller.photocraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
# The single-file .flatpak attached to each release comes from ai.storyteller.photocraft.bundle.yml,
# which repackages the prebuilt release binaries. Keep runtime-version and finish-args identical.
id: ai.storyteller.photocraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: photocraft
finish-args:
- --share=ipc
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
# portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
# path from the command line.
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
# Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
# the sandbox; `flatpak override --user --share=network ai.storyteller.photocraft` exposes it to
# host agents) and home/host filesystem access.
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules: