mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 04:36:49 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecfd080416 | ||
|
|
56080328ed | ||
|
|
10203e7a60 | ||
|
|
20783fdc70 | ||
|
|
231bf5fe81 | ||
|
|
4195f25f27 | ||
|
|
6293f51954 | ||
|
|
a202171891 | ||
|
|
a6c5fa6732 | ||
|
|
177620a58d | ||
|
|
3c54849ba6 | ||
|
|
28a63fe27e | ||
|
|
7862267069 | ||
|
|
800f264ee3 | ||
|
|
b4b1aab4ad |
No files matched your search
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -162,6 +162,8 @@ pub struct Preview {
|
||||
/// Hash of the params the mask texture shows.
|
||||
mask_key: u64,
|
||||
mask: Option<egui::TextureHandle>,
|
||||
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash(text: &str) -> u64 {
|
||||
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
|
||||
|
||||
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
|
||||
/// session (Out Of Gamut uses the document's own proof setup).
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
|
||||
let area = proxy.bounds();
|
||||
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
|
||||
let pv = app.session.color.proof(proxy.id);
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
|
||||
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
|
||||
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
}
|
||||
let mut p = params.clone();
|
||||
// Eyedropper points are in document pixels; the proxy is 1/k the size.
|
||||
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
|
||||
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
|
||||
let _ = s.select_layer(id);
|
||||
}
|
||||
s.execute(COMMAND, p).ok()?;
|
||||
let d = s.active()?;
|
||||
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
|
||||
let d = s.active().ok_or("no preview document")?;
|
||||
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
}
|
||||
|
||||
/// Record why the preview failed (logged once per new reason), or clear it.
|
||||
fn report(app: &mut PhotocraftApp, error: Option<String>) {
|
||||
let Some(p) = app.color_range.as_mut() else { return };
|
||||
if p.error != error {
|
||||
if let Some(e) = &error {
|
||||
log::warn!("Color Range preview: {e}");
|
||||
}
|
||||
p.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
|
||||
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let side = doc.size.width.max(doc.size.height).max(1);
|
||||
let k = side.div_ceil(PREVIEW).max(1);
|
||||
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
|
||||
}
|
||||
let image_view = s(f, "__view", "selection") == "image";
|
||||
let params = params(f);
|
||||
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
|
||||
let size = [thumb.width as usize, thumb.height as usize];
|
||||
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
|
||||
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
|
||||
return None;
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
|
||||
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
|
||||
}
|
||||
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
|
||||
let mask = proxy_mask(app, &proxy, k, ¶ms).unwrap_or_else(|| vec![0.0; w * h]);
|
||||
let mask = match proxy_mask(app, &proxy, k, ¶ms) {
|
||||
Ok(m) => {
|
||||
report(app, None);
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
report(app, Some(e));
|
||||
vec![0.0; w * h]
|
||||
}
|
||||
};
|
||||
if mask.len() != w * h {
|
||||
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
|
||||
return None;
|
||||
}
|
||||
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
|
||||
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
pick(app, f, at, ui.input(|i| i.modifiers));
|
||||
}
|
||||
}
|
||||
// A preview that can't be drawn says why (#145) instead of staying blank.
|
||||
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
|
||||
ui.painter().rect_filled(frame, 0.0, t.canvas);
|
||||
let msg = format!(
|
||||
"Preview unavailable:
|
||||
{e}"
|
||||
);
|
||||
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
|
||||
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
|
||||
}
|
||||
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
|
||||
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
|
||||
Some(e) => format!("Color Range preview unavailable: {e}"),
|
||||
None => "Color Range preview".to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(c.sampling, |ui| {
|
||||
@@ -704,4 +742,51 @@ mod tests {
|
||||
app.run(COMMAND, params(&f)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
|
||||
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
|
||||
/// from the menu opens the dialog on screen and draws both previews.
|
||||
#[test]
|
||||
fn opens_and_previews_in_the_full_app_without_a_gpu() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
|
||||
app_with_doc()
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state().gpu.is_none(), "the CPU canvas");
|
||||
h.get_all_by_label("Select").next().expect("the Select menu").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Color Range…").click();
|
||||
h.run_steps(6);
|
||||
let id = dialog_id(h.state());
|
||||
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
|
||||
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
|
||||
assert!(h.query_by_label("Color Range preview").is_some());
|
||||
let p = h.state().color_range.as_ref().expect("the preview is cached");
|
||||
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "__view", json!("image"));
|
||||
let p = h.state().color_range.as_ref().unwrap();
|
||||
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "select", json!("reds"));
|
||||
ok(&mut h);
|
||||
h.run_steps(3);
|
||||
assert_eq!(coverage(h.state(), 5, 5), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_failure_is_shown_not_silent() {
|
||||
let mut h = harness(app_with_doc());
|
||||
open(h.state_mut());
|
||||
h.run_steps(3);
|
||||
// The engine refuses these params: the preview says so instead of staying blank.
|
||||
let id = dialog_id(h.state());
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
|
||||
h.run_steps(3);
|
||||
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
|
||||
assert!(err.is_some(), "the failure is recorded");
|
||||
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
|
||||
set(&mut h, "select", json!("reds"));
|
||||
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Native-menu behaviour for the menu bar's menus (#138): a menu or submenu taller than the
|
||||
//! window scrolls (mouse wheel / trackpad, and the ▲ / ▼ arrows at its top and bottom that scroll
|
||||
//! while the pointer rests on them, like macOS and Windows menus on a small display), and the
|
||||
//! keyboard drives an open menu: ↑ / ↓ move the highlight (and keep it in view), → opens a
|
||||
//! submenu or the next menu, ← closes the submenu or opens the previous menu, ↩ / Space run the
|
||||
//! highlighted item, Esc closes.
|
||||
//!
|
||||
//! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too,
|
||||
//! so the mouse and the keyboard share one highlighted row like a native menu.
|
||||
|
||||
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
|
||||
use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
/// Height of a scroll-arrow strip.
|
||||
pub const ARROW: f32 = 16.0;
|
||||
/// Gap kept between a menu and the window edge.
|
||||
const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
/// One actionable row of a menu level: a command or a submenu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Row {
|
||||
/// The row's widget id (a submenu's popup id derives from it).
|
||||
pub widget: Id,
|
||||
pub enabled: bool,
|
||||
/// The command it runs; `None` for a submenu.
|
||||
pub command: Option<String>,
|
||||
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
/// Keyboard / highlight state of the open menu.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Nav {
|
||||
/// The open top-level menu (index into the menu bar).
|
||||
top: Option<usize>,
|
||||
/// Popup ids of the top-level menus, from the last frame.
|
||||
tops: Vec<Id>,
|
||||
/// The level the arrow keys move in (0 = the top-level menu).
|
||||
pub(crate) level: usize,
|
||||
/// Highlighted row per level.
|
||||
hi: Vec<Option<usize>>,
|
||||
/// This frame's rows per level (deeper levels are dropped when their parent redraws).
|
||||
pub(crate) rows: Vec<Vec<Row>>,
|
||||
/// Menu-root id per level (the owner of egui's `MenuState` for that level).
|
||||
roots: Vec<Id>,
|
||||
/// Bottom of the menu bar: where top-level menus hang from.
|
||||
pub bar_bottom: Option<f32>,
|
||||
/// The visible (scrolled) part of each level.
|
||||
pub(crate) views: Vec<Rect>,
|
||||
/// Scroll the highlighted row of `level` into view when it is next drawn.
|
||||
reveal: bool,
|
||||
/// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened
|
||||
/// submenu).
|
||||
first_at: Option<usize>,
|
||||
}
|
||||
|
||||
fn nav_id() -> Id {
|
||||
Id::new("pc-menu-nav")
|
||||
}
|
||||
|
||||
fn open_id() -> Id {
|
||||
Id::new("pc-menu-open")
|
||||
}
|
||||
|
||||
/// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.)
|
||||
pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
// Only while the menu bar is still drawn (Full Screen Mode hides it with a menu open).
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data(|d| d.get_temp::<u64>(open_id())).is_some_and(|seen| seen + 2 >= pass)
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
/// Last frame's state, reset when another top-level menu (or none) is open now.
|
||||
pub fn load(ctx: &egui::Context) -> Nav {
|
||||
let mut nav: Nav = ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default();
|
||||
let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id));
|
||||
if top != nav.top {
|
||||
nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() };
|
||||
}
|
||||
nav
|
||||
}
|
||||
|
||||
/// The state as the last frame left it (tests, inspection).
|
||||
pub fn current(ctx: &egui::Context) -> Nav {
|
||||
ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The highlighted row of `level`, if any.
|
||||
pub fn highlighted(&self, level: usize) -> Option<usize> {
|
||||
self.hi.get(level).copied().flatten()
|
||||
}
|
||||
|
||||
fn set_hi(&mut self, level: usize, row: Option<usize>) {
|
||||
if self.hi.len() <= level {
|
||||
self.hi.resize(level + 1, None);
|
||||
}
|
||||
self.hi[level] = row;
|
||||
}
|
||||
|
||||
/// Start drawing `level`: forget its rows from before, and every deeper level's.
|
||||
fn begin_level(&mut self, level: usize, root: Id) {
|
||||
self.rows.truncate(level);
|
||||
self.rows.resize(level + 1, Vec::new());
|
||||
self.roots.truncate(level);
|
||||
self.roots.resize(level + 1, root);
|
||||
self.views.truncate(level);
|
||||
self.views.resize(level + 1, Rect::NOTHING);
|
||||
}
|
||||
|
||||
/// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the
|
||||
/// pointer) is on it, and register it for the keys. `add` returns the row's response.
|
||||
pub fn row<R>(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R {
|
||||
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
|
||||
let lit = enabled && self.highlighted(level) == Some(i);
|
||||
let saved = ui.visuals().widgets.inactive;
|
||||
if lit {
|
||||
ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered;
|
||||
}
|
||||
let (r, out) = add(ui, self);
|
||||
ui.visuals_mut().widgets.inactive = saved;
|
||||
if let Some(rows) = self.rows.get_mut(level) {
|
||||
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect });
|
||||
self.after_row(level, i, lit, &r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls
|
||||
/// the highlighted row into view.
|
||||
fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) {
|
||||
let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO);
|
||||
if moved && r.hovered() && !lit {
|
||||
self.set_hi(level, Some(i));
|
||||
self.level = level;
|
||||
r.ctx.request_repaint();
|
||||
}
|
||||
if lit && self.reveal && level == self.level {
|
||||
r.scroll_to_me(None);
|
||||
self.reveal = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled(&self, level: usize) -> Vec<usize> {
|
||||
self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// ↑ / ↓: the next enabled row (wrapping), or the first / last one.
|
||||
fn step(&mut self, level: usize, down: bool) {
|
||||
let on = self.enabled(level);
|
||||
let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return };
|
||||
let next = match self.highlighted(level) {
|
||||
None => {
|
||||
if down {
|
||||
first
|
||||
} else {
|
||||
last
|
||||
}
|
||||
}
|
||||
Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first),
|
||||
Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last),
|
||||
};
|
||||
self.set_hi(level, Some(next));
|
||||
self.hi.truncate(level + 1);
|
||||
self.level = level;
|
||||
self.reveal = true;
|
||||
}
|
||||
|
||||
/// Open the highlighted submenu of `level` and move the keyboard into it.
|
||||
fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) {
|
||||
let Some(&root) = self.roots.get(level) else { return };
|
||||
let sub = SubMenu::id_from_widget_id(widget);
|
||||
// egui drops an open item it has never drawn: mark it shown so it survives to the next frame.
|
||||
MenuState::mark_shown(ctx, sub);
|
||||
MenuState::from_id(ctx, root, |s| s.open_item = Some(sub));
|
||||
self.level = level + 1;
|
||||
self.hi.truncate(level + 1);
|
||||
self.first_at = Some(level + 1);
|
||||
}
|
||||
|
||||
/// Handle this frame's navigation keys (after the menus were drawn). `tops` are the menu
|
||||
/// bar's popup ids; an activated command lands in `clicked`.
|
||||
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option<String>) {
|
||||
self.tops = tops.to_vec();
|
||||
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
|
||||
let level = self.level.min(self.rows.len().saturating_sub(1));
|
||||
self.level = level;
|
||||
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
|
||||
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
|
||||
let switch = |by: usize| {
|
||||
let n = tops.len();
|
||||
if let Some(&id) = tops.get((top + by) % n) {
|
||||
egui::Popup::open_id(ctx, id);
|
||||
}
|
||||
};
|
||||
if press(Key::Escape) {
|
||||
egui::Popup::close_all(ctx);
|
||||
} else if press(Key::ArrowDown) {
|
||||
self.step(level, true);
|
||||
} else if press(Key::ArrowUp) {
|
||||
self.step(level, false);
|
||||
} else if press(Key::ArrowRight) {
|
||||
match current {
|
||||
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
|
||||
_ => switch(1),
|
||||
}
|
||||
} else if press(Key::ArrowLeft) {
|
||||
if level > 0 {
|
||||
if let Some(&root) = self.roots.get(level - 1) {
|
||||
MenuState::from_id(ctx, root, |s| s.open_item = None);
|
||||
}
|
||||
self.hi.truncate(level);
|
||||
self.level = level - 1;
|
||||
} else {
|
||||
switch(tops.len() - 1);
|
||||
}
|
||||
} else if press(Key::Enter) || press(Key::Space) {
|
||||
match current {
|
||||
Some(Row { enabled: true, command: Some(c), .. }) => {
|
||||
*clicked = Some(c);
|
||||
egui::Popup::close_all(ctx);
|
||||
}
|
||||
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
pub fn store(self, ctx: &egui::Context) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
if self.top.is_some() {
|
||||
d.insert_temp(open_id(), pass);
|
||||
} else {
|
||||
d.remove::<u64>(open_id());
|
||||
}
|
||||
d.insert_temp(nav_id(), self);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
|
||||
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
|
||||
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
|
||||
}
|
||||
|
||||
/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with
|
||||
/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu.
|
||||
pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let level = depth.saturating_sub(1);
|
||||
nav.begin_level(level, find_menu_root(ui).id);
|
||||
let screen = ctx.content_rect();
|
||||
// The menu frame's margin and stroke around the rows.
|
||||
let frame = ui.spacing().menu_margin.sum().y + 2.0;
|
||||
// A top-level menu hangs below the menu bar; a submenu may slide up to fit the window.
|
||||
// (From the menu bar, not from where the popup is now: egui slides a popup that doesn't fit.)
|
||||
let top = nav.bar_bottom.unwrap_or(screen.top()) + EDGE;
|
||||
let room = if depth <= 1 { screen.bottom() - top - EDGE } else { screen.height() - 2.0 * EDGE } - frame;
|
||||
let room = room.max(4.0 * ARROW);
|
||||
let key = ui.id().with(("pc-menu-level", depth));
|
||||
// Rows' height from the last frame: does this level overflow?
|
||||
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
|
||||
let over = content.is_some_and(|h| h > room + 0.5);
|
||||
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
// Wheel, scroll bar and dragging the rows all scroll (#160).
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(if over { room - 2.0 * ARROW } else { room })
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, |ui| rows(ui, nav));
|
||||
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
|
||||
if let Some(v) = nav.views.get_mut(level) {
|
||||
*v = out.inner_rect;
|
||||
}
|
||||
if nav.first_at == Some(level) {
|
||||
nav.first_at = None;
|
||||
nav.step(level, true);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (Some(up), Some(down)) = (up, down) else { return };
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
let x = ui.min_rect().x_range();
|
||||
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
let offset = out.state.offset.y;
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut target = offset;
|
||||
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
|
||||
let strip = Rect::from_x_y_ranges(x, strip.y_range());
|
||||
let c = strip.center();
|
||||
let (h, w) = (4.5, 6.0);
|
||||
let tri = if dir < 0.0 {
|
||||
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
|
||||
} else {
|
||||
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_faint.gamma_multiply(0.5) }, egui::Stroke::NONE));
|
||||
let id = key.with(dir < 0.0);
|
||||
let r = ui.interact(strip, id, Sense::hover());
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }));
|
||||
if live && pointer_on(ui, strip) {
|
||||
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The wheel scrolls over the arrows too.
|
||||
if pointer_on(ui, strip) {
|
||||
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
if wheel != 0.0 {
|
||||
target = (target - wheel).clamp(0.0, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
if target != offset {
|
||||
let mut st = out.state;
|
||||
st.offset.y = target;
|
||||
st.store(&ctx, out.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "menu_nav_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Menus on small displays (#138): every row of the longest menus is reachable by keyboard, wheel
|
||||
//! and the scroll arrows, and the keyboard drives submenus and runs commands.
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
/// Window size in pixels and UI scale: the small displays of #138.
|
||||
const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.0), (1280.0, 720.0, 1.5), (1024.0, 600.0, 1.5)];
|
||||
/// The tallest menus.
|
||||
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.sync_views();
|
||||
app
|
||||
}
|
||||
|
||||
fn harness((w, h, scale): (f32, f32, f32)) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(w / scale, h / scale))
|
||||
.with_pixels_per_point(scale)
|
||||
.build_ui_state(|ui, app| crate::menus::menu_bar(app, ui), app());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none());
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'static, PhotocraftApp>, top: &str) {
|
||||
h.get_by_label(top).click();
|
||||
h.run_steps(4);
|
||||
assert!(Nav::current(&h.ctx).rows.first().is_some_and(|r| !r.is_empty()), "{top} did not open");
|
||||
}
|
||||
|
||||
/// Is row `i` of `level` entirely inside its level's visible (scrolled) area, on screen?
|
||||
fn visible(h: &Harness<'static, PhotocraftApp>, level: usize, i: usize) -> bool {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
let (Some(row), Some(view)) = (nav.rows.get(level).and_then(|r| r.get(i)), nav.views.get(level)) else { return false };
|
||||
view.expand(0.5).contains_rect(row.rect) && h.ctx.content_rect().expand(0.5).contains_rect(*view)
|
||||
}
|
||||
|
||||
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<Row> {
|
||||
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
|
||||
h.key_press(k);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_of_the_longest_menus_is_reachable_with_the_arrow_keys() {
|
||||
for display in DISPLAYS {
|
||||
for top in LONGEST {
|
||||
let mut h = harness(display);
|
||||
open(&mut h, top);
|
||||
let all = rows(&h, 0);
|
||||
let enabled: Vec<usize> = all.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect();
|
||||
assert!(enabled.len() > 5, "{top}: {} enabled rows", enabled.len());
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..enabled.len() {
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
let i = Nav::current(&h.ctx).highlighted(0).expect("a highlighted row");
|
||||
assert!(
|
||||
visible(&h, 0, i),
|
||||
"{display:?} {top}: highlighted row {i} ({:?}) is scrolled out of view",
|
||||
rows(&h, 0).get(i).map(|r| r.command.clone())
|
||||
);
|
||||
seen.push(i);
|
||||
}
|
||||
seen.sort_unstable();
|
||||
assert_eq!(seen, enabled, "{display:?} {top}: ↓ visits every enabled row once");
|
||||
// ↓ wraps to the first row (scrolling back up); ↑ wraps to the last.
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
assert_eq!(Nav::current(&h.ctx).highlighted(0), enabled.first().copied());
|
||||
assert!(visible(&h, 0, enabled[0]));
|
||||
key(&mut h, egui::Key::ArrowUp);
|
||||
let last = *enabled.last().unwrap();
|
||||
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(last));
|
||||
assert!(visible(&h, 0, last), "{display:?} {top}: the last row is reachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_displays_overflow_and_the_menu_stays_in_the_window() {
|
||||
// 1024 × 600 at 150 %: 683 × 400 points, far shorter than the Filter menu.
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
let all = rows(&h, 0);
|
||||
let last = all.len() - 1;
|
||||
assert!(!visible(&h, 0, last), "the Filter menu must overflow at 683 × 400 pt for this test to mean anything");
|
||||
let nav = Nav::current(&h.ctx);
|
||||
let screen = h.ctx.content_rect();
|
||||
assert!(nav.views[0].bottom() <= screen.bottom() && nav.views[0].top() >= screen.top());
|
||||
let bar = h.get_by_label("Filter").rect();
|
||||
assert!(nav.views[0].top() >= bar.bottom(), "the menu hangs below the menu bar instead of sliding over it: {:?} vs {bar:?}", nav.views[0]);
|
||||
// Scroll arrows show when the rows overflow.
|
||||
assert!(h.query_by_label("Scroll menu down").is_some() && h.query_by_label("Scroll menu up").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mouse_wheel_scrolls_a_long_menu() {
|
||||
for display in DISPLAYS {
|
||||
let mut h = harness(display);
|
||||
open(&mut h, "Filter");
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(view.center());
|
||||
h.run_steps(2);
|
||||
for _ in 0..60 {
|
||||
if visible(&h, 0, last) {
|
||||
break;
|
||||
}
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(visible(&h, 0, last), "{display:?}: the wheel reaches the last Filter row");
|
||||
assert!(rows(&h, 0).len() == last + 1, "the menu stays open while scrolling");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resting_on_the_scroll_arrows_scrolls() {
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
assert!(visible(&h, 0, 0) && !visible(&h, 0, last));
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(egui::pos2(view.center().x, view.bottom() + ARROW / 2.0));
|
||||
for _ in 0..240 {
|
||||
h.run_steps(1);
|
||||
if visible(&h, 0, last) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(visible(&h, 0, last), "resting on ▼ scrolls to the end");
|
||||
assert!(!visible(&h, 0, 0));
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(egui::pos2(view.center().x, view.top() - ARROW / 2.0));
|
||||
for _ in 0..240 {
|
||||
h.run_steps(1);
|
||||
if visible(&h, 0, 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(visible(&h, 0, 0), "resting on ▲ scrolls back to the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_open_and_close_submenus_and_enter_runs_a_command() {
|
||||
let mut h = harness((1024.0, 600.0, 1.5));
|
||||
open(&mut h, "Filter");
|
||||
// Walk down to the first submenu (Blur, Distort, …) and open it with →.
|
||||
for _ in 0..rows(&h, 0).len() {
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.is_none()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
key(&mut h, egui::Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
assert_eq!(nav.level, 1, "→ moves into the submenu");
|
||||
assert!(nav.rows.get(1).is_some_and(|r| !r.is_empty()), "the submenu is open");
|
||||
let first = nav.rows[1].iter().position(|r| r.enabled);
|
||||
assert_eq!(nav.highlighted(1), first, "its first enabled row is highlighted");
|
||||
assert!(visible(&h, 1, first.unwrap()));
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
assert_eq!(Nav::current(&h.ctx).level, 1);
|
||||
// ← closes it again.
|
||||
key(&mut h, egui::Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
let nav = Nav::current(&h.ctx);
|
||||
assert_eq!((nav.level, nav.rows.len()), (0, 1), "← closes the submenu");
|
||||
// ↩ runs the highlighted command and closes the menu: Select › All.
|
||||
key(&mut h, egui::Key::Escape);
|
||||
open(&mut h, "Select");
|
||||
for _ in 0..rows(&h, 0).len() {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.as_deref() == Some("select.all")) {
|
||||
break;
|
||||
}
|
||||
key(&mut h, egui::Key::ArrowDown);
|
||||
}
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_none());
|
||||
key(&mut h, egui::Key::Enter);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "↩ ran Select › All");
|
||||
assert!(!is_open(&h.ctx), "and closed the menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() {
|
||||
let mut h = harness((1280.0, 720.0, 1.0));
|
||||
assert!(!is_open(&h.ctx));
|
||||
open(&mut h, "File");
|
||||
assert!(is_open(&h.ctx), "shortcuts leave the keys to an open menu");
|
||||
key(&mut h, egui::Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_none(), "→ leaves File");
|
||||
assert!(h.query_by_label_contains("Undo").is_some(), "→ opens Edit");
|
||||
key(&mut h, egui::Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_some(), "← back to File");
|
||||
key(&mut h, egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("Open…").is_none(), "Esc closes the menu");
|
||||
assert!(!is_open(&h.ctx), "shortcuts work again");
|
||||
}
|
||||
+62
-11
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
Executable
+72
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user