mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 13:44:11 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e028c3d4 | ||
|
|
ace983404e | ||
|
|
23e76002de |
No files matched your search
@@ -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'"
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-15
@@ -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,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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms) {
|
||||
Ok(m) => {
|
||||
report(app, None);
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
report(app, Some(e));
|
||||
vec![0.0; w * h]
|
||||
}
|
||||
};
|
||||
let mask = proxy_mask(app, &proxy, k, ¶ms).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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -44,13 +44,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 +65,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user