Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 7278b7dd22 Dock: fixed-height panel groups with splitters, collapse, reorder and a remembered layout
Panel group heights no longer follow their content (#88): switching between a
pixel and an adjustment layer used to grow Properties and squeeze Layers.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:36:39 -04:00
53 changed files with 616 additions and 5251 deletions

No files matched your search

+1 -10
View File
@@ -50,13 +50,4 @@ 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 - <<'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
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'"
+1 -39
View File
@@ -201,44 +201,6 @@ 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
@@ -274,7 +236,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, web]
needs: [version, macos, windows, linux, web]
runs-on: ubuntu-latest
environment: release
permissions:
+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **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/`.
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/`.
## 1. Orientation (5 minutes)
+1 -8
View File
@@ -257,14 +257,7 @@ 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). 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.
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.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
-182
View File
@@ -1,182 +0,0 @@
//! 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(())
}
+2 -34
View File
@@ -84,11 +84,6 @@ 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) {
@@ -105,36 +100,9 @@ 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> {
// 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()))?;
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
merge(&mut v, patch);
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)
serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
}
fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPreset> {
-39
View File
@@ -176,45 +176,6 @@ fn set_brush_merges_fields() {
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
}
#[test]
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
let base = BrushSettings {
tip: TipShape::Sampled(tile(900, 700, 3)),
dual_brush: photocraft_paint::DualBrush { tip: TipShape::Sampled(tile(30, 20, 2)), ..Default::default() },
texture: photocraft_paint::Texture { pattern: photocraft_paint::Pattern::Tile(tile(64, 64, 1)), ..Default::default() },
..Default::default()
};
let cmd = "tools.setBrush";
// Untouched bitmaps survive any patch that doesn't name them.
for patch in [json!({"size": 12}), json!({"dualBrush": {"size": 9}}), json!({"texture": {"depth": 0.5}}), json!({})] {
let b = merge_brush(&base, &patch, cmd).unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip, &b.texture.pattern), (&base.tip, &base.dual_brush.tip, &base.texture.pattern), "{patch}");
}
let b = merge_brush(&base, &json!({"size": 12, "dualBrush": {"size": 9}}), cmd).unwrap();
assert_eq!((b.size, b.dual_brush.size), (12.0, 9.0));
// Named ones are replaced.
let b = merge_brush(
&base,
&json!({"tip": "round", "dualBrush": {"tip": "round"}, "texture": {"pattern": {"procedural": {"style": "dots", "size": 32, "seed": 2}}}}),
cmd,
)
.unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip), (&TipShape::Round, &TipShape::Round));
assert!(matches!(b.texture.pattern, photocraft_paint::Pattern::Procedural { size: 32, .. }));
// A section replaced by a non-object is an error, not a silently kept bitmap.
assert!(merge_brush(&base, &json!({"dualBrush": 3}), cmd).is_err());
assert!(merge_brush(&base, &json!(null), cmd).is_err());
// Speed: a panel edit on a big imported tip doesn't base64 the tip.
let big = BrushSettings { tip: TipShape::Sampled(tile(2500, 2500, 7)), ..Default::default() };
let t0 = std::time::Instant::now();
for i in 0..10 {
merge_brush(&big, &json!({"size": 10 + i}), cmd).unwrap();
}
let ms = t0.elapsed().as_secs_f64() * 100.0;
eprintln!("merge_brush on a 2500 px tip: {ms:.2} ms per edit");
}
#[test]
fn define_brush_from_selection() {
let mut s = session(40, 40);
-2
View File
@@ -988,8 +988,6 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::wia_cmds::specs());
v.extend(crate::variables_cmds::specs());
v.extend(crate::plugin_cmds::specs());
v.extend(crate::group_view_cmds::specs());
v.extend(crate::fx_view_cmds::specs());
v
}
-134
View File
@@ -1,134 +0,0 @@
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
//! clean document stays clean.
use photocraft_doc::LayerId;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const ID: &str = "layer.setEffectsExpanded";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
fn has_effects(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
}
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(format!("`{key}` must be true or false"))),
}
}
/// `{"layer":id?,"expanded":bool?,"all":bool?}`: show or hide a layer's effects list (no
/// `expanded`: toggle). With `all`, every layer with effects takes the new state (⌥-click).
fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
let want = opt_bool(p, "expanded")?;
let all = opt_bool(p, "all")?.unwrap_or(false);
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let layer = match p.get("layer") {
None | Some(Value::Null) if all => None,
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
let current = match layer {
Some(id) => {
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if l.effects.items.is_empty() {
return Err(bad(format!("layer {} has no effects", id.0)));
}
!st.fx_collapsed.contains(&id)
}
None => with_fx.iter().any(|id| !st.fx_collapsed.contains(id)),
};
let expanded = want.unwrap_or(!current);
let targets = if all { with_fx } else { layer.into_iter().collect() };
// Forget ids no longer in the document so the list can't grow without bound.
let doc = st.doc.clone();
st.fx_collapsed.retain(|id| doc.layer(*id).is_some() && !targets.contains(id));
if !expanded {
st.fx_collapsed.extend(targets.iter().copied());
}
Ok(json!({"expanded": expanded, "layers": targets.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Effects",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
enabled: has_effects,
run: set_expanded,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
s.execute("layer.layerStyle.stroke", json!({})).unwrap();
(s, a, b)
}
fn open(s: &Session, id: u64) -> bool {
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
}
#[test]
fn toggles_one_layer_or_all_without_history() {
let (mut s, a, b) = session();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
let steps = s.active().unwrap().history.entries().len();
assert!(open(&s, a) && open(&s, b), "effects lists start open");
assert_eq!(s.execute(ID, json!({"layer": a})).unwrap()["expanded"], false);
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().history.entries().len(), steps, "no history step");
assert_eq!(s.active().unwrap().saved_revision, s.active().unwrap().revision, "still clean");
s.execute(ID, json!({"layer": a, "all": true})).unwrap();
assert!(open(&s, a) && open(&s, b), "toggling an open-and-closed set from a closed layer opens all");
s.execute(ID, json!({"all": true, "expanded": false})).unwrap();
assert!(!open(&s, a) && !open(&s, b));
s.execute(ID, json!({"layer": b, "expanded": true})).unwrap();
assert!(!open(&s, a) && open(&s, b));
assert_eq!(s.active().unwrap().fx_collapsed.len(), 1, "no duplicates");
}
#[test]
fn bad_params_fail_gracefully() {
let (mut s, _, _) = session();
let bg = s.active().unwrap().doc.layers[0].id.0;
for p in [
json!({"layer": bg}),
json!({"layer": 9999}),
json!({"layer": "x"}),
json!({"layer": -1}),
json!({"expanded": "yes", "all": true}),
json!({"all": 3}),
] {
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute(ID, json!({})).is_err());
empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(empty.execute(ID, json!({"all": true})).is_err(), "no effects: disabled");
}
}
-158
View File
@@ -1,158 +0,0 @@
//! 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");
}
}
+3 -10
View File
@@ -314,12 +314,10 @@ 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);
@@ -552,11 +550,6 @@ mod tests {
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::U8));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s).depth, SampleType::F32);
let revision = s.active().unwrap().revision;
s.execute("image.mode.bits32", json!({})).unwrap();
assert_eq!(s.active().unwrap().revision, revision, "an unchanged bit depth must preserve the history branch");
assert!(s.redo(), "selecting the current depth must not discard the undone conversion");
assert_eq!(doc(&s).depth, SampleType::U8);
}
#[test]
-1
View File
@@ -85,7 +85,6 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
}
-6
View File
@@ -30,9 +30,7 @@ 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;
@@ -145,9 +143,6 @@ 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 {
@@ -166,7 +161,6 @@ 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.
+3 -30
View File
@@ -358,24 +358,16 @@ 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)?;
let (r, damage) = s.edit(label, |doc, _| {
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);
// 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)
Ok(r)
})
}
fn layer_name(text: &str) -> String {
@@ -764,25 +756,6 @@ 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();
+5 -12
View File
@@ -91,20 +91,13 @@ impl Default for LoadOptions {
}
}
/// `true` when ZIP bytes contain a `manifest.json` entry.
///
/// Bundles written here place the manifest first; re-zipped bundles may reorder entries.
/// `true` if `bytes` look like a `.pcraft` ZIP bundle (a ZIP whose first
/// entry is `manifest.json`, which is how we write them).
pub fn is_pcraft(bytes: &[u8]) -> bool {
if !bytes.starts_with(b"PK\x03\x04") {
return false;
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.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
+11 -29
View File
@@ -126,10 +126,10 @@ pub struct ZipReader<'a> {
}
fn u16_at(b: &[u8], o: usize) -> Result<u16, FormatError> {
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"))
b.get(o..o + 2).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> {
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"))
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"))
}
impl<'a> ZipReader<'a> {
@@ -146,11 +146,12 @@ 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;
let cd_end = cd_off.checked_add(cd_size).filter(|end| *end <= eocd).ok_or_else(|| FormatError::corrupt("zip central directory out of range"))?;
if cd_off.checked_add(cd_size).is_none_or(|e| e > eocd) {
return Err(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"));
}
@@ -163,36 +164,23 @@ 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_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 = bytes.get(p + 46..p + 46 + nlen).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 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()) {
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()) {
return Err(FormatError::corrupt(format!("zip entry `{name}` out of range")));
}
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
p = entry_end;
p += 46 + nlen + elen + clen;
}
Ok(ZipReader { bytes, entries })
}
@@ -270,10 +258,4 @@ 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());
}
}
+2 -15
View File
@@ -84,10 +84,10 @@ impl Rect {
Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1))
}
pub fn translate(&self, dx: i32, dy: i32) -> Rect {
Rect::new(self.x0.saturating_add(dx), self.y0.saturating_add(dy), self.x1.saturating_add(dx), self.y1.saturating_add(dy))
Rect::new(self.x0 + dx, self.y0 + dy, self.x1 + dx, self.y1 + dy)
}
pub fn inflate(&self, d: i32) -> Rect {
Rect::new(self.x0.saturating_sub(d), self.y0.saturating_sub(d), self.x1.saturating_add(d), self.y1.saturating_add(d))
Rect::new(self.x0 - d, self.y0 - d, self.x1 + d, self.y1 + d)
}
/// All tiles overlapping this rect.
pub fn tiles(&self) -> impl Iterator<Item = TileCoord> + use<> {
@@ -242,17 +242,4 @@ 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);
}
}
+1 -6
View File
@@ -108,8 +108,7 @@ 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> {
// 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) {
if photocraft_format::is_pcraft(bytes) {
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
}
if is_psd(bytes) {
@@ -128,10 +127,6 @@ fn extension(name_or_ext: &str) -> String {
name_or_ext.rsplit(['.', '/', '\\']).next().unwrap_or(name_or_ext).to_ascii_lowercase()
}
fn has_extension(name: &str, expected: &str) -> bool {
name.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).is_some_and(|(base, ext)| !base.is_empty() && ext.eq_ignore_ascii_case(expected))
}
/// Exports `doc` to the format named by `name_or_ext` (a file name, path or
/// bare extension).
pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result<ExportResult, IoError> {
-18
View File
@@ -216,22 +216,4 @@ fn synthetic_pcraft_roundtrip_keeps_everything() {
let c = &fresh.layer_comps[0];
assert_eq!(c.states.len(), fresh.layer_count());
assert!(c.states.iter().all(|s| fresh.layer(s.layer).is_some()));
// Native identity is independent of ZIP entry order and the filename extension.
let zip = photocraft_format::zip::ZipReader::new(&bytes).unwrap();
let mut reordered = photocraft_format::zip::ZipWriter::new();
reordered.add("extra.txt", b"not the manifest").unwrap();
for entry in &zip.entries {
reordered.add(&entry.name, &zip.read(entry, bytes.len()).unwrap()).unwrap();
}
let reordered = reordered.finish().unwrap();
for name in ["x.PCRAFT", "no-extension"] {
let imported = import(name, &reordered).unwrap().document;
assert_eq!(imported.layer_comps, d.layer_comps);
assert_eq!(imported.layers[1].artboard(), d.layers[1].artboard());
}
let png = export(&d, "x.png", &ExportOptions::default()).unwrap().bytes;
assert!(import("pcraft", &png).is_ok());
assert!(matches!(import("broken.pcraft", b"not a native bundle"), Err(photocraft_io::IoError::Pcraft(_))));
}
-302
View File
@@ -1,302 +0,0 @@
//! Imported type-layer geometry (#148): PSD type layers built the way Photoshop writes them (a
//! `TySh` with the text-space → document transform, `EngineData` with `StyleRun`/`ParagraphRun`
//! sizes in text-space units, `BoxBounds` for paragraph text, `bounds`/`boundingBox` and the
//! trailing integer bounds) must import with the right model, so that
//! - the layer's pixels (Photoshop's cache) keep their full extent: nothing is clipped,
//! - re-rendering the imported model with our engine lands on the same pixels (no transform
//! scale applied twice or not at all, no 72-vs-document-dpi mix-up, no box height used as a
//! clip for point text), and
//! - the document composite matches the file's merged composite.
//!
//! Fonts: the bundled Inter (deterministic), so our render is the "Photoshop" render here.
mod common;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::TextShape;
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
use photocraft_geom::{Affine, Rect};
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D};
use photocraft_raster::Surface;
use photocraft_text::engine_data::Value as E;
use photocraft_text::psd::{TySh, write_tysh};
/// One character run: (UTF-16 length, font size in text-space units, explicit leading).
struct Run {
len: usize,
size: f64,
leading: Option<f64>,
}
fn real(v: f64) -> E {
E::Real(v)
}
fn style(size: f64, leading: Option<f64>) -> E {
E::Dict(vec![
("Font".into(), E::Int(0)),
("FontSize".into(), real(size)),
("AutoLeading".into(), E::Bool(leading.is_none())),
("Leading".into(), real(leading.unwrap_or(0.0))),
("FillColor".into(), E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![real(1.0), real(0.0), real(0.0), real(0.0)]))])),
])
}
/// Photoshop-shaped EngineData for `text` (paragraphs separated by `\r`).
fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>) -> E {
let ed_text = format!("{text}\r");
let n16 = ed_text.encode_utf16().count() as i64;
let mut sruns = Vec::new();
let mut slens = Vec::new();
for (i, r) in runs.iter().enumerate() {
sruns.push(E::Dict(vec![("StyleSheet".into(), E::Dict(vec![("StyleSheetData".into(), style(r.size, r.leading))]))]));
slens.push(E::Int(r.len as i64 + i64::from(i + 1 == runs.len())));
}
let para = E::Dict(vec![("Justification".into(), E::Int(0)), ("AutoLeading".into(), real(1.2))]);
let mut photoshop = vec![("ShapeType".into(), E::Int(i64::from(box_bounds.is_some())))];
match box_bounds {
Some(b) => photoshop.push(("BoxBounds".into(), E::Array(b.iter().map(|v| real(*v)).collect()))),
None => photoshop.push(("PointBase".into(), E::Array(vec![real(0.0), real(0.0)]))),
}
let shape = E::Dict(vec![
("ShapeType".into(), E::Int(i64::from(box_bounds.is_some()))),
("Cookie".into(), E::Dict(vec![("Photoshop".into(), E::Dict(photoshop))])),
]);
let resources = E::Dict(vec![
("FontSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Inter-Regular".into())), ("Script".into(), E::Int(0))])])),
("StyleSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("StyleSheetData".into(), style(12.0, None))])])),
("ParagraphSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("Properties".into(), para.clone())])])),
]);
E::Dict(vec![
(
"EngineDict".into(),
E::Dict(vec![
("Editor".into(), E::Dict(vec![("Text".into(), E::String(ed_text))])),
(
"ParagraphRun".into(),
E::Dict(vec![
("RunArray".into(), E::Array(vec![E::Dict(vec![("ParagraphSheet".into(), E::Dict(vec![("Properties".into(), para)]))])])),
("RunLengthArray".into(), E::Array(vec![E::Int(n16)])),
]),
),
("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(sruns)), ("RunLengthArray".into(), E::Array(slens))])),
("Rendered".into(), E::Dict(vec![("Shapes".into(), E::Dict(vec![("Children".into(), E::Array(vec![shape]))]))])),
]),
),
("ResourceDict".into(), resources),
])
}
/// A `TySh` block as Photoshop writes it.
fn tysh(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transform: Affine, ink: [f64; 4]) -> Vec<u8> {
let rect = |cls: &str| {
D::Descriptor(
Descriptor::new(cls)
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
)
};
let desc = Descriptor::new("TxLr")
.with("Txt ", D::Text(UnicodeString::new_nul(text)))
.with("textGridding", D::Enumerated { type_id: Id::new("textGridding"), value: Id::new("None") })
.with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") })
.with("AntA", D::Enumerated { type_id: Id::new("Annt"), value: Id::new("AnSm") })
.with("bounds", rect("bounds"))
.with("boundingBox", rect("boundingBox"))
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds))));
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
}
struct Case {
name: &'static str,
dpi: f32,
text: &'static str,
runs: Vec<Run>,
box_bounds: Option<[f64; 4]>,
transform: Affine,
/// Expected Character-panel size of the first run, in points.
size_pt: f32,
}
fn cases() -> Vec<Case> {
let r = |len: usize, size: f64| Run { len, size, leading: None };
let t = |x: f64, y: f64| Affine::translate(x, y);
// Photoshop keeps the font size in text space and the scale in the transform.
let scaled = |s: f64, x: f64, y: f64| Affine { m: [s, 0.0, 0.0, s, x, y] };
let rotated = |deg: f64, s: f64, x: f64, y: f64| {
let (sin, cos) = deg.to_radians().sin_cos();
Affine { m: [s * cos, s * sin, -s * sin, s * cos, x, y] }
};
vec![
Case {
name: "point 36pt @72",
dpi: 72.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 36.0)],
box_bounds: None,
transform: t(40.0, 80.0),
size_pt: 36.0,
},
Case {
name: "point 36pt @300",
dpi: 300.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 150.0)],
box_bounds: None,
transform: t(40.0, 200.0),
size_pt: 36.0,
},
Case {
name: "point 12×3 scaled",
dpi: 72.0,
text: "Headline 105: Autumn collection",
runs: vec![r(31, 12.0)],
box_bounds: None,
transform: scaled(3.0, 40.0, 80.0),
size_pt: 12.0,
},
Case {
name: "point rotated",
dpi: 72.0,
text: "Autumn collection",
runs: vec![r(17, 18.0)],
box_bounds: None,
transform: rotated(-20.0, 2.0, 60.0, 300.0),
size_pt: 18.0,
},
Case {
name: "point multi-line",
dpi: 72.0,
text: "Headline\rAutumn collection\rgy",
runs: vec![Run { len: 29, size: 36.0, leading: Some(50.0) }],
box_bounds: None,
transform: t(40.0, 60.0),
size_pt: 36.0,
},
Case {
name: "point mixed sizes",
dpi: 72.0,
text: "Big small Big",
runs: vec![r(4, 60.0), r(6, 14.0), r(3, 60.0)],
box_bounds: None,
transform: t(30.0, 120.0),
size_pt: 60.0,
},
Case {
name: "paragraph box",
dpi: 72.0,
text: "Paragraph text that wraps over several lines in its box",
runs: vec![r(55, 24.0)],
box_bounds: Some([0.0, 0.0, 260.0, 200.0]),
transform: t(20.0, 20.0),
size_pt: 24.0,
},
Case {
name: "paragraph box scaled",
dpi: 144.0,
text: "Paragraph text that wraps over several lines in its box",
runs: vec![r(55, 20.0)],
box_bounds: Some([0.0, 0.0, 200.0, 150.0]),
transform: scaled(1.5, 20.0, 20.0),
size_pt: 10.0,
},
]
}
fn ink_alpha(s: &Surface, r: Rect) -> Vec<f32> {
let n = s.channels();
s.read_region(r).chunks_exact(n).map(|p| p[n - 1]).collect()
}
fn iou(a: &Surface, b: &Surface) -> f32 {
let u = a.content_bounds().union(&b.content_bounds());
let (x, y) = (ink_alpha(a, u), ink_alpha(b, u));
let inter: f32 = x.iter().zip(&y).map(|(p, q)| p.min(*q)).sum();
let union: f32 = x.iter().zip(&y).map(|(p, q)| p.max(*q)).sum();
if union > 0.0 { inter / union } else { 1.0 }
}
fn close(a: Rect, b: Rect, tol: i32) -> bool {
(a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol
}
/// Builds a one-layer document whose type layer carries `data` as its `TySh` and "Photoshop's"
/// pixels (our render of the parsed model), exports it to PSD and imports it back.
fn import_case(c: &Case) -> (Rect, TextLayer, Document, Vec<[f32; 4]>) {
let (w, h) = (900, 700);
let mut doc = Document::new("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = c.dpi;
// Photoshop's text `bounds` are the logical bounds in text space; measure them like it does.
let probe = photocraft_text::psd::text_layer_from_tysh(&tysh(c.text, &c.runs, c.box_bounds, c.transform, [0.0; 4]), c.dpi).unwrap();
let mut engine = photocraft_text::TextEngine::new();
let ink = engine.layout(&probe, c.dpi).bounds().unwrap_or([0.0; 4]).map(f64::from);
let data = tysh(c.text, &c.runs, c.box_bounds, c.transform, ink);
let mut t = photocraft_text::psd::text_layer_from_tysh(&data, c.dpi).unwrap();
engine.render_layer(&mut t, c.dpi, doc.pixel_format());
let drawn = t.cache.as_ref().unwrap().content_bounds();
t.psd_raw = Some(std::sync::Arc::new(data));
doc.layers.push(Layer::new(c.name, LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
let merged = photocraft_io::merged_composite(&file).unwrap();
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("{}: not a type layer", c.name) };
(drawn, bt.clone(), back, merged)
}
#[test]
fn imported_type_layers_keep_their_full_bounds() {
for c in cases() {
let (drawn, t, doc, merged) = import_case(&c);
assert!(!drawn.is_empty(), "{}: nothing drawn", c.name);
// Model: size in points at the document resolution, transform and shape as written.
assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt);
assert_eq!(t.transform, c.transform, "{}", c.name);
match (c.box_bounds, t.shape) {
(None, TextShape::Point) => {}
(Some(b), TextShape::Box { x, y, width, height }) => {
assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name)
}
(want, got) => panic!("{}: shape {got:?}, want box {want:?}", c.name),
}
// The imported pixels (what the canvas shows, and what Properties measures) are the
// whole of Photoshop's rendering.
let cache = t.cache.as_ref().unwrap();
assert_eq!(cache.content_bounds(), drawn, "{}: imported pixels clipped", c.name);
let layer_bounds = photocraft_compose::layer_bounds(&doc.layers[0], doc.bounds());
assert_eq!(layer_bounds, drawn, "{}: layer bounds", c.name);
// Re-rendering the imported model (what the type tool does on click) draws the same
// text in the same place: nothing clipped, no scale or dpi drift.
let mut engine = photocraft_text::TextEngine::new();
let (_, ours) = engine.render(&t, doc.resolution_dpi, doc.pixel_format());
let ours_rect = ours.surface.content_bounds();
assert!(close(ours_rect, drawn, 1), "{}: re-render {ours_rect:?} vs file {drawn:?}", c.name);
let o = iou(&ours.surface, cache);
assert!(o > 0.97, "{}: re-render overlap {o}", c.name);
// And the document composite is the file's merged composite.
let px = photocraft_compose::flatten(&doc).px;
let worst = common::max_diff(&px, &merged);
assert!(worst <= 2.0 / 255.0, "{}: composite differs from the merged image by {worst}", c.name);
}
}
#[test]
fn point_text_height_follows_the_type_size() {
// 36 pt at 72 dpi: caps and ascenders of "Headline" are about 0.73 em tall; a line with a
// descender spans about 0.95 em. Properties' H is the pixel height of the layer.
let c = &cases()[0];
let (drawn, ..) = import_case(c);
assert!((24..=30).contains(&drawn.height()), "no-descender line: H {}", drawn.height());
let c = Case { text: "Headline: Autumn typography", runs: vec![Run { len: 27, size: 36.0, leading: None }], ..cases().remove(0) };
let (drawn, ..) = import_case(&c);
assert!((32..=38).contains(&drawn.height()), "line with descenders: H {}", drawn.height());
// Same text at 300 dpi: 36 pt = 150 px.
let c = Case { dpi: 300.0, runs: vec![Run { len: 27, size: 150.0, leading: None }], ..c };
let (drawn300, ..) = import_case(&c);
let ratio = drawn300.height() as f32 / drawn.height() as f32;
assert!((ratio - 300.0 / 72.0).abs() < 0.2, "300 dpi H {} vs 72 dpi H {}", drawn300.height(), drawn.height());
}
-22
View File
@@ -340,10 +340,6 @@ 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"));
}
@@ -354,21 +350,3 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
}
v
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#[test]
#[cfg(target_os = "linux")]
fn scans_flatpak_host_fonts() {
let dirs = super::system_font_dirs();
assert!(dirs.iter().any(|d| d.ends_with("run/host/fonts")));
assert!(dirs.iter().any(|d| d.ends_with("run/host/user-fonts")));
}
#[test]
fn missing_font_dirs_are_skipped() {
let mut files = Vec::new();
super::collect_font_files(std::path::Path::new("/nonexistent/photocraft/fonts"), 0, &mut files);
assert!(files.is_empty());
}
}
-44
View File
@@ -485,47 +485,3 @@ fn engine_data_template_without_engine_dict() {
assert!(matches!(text, Some(E::String(s)) if s == "Hi\r"), "{text:?}");
}
}
/// #123: the caret geometry (clusters, lines) sits on the rendered glyphs, so a click on a glyph
/// lands next to it: its left part before it, its right part after it. Plain text, tracking,
/// horizontal scale, mixed sizes, and centred, wrapped paragraph text.
#[test]
fn clusters_sit_on_rendered_glyphs() {
let mut e = TextEngine::new();
let big = CharStyle { size_pt: 40.0, ..Default::default() };
let small = CharStyle { size_pt: 18.0, ..Default::default() };
let mixed =
TextLayer { text: "HOHOHO".into(), runs: vec![TextRun { len: 3, style: big.clone() }, TextRun { len: 3, style: small.clone() }], ..Default::default() };
let boxed = with_para(
TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 150.0, height: 200.0 }, ..styled("HOH HOH HOH HOH", small.clone()) },
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
);
let cases = [
("plain", styled("HOHOH", big.clone())),
("tracking", styled("HOHOH", CharStyle { tracking: 300.0, ..big.clone() })),
("hscale", styled("HOHOH", CharStyle { horizontal_scale: 1.6, ..big.clone() })),
("mixed", mixed),
("box", boxed),
];
for (name, t) in cases {
let t = TextLayer { transform: Affine::translate(7.0, 60.0), ..t };
let (l, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
assert!(l.lines.len() >= if name == "box" { 2 } else { 1 }, "{name}");
for c in l.clusters.iter().filter(|c| !t.text[c.range.clone()].trim().is_empty()) {
let ln = &l.lines[c.line];
// Ink under the cluster's middle, between the line's ascent and descent.
let col = photocraft_geom::Rect::new(
(c.x + c.advance * 0.3 + 7.0).floor() as i32,
(ln.baseline - ln.ascent + 60.0).floor() as i32,
(c.x + c.advance * 0.55 + 7.0).ceil() as i32,
(ln.baseline + ln.descent + 60.0).ceil() as i32,
);
assert!(alpha_sum(&r.surface, col) > 1.0, "{name}: no ink under cluster {:?} ({col:?})", c.range);
let y = ln.baseline - ln.ascent * 0.4;
assert_eq!(l.hit_test(c.x + c.advance * 0.2, y), c.range.start, "{name}: left part of {:?}", c.range);
assert_eq!(l.hit_test(c.x + c.advance * 0.8, y), c.range.end, "{name}: right part of {:?}", c.range);
let (cx, top, bottom) = l.caret(c.range.start);
assert!((cx - c.x).abs() < 1e-3 && top < y && bottom > y, "{name}: caret at {:?}", c.range);
}
}
}
-90
View File
@@ -1,90 +0,0 @@
//! 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),
);
}
+427 -325
View File
@@ -1,28 +1,18 @@
//! 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.
//! 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;
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, TipShape};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use photocraft_engine::paint::{self, Control, Dynamic, MaskMode, Pattern, PatternStyle, StrokePoint};
use serde_json::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),
@@ -40,21 +30,7 @@ pub const SECTIONS: [(&str, bool); 13] = [
("Protect Texture", true),
];
/// 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).
/// The enable flag behind section `i` (None for Brush Tip Shape).
pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
Some(match i {
1 => &mut b.shape_dynamics.enabled,
@@ -67,68 +43,324 @@ 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,
})
}
/// 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;
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);
}
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)
changed
}
/// 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))
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;
}
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
});
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),
}
}
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
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()
}
/// 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;
/// 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;
}
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.
@@ -147,135 +379,49 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
/// the full comparison clones the preset and its tip.
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) {
fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
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();
}
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;
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;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
for (group, items) in groups {
let items: Vec<usize> = items
.into_iter()
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
.collect();
if items.is_empty() {
continue;
}
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
ui.painter().text(
egui::pos2(r.right() - 8.0, r.center().y),
egui::Align2::RIGHT_CENTER,
items.len().to_string(),
theme::medium(11.0),
t.text_faint,
);
if resp.clicked() {
toggle = Some(group.clone());
}
if !open {
continue;
}
ui.add_space(4.0);
ui.label(RichText::new(&group).font(theme::semibold(12.0)).color(t.text));
ui.add_space(2.0);
for i in items {
let Some(p) = app.session.tools.presets.get(i) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click());
if !ui.is_rect_visible(r) {
continue;
}
if is_current(&p.brush, brush) {
if same(&p.brush, brush) {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
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);
ui.painter().text(
egui::pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(sr.right() + 12.0, r.center().y),
egui::pos2(ir.right() + 10.0, r.center().y),
egui::Align2::LEFT_CENTER,
&p.name,
egui::FontId::proportional(12.0),
@@ -285,137 +431,13 @@ fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
clicked = Some(p.name.clone());
}
}
ui.add_space(2.0);
}
});
if let Some(g) = toggle {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => {
c.remove(i);
}
None => c.push(g),
}
if let Some(name) = clicked
&& let Err(e) = app.session.execute("tools.setBrush", json!({"preset": name}))
{
app.ui.status = e.to_string();
}
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) {
@@ -436,9 +458,9 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
.title_bar(false)
.resizable(false)
.frame(frame)
.default_pos(egui::pos2((canvas.right() - WIDTH - 30.0).max(canvas.left() + 8.0), canvas.top() + 24.0))
.default_pos(egui::pos2((canvas.right() - 500.0).max(canvas.left() + 8.0), canvas.top() + 40.0))
.show(ctx, |ui| {
ui.set_width(WIDTH);
ui.set_width(470.0);
ui.horizontal(|ui| {
let mut tab = app.ui.brush_tab;
for (i, name) in ["Brush Settings", "Brushes"].iter().enumerate() {
@@ -457,9 +479,65 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(6.0);
if app.ui.brush_tab == 1 {
brushes_tab(app, ui);
} else {
settings_tab(app, ui);
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;
}
});
if !open {
@@ -468,5 +546,29 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
#[cfg(test)]
#[path = "brush_panel_tests.rs"]
mod tests;
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);
}
}
-272
View File
@@ -1,272 +0,0 @@
use egui::vec2;
use egui_kittest::Harness;
use photocraft_engine::paint::{Control, GrayTile, MaskMode, Pattern, PatternStyle, TipShape};
use super::*;
fn app() -> PhotocraftApp {
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
}
fn last_journal(app: &PhotocraftApp) -> Option<(String, Value)> {
app.session.journal.last().cloned()
}
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
for (i, (_, has_box)) in SECTIONS.iter().enumerate() {
assert_eq!(section_flag(&mut b, i).is_some(), *has_box, "{i}");
}
*section_flag(&mut b, 3).unwrap() = true;
assert!(b.texture.enabled);
}
/// What each section's controls edit, set to non-default values.
fn edit_section(i: usize, b: &mut BrushSettings) {
let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum };
match i {
0 => {
b.size = 87.0;
b.flip_x = true;
b.flip_y = true;
b.angle = -35.0;
b.roundness = 0.4;
b.hardness = 0.25;
b.spacing = 0.6;
}
1 => {
let sd = &mut b.shape_dynamics;
sd.enabled = true;
sd.size = dynamic(0.3, Control::Fade, 0.2);
sd.angle = dynamic(0.1, Control::Direction, 0.0);
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
sd.flip_x_jitter = true;
sd.flip_y_jitter = true;
}
2 => {
let sc = &mut b.scattering;
sc.enabled = true;
sc.scatter = dynamic(2.5, Control::PenPressure, 0.0);
sc.both_axes = true;
sc.count = 4;
sc.count_jitter = dynamic(0.4, Control::StylusWheel, 0.0);
}
3 => {
let tx = &mut b.texture;
tx.enabled = true;
tx.pattern = Pattern::Procedural { style: PatternStyle::Canvas, size: 200, seed: 1 };
tx.invert = true;
tx.scale = 1.5;
tx.brightness = -0.2;
tx.contrast = 0.6;
tx.each_tip = true;
tx.mode = MaskMode::HardMix;
tx.depth = 0.7;
tx.depth_jitter = dynamic(0.3, Control::Rotation, 0.1);
}
4 => {
let d = &mut b.dual_brush;
d.enabled = true;
d.mode = MaskMode::ColorBurn;
d.flip = true;
d.tip = TipShape::Sampled(GrayTile::from_fn(8, 6, |x, y| ((x + y) % 2) as f32));
d.size = 33.0;
d.hardness = 0.5;
d.spacing = 0.8;
d.scatter = 1.2;
d.both_axes = true;
d.count = 3;
}
5 => {
let c = &mut b.color_dynamics;
c.enabled = true;
c.per_tip = false;
c.fg_bg = dynamic(0.6, Control::Fade, 0.0);
c.hue_jitter = 0.1;
c.saturation_jitter = 0.2;
c.brightness_jitter = 0.3;
c.purity = -0.4;
}
6 => {
b.transfer.enabled = true;
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
}
7 => {
let p = &mut b.pose;
p.enabled = true;
p.tilt_x = 45.0;
p.tilt_y = -27.0;
p.override_tilt = true;
p.rotation = 120.0;
p.override_rotation = true;
p.pressure = 0.4;
p.override_pressure = true;
}
8 => b.noise = true,
9 => b.wet_edges = true,
10 => {
b.build_up = true;
b.build_up_rate = 55.0;
}
11 => {
let s = &mut b.smoothing;
s.amount = 0.45;
s.pulled_string = true;
s.catch_up = false;
s.catch_up_on_end = false;
s.adjust_for_zoom = false;
}
_ => b.protect_texture = true,
}
}
#[test]
fn every_section_round_trips_through_set_brush() {
for (i, (name, _)) in SECTIONS.iter().enumerate() {
let mut app = app();
let before = app.session.tools.brush.clone();
let mut after = before.clone();
edit_section(i, &mut after);
assert_ne!(before, after, "section {i} edits nothing");
commit(&mut app, &before, &after);
assert_eq!(app.session.tools.brush, after, "section {i} ({name})");
// Through the command, with only the changed fields.
let (id, p) = last_journal(&app).expect("journaled");
assert_eq!(id, "tools.setBrush");
let patch = &p["brush"];
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
// And the engine's view (`brush.get`) reads back the same settings.
let got = app.session.execute("brush.get", json!({})).unwrap();
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
// Replaying the journaled call onto a fresh session gives the same brush (drivable).
let mut s = photocraft_engine::Session::new();
s.execute(&id, p).unwrap();
assert_eq!(s.tools.brush, after, "replay of section {i}");
}
}
#[test]
fn patches_are_minimal_and_skip_unchanged_bitmaps() {
let tip = TipShape::Sampled(GrayTile::from_fn(400, 300, |x, y| ((x * y) % 7) as f32 / 7.0));
let old = BrushSettings {
tip: tip.clone(),
texture: paint::Texture { pattern: Pattern::Tile(GrayTile::from_fn(64, 64, |x, _| x as f32 / 64.0)), ..Default::default() },
..Default::default()
};
let new = BrushSettings { size: 44.0, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"size": 44.0}));
let new = BrushSettings { shape_dynamics: paint::ShapeDynamics { enabled: true, ..Default::default() }, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"shapeDynamics": {"enabled": true}}));
assert_eq!(brush_patch(&old, &old), json!({}));
// A changed tip is sent whole.
let new = BrushSettings { tip: TipShape::Round, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"tip": "round"}));
// Applying any of these through the engine gives back `new`.
let mut app = app();
app.session.tools.brush = old.clone();
let new = BrushSettings { size: 9.0, roundness: 0.5, ..old.clone() };
commit(&mut app, &old, &new);
assert_eq!(app.session.tools.brush, new);
// No change, no command.
let n = app.session.journal.len();
commit(&mut app, &new, &new);
assert_eq!(app.session.journal.len(), n);
}
#[test]
fn bad_patches_fail_without_touching_the_brush() {
let mut app = app();
let before = app.session.tools.brush.clone();
for bad in [
json!({"brush": {"size": "huge"}}),
json!({"brush": {"shapeDynamics": {"size": {"control": "telepathy"}}}}),
json!({"brush": {"tip": {"sampled": {"width": 0}}}}),
] {
assert!(app.run("tools.setBrush", bad).is_err());
assert_eq!(app.session.tools.brush, before);
}
}
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.panels.brush_settings = true;
app.ui.brush_tab = tab;
app.ui.brush_section = section;
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
window(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
#[test]
fn preview_strip_re_renders_only_when_the_brush_changes() {
let mut h = harness(0, 0);
let ctx = h.ctx.clone();
let n0 = brush_preview::render_count(&ctx);
assert!(n0 >= 1, "the strip rendered");
h.run_steps(6);
assert_eq!(brush_preview::render_count(&ctx), n0, "idle frames re-rendered a preview");
// Change the brush through the engine: the strip renders once more, then stays cached.
h.state_mut().run("tools.setBrush", json!({"brush": {"scattering": {"enabled": true, "scatter": {"jitter": 2.0}}}})).unwrap();
h.run_steps(5);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
// Colour and the jitter seed of strokes aren't preview inputs: no re-render.
h.state_mut().session.tools.brush.color = [1.0, 0.0, 0.0, 1.0];
h.run_steps(3);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
}
#[test]
fn every_section_draws_without_issuing_commands() {
for tab in 0..2 {
for section in 0..SECTIONS.len() {
let mut h = harness(tab, section);
// A brush with every section on and a sampled tip.
let mut b = BrushSettings::default();
for i in 0..SECTIONS.len() {
edit_section(i, &mut b);
}
b.tip = TipShape::Sampled(GrayTile::from_fn(50, 40, |x, y| ((x ^ y) & 1) as f32));
h.state_mut().session.tools.brush = b.clone();
let n = h.state().session.journal.len();
h.run_steps(4);
// Merely showing the panel must not rewrite the brush (no rounding drift, no commands).
assert_eq!(h.state().session.journal.len(), n, "tab {tab} section {section}");
assert_eq!(h.state().session.tools.brush, b, "tab {tab} section {section}");
}
}
}
#[test]
fn brushes_panel_groups_collapse_and_filter() {
let mut h = harness(1, 0);
let groups = grouped_presets(&h.state().session.tools.presets);
assert!(groups.len() >= 2, "{groups:?}");
let ctx = h.ctx.clone();
let shown = |h: &mut Harness<'static, PhotocraftApp>| {
h.run_steps(2);
brush_preview::with_cache(&ctx, |c| c.len())
};
let all = shown(&mut h);
// Collapse every group: no more previews are needed, and the old ones are kept (cached).
h.state_mut().ui.brushes_panel.collapsed = groups.iter().map(|(g, _)| g.clone()).collect();
let n = brush_preview::render_count(&ctx);
assert_eq!(shown(&mut h), all);
assert_eq!(brush_preview::render_count(&ctx), n);
// A filter shows matching presets even in collapsed groups.
let first = h.state().session.tools.presets[0].name.clone();
h.state_mut().ui.brushes_panel.filter = first.to_uppercase();
h.run_steps(2);
assert!(brush_preview::render_count(&ctx) >= n);
}
-333
View File
@@ -1,333 +0,0 @@
//! Brush previews for the Brush Settings and Brushes panels: the S-curve stroke strip and tip
//! thumbnails, rendered with the real brush engine and cached per slot on a cheap signature of the
//! settings, so a preview re-renders only when something that changes it changes.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use egui::Color32;
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, Pattern, StrokePoint, TipShape};
/// Values cached per named slot, each tagged with the signature it was rendered for. A slot holds
/// one value, so dragging a slider replaces the slot's texture instead of piling up new ones.
#[derive(Clone)]
pub struct SigCache<T> {
slots: HashMap<String, (u64, T)>,
/// Number of renders so far (tests and the perf overlay).
pub renders: u64,
}
impl<T> Default for SigCache<T> {
fn default() -> Self {
Self { slots: HashMap::new(), renders: 0 }
}
}
impl<T: Clone> SigCache<T> {
/// The value for `slot`, rendered by `render` only when the slot is empty or `sig` changed.
pub fn get(&mut self, slot: &str, sig: u64, render: impl FnOnce() -> T) -> T {
if let Some((s, v)) = self.slots.get(slot)
&& *s == sig
{
return v.clone();
}
self.renders += 1;
let v = render();
self.slots.insert(slot.to_string(), (sig, v.clone()));
v
}
/// Drop slots not in `keep` (presets deleted from the session).
pub fn retain(&mut self, keep: impl Fn(&str) -> bool) {
self.slots.retain(|k, _| keep(k));
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
/// Size plus a strided sample of a bitmap: imported tips can be millions of pixels.
fn tile_sig(h: &mut DefaultHasher, t: &paint::GrayTile) {
(t.width, t.height, t.data.len()).hash(h);
let step = (t.data.len() / 4096).max(1);
t.data.iter().step_by(step).for_each(|v| v.hash(h));
}
fn tip_shape_sig(h: &mut DefaultHasher, t: &TipShape) {
match t {
TipShape::Round => 0u8.hash(h),
TipShape::Sampled(g) => tile_sig(h, g),
}
}
/// Cheap signature of everything that changes a brush's stroke preview.
pub fn preview_sig(b: &BrushSettings) -> u64 {
let mut h = DefaultHasher::new();
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
tip_shape_sig(&mut h, &b.tip);
let d = &b.dual_brush;
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
tip_shape_sig(&mut h, &d.tip);
let t = &b.texture;
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
match &t.pattern {
Pattern::Tile(g) => tile_sig(&mut h, g),
p => js(&mut h, p),
}
h.finish()
}
/// Signature of a tip thumbnail's inputs.
pub fn tip_sig(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool)) -> u64 {
let mut h = DefaultHasher::new();
tip_shape_sig(&mut h, tip);
(hardness.to_bits(), angle.to_bits(), roundness.to_bits(), flip).hash(&mut h);
h.finish()
}
/// A stroke rendered with the brush (scaled down to fit), as straight RGBA8: Photoshop's preview
/// S-curve with pressure tapering in and out.
pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec<u8> {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let mut brush = b.clone();
let k = ((h as f32 * 0.55) / brush.size.max(1.0)).min(1.0);
brush.size = (brush.size * k).max(1.0);
brush.dual_brush.size = (brush.dual_brush.size * k).max(1.0);
brush.color = color;
brush.erase = false;
brush.seed = 7;
// Build-up adds dabs over (simulated) time: the preview has none.
brush.build_up = false;
let n = 64;
let points: Vec<StrokePoint> = (0..=n)
.map(|i| {
let s = i as f64 / n as f64;
let x = 10.0 + s * (w as f64 - 20.0);
let y = h as f64 / 2.0 - (s * std::f64::consts::TAU).sin() * h as f64 * 0.22;
StrokePoint::new(x, y, (s * std::f64::consts::PI).sin().max(0.05) as f32)
})
.collect();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = photocraft_raster::Surface::new(fmt);
paint::apply_stroke(&mut s, &paint::Stroke { brush, points }, None, false);
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut px = vec![[0.0f32; 4]; (w * h) as usize];
s.read_rgba_into(r, &mut px);
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
}
/// Box-downsample a tip bitmap so its larger side is at most `max` (cheap bilinear lookups after).
fn shrink(g: &paint::GrayTile, max: u32) -> (u32, u32, Vec<f32>) {
let (w, h) = (g.width.max(1), g.height.max(1));
if !g.is_valid() {
return (1, 1, vec![0.0]);
}
let f = (w.max(h) as f32 / max.max(1) as f32).max(1.0);
let (nw, nh) = (((w as f32 / f).ceil() as u32).max(1), ((h as f32 / f).ceil() as u32).max(1));
let mut sum = vec![0.0f32; (nw * nh) as usize];
let mut cnt = vec![0u32; (nw * nh) as usize];
for y in 0..h {
let ny = ((y as f32 / f) as u32).min(nh - 1);
for x in 0..w {
let nx = ((x as f32 / f) as u32).min(nw - 1);
let i = (ny * nw + nx) as usize;
sum[i] += f32::from(g.data[(y * w + x) as usize]) / 65535.0;
cnt[i] += 1;
}
}
(nw, nh, sum.iter().zip(&cnt).map(|(s, c)| if *c > 0 { s / *c as f32 } else { 0.0 }).collect())
}
/// Coverage (0..1) of a brush tip drawn into an `n × n` cell, with the tip's angle, roundness,
/// flips and (for computed tips) hardness: the Brush Tip Shape grid and Brushes panel thumbnails.
pub fn tip_alpha(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool), n: u32) -> Vec<f32> {
let n = n.clamp(2, 512);
let c = n as f32 / 2.0;
let r = c - 1.0;
let (sin, cos) = angle.to_radians().sin_cos();
let round = roundness.clamp(0.01, 1.0);
let sampled = match tip {
TipShape::Sampled(g) => Some(shrink(g, n * 2)),
TipShape::Round => None,
};
let mut out = vec![0.0f32; (n * n) as usize];
// 2×2 supersampling keeps small thumbnails smooth.
for y in 0..n {
for x in 0..n {
let mut acc = 0.0;
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let (dx, dy) = (x as f32 + sx - c, c - (y as f32 + sy));
// Into tip space: undo the counter-clockwise angle, then the roundness squash.
let mut u = dx * cos + dy * sin;
let mut v = (-dx * sin + dy * cos) / round;
if flip.0 {
u = -u;
}
if flip.1 {
v = -v;
}
acc += match &sampled {
None => paint::dab_coverage(u.hypot(v), r, hardness),
Some((tw, th, data)) => {
// The tip's larger side spans the cell diameter.
let k = (*tw).max(*th) as f32 / (2.0 * r);
let tx = u * k + *tw as f32 / 2.0 - 0.5;
let ty = -v * k + *th as f32 / 2.0 - 0.5;
bilinear(data, *tw, *th, tx, ty)
}
};
}
out[(y * n + x) as usize] = (acc / 4.0).clamp(0.0, 1.0);
}
}
out
}
fn bilinear(data: &[f32], w: u32, h: u32, x: f32, y: f32) -> f32 {
if !(x > -1.0 && y > -1.0 && x < w as f32 && y < h as f32) {
return 0.0;
}
let at = |xi: i32, yi: i32| -> f32 {
if xi < 0 || yi < 0 || xi >= w as i32 || yi >= h as i32 { 0.0 } else { data.get((yi as u32 * w + xi as u32) as usize).copied().unwrap_or(0.0) }
};
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let (xi, yi) = (x0 as i32, y0 as i32);
let top = at(xi, yi) * (1.0 - fx) + at(xi + 1, yi) * fx;
let bottom = at(xi, yi + 1) * (1.0 - fx) + at(xi + 1, yi + 1) * fx;
top * (1.0 - fy) + bottom * fy
}
fn upload(ctx: &egui::Context, name: &str, w: u32, h: u32, rgba: &[u8]) -> egui::TextureHandle {
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba);
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
}
fn cache_id() -> egui::Id {
egui::Id::new("brush-preview-cache")
}
/// Run `f` on the context's preview cache. The cache is taken out of egui's memory while `f` runs,
/// because uploading a texture needs the context (which `data_mut` holds locked).
pub fn with_cache<R>(ctx: &egui::Context, f: impl FnOnce(&mut SigCache<egui::TextureHandle>) -> R) -> R {
let mut cache = ctx.data_mut(|d| std::mem::take(d.get_temp_mut_or_default::<SigCache<egui::TextureHandle>>(cache_id())));
let r = f(&mut cache);
ctx.data_mut(|d| d.insert_temp(cache_id(), cache));
r
}
/// Renders done by the preview cache so far.
pub fn render_count(ctx: &egui::Context) -> u64 {
with_cache(ctx, |c| c.renders)
}
/// The stroke preview of `b` for `slot`, re-rendered only when the brush or size changes.
pub fn stroke_texture(ctx: &egui::Context, slot: &str, b: &BrushSettings, w: u32, h: u32, color: Color32) -> egui::TextureHandle {
let mut hs = DefaultHasher::new();
(preview_sig(b), w, h, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let c = color.to_normalized_gamma_f32();
upload(ctx, slot, w, h, &preview_pixels(b, w, h, [c[0], c[1], c[2], 1.0]))
})
})
}
/// A tip thumbnail for `slot` (`n × n`; `shape` is hardness, angle, roundness), re-rendered only
/// when the tip changes.
pub fn tip_texture(ctx: &egui::Context, slot: &str, tip: &TipShape, shape: (f32, f32, f32), n: u32, color: Color32) -> egui::TextureHandle {
let (hardness, angle, roundness) = shape;
let mut hs = DefaultHasher::new();
(tip_sig(tip, hardness, angle, roundness, (false, false)), n, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let a = tip_alpha(tip, hardness, angle, roundness, (false, false), n);
let px: Vec<u8> = a.iter().flat_map(|v| [color.r(), color.g(), color.b(), (v * 255.0).round() as u8]).collect();
upload(ctx, slot, n, n, &px)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sig_cache_renders_only_on_change() {
let mut c: SigCache<u32> = SigCache::default();
let mut b = BrushSettings::default();
let mut calls = 0;
for _ in 0..5 {
c.get("strip", preview_sig(&b), || {
calls += 1;
calls
});
}
assert_eq!((calls, c.renders), (1, 1));
b.shape_dynamics.size.jitter = 0.5;
assert_eq!(c.get("strip", preview_sig(&b), || 99), 99);
assert_eq!(c.get("strip", preview_sig(&b), || 100), 99);
assert_eq!((c.renders, c.len()), (2, 1));
// Colour and the session's foreground don't matter; the brush's own fields do.
let base = preview_sig(&BrushSettings::default());
assert_eq!(base, preview_sig(&BrushSettings { color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }));
assert_ne!(base, preview_sig(&BrushSettings { texture: paint::Texture { enabled: true, ..Default::default() }, ..Default::default() }));
c.retain(|_| false);
assert!(c.is_empty());
}
#[test]
fn preview_draws_a_stroke_that_fits() {
let mut b = BrushSettings { size: 400.0, ..Default::default() };
let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px.len(), 120 * 40 * 4);
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count();
assert!(covered > 200 && covered < 120 * 40 / 2, "{covered}");
// Dynamics change the preview.
b.scattering.enabled = true;
b.scattering.scatter.jitter = 3.0;
assert_ne!(preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]), px);
// The Brush Settings strip with several sections on stays cheap to re-render.
b.shape_dynamics.enabled = true;
b.shape_dynamics.size.jitter = 0.4;
b.texture.enabled = true;
b.dual_brush.enabled = true;
let t0 = std::time::Instant::now();
preview_pixels(&b, 488, 76, [1.0; 4]);
eprintln!("strip render: {:.2} ms", t0.elapsed().as_secs_f64() * 1e3);
}
#[test]
fn tip_thumbnails_follow_shape_angle_and_roundness() {
let n = 32;
let at = |a: &[f32], x: u32, y: u32| a[(y * n + x) as usize];
let hard = tip_alpha(&TipShape::Round, 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&hard, 16, 16) > 0.99 && at(&hard, 0, 0) < 0.01 && at(&hard, 29, 16) > 0.9);
let soft = tip_alpha(&TipShape::Round, 0.0, 0.0, 1.0, (false, false), n);
assert!(at(&soft, 26, 16) < at(&hard, 26, 16));
// 25 % roundness: flat ellipse, wide along x; rotated 90°, tall along y.
let flat = tip_alpha(&TipShape::Round, 1.0, 0.0, 0.25, (false, false), n);
assert!(at(&flat, 28, 16) > 0.9 && at(&flat, 16, 4) < 0.01);
let tall = tip_alpha(&TipShape::Round, 1.0, 90.0, 0.25, (false, false), n);
assert!(at(&tall, 16, 4) > 0.9 && at(&tall, 28, 16) < 0.01);
// A sampled tip: a 300×100 bar fills the cell's width, centred.
let bar = paint::GrayTile::from_fn(300, 100, |_, _| 1.0);
let s = tip_alpha(&TipShape::Sampled(bar), 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&s, 3, 16) > 0.9 && at(&s, 16, 2) < 0.01);
// Degenerate input never panics.
let empty = paint::GrayTile { width: 0, height: 0, data: vec![] };
assert!(tip_alpha(&TipShape::Sampled(empty), 1.0, 0.0, 0.0, (true, true), 0).iter().all(|v| *v == 0.0));
}
}
-606
View File
@@ -1,606 +0,0 @@
//! 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);
}
}
+1 -3
View File
@@ -1133,10 +1133,8 @@ 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) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
&& let Some(p) = response.interact_pointer_pos()
{
if tool == Tool::Move && app.ui.transform.is_none() {
begin_transform_controls_at(app, &ctx, &xf, p);
+9 -94
View File
@@ -162,8 +162,6 @@ 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 {
@@ -172,13 +170,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) -> Result<Vec<f32>, String> {
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
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).map_err(|e| e.to_string())?;
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
return Some(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.
@@ -205,20 +203,9 @@ 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).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;
}
s.execute(COMMAND, p).ok()?;
let d = s.active()?;
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
}
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
@@ -232,7 +219,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, error: None });
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
}
let image_view = s(f, "__view", "selection") == "image";
let params = params(f);
@@ -247,7 +234,6 @@ 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);
@@ -259,18 +245,8 @@ 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 = match proxy_mask(app, &proxy, k, &params) {
Ok(m) => {
report(app, None);
m
}
Err(e) => {
report(app, Some(e));
vec![0.0; w * h]
}
};
let mask = proxy_mask(app, &proxy, k, &params).unwrap_or_else(|| 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();
@@ -392,22 +368,8 @@ 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);
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));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
ui.add_space(10.0);
ui.vertical(|ui| {
ui.add_enabled_ui(c.sampling, |ui| {
@@ -742,51 +704,4 @@ 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");
}
}
-4
View File
@@ -142,10 +142,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
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);
+2 -3
View File
@@ -400,9 +400,8 @@ mod tests {
h.step();
}
let drawn = h.state().ui.crop_rect.unwrap();
// 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%");
// 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%");
h.event(space(true));
h.step();
for p in [pos2(280.0, 200.0), pos2(300.0, 210.0)] {
-4
View File
@@ -323,10 +323,6 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
}
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();
-226
View File
@@ -1,226 +0,0 @@
//! Layers panel row: the right-hand indicators and the name between them and the thumbnails
//! (#144).
//!
//! The indicators (lock, fx badge with its effects triangle, link, the blend-mode label) are
//! laid out first, from the row's right edge inward; the name gets the width that is left and
//! is cut with an ellipsis, so a long name never runs under an indicator at any panel width or
//! UI scale. The blend-mode label is optional: it is dropped before the name gets too narrow.
//! The rects drawn each frame are recorded ([`recorded`]) for tests and automation.
use egui::{Align2, FontId, Galley, Painter, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_color::BlendMode;
use photocraft_doc::Layer;
use serde_json::{Value, json};
use std::sync::Arc;
use crate::icons;
use crate::theme::{self, Tokens};
/// Space kept free at a row's right edge: the overlay scrollbar sits there.
pub const RIGHT_PAD: f32 = 10.0;
/// Gap between indicators, and between the name and the first indicator.
const GAP: f32 = 4.0;
/// Width of an icon indicator (lock, link).
const ICON_W: f32 = 14.0;
/// Width of the effects triangle beside the fx badge.
const TRIANGLE_W: f32 = 10.0;
/// The blend-mode label is shown only while the name keeps at least this much room.
const MIN_NAME_W: f32 = 64.0;
/// A right-hand row indicator.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Indicator {
Lock,
FxTriangle,
Fx,
Link,
Blend,
}
/// Where one row's name and indicators went this frame (screen points).
#[derive(Clone, Debug)]
pub struct RowRects {
pub layer: u64,
pub row: Rect,
/// The name as painted (after truncation); `None` when there was no room for it.
pub name: Option<Rect>,
pub indicators: Vec<(Indicator, Rect)>,
}
/// Lay out `items` (right to left, with their widths; `Blend` is optional) inside `row`,
/// leaving the name the span from `name_left`. Returns the indicator rects and the name's
/// right limit.
pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(Indicator, Rect)>, f32) {
let mut right = row.right() - RIGHT_PAD;
let mut out = Vec::with_capacity(items.len());
for &(kind, w) in items {
let w = w.max(0.0);
// The fx badge hugs its triangle; everything else keeps a gap.
let gap = if kind == Indicator::Fx && out.last().is_some_and(|(k, _)| *k == Indicator::FxTriangle) { 1.0 } else { GAP };
let left = right - w;
if kind == Indicator::Blend && left - GAP - name_left < MIN_NAME_W {
continue;
}
out.push((kind, Rect::from_min_max(pos2(left, row.top()), pos2(right, row.bottom()))));
right = left - gap;
}
let name_right = out.last().map_or(right, |(_, r)| r.left() - GAP);
(out, name_right)
}
/// Width the always-shown indicators of `l` take at the row's right (blend label excluded).
pub fn reserved_width(l: &Layer) -> f32 {
let mut w = RIGHT_PAD;
if l.locks.transparency || l.locks.position || l.locks.all {
w += ICON_W + GAP;
}
if !l.effects.items.is_empty() {
w += TRIANGLE_W + 1.0 + 13.0 + GAP;
}
if l.link_group.is_some() {
w += ICON_W + GAP;
}
w
}
/// Group indentation for a row at `depth`: 14 pt a level, squeezed when the panel is so narrow
/// that it would leave the name less than [`MIN_NAME_W`] (`fixed` = everything else in the row).
pub fn indent(depth: usize, row_width: f32, fixed: f32) -> f32 {
(depth as f32 * 14.0).min((row_width - fixed - MIN_NAME_W).max(0.0))
}
/// A single-line galley cut with "…" to `max_width` (nothing when there's no room).
pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color32, italics: bool, max_width: f32) -> Option<Arc<Galley>> {
if max_width < 8.0 {
return None;
}
let mut job = egui::text::LayoutJob::default();
job.append(text, 0.0, egui::TextFormat { font_id: font, color, italics, ..Default::default() });
job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
Some(painter.layout_job(job))
}
/// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the
/// name's right limit, the indicator rects, and whether the triangle was clicked this frame (so
/// the row doesn't also treat the click as a selection).
pub fn indicators(
ui: &egui::Ui,
painter: &Painter,
row: Rect,
name_left: f32,
l: &Layer,
fx_open: bool,
actions: &mut Vec<(String, Value)>,
) -> (f32, Vec<(Indicator, Rect)>, bool) {
let t = Tokens::get(ui.ctx());
let locked = l.locks.transparency || l.locks.position || l.locks.all;
let fx_font = theme::semibold(11.0);
let blend_font = FontId::proportional(10.5);
let has_fx = !l.effects.items.is_empty();
let show_blend = l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough;
let fx_galley = has_fx.then(|| painter.layout_no_wrap("fx".into(), fx_font, t.text_dim));
let blend_galley = show_blend.then(|| painter.layout_no_wrap(l.blend.label().into(), blend_font, t.text_faint));
let mut items = Vec::new();
if locked {
items.push((Indicator::Lock, ICON_W));
}
if let Some(g) = &fx_galley {
items.push((Indicator::FxTriangle, TRIANGLE_W));
items.push((Indicator::Fx, g.size().x));
}
if l.link_group.is_some() {
items.push((Indicator::Link, ICON_W));
}
if let Some(g) = &blend_galley {
items.push((Indicator::Blend, g.size().x));
}
let (rects, name_right) = layout(row, name_left, &items);
let mut clicked = false;
let cy = row.center().y;
for &(kind, r) in &rects {
match kind {
Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link", 12.0, t.text_faint),
Indicator::Fx => {
if let Some(g) = fx_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim);
}
}
Indicator::Blend => {
if let Some(g) = blend_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_faint);
}
}
Indicator::FxTriangle => {
let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click());
paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon });
if resp.clicked() {
clicked = true;
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setEffectsExpanded".into(), json!({"layer": l.id.0, "expanded": !fx_open, "all": all})));
}
let (verb, name) = (if fx_open { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if fx_open {
"Hide the layer's effects (⌥-click: all layers)"
} else {
"Show the layer's effects (⌥-click: all layers)"
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} effects {name}")));
}
}
}
(name_right, rects, clicked)
}
/// A small disclosure triangle: pointing down when open, right when closed.
pub fn paint_triangle(painter: &Painter, c: Pos2, open: bool, color: egui::Color32) {
let s = 3.0;
let pts = if open {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
painter.add(Shape::convex_polygon(pts, color, Stroke::NONE));
}
/// Paint `text` left-aligned at `x`, vertically centred on `cy`, cut to end before `right`.
/// Returns the painted rect.
pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: FontId, color: egui::Color32) -> Option<Rect> {
let g = truncated(painter, text, font, color, false, right - x)?;
let r = Align2::LEFT_CENTER.anchor_size(pos2(x, cy), g.size());
painter.galley(r.min, g, color);
Some(r)
}
/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout).
pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) {
let can = app.session.is_enabled("layer.setExpanded");
if ui.add_enabled(can, egui::Button::new("Collapse All Groups")).clicked() {
if let Err(e) = app.run("layer.setExpanded", json!({"all": true, "expanded": false})) {
app.ui.status = e;
app.ui.status_error = true;
}
ui.close();
}
}
fn rects_id() -> egui::Id {
egui::Id::new("layer-row-rects")
}
/// Start a frame's record (the Layers panel calls it before drawing its rows).
pub fn begin(ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(rects_id(), Vec::<RowRects>::new()));
}
pub fn record(ctx: &egui::Context, r: RowRects) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Vec<RowRects>>(rects_id()).push(r));
}
/// The rows the Layers panel drew last frame.
pub fn recorded(ctx: &egui::Context) -> Vec<RowRects> {
ctx.data(|d| d.get_temp::<Vec<RowRects>>(rects_id())).unwrap_or_default()
}
#[cfg(test)]
mod tests;
-161
View File
@@ -1,161 +0,0 @@
//! #144: the name never runs under the right-hand indicators; the fx triangle; #143: Collapse
//! All Groups.
use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::{Indicator, RowRects, layout, recorded};
use crate::PhotocraftApp;
const LONG: &str = "Button / Primary / Hover state — a very long descriptive layer name 背景のテクスチャ that keeps going";
/// Long-named layers with every indicator, nested four groups deep.
fn busy() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let mut prev = None;
for depth in 0..4 {
let a = s.execute("layer.new.layer", json!({"name": format!("{LONG} {depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "blend": "Multiply", "locks": {"all": true}})).unwrap();
s.execute("layer.layerMask.revealAll", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": format!("{LONG} b{depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": b, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap();
s.execute("layer.linkLayers", json!({})).unwrap();
if let Some(p) = prev {
s.execute("layer.select", json!({"layer": p, "mode": "toggle"})).unwrap();
}
let g = s.execute("layer.groupLayers", json!({"name": format!("{LONG} group {depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"layer": g})).unwrap();
prev = Some(g);
}
s
}
fn harness(session: photocraft_engine::Session, ppp: f32, theme: &str, dock_width: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(session, crate::Services::default())
});
let ctx = h.ctx.clone();
let (req, _rx) = crate::control::ControlRequest::new(
"ui.set",
json!({"theme": theme, "dockWidth": dock_width, "dock": {"collapsed": ["color", "properties", "history", "navigator"]}}),
);
crate::control::handle(h.state_mut(), &ctx, &req);
h.run_steps(8);
h
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.intersects(b) && a.intersect(b).area() > 0.0
}
fn check(rows: &[RowRects], what: &str) {
assert!(rows.len() >= 8, "{what}: rows drawn ({})", rows.len());
let mut with_fx = 0;
for r in rows {
let name = r.name.unwrap_or_else(|| panic!("{what}: layer {} has no room for its name", r.layer));
assert!(name.right() <= r.row.right(), "{what}: name inside its row");
for (k, ind) in &r.indicators {
assert!(!overlaps(name, *ind), "{what}: layer {} name {name:?} overlaps {k:?} {ind:?}", r.layer);
assert!(ind.right() <= r.row.right() - super::RIGHT_PAD + 0.01, "{what}: {k:?} clear of the scrollbar");
}
for (i, (ka, a)) in r.indicators.iter().enumerate() {
for (kb, b) in &r.indicators[i + 1..] {
assert!(!overlaps(*a, *b), "{what}: {ka:?} overlaps {kb:?}");
}
}
with_fx += r.indicators.iter().any(|(k, _)| *k == Indicator::Fx) as usize;
}
assert!(with_fx >= 4, "{what}: fx badges drawn ({with_fx})");
}
#[test]
fn layout_gives_the_name_what_the_indicators_leave() {
let row = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 32.0));
let items = [(Indicator::Lock, 14.0), (Indicator::FxTriangle, 10.0), (Indicator::Fx, 12.0), (Indicator::Link, 14.0), (Indicator::Blend, 40.0)];
let (rects, right) = layout(row, 100.0, &items);
assert_eq!(rects.len(), 5);
assert!(rects.iter().all(|(_, r)| r.left() > right && r.right() <= 300.0 - super::RIGHT_PAD));
// Narrow: the optional blend label goes first; the rest stay.
let (rects, right) = layout(row, 200.0, &items);
assert_eq!(rects.iter().map(|(k, _)| *k).collect::<Vec<_>>(), [Indicator::Lock, Indicator::FxTriangle, Indicator::Fx, Indicator::Link]);
assert!(right < rects[3].1.left());
}
#[test]
fn long_names_never_run_under_the_indicators() {
for ppp in [1.0, 1.5, 2.0] {
for width in [250.0, 290.0, 520.0] {
let h = harness(busy(), ppp, "promedium", width);
check(&recorded(&h.ctx), &format!("@{ppp}x {width}pt"));
}
}
for theme in ["pro", "studio", "studiolight", "classic"] {
let h = harness(busy(), 1.0, theme, 250.0);
check(&recorded(&h.ctx), theme);
}
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn the_fx_triangle_hides_and_shows_the_effects_rows() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
let top = h.state().session.active().unwrap().doc.layers.last().unwrap().clone();
let rows = |h: &Harness<'_, PhotocraftApp>| recorded(&h.ctx).iter().map(|r| r.row.top()).collect::<Vec<_>>();
let before = rows(&h);
let label = format!("Collapse effects {}", top.name);
let p = h.get_by_label(&label).rect().center();
let active = h.state().session.active().unwrap().active_layer;
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.contains(&top.id));
assert_eq!(h.state().session.active().unwrap().active_layer, active, "the triangle doesn't select");
// Its Effects / Drop Shadow sub-rows are gone, so the next layer row moved up.
let after = rows(&h);
assert!(after[1] < before[1], "rows moved up: {:?} -> {:?}", &before[..2], &after[..2]);
let p = h.get_by_label(&format!("Expand effects {}", top.name)).rect().center();
click(&mut h, p);
assert!(h.state().session.active().unwrap().fx_collapsed.is_empty());
}
fn groups_open(s: &photocraft_engine::Session) -> Vec<bool> {
s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| if let LayerContent::Group(g) = &l.content { Some(g.expanded) } else { None }).collect()
}
#[test]
fn collapse_all_groups_from_the_panel_menu_and_it_is_saved() {
let mut h = harness(busy(), 1.0, "promedium", 290.0);
assert!(groups_open(&h.state().session).iter().all(|o| *o));
let menu = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("layers group").1;
// The hamburger sits at the right end of the group's tab strip.
click(&mut h, pos2(menu.right() - 14.0, menu.top() + 14.0));
let item = h.get_by_label("Collapse All Groups").rect().center();
click(&mut h, item);
let open = groups_open(&h.state().session);
assert_eq!(open.len(), 4);
assert!(open.iter().all(|o| !o), "every group closed: {open:?}");
// Collapsed state is document data: it survives a PSD and a .pcraft round trip.
let doc = (*h.state().session.active().unwrap().doc).clone();
for name in ["c.psd", "c.pcraft"] {
let bytes = photocraft_io::export(&doc, name, &Default::default()).unwrap().bytes;
let back = photocraft_io::import(name, &bytes).unwrap().document;
let mut s = photocraft_engine::Session::new();
s.add_document(back, None);
assert!(groups_open(&s).iter().all(|o| !o), "{name}: groups stay closed");
}
}
-71
View File
@@ -1,71 +0,0 @@
//! Layers panel tree: the rows in display order (top of the stack first, each group directly
//! above its contents) and the groups' disclosure triangles (#126).
//!
//! Clicking a triangle opens or closes the group; ⌥-click sets every group in the document to
//! the new state (Photoshop). The state is the group's `expanded` flag, document data saved in
//! `.pcraft` and as the PSD section divider's open/closed folder type, toggled through
//! `layer.setExpanded` (a view change: no history step).
use egui::{Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_doc::{Document, Layer, LayerContent};
use serde_json::{Value, json};
use crate::theme::Tokens;
/// Width the disclosure triangle takes in a group row.
pub const TRIANGLE_W: f32 = 14.0;
/// Rows as the Layers panel lists them: topmost layer first, a group directly above its
/// children, and the contents of closed groups left out (unless `show_all`, used while the
/// panel is filtered by kind so matches inside closed groups still show).
pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> {
fn rec<'a>(layers: &'a [Layer], depth: usize, show_all: bool, out: &mut Vec<(usize, &'a Layer)>) {
for l in layers.iter().rev() {
out.push((depth, l));
if let LayerContent::Group(g) = &l.content
&& (g.expanded || show_all)
{
rec(&g.children, depth + 1, show_all, out);
}
}
}
let mut out = Vec::new();
rec(&doc.layers, 0, show_all, &mut out);
out
}
/// The hit area of a group row's triangle starting at `x`.
pub fn triangle_rect(row: Rect, x: f32) -> Rect {
Rect::from_min_size(pos2(x - 2.0, row.top()), vec2(TRIANGLE_W + 2.0, row.height()))
}
/// Paint and handle a group row's disclosure triangle at `*x` (advancing it). Returns true when
/// it was clicked this frame, so the row doesn't also treat the click as a selection.
pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: &mut Vec<(String, Value)>) -> bool {
let LayerContent::Group(g) = &l.content else { return false };
let hit = triangle_rect(row, *x);
let resp = ui.interact(hit, ui.id().with(("disclosure", l.id.0)), Sense::click());
let t = Tokens::get(ui.ctx());
let c = pos2(*x + TRIANGLE_W / 2.0 - 1.0, row.center().y);
let s = 3.5;
let pts = if g.expanded {
vec![pos2(c.x - s, c.y - s * 0.6), pos2(c.x + s, c.y - s * 0.6), pos2(c.x, c.y + s * 0.6)]
} else {
vec![pos2(c.x - s * 0.6, c.y - s), pos2(c.x + s * 0.6, c.y), pos2(c.x - s * 0.6, c.y + s)]
};
let color = if resp.hovered() { t.text } else { t.icon };
ui.painter().add(Shape::convex_polygon(pts, color, Stroke::NONE));
*x += TRIANGLE_W;
let clicked = resp.clicked();
if clicked {
let all = ui.input(|i| i.modifiers.alt);
actions.push(("layer.setExpanded".into(), json!({"layer": l.id.0, "expanded": !g.expanded, "all": all})));
}
let (verb, name) = (if g.expanded { "Collapse" } else { "Expand" }, l.name.clone());
let resp = resp.on_hover_text(if g.expanded { "Collapse group (⌥-click: all groups)" } else { "Expand group (⌥-click: all groups)" });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} group {name}")));
clicked
}
#[cfg(test)]
mod tests;
-141
View File
@@ -1,141 +0,0 @@
//! #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);
}
-6
View File
@@ -15,8 +15,6 @@ 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;
@@ -44,13 +42,10 @@ 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;
@@ -68,7 +63,6 @@ pub mod proxy;
pub mod puppet_ui;
pub mod retouch_ui;
pub mod rulers;
pub mod shortcut_dispatch;
pub mod shortcuts;
mod sizing;
pub mod slice_ui;
-327
View File
@@ -1,327 +0,0 @@
//! Native-menu behaviour for the menu bar's menus (#138): a menu or submenu taller than the
//! window scrolls (mouse wheel / trackpad, and the ▲ / ▼ arrows at its top and bottom that scroll
//! while the pointer rests on them, like macOS and Windows menus on a small display), and the
//! keyboard drives an open menu: ↑ / ↓ move the highlight (and keep it in view), → opens a
//! submenu or the next menu, ← closes the submenu or opens the previous menu, ↩ / Space run the
//! highlighted item, Esc closes.
//!
//! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too,
//! so the mouse and the keyboard share one highlighted row like a native menu.
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2};
/// Height of a scroll-arrow strip.
pub const ARROW: f32 = 16.0;
/// Gap kept between a menu and the window edge.
const EDGE: f32 = 6.0;
/// How fast resting on a scroll arrow scrolls, in points per second.
const SPEED: f32 = 600.0;
/// One actionable row of a menu level: a command or a submenu.
#[derive(Clone, Debug)]
pub struct Row {
/// The row's widget id (a submenu's popup id derives from it).
pub widget: Id,
pub enabled: bool,
/// The command it runs; `None` for a submenu.
pub command: Option<String>,
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
pub rect: Rect,
}
/// Keyboard / highlight state of the open menu.
#[derive(Clone, Debug, Default)]
pub struct Nav {
/// The open top-level menu (index into the menu bar).
top: Option<usize>,
/// Popup ids of the top-level menus, from the last frame.
tops: Vec<Id>,
/// The level the arrow keys move in (0 = the top-level menu).
pub(crate) level: usize,
/// Highlighted row per level.
hi: Vec<Option<usize>>,
/// This frame's rows per level (deeper levels are dropped when their parent redraws).
pub(crate) rows: Vec<Vec<Row>>,
/// Menu-root id per level (the owner of egui's `MenuState` for that level).
roots: Vec<Id>,
/// Bottom of the menu bar: where top-level menus hang from.
pub bar_bottom: Option<f32>,
/// The visible (scrolled) part of each level.
pub(crate) views: Vec<Rect>,
/// Scroll the highlighted row of `level` into view when it is next drawn.
reveal: bool,
/// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened
/// submenu).
first_at: Option<usize>,
}
fn nav_id() -> Id {
Id::new("pc-menu-nav")
}
fn open_id() -> Id {
Id::new("pc-menu-open")
}
/// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.)
pub fn is_open(ctx: &egui::Context) -> bool {
// Only while the menu bar is still drawn (Full Screen Mode hides it with a menu open).
let pass = ctx.cumulative_pass_nr();
ctx.data(|d| d.get_temp::<u64>(open_id())).is_some_and(|seen| seen + 2 >= pass)
}
impl Nav {
/// Last frame's state, reset when another top-level menu (or none) is open now.
pub fn load(ctx: &egui::Context) -> Nav {
let mut nav: Nav = ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default();
let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id));
if top != nav.top {
nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() };
}
nav
}
/// The state as the last frame left it (tests, inspection).
pub fn current(ctx: &egui::Context) -> Nav {
ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default()
}
/// The highlighted row of `level`, if any.
pub fn highlighted(&self, level: usize) -> Option<usize> {
self.hi.get(level).copied().flatten()
}
fn set_hi(&mut self, level: usize, row: Option<usize>) {
if self.hi.len() <= level {
self.hi.resize(level + 1, None);
}
self.hi[level] = row;
}
/// Start drawing `level`: forget its rows from before, and every deeper level's.
fn begin_level(&mut self, level: usize, root: Id) {
self.rows.truncate(level);
self.rows.resize(level + 1, Vec::new());
self.roots.truncate(level);
self.roots.resize(level + 1, root);
self.views.truncate(level);
self.views.resize(level + 1, Rect::NOTHING);
}
/// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the
/// pointer) is on it, and register it for the keys. `add` returns the row's response.
pub fn row<R>(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R {
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
let lit = enabled && self.highlighted(level) == Some(i);
let saved = ui.visuals().widgets.inactive;
if lit {
ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered;
}
let (r, out) = add(ui, self);
ui.visuals_mut().widgets.inactive = saved;
if let Some(rows) = self.rows.get_mut(level) {
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect });
self.after_row(level, i, lit, &r);
}
out
}
/// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls
/// the highlighted row into view.
fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) {
let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO);
if moved && r.hovered() && !lit {
self.set_hi(level, Some(i));
self.level = level;
r.ctx.request_repaint();
}
if lit && self.reveal && level == self.level {
r.scroll_to_me(None);
self.reveal = false;
}
}
fn enabled(&self, level: usize) -> Vec<usize> {
self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default()
}
/// ↑ / ↓: the next enabled row (wrapping), or the first / last one.
fn step(&mut self, level: usize, down: bool) {
let on = self.enabled(level);
let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return };
let next = match self.highlighted(level) {
None => {
if down {
first
} else {
last
}
}
Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first),
Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last),
};
self.set_hi(level, Some(next));
self.hi.truncate(level + 1);
self.level = level;
self.reveal = true;
}
/// Open the highlighted submenu of `level` and move the keyboard into it.
fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) {
let Some(&root) = self.roots.get(level) else { return };
let sub = SubMenu::id_from_widget_id(widget);
// egui drops an open item it has never drawn: mark it shown so it survives to the next frame.
MenuState::mark_shown(ctx, sub);
MenuState::from_id(ctx, root, |s| s.open_item = Some(sub));
self.level = level + 1;
self.hi.truncate(level + 1);
self.first_at = Some(level + 1);
}
/// Handle this frame's navigation keys (after the menus were drawn). `tops` are the menu
/// bar's popup ids; an activated command lands in `clicked`.
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option<String>) {
self.tops = tops.to_vec();
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
let level = self.level.min(self.rows.len().saturating_sub(1));
self.level = level;
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
let switch = |by: usize| {
let n = tops.len();
if let Some(&id) = tops.get((top + by) % n) {
egui::Popup::open_id(ctx, id);
}
};
if press(Key::Escape) {
egui::Popup::close_all(ctx);
} else if press(Key::ArrowDown) {
self.step(level, true);
} else if press(Key::ArrowUp) {
self.step(level, false);
} else if press(Key::ArrowRight) {
match current {
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => switch(1),
}
} else if press(Key::ArrowLeft) {
if level > 0 {
if let Some(&root) = self.roots.get(level - 1) {
MenuState::from_id(ctx, root, |s| s.open_item = None);
}
self.hi.truncate(level);
self.level = level - 1;
} else {
switch(tops.len() - 1);
}
} else if press(Key::Enter) || press(Key::Space) {
match current {
Some(Row { enabled: true, command: Some(c), .. }) => {
*clicked = Some(c);
egui::Popup::close_all(ctx);
}
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => {}
}
} else {
return;
}
ctx.request_repaint();
}
pub fn store(self, ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
if self.top.is_some() {
d.insert_temp(open_id(), pass);
} else {
d.remove::<u64>(open_id());
}
d.insert_temp(nav_id(), self);
});
}
}
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
}
/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with
/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu.
pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) {
let ctx = ui.ctx().clone();
let level = depth.saturating_sub(1);
nav.begin_level(level, find_menu_root(ui).id);
let screen = ctx.content_rect();
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
// A top-level menu hangs below the menu bar; a submenu may slide up to fit the window.
// (From the menu bar, not from where the popup is now: egui slides a popup that doesn't fit.)
let top = nav.bar_bottom.unwrap_or(screen.top()) + EDGE;
let room = if depth <= 1 { screen.bottom() - top - EDGE } else { screen.height() - 2.0 * EDGE } - frame;
let room = room.max(4.0 * ARROW);
let key = ui.id().with(("pc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
// Wheel, scroll bar and dragging the rows all scroll (#160).
let out = egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(if over { room - 2.0 * ARROW } else { room })
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, |ui| rows(ui, nav));
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
if let Some(v) = nav.views.get_mut(level) {
*v = out.inner_rect;
}
if nav.first_at == Some(level) {
nav.first_at = None;
nav.step(level, true);
ctx.request_repaint();
}
let (Some(up), Some(down)) = (up, down) else { return };
let t = crate::theme::Tokens::get(&ctx);
let x = ui.min_rect().x_range();
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
let offset = out.state.offset.y;
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut target = offset;
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
let strip = Rect::from_x_y_ranges(x, strip.y_range());
let c = strip.center();
let (h, w) = (4.5, 6.0);
let tri = if dir < 0.0 {
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
} else {
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
};
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_faint.gamma_multiply(0.5) }, egui::Stroke::NONE));
let id = key.with(dir < 0.0);
let r = ui.interact(strip, id, Sense::hover());
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }));
if live && pointer_on(ui, strip) {
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
ctx.request_repaint();
}
// The wheel scrolls over the arrows too.
if pointer_on(ui, strip) {
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
if wheel != 0.0 {
target = (target - wheel).clamp(0.0, max);
}
}
}
if target != offset {
let mut st = out.state;
st.offset.y = target;
st.store(&ctx, out.id);
}
}
#[cfg(test)]
#[path = "menu_nav_tests.rs"]
mod tests;
-218
View File
@@ -1,218 +0,0 @@
//! 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");
}
+11 -51
View File
@@ -60,7 +60,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
];
/// Photoshop's Window › <panel> ids for the panels the shell already has, as `window.toggle.*`.
pub(crate) fn panel_alias(id: &str) -> Option<&'static str> {
fn panel_alias(id: &str) -> Option<&'static str> {
Some(match id.strip_prefix("window.panel.")? {
"layers" => "window.toggle.layers",
"history" => "window.toggle.history",
@@ -698,7 +698,6 @@ 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;
@@ -708,7 +707,6 @@ 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();
@@ -717,16 +715,13 @@ 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, &mut nav);
render_level(ui, &mine, 1, &mut clicked);
});
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!({}));
@@ -748,10 +743,6 @@ 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
{
@@ -760,13 +751,14 @@ 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>, 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(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_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
let t = crate::theme::Tokens::get(ui.ctx());
if t.pro {
// Spectrum/macOS menus: blue highlight row with white text.
@@ -801,12 +793,7 @@ 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));
}
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 {
if ui.add_enabled(it.enabled, b).clicked() {
*clicked = Some(it.id.clone());
ui.close();
}
@@ -819,9 +806,8 @@ 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 != "---");
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, ()));
ui.add_enabled_ui(any_enabled || !child.is_empty(), |ui| {
ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked));
});
last_was_sep = false;
}
@@ -974,32 +960,6 @@ 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());
+40 -65
View File
@@ -748,19 +748,6 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
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) {
use crate::dock::Group;
// Floating in Studio, Properties docks only in Pro (Photoshop).
@@ -778,14 +765,15 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
return;
}
let margin = if t.pro { 2 } else { 8 };
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);
});
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| {
// 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.
@@ -1141,8 +1129,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(2.0);
}
// 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 rows = doc.walk();
let ctx = ui.ctx().clone();
let footer = 38.0;
let fill = ui.available_height() > footer + 60.0;
@@ -1154,9 +1141,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.auto_shrink([false, !fill])
.show(ui, |ui| {
let filter = app.ui.layer_filter.clone();
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 {
for (_, depth, l) in rows.iter().rev() {
// Select › Isolate Layers.
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
continue;
@@ -1175,11 +1160,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() && fx_collapsed.iter().all(|id| *id != l.id) {
effect_rows(app, ui, l, depth);
layer_row(app, &ctx, ui, &doc, l, *depth, row, &mut actions);
if !l.effects.items.is_empty() {
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.
@@ -1316,8 +1301,6 @@ 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 {
@@ -1341,20 +1324,12 @@ fn layer_row(
if eye_resp.clicked() {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
}
let ts = if t.pro { 24.0 } else { 34.0 };
// Everything but the indentation and the name, so a narrow panel squeezes the indentation first.
let fixed = 6.0
+ 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);
x += 28.0 + depth as f32 * 14.0;
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;
@@ -1387,30 +1362,39 @@ 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 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
});
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);
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(),
};
crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint);
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::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)| {
@@ -1421,7 +1405,7 @@ fn layer_row(
});
if let Some(p) = thumb_load {
actions.push(("select.loadSelection".into(), p));
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled {
} else if resp.clicked() && !eye_resp.clicked() {
let mode = select_mode(ui.input(|i| i.modifiers));
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
// Clicking a thumbnail picks what painting targets; adjustment/fill layers target their mask.
@@ -1441,7 +1425,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(name_right.max(x + 40.0), rect.center().y + 11.0));
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 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)));
@@ -2111,16 +2095,7 @@ 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);
}
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 },
);
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 });
if resp.double_clicked() {
crate::layer_style::open(app, kind);
}
-224
View File
@@ -1,224 +0,0 @@
//! 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;
@@ -1,296 +0,0 @@
//! #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");
}
+41 -23
View File
@@ -156,35 +156,19 @@ 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);
}
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 {
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() {
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;
@@ -213,10 +197,44 @@ 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 and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]).
if let Some(id) = pressed_command(app, ctx, focus, editing) {
dispatch(app, ctx, &id);
return;
// 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;
}
}
if editing {
return;
-4
View File
@@ -595,9 +595,6 @@ pub struct UiState {
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,
@@ -660,7 +657,6 @@ impl Default for UiState {
dock: Default::default(),
brush_section: 0,
brush_tab: 0,
brushes_panel: Default::default(),
selection_mode: 0,
tool_options: ToolOptions::default(),
polygon: Vec::new(),
+32 -90
View File
@@ -62,42 +62,12 @@ 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| {
// 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 Some((l, aff, _)) = layout(app, *id) else { return false };
let (tx, ty) = to_text(&aff, x, y);
shown || l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop)
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);
@@ -129,10 +99,8 @@ 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: key, created: false, dragging: true, preedit: None });
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true, preedit: None });
return true;
}
false
@@ -598,35 +566,17 @@ 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, ctx: &egui::Context, props: serde_json::Value) {
fn apply(app: &mut PhotocraftApp, 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(key) = app.ui.text_edit.as_ref().map(|ed| ed.session.clone()).or_else(|| drag_key(ctx)) {
p["coalesce"] = json!(key);
if let Some(ed) = &app.ui.text_edit {
p["coalesce"] = json!(ed.session);
}
let _ = app.run("type.setStyle", p);
}
@@ -639,9 +589,7 @@ 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);
// 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))
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), tl.size_pt, tl.color))
});
let o = app.ui.tool_options.clone();
let (mut fam, mut style, mut size) = match &shown {
@@ -654,20 +602,19 @@ 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, ui.ctx(), json!({"font": fam, "fontStyle": style}));
apply(app, 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, ui.ctx(), json!({"fontStyle": style}));
apply(app, 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;
let k = shown_scale(app);
apply(app, ui.ctx(), json!({"size": size / k}));
apply(app, json!({"size": size}));
}
let mut aa = o.type_aa.clone();
let aa_opts = [
@@ -694,7 +641,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, ui.ctx(), json!({"align": align}));
apply(app, json!({"align": align}));
}
}
ui.spacing_mut().item_spacing.x = 8.0;
@@ -716,7 +663,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, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
if app.ui.text_edit.is_some() {
@@ -817,7 +764,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, ui.ctx(), json!({"font": fam, "fontStyle": style}));
apply(app, json!({"font": fam, "fontStyle": style}));
}
});
ui.horizontal(|ui| {
@@ -825,19 +772,18 @@ 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, ui.ctx(), json!({"fontStyle": style}));
apply(app, json!({"fontStyle": style}));
}
});
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
ui.horizontal(|ui| {
let k = shown_scale(app);
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, ui.ctx(), json!({"size": v / k}));
apply(app, json!({"size": v}));
}
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01));
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
apply(app, ui.ctx(), json!({"leading": v / k}));
apply(app, json!({"leading": v}));
}
});
ui.horizontal(|ui| {
@@ -855,23 +801,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, ui.ctx(), json!({"kerning": k}));
apply(app, json!({"kerning": k}));
}
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
apply(app, ui.ctx(), json!({"tracking": v}));
apply(app, json!({"tracking": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"verticalScale": v}));
apply(app, json!({"verticalScale": v}));
}
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"horizontalScale": v}));
apply(app, json!({"horizontalScale": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"baselineShift": v}));
apply(app, json!({"baselineShift": v}));
}
// Colour chip.
ui.add_space(8.0);
@@ -884,7 +830,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, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
});
@@ -941,7 +887,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, ui.ctx(), props);
apply(app, props);
}
}
});
@@ -977,34 +923,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, ui.ctx(), json!({"align": key}));
apply(app, 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, ui.ctx(), json!({"startIndent": v}));
apply(app, json!({"startIndent": v}));
}
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"endIndent": v}));
apply(app, 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, ui.ctx(), json!({"firstLineIndent": v}));
apply(app, json!({"firstLineIndent": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"spaceBefore": v}));
apply(app, json!({"spaceBefore": v}));
}
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"spaceAfter": v}));
apply(app, json!({"spaceAfter": v}));
}
});
let mut hy = para.hyphenate;
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
apply(app, ui.ctx(), json!({"hyphenate": hy}));
apply(app, json!({"hyphenate": hy}));
}
}
@@ -1017,10 +963,6 @@ pub fn cancel(app: &mut PhotocraftApp) {
}
}
#[cfg(test)]
#[path = "type_tool_tests.rs"]
mod canvas_tests;
#[cfg(test)]
mod tests {
use super::*;
-290
View File
@@ -1,290 +0,0 @@
//! 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);
}
+1 -2
View File
@@ -35,7 +35,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
- `ui.set {tool?, panels?, dockTabs?, dock?, 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)
- `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,7 +61,6 @@ 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}` |
+8 -32
View File
@@ -44,8 +44,8 @@ in the dialog.
| macOS 11+ (universal: Apple silicon + Intel) | `photocraft-<v>-macos-universal.dmg`, `photocraft-cli-<v>-macos-universal.zip` | `macos-15` |
| Windows 10+ x64 | `photocraft-<v>-windows-x64.msi`, `photocraft-<v>-windows-x64-portable.zip` | `windows-latest` |
| Windows 10+ x86 (32-bit) | `photocraft-<v>-windows-x86.msi`, `photocraft-<v>-windows-x86-portable.zip` | `windows-latest` |
| Linux x86_64 | `photocraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz,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`) |
| 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` |
| 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,34 +128,11 @@ 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 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.
- **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.
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,
@@ -215,5 +192,4 @@ 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 that the two Flatpak
manifests agree on the runtime and permissions.
`desktop-file-validate`, `appstreamcli validate`, and a YAML check of the Flatpak manifest.
+8 -51
View File
@@ -1,14 +1,12 @@
# Roadmap
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-05.
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-01.
**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.
**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).
| M | Status | Where we are |
|---|---|---|
@@ -28,45 +26,6 @@ readiness; treat it as an upper bound.
**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:
@@ -77,10 +36,8 @@ Landed on 2026-10-01:
(`docs/releasing.md`).
Next:
1. **Quality of what exists** (from user feedback after 0.2.0, see the assessment above): workflow
acceptance tests, real-file corpus, visual QA, and the open user issues. 0.2.0 shipped signed and
notarized on 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be
obtained.
1. **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.
2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels
curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal
modes in Lab documents; Photoshop smart filters in `SoLd`.
-72
View File
@@ -1,72 +0,0 @@
#!/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"
@@ -1,50 +0,0 @@
# 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,32 +8,22 @@
# 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: '26.08'
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: photocraft
finish-args:
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --share=ipc
- --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.
# 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.
- --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: