diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 386989022..da85536c4 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,3 +23,27 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main with: extra-checks-script: packaging/check-app-specific.sh + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 2ee35f176..400a81944 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -32,4 +32,8 @@ jobs: - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - run: cargo test --locked -p vectorcraft - name: Check pending submission and queue refusal feedback - run: cargo test --locked -p vectorcraft-ui-egui tests_printps + shell: bash + # A filter that matches nothing still passes, so fail unless some test ran. + run: | + cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log + grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; } diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2dc19466c..7fc9b5925 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: vectorcraft app-dir: VectorCraft + install-relative-path: Programs/ArtCraft/VectorCraft + install-scope: perUser diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml deleted file mode 100644 index c638bcef5..000000000 --- a/.github/workflows/windows7.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and -# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts -# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build -# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host -# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support. -name: Windows 7 x64 compatibility - -on: - pull_request: - paths: - - "Cargo.toml" - - "Cargo.lock" - - "apps/vectorcraft/Cargo.toml" - - "apps/vectorcraft/src/printing.rs" - - "apps/vectorcraft/src/printing/**" - - "crates/ui-egui/src/print.rs" - - "crates/ui-egui/src/tests/printps.rs" - - "packaging/windows/windows7.ps1" - - ".github/workflows/windows7.yml" - schedule: - - cron: "23 5 * * 1" # weekly - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - -jobs: - build: - runs-on: windows-2022 - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 - with: - arch: x64 - - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master - with: - toolchain: nightly-2026-10-01 - components: rust-src - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: windows7-x64 - - name: Build, check and test the portable package - shell: pwsh - run: ./packaging/windows/windows7.ps1 -Test - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: vectorcraft-windows7-x64-portable - path: dist/release/vectorcraft-windows7-x64-portable.zip - if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index cbf9de129..0460b6192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input. - Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort. - Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible. + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it. - Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them. - Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why. - Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that. diff --git a/Cargo.lock b/Cargo.lock index d329c26a7..78306baae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,7 +1310,6 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "image", @@ -3283,6 +3282,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6053,8 +6053,10 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-core-text", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "pollster 1.0.1", "raw-window-handle", "rfd", @@ -6077,7 +6079,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.17.0" +version = "0.18.0" dependencies = [ "crc32fast", "flate2", @@ -6088,7 +6090,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6100,7 +6102,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6116,7 +6118,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "log", "objc2-core-foundation", @@ -6138,7 +6140,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6150,7 +6152,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6169,7 +6171,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6185,7 +6187,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "crc32fast", @@ -6224,7 +6226,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "flate2", @@ -6248,7 +6250,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "proptest", @@ -6263,7 +6265,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6275,7 +6277,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "log", @@ -6294,7 +6296,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6308,7 +6310,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.17.0" +version = "0.18.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6320,12 +6322,13 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.17.0" +version = "0.18.0" dependencies = [ "aes", "chrono", "craft-pdf", "flate2", + "getrandom 0.4.3", "image", "jpeg-encoder", "krilla", @@ -6351,7 +6354,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.17.0" +version = "0.18.0" dependencies = [ "serde", "serde_json", @@ -6365,7 +6368,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "gif", @@ -6394,14 +6397,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "flate2", @@ -6426,7 +6429,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "image", @@ -6444,7 +6447,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.17.0" +version = "0.18.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6465,7 +6468,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.17.0" +version = "0.18.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6473,7 +6476,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6489,7 +6492,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6501,7 +6504,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6539,7 +6542,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "eframe", "egui", @@ -7670,7 +7673,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.17.0" +version = "0.18.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 644bffe9d..c134a3c19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.17.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -116,6 +116,9 @@ webbrowser = "1" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see # AGENTS.md › Robustness). Tests may unwrap (clippy.toml). diff --git a/NOTICE b/NOTICE index 8698b2dfe..0d0758820 100644 --- a/NOTICE +++ b/NOTICE @@ -30,11 +30,6 @@ based in part on the work of the Independent JPEG Group. WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is under the Apache License 2.0 with the LLVM exception. -The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the -windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree -through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every -other build uses the unmodified crate from crates.io. - Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest; diff --git a/README.md b/README.md index d85cef156..fd45eb9cd 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,6 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-roo The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md). -For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md). - ## Status VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension @@ -249,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files, and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). The interface -speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian; +speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan; [localization-parity.md](docs/localization-parity.md) has the details. The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index 28c15f326..8a4d00db9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,7 +112,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Korean | `ko` | full | 100% | | Vietnamese | `vi` | full | 100% | -Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending). +Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending). The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md). ## Upcoming @@ -144,6 +144,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-29 | Linux: a clipboard that can't be opened (the Flatpak on GNOME, where arboard has neither data-control nor X11) is retried after 1 s, doubling to a minute, and logged once, where the background Paste check used to reconnect and log a warning every 250 ms (267k warnings in 18 h). Scores and estimates unchanged. | | 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. | | 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. | | 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. | @@ -198,6 +199,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 82f0b100d..cb9183315 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -35,7 +35,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR # Apache-2.0. -[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 6ec320ea2..bba1976c1 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -46,7 +46,7 @@ mod perf; #[path = "../../vectorcraft/src/prefs_dir.rs"] mod prefs_dir; /// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts. -#[cfg(all(windows, not(target_vendor = "win7")))] +#[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; @@ -162,7 +162,7 @@ fn help() -> Result<(), String> { fn main() -> ExitCode { // Before any font lookup: the fonts font services load (#579). - #[cfg(all(windows, not(target_vendor = "win7")))] + #[cfg(windows)] system_fonts::install(); #[cfg(target_os = "macos")] mac_fonts::install(); diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index c45c5d867..9caf4bc92 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() { let mut stdin = child.stdin.take().unwrap(); let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); let mut notes = vec![]; - - fn send(stdin: &mut std::process::ChildStdin, v: Value) { - writeln!(stdin, "{v}").unwrap(); + exercise_mcp_core(&mut stdin, &mut lines, &mut notes); + exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes); + exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes); + exercise_mcp_logging(&mut stdin, &mut lines, &mut notes); + exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes); + drop(stdin); + for line in lines { + let v: Value = serde_json::from_str(&line.unwrap()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + notes.push(v); } - // Next reply with this id; notifications (no id) are stashed for later. - fn recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { - loop { - let line = lines.next().unwrap().unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - if v.get("id").and_then(|i| i.as_u64()) == Some(id) { - return v; - } - notes.push(v); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); + let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); + assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); +} + +fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) { + writeln!(stdin, "{v}").unwrap(); +} + +fn mcp_recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { + loop { + let line = lines.next().unwrap().unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + if v.get("id").and_then(|i| i.as_u64()) == Some(id) { + return v; } + notes.push(v); } +} - send( - &mut stdin, +fn exercise_mcp_core( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), ); - let r = recv(&mut lines, &mut notes, 1); + let r = mcp_recv(lines, notes, 1); assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft"); assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); - send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); - let r = recv(&mut lines, &mut notes, 2); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); + let r = mcp_recv(lines, notes, 2); assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); - let r = recv(&mut lines, &mut notes, 3); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); + let r = mcp_recv(lines, notes, 3); assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}"); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}), ); - let r = recv(&mut lines, &mut notes, 4); + let r = mcp_recv(lines, notes, 4); assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); - send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); + assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); + assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}), ); - let r = recv(&mut lines, &mut notes, 7); + let r = mcp_recv(lines, notes, 7); assert_eq!(r["result"]["isError"], false, "{r}"); let id: i64 = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap(); - send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); - let r = recv(&mut lines, &mut notes, 8); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); + let r = mcp_recv(lines, notes, 8); let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["id"].as_i64(), Some(id), "{body}"); +} +fn exercise_mcp_resource_errors( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Unknown resources (-32002) and bad template values (-32602) stay apart. - send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); - assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002); - send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); - assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); + assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); + assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602); +} +fn exercise_mcp_document_resources( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Large containers slice through run_command document.node: a truncated level reports // childCount, and a bad limit is a tool error, not a crash or a full read. - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}), ); - assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false); - let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}); - send(&mut stdin, run(21, "select.all", json!({}))); - recv(&mut lines, &mut notes, 21); - send(&mut stdin, run(22, "object.group", json!({}))); - let r = recv(&mut lines, &mut notes, 22); + assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false); + mcp_send(stdin, run_command(21, "select.all", json!({}))); + mcp_recv(lines, notes, 21); + mcp_send(stdin, run_command(22, "object.group", json!({}))); + let r = mcp_recv(lines, notes, 22); let group = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); - send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); - let r = recv(&mut lines, &mut notes, 23); + mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); + let r = mcp_recv(lines, notes, 23); let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}"); assert_eq!(sliced["childCount"], 2, "{sliced}"); - send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); - assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true); + mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); + assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true); // Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and // document.find locates nodes without dumping the tree. - send(&mut stdin, run(25, "document.inspect", json!({"depth": 0}))); - let r = recv(&mut lines, &mut notes, 25); + mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0}))); + let r = mcp_recv(lines, notes, 25); let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert!(skel["layers"][0].get("children").is_none(), "{skel}"); assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}"); - send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"}))); - let r = recv(&mut lines, &mut notes, 26); + mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"}))); + let r = mcp_recv(lines, notes, 26); let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(found["total"], 1, "{found}"); assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}"); - send(&mut stdin, run(27, "document.find", json!({}))); - assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true); + mcp_send(stdin, run_command(27, "document.find", json!({}))); + assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true); +} +fn exercise_mcp_logging( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // The binary installed its logger: records arrive as notifications/message, and only // after the client asked for them. - send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); - assert!(recv(&mut lines, &mut notes, 11).get("error").is_none()); - send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); + assert!(mcp_recv(lines, notes, 11).get("error").is_none()); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); + assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); +} +fn exercise_mcp_parse_error( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Malformed input is a parse error, and the server keeps serving afterwards. stdin.write_all(b"{not json\n").unwrap(); let parse_err: Value = loop { @@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() { }; assert_eq!(parse_err["error"]["code"], -32700); assert!(parse_err["id"].is_null(), "{parse_err}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); - assert!(recv(&mut lines, &mut notes, 13).get("error").is_none()); - drop(stdin); - // Drain whatever the debug window queued; every line off the protocol stream parses. - for line in lines.by_ref() { - let line = line.unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - notes.push(v); - } - let out = child.wait_with_output().unwrap(); - assert!(out.status.success()); - assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); - let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); - assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); + assert!(mcp_recv(lines, notes, 13).get("error").is_none()); +} + +fn run_command(id: u64, command: &str, params: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}) } /// `vectorcraft-cli commands | head -1` panicked with "failed printing to diff --git a/apps/vectorcraft-web/index.html b/apps/vectorcraft-web/index.html index 42eaaf74e..2ccfc271b 100644 --- a/apps/vectorcraft-web/index.html +++ b/apps/vectorcraft-web/index.html @@ -9,6 +9,13 @@ VectorCraft + + + + + + + x"#); + let t = only_text(&d); + assert_eq!(t.first_style().fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000")); +} + #[test] fn css_selectors_reach_text() { let d = open( diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index d79615cd8..656bf4a8b 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -52,6 +52,38 @@ pub fn select(s: &mut Session, ids: &[NodeId]) { exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::>()})); } +/// Place `png` as `cut.png` with `params` (`file.place`'s, besides `name` and `dataBase64`), then +/// cut the placed image's pixel data short right after its first `IDAT` chunk's type, as a +/// document saved with a damaged image holds it: the header still reads, but the renderer can't +/// decode the image and draws nothing. Place refuses such a file, so tests cut a placed one. The +/// image is stored under the key of its new bytes. → `file.place`'s reply. +pub fn place_cut_png(s: &mut Session, png: &[u8], params: Value) -> Value { + let mut p = json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(png)}); + if let (Some(o), Value::Object(extra)) = (p.as_object_mut(), params) { + o.extend(extra); + } + let r = exec(s, "file.place", p); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Image(im)) = doc.node(id).map(|n| &n.kind) else { panic!("{id:?} is not an image") }; + let old = im.key.clone(); + let mut bytes = doc.images[&old].bytes.to_vec(); + let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap(); + bytes.truncate(idat + 8); + let cut = vectorcraft_doc::ImageBlob::new("image/png", bytes); + let key = cut.content_key(); + doc.images.insert(key.clone(), cut); + if let Some(Node { kind: NodeKind::Image(im), .. }) = doc.node_mut(id) { + im.key = key; + } + let mut shown = false; + doc.visit_images(|_, im| shown |= im.key == old); + if !shown { + doc.images.remove(&old); + } + r +} + /// The three standard fixture states for sweeping commands. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Fixture { diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index fe857506c..4dbac8f6e 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -141,6 +141,8 @@ pub enum Op { ClipRelease, Compound, CompoundRelease, + /// Make Compound Shape in one of the four shape modes, a member's mode, Release or Expand. + CompoundShape(u8), Lock, UnlockAll, Hide, @@ -238,6 +240,7 @@ fn arb_leaf_op() -> impl Strategy { 1 => Just(Op::ClipRelease), 1 => Just(Op::Compound), 1 => Just(Op::CompoundRelease), + 1 => (0u8..7).prop_map(Op::CompoundShape), 1 => Just(Op::Lock), 1 => Just(Op::UnlockAll), 1 => Just(Op::Hide), @@ -369,6 +372,15 @@ impl Op { Op::ClipRelease => c("object.clippingMask.release", json!({})), Op::Compound => c("object.compoundPath.make", json!({})), Op::CompoundRelease => c("object.compoundPath.release", json!({})), + Op::CompoundShape(i) => { + const MODES: [&str; 4] = ["add", "subtract", "intersect", "exclude"]; + match i % 7 { + i @ 0..4 => c("object.compoundShape.make", json!({"mode": MODES[i as usize]})), + 4 => c("object.compoundShape.setMode", json!({"mode": "exclude"})), + 5 => c("object.compoundShape.release", json!({})), + _ => c("object.compoundShape.expand", json!({})), + } + } Op::Lock => c("object.lock", json!({})), Op::UnlockAll => c("object.unlockAll", json!({})), Op::Hide => c("object.hide", json!({})), diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index ac1cd959f..dbfa37431 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -87,6 +87,33 @@ fn fitness(r: f64, ragged: bool) -> u8 { } } +/// Adjustment ratio for a candidate line; `None` means a ragged line is already too long. +fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: bool, p: &Params) -> Option { + if p.ragged { + if natural > width + EPS { + return None; + } + return Some(if last && !p.justify_last { + ((width * SHORT_LAST - natural) / (width * SHORT_LAST_ZONE).max(1.0)).max(0.0) + } else { + (width - natural).max(0.0) / (width * RAG_ZONE).max(1.0) + }); + } + Some(if natural < width { + if last && !p.justify_last { + 0.0 + } else if stretch > EPS { + (width - natural) / stretch + } else { + f64::INFINITY + } + } else if natural > width { + if shrink > EPS { (width - natural) / shrink } else { f64::NEG_INFINITY } + } else { + 0.0 + }) +} + /// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`; /// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets /// an opening bracket there flush with the line's start); `cands` are the break opportunities @@ -146,32 +173,7 @@ pub(crate) fn compose( let natural = w[t] - w[a.pos] - credit + bp.hyphen; let lw = width(a.line); // Adjustment ratio, badness and whether the line ends the active node's reach. - let ratio = if p.ragged { - if natural > lw + EPS { - // Ragged lines never shrink; longer lines from this node only get worse. - continue; - } - if last && !p.justify_last { - ((lw * SHORT_LAST - natural) / (lw * SHORT_LAST_ZONE).max(1.0)).max(0.0) - } else { - (lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0) - } - } else { - let (stretch, shrink) = (st[t] - st[a.pos], sh[t] - sh[a.pos]); - if natural < lw { - if last && !p.justify_last { - 0.0 - } else if stretch > EPS { - (lw - natural) / stretch - } else { - f64::INFINITY - } - } else if natural > lw { - if shrink > EPS { (lw - natural) / shrink } else { f64::NEG_INFINITY } - } else { - 0.0 - } - }; + let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; if ratio < -1.0 { // Too long already; longer lines from this node only get worse. continue; diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index a4745e56d..128cd49b3 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -1,7 +1,7 @@ use super::face::{FontBytes, FontFace, Variation}; use super::names::{name, norm}; use super::traits::FaceStyle; -use super::*; +use super::{HashMap, Location, MetadataProvider, NEXT_ID, Ordering, Size, StringId, Tag}; /// `face` at the axis settings `variations` (user units), as face `id`: its names, kind, italic /// and file, and its weight the `wght` setting when there is one. diff --git a/crates/text/src/fontdb/cache.rs b/crates/text/src/fontdb/cache.rs index e6d711075..09b03c410 100644 --- a/crates/text/src/fontdb/cache.rs +++ b/crates/text/src/fontdb/cache.rs @@ -1,5 +1,5 @@ use super::traits::FontTraits; -use super::*; +use super::{Arc, ColourContent, GlyphImage, HashMap, IMAGE_CACHE_BYTES, IMAGE_CACHE_MAX, PathBuf}; /// One installed family found by the system font scan: its name and the file of each style. #[derive(Debug, Default)] diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 84b3e7e51..0d8eb0e3f 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -17,7 +17,9 @@ use super::scan::{file_face_names, is_font_file}; use super::traits::faces_published; #[cfg(not(target_arch = "wasm32"))] use super::traits::{Alias, FaceStyle}; -use super::*; +use super::{Arc, AtomicU64, FontDb, FontPaths, HashMap, Mutex, Ordering, Path, PathBuf, RwLock, StringId}; +#[cfg(not(target_arch = "wasm32"))] +use super::{ScanStamp, modified}; impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders, diff --git a/crates/text/src/fontdb/dirs.rs b/crates/text/src/fontdb/dirs.rs index 2b39edbc1..0ff448e5d 100644 --- a/crates/text/src/fontdb/dirs.rs +++ b/crates/text/src/fontdb/dirs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Path, PathBuf}; /// A font file holding `tables` (tag, data), sorted by tag as table directories are. #[cfg(any(test, not(target_arch = "wasm32")))] diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5f1973ccd..758acc2c2 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -1,7 +1,7 @@ use super::build::base_values; use super::names::{localized, name}; use super::traits::{FaceKeys, FontTraits}; -use super::*; +use super::{Arc, GlyphId, HashMap, Location, LocationRef, MetadataProvider, Size, StringId}; /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -214,6 +214,11 @@ impl FontFace { pub fn vertical_metrics(&self) -> (f64, f64) { (self.ascent, self.descent) } + /// The height of the lowercase letters in font units, positive (estimated from the ascent + /// when the font has no OS/2 value). + pub fn x_height(&self) -> f64 { + self.x_height + } /// The line gap the font asks for between lines, in font units (from the same table as /// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none). pub fn line_gap(&self) -> f64 { diff --git a/crates/text/src/fontdb/glyph_pen.rs b/crates/text/src/fontdb/glyph_pen.rs index 1a45e0b95..7f0251703 100644 --- a/crates/text/src/fontdb/glyph_pen.rs +++ b/crates/text/src/fontdb/glyph_pen.rs @@ -1,5 +1,5 @@ use super::face::FontFace; -use super::*; +use super::{BezPath, OutlinePen}; /// Is glyph `gid` of `face` (as `font`) drawn from an SVG document, if it has one: the face has /// SVG glyphs, and the glyph no COLR layers, which come first. diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index a5758b884..e01a47493 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::PathBuf; use super::build::{instanced, style_distance, style_italic, style_weight}; use super::cache::Catalog; use super::face::{FALLBACK_FAMILY, FontFace, MAX_LIVE_INSTANCES, Variation, VariationAxis}; @@ -8,7 +10,10 @@ use super::names::{norm, norm_chars, script_families}; #[cfg(not(target_arch = "wasm32"))] use super::scan::{enumerate_faces, font_files}; use super::traits::{Alias, FontMatch, FontTraits}; -use super::*; +use super::{ + Arc, BezPath, CANONICAL_CACHE_MAX, Color, ColourContent, DrawSettings, FontDb, GlyphId, GlyphImage, MetadataProvider, NEXT_ID, OUTLINE_CACHE_MAX, + Ordering, Size, +}; impl FontDb { /// The family's own name and the style for a name given in a document: any of a family's diff --git a/crates/text/src/fontdb/names.rs b/crates/text/src/fontdb/names.rs index b027e2a37..fe0e24cc0 100644 --- a/crates/text/src/fontdb/names.rs +++ b/crates/text/src/fontdb/names.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MetadataProvider, StringId}; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] diff --git a/crates/text/src/fontdb/scan.rs b/crates/text/src/fontdb/scan.rs index 321b282ea..ebe4d4d44 100644 --- a/crates/text/src/fontdb/scan.rs +++ b/crates/text/src/fontdb/scan.rs @@ -4,7 +4,7 @@ use super::dirs::sfnt_of; use super::face::{MAX_AXES, MAX_INSTANCES}; use super::names::{all_names, name, norm}; use super::traits::{FaceKeys, FaceStyle, FontTraits, face_styles}; -use super::*; +use super::{FileRef, MetadataProvider, Path, StringId, Tag}; /// The named instances of a variable font face (its `fvar` table) as styles of the face's family, /// named by their subfamily names, without the default instance (the face itself) and without diff --git a/crates/text/src/fontdb/traits.rs b/crates/text/src/fontdb/traits.rs index 2d6b8c6e1..b6244b81d 100644 --- a/crates/text/src/fontdb/traits.rs +++ b/crates/text/src/fontdb/traits.rs @@ -1,7 +1,7 @@ use super::face::{MAX_AXES, Variation}; use super::names::{MAX_LEGACY_NAMES, all_names, localized, name, native_family_name, norm}; use super::scan::named_instances; -use super::*; +use super::{MetadataProvider, StringId}; /// The names a face answers to besides its (English) family and style, normalized ([`norm`]): /// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise diff --git a/crates/text/src/fontdb/wanted.rs b/crates/text/src/fontdb/wanted.rs index d06f129e4..31539737a 100644 --- a/crates/text/src/fontdb/wanted.rs +++ b/crates/text/src/fontdb/wanted.rs @@ -2,7 +2,7 @@ use super::names::norm; #[cfg(not(target_arch = "wasm32"))] use super::scan::{file_face_names_within, is_font_file}; use super::traits::FaceStyle; -use super::*; +use super::{HashMap, Path}; /// A font a document names that isn't available, to look for in font files ([`WantedFonts`]). #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 9d1cc358c..16aa674ca 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -90,6 +90,26 @@ fn parse_exception(entry: &str) -> (String, Exception) { (word, Exception::Only(points)) } +/// Exception points count chars of the lowercased word; lowercasing can add chars ('İ' becomes +/// 'i' + U+0307), so map each point back to the char index in `word` it falls before. Points +/// inside one char's lowercase expansion are dropped; a point at the end of the word is kept. +fn lowercase_points_to_word(word: &str, pts: &[usize]) -> Vec { + let mut out = Vec::with_capacity(pts.len()); + let mut lower = 0; + let mut len = 0; + for (i, ch) in word.chars().enumerate() { + if pts.contains(&lower) { + out.push(i); + } + lower += ch.to_lowercase().count(); + len = i + 1; + } + if pts.contains(&lower) { + out.push(len); + } + out +} + fn normalize_word(word: &str) -> String { word.chars().flat_map(|c| c.to_lowercase()).collect() } @@ -140,7 +160,7 @@ pub fn hyphen_points_with(word: &str, opts: &HyphenationOptions) -> Vec { if let Some(ex) = exception_for(word) { return match ex { Exception::Never => vec![], - Exception::Only(pts) => pts, + Exception::Only(pts) => lowercase_points_to_word(word, &pts), }; } let c: Vec = word.chars().collect(); @@ -243,6 +263,17 @@ mod tests { assert_eq!(hyphen_points("typography"), pattern, "override cleared"); } + /// Exception break points are char indices into the word itself, even when lowercasing + /// changes the char count ('İ' lowercases to 'i' + U+0307). + #[test] + fn exception_points_index_the_original_word() { + with_exceptions("İs-tan-bul", || { + assert_eq!(hyphen_points("İstanbul"), vec![2, 5]); + assert_eq!(hyphenate_word("İstanbul"), "İs-tan-bul"); + assert_eq!(hyphenate_word("İSTANBUL"), "İS-TAN-BUL"); + }); + } + /// The default Hyphenation options are the limits of [`hyphen_points`]: words of 6 letters or /// more, 3 letters on each side of the hyphen. #[test] diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index 33fbe91b3..ef7f13be8 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -55,7 +55,7 @@ pub(crate) fn is_word_space(g: &SGlyph) -> bool { /// A glyph letter spacing follows: a visible glyph followed by another one in the same word. fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool { - !g.is_space() && g.inline.is_none() && next.is_some_and(|n| !n.is_space()) + !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}') } /// The width of a word space in `g`'s font and size: what letter spacing is a share of. @@ -83,7 +83,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g f.word = (base * (w_max - w_des).max(0.0), base * (w_des - w_min).max(0.0)); continue; } - if g.is_space() || g.inline.is_some() { + if g.is_space() || g.inline.is_some() || g.ch == '\u{2028}' { continue; } let base = g.adv; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 838940ddb..66cdaf304 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -182,7 +182,7 @@ impl Ctx<'_> { fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). - let blank = g.is_soft_hyphen() || g.ch.is_control(); + let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}'; // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or // COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps. // Art in the text's colours takes the run's fill (black when it isn't a colour) and @@ -216,6 +216,12 @@ impl Ctx<'_> { Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy) } }; + let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { + let placed = Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + placed.inverse() * Affine::rotate(g.rotation.to_radians()) * placed + } else { + Affine::IDENTITY + }; let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. @@ -271,6 +277,7 @@ impl Ctx<'_> { image, colour, bend: None, + unturned, }); } diff --git a/crates/text/src/layout/bidi.rs b/crates/text/src/layout/bidi.rs index 5512725a6..e4b626324 100644 --- a/crates/text/src/layout/bidi.rs +++ b/crates/text/src/layout/bidi.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BidiInfo, Level, ParaDirection, Range, SGlyph}; /// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, /// without one, from its first strong character. `None` for the usual paragraph with nothing right diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 6dea3d9c6..b83640d02 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,8 @@ use super::region::Region; -use super::*; +use super::{ + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, + hyphen_points_with, no_line_end, no_line_start, soft_line_start, +}; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). #[derive(Clone, Copy, Debug, PartialEq)] @@ -166,7 +169,7 @@ impl Pen<'_> { pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; - let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); + let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); let width: f64 = seg.iter().map(|g| g.adv).sum(); let target = |pos: f64, align: TabAlign, on: char| match align { TabAlign::Left => pos, @@ -279,6 +282,9 @@ pub(super) fn break_line( kinsoku: Kinsoku, ) -> (usize, bool) { if !width.is_finite() { + if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { + return (i + pos + 1, false); + } return (g.len(), false); } let mut x = 0.0; @@ -294,9 +300,9 @@ pub(super) fn break_line( cluster_end += 1; } // No Break: the marked characters stay on one line, including across a space inside the run. - if gl.no_break { + if gl.no_break && gl.ch != '\u{2028}' { while let Some(n) = g.get(cluster_end) { - if !n.no_break { + if !n.no_break || n.ch == '\u{2028}' { break; } let b = n.byte; @@ -311,6 +317,9 @@ pub(super) fn break_line( } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() }; + if gl.ch == '\u{2028}' { + return (cluster_end, false); + } if j > first && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 2c8f74b0c..33d25f34f 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -3,7 +3,9 @@ use super::on_path::on_path; use super::region::Region; use super::ruby::ruby_layout; use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds}; -use super::*; +use super::{ + Affine, BezPath, CharStyle, Ctx, FontDb, LayoutOptions, OtFeatures, Point, Rect, Shape, TextKind, TextLayout, TextObject, VerticalAlign, +}; /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index b923328f7..03c2eb3c4 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -5,7 +5,11 @@ use super::breaking::{ }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; -use super::*; +use super::{ + Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle, + Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, + is_word_space, kashida, mark_word_ends, style_metrics, +}; /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -32,7 +36,15 @@ fn compose_para( // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). // The tab after a list marker always starts the first line and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); - if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { + let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); + if composer != Composer::EveryLine + || pen.regions.is_none() + || sg.len() < 2 + || sg.len() > MAX_COMPOSE_GLYPHS + || may_hang + || has_tab + || has_soft_break + { return None; } let m = Metrics::of(&sg[0]); @@ -130,451 +142,687 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg no_room_for: f64::INFINITY, }; let markers = list_markers(t, paras); - 'paras: for (pi, pr) in paras.iter().enumerate() { - let para = t.para_at(pi); + for (pi, pr) in paras.iter().enumerate() { let marker_text = markers.get(pi).and_then(Option::as_deref); - // The leading model is a paragraph attribute: this paragraph's lines (and the space to - // its first line) follow its own. - pen.model = para.leading_model; - let text = cx.text; - let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); - let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); - // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. - mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); - // Japanese composition: consecutive punctuation shares one half-em space. - let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; - // …and a quarter em between Japanese and Latin text. - let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; - // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); - // Threaded text: a first paragraph that continues from the frame before has its first - // line, first-line indent and list marker there. - let continued = pi == 0 && t.first_para_continues; - // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. - // Justification and Optical Margin Alignment leave them as they are. - let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; - if marker > 0 { - flex.splice(0..0, vec![Flex::default(); marker]); - if !compressed.is_empty() { - compressed.splice(0..0, vec![false; marker]); - } - if !wakan.is_empty() { - wakan.splice(0..0, vec![0.0; marker]); - } + if !flow_paragraph(cx, &mut pen, t, pi, pr.clone(), marker_text, regions) { + break; } - // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. - let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); - if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { - for g in sg.iter_mut().filter(|g| g.inline.is_none()) { - g.leading = auto_leading(cx.style_at(g.byte)); - } + } +} + +fn flow_paragraph( + cx: &mut Ctx<'_>, + pen: &mut Pen<'_>, + t: &TextObject, + pi: usize, + pr: Range, + marker_text: Option<&str>, + regions: Option<&[Region]>, +) -> bool { + let paragraph = prepare_paragraph(cx, pen, t, pi, pr, marker_text, regions); + run_paragraph_lines(cx, pen, t, paragraph) +} + +struct ParagraphData<'a, 'text> { + para: &'a ParaStyle, + pr: Range, + marker_text: Option<&'a str>, + bidi: Option>, + rtl: bool, + continued: bool, + sg: Vec, + compressed: Vec, + wakan: Vec, + flex: Vec, + marker: usize, + pm: Metrics, + composed: Option>, + zone: f64, + hyphen_limit: usize, + mirror: bool, + regions: Option<&'a [Region]>, +} + +fn prepare_paragraph<'a, 'text>( + cx: &mut Ctx<'text>, + pen: &mut Pen<'_>, + t: &'a TextObject, + pi: usize, + pr: Range, + marker_text: Option<&'a str>, + regions: Option<&'a [Region]>, +) -> ParagraphData<'a, 'text> { + let para = t.para_at(pi); + // The leading model is a paragraph attribute: this paragraph's lines (and the space to + // its first line) follow its own. + pen.model = para.leading_model; + let text = cx.text; + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(&pr)); + let rtl = is_rtl(bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. + mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Japanese composition: consecutive punctuation shares one half-em space. + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + // …and a quarter em between Japanese and Latin text. + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how + // far a justified line may move each one from it. + let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // Threaded text: a first paragraph that continues from the frame before has its first + // line, first-line indent and list marker there. + let continued = pi == 0 && t.first_para_continues; + // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. + // Justification and Optical Margin Alignment leave them as they are. + let marker = if continued { 0 } else { cx.prepend_marker(&pr, para, marker_text, rtl, &mut sg) }; + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); } - let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); - let lead = auto_leading(cx.style_at(pr.start)); - let st = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre()); - Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } + // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. + let auto_leading = |style: &CharStyle| style.leading.unwrap_or_else(|| para.justification.auto_leading_for(style.size)); + if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { + for glyph in sg.iter_mut().filter(|glyph| glyph.inline.is_none()) { + glyph.leading = auto_leading(cx.style_at(glyph.byte)); + } + } + let pm = { + let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let lead = auto_leading(cx.style_at(pr.start)); + let style = cx.style_at(pr.start); + let (cap, xh) = cap_x_heights(cx.db, style); + let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } + }; + if pi > 0 { + pen.pending += para.space_before; + } + let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued); + // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the + // Hyphen Limit counts the lines in a row that end with a hyphen. + let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); + let mirror = mirrors_indents(para, rtl, cx.vertical); + ParagraphData { + para, + pr, + marker_text, + bidi, + rtl, + continued, + sg, + compressed, + wakan, + flex, + marker, + pm, + composed, + zone, + hyphen_limit, + mirror, + regions, + } +} + +struct PlacementContext<'a> { + para: &'a ParaStyle, + pr: &'a Range, + sg: &'a mut [SGlyph], + continued: bool, + rtl: bool, + mirror: bool, + pm: Metrics, + composed: &'a Option>, + hyphen_run: usize, + hyphen_limit: usize, + zone: f64, + index: usize, + para_line: usize, +} + +struct LinePosition { + baseline: f64, + x0: f64, + x1: f64, + end: usize, + hyph: bool, + m: Metrics, + ind_l: f64, + ind_r: f64, +} + +fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { + let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let n = sg.len(); + let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; + let first_line = *para_line == 0 && !*continued; + let (ind_l, ind_r) = line_indents(para, first_line, *mirror); + // Measured before Mojikumi narrows an opening bracket, as the composer measures it. + let word = word_width(sg, *i); + // Mojikumi: an opening bracket starting a line is set flush with the line's start (the + // space before it goes), and the line has that much more room. At a paragraph's start + // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). + if !*rtl + && para.mojikumi == Mojikumi::LineEndHalf + && let Some(glyph) = sg.get_mut(*i) + && glyph.lead <= 0.0 + && let Some(half) = punct_half(glyph, Punct::Opening) + { + glyph.adv -= half; + glyph.lead += half; + } + // Place, break, then settle the baseline on the line's real metrics (moving on to the + // next row/column if it no longer fits). + loop { + let first_in_region = pen.prev.is_none(); + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; + return None; }; - if pi > 0 { - pen.pending += para.space_before; - } - let n = sg.len(); - let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); - // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the - // Hyphen Limit counts the lines in a row that end with a hyphen. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; - let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); - let mut hyphen_run = 0; - let mut li_para = 0; - let mut i = 0; - let mirror = mirrors_indents(para, rtl, cx.vertical); - loop { - let est = if i < n { Metrics::of(&sg[i]) } else { pm }; - let first_line = li_para == 0 && !continued; - let (ind_l, ind_r) = line_indents(para, first_line, mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(&sg, i); - // Mojikumi: an opening bracket starting a line is set flush with the line's start (the - // space before it goes), and the line has that much more room. At a paragraph's start - // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). - if !rtl - && para.mojikumi == Mojikumi::LineEndHalf - && let Some(g) = sg.get_mut(i) - && g.lead <= 0.0 - && let Some(h) = punct_half(g, Punct::Opening) - { - g.adv -= h; - g.lead += h; - } - // Place, break, then settle the baseline on the line's real metrics (moving on to the - // next row/column if it no longer fits). - let (baseline, x0, x1, end, hyph, m) = loop { - let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - }; - let width = x1 - x0 - ind_l - ind_r; - let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); - let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { - Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), - }; - let m = Metrics::max(&sg[i..end]).unwrap_or(pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { - 0.0 - } else if pen.model == LeadingModel::EmBoxTop { - // The em box top is fixed (the frame's top, or the line above's): the baseline - // hangs from it by this line's tallest em box. - if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } - } else if first_in_region { - m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) - } else { - m.lead - est.lead - }; - if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { - // Try the next row/column; overflow if there is none. - pen.ri += 1; - pen.prev = None; - pen.pending = 0.0; - pen.queued.clear(); - continue; - } - break (baseline, x0, x1, end, hyph, m); - }; - hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; - let width = ax1 - ax0; + let width = x1 - x0 - ind_l - ind_r; + let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); + let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, hyph)) if end > *i => (end, hyph), + _ if *i < n => break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), + _ => (n, false), + }; + let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); + baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + 0.0 + } else if pen.model == LeadingModel::EmBoxTop { + // The em box top is fixed (the frame's top, or the line above's): the baseline + // hangs from it by this line's tallest em box. + if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } + } else if first_in_region { + m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) + } else { + m.lead - est.lead + }; + if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { + // Try the next row/column; overflow if there is none. + pen.ri += 1; + pen.prev = None; pen.pending = 0.0; - let last_of_para = end >= n; - let mut trimmed = end; - while trimmed > i && sg[trimmed - 1].is_space() { - trimmed -= 1; + pen.queued.clear(); + continue; + } + return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + } +} + +struct LineGeometry { + position: LinePosition, + index: usize, + trimmed: usize, + body: usize, + end_trim: f64, + hyphen: Option, + ax0: f64, + ax1: f64, + width: f64, + render_width: f64, + order: Vec, + drawn: Vec, + opt_l: f64, + opt_r: f64, + held: Vec, + hung: Vec, + last_of_para: bool, +} + +struct LineJustification { + per_space: f64, + per_gap: f64, + per_cjk: f64, + fit: LineFit, + start_x: f64, + after: Vec>, + kashidas: Vec<(usize, f64)>, +} + +fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { + let n = paragraph.sg.len(); + let mut hyphen_run = 0; + let mut line_in_paragraph = 0; + let mut index = 0; + let mut emitted_empty_continuation = false; + loop { + let pm = if index >= n + && let Some(last) = paragraph.sg.last() + { + Metrics::of(last) + } else { + paragraph.pm + }; + let mut placement = PlacementContext { + para: paragraph.para, + pr: ¶graph.pr, + sg: &mut paragraph.sg, + continued: paragraph.continued, + rtl: paragraph.rtl, + mirror: paragraph.mirror, + pm, + composed: ¶graph.composed, + hyphen_run, + hyphen_limit: paragraph.hyphen_limit, + zone: paragraph.zone, + index, + para_line: line_in_paragraph, + }; + let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false }; + hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 }; + pen.pending = 0.0; + let geometry = prepare_line_geometry(cx, t, ¶graph, position, index); + let justification = justify_flow_line(cx.vertical, cx.text, ¶graph, &geometry); + emit_flow_line(cx, pen, ¶graph, &geometry, &justification); + line_in_paragraph += 1; + index = geometry.position.end; + if index >= n { + if emitted_empty_continuation { + break; } - let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { - Some(k) => { - let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0) - } - None => 0.0, - }; - let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; - let line_start = sg.get(i).map_or(pr.start, |g| g.byte); - let line_end = sg.get(end).map_or(pr.end, |g| g.byte); - let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); - // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. - let share: Option f64> = - (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); - // The glyphs before `stop` in visual order, each with the advance it is set with. A list - // marker does not hang: it is counted with no width. - let set = |j: usize, g: &SGlyph| { - if j < marker { - 0.0 - } else if j + 1 == trimmed { - g.adv - end_trim - } else { - g.adv - } - }; - let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { - let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); - let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); - (at, glyphs) - }; - // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: - // when it doesn't fit, counting what hangs at the line's start as room; Forced: - // always). The rest of the line is aligned and justified without it, and it follows - // the line's last character. - let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); - let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) - .then(|| sg.get(trimmed - 1)) - .flatten() - .filter(|g| hangs(g)) - .map(|g| g.adv - end_trim) - .filter(|_| match para.burasagari { - Burasagari::None => false, - Burasagari::Standard => w > width + room_at_start() + EPS, - Burasagari::Forced => true, - }); - let body = if hang.is_some() { trimmed - 1 } else { trimmed }; - let w = w - hang.unwrap_or(0.0); - // The glyphs before the line's trailing spaces (and its burasagari mark), in the order - // they are drawn. - let (drawn, mut visible) = visible(body); - // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins - // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much - // wider at each end. A left-aligned line then hangs only at its left, a right-aligned - // line hangs only at its right, and centered and justified lines hang at both; Auto - // aligns a right-to-left line at its right. A right-to-left line is read from right to - // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its - // left. Justification adds no space after a punctuation mark hanging at the left, nor - // before one hanging at the right (`held`). A letter that hangs by part of its width - // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so - // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph - // attribute) hangs the same punctuation by the same amounts, and no letters; with - // Optical Margin Alignment on as well, the letters hang too. - let (opt_l, opt_r, held, hung) = if let Some(share) = share { - if let Some(h) = &hyphen { - visible.push((h, h.adv)); - } - let (l, r, left_glyphs, right_glyphs) = - if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; - let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; - let n_visible = visible.len(); - let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let held: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) - .map(|(_, &j)| j) - .collect(); - let hung: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) - .map(|(_, &j)| j) - .collect(); - (l, r, held, hung) - } else { - (0.0, 0.0, vec![], vec![]) - }; - // Justification adds nothing to the list marker's glyphs. - let mut held = held; - held.extend(i..marker.min(body)); - // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph - // scaling of a `hung` one. - let line_flex = |j: usize| { - if held.contains(&j) { - return Flex::default(); - } - let mut f = flex.get(j).copied().unwrap_or_default(); - if hung.contains(&j) { - f.glyph = (0.0, 0.0); - } - f - }; - let room = width + opt_l + opt_r; - let (align, justify) = match para.justify { - Justify::Auto => (if rtl { 2 } else { 0 }, false), - Justify::Left => (0, false), - Justify::Center => (1, false), - Justify::Right => (2, false), - Justify::JustifyLeft => (0, !last_of_para), - Justify::JustifyCenter => (1, !last_of_para), - Justify::JustifyRight => (2, !last_of_para), - Justify::JustifyAll => (0, true), - }; - let justify = justify && regions.is_some(); - let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); - let mut fit = LineFit::default(); - let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); - // A gap that justification adds between two glyphs goes into the advance of the one - // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a - // right-to-left line comes before it in the text. - let mut after: Vec> = vec![None; body.saturating_sub(i)]; - for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { - if let Some(slot) = after.get_mut(a - i) { - *slot = Some(b); - } - } - let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); - // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the - // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. - let cjk_gap = |j: usize| { - !held.contains(&j) - && drawn_after(j) - .and_then(|k| sg.get(j).zip(sg.get(k))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) - }; - let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); - // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; - let (align, justify) = match (single_word, para.justification.single_word) { - (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), - (true, SingleWordJustify::AlignLeft) => (0, false), - (true, SingleWordJustify::AlignCenter) => (1, false), - (true, SingleWordJustify::AlignRight) => (2, false), - }; - // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, - // and the word spaces and gaps take what is left. - let kashidas = - if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; - let w = w + kashidas.iter().map(|k| k.1).sum::(); - if justify && (room - w).abs() > EPS { - if cjk_gaps > 0 && room > w { - // Spread over the CJK gaps and the word spaces alike. - let per = (room - w) / (cjk_gaps + spaces) as f64; - (per_space, per_cjk) = (per, per); - } else if spaces > 0 { - // Word spacing, letter spacing and glyph scaling within their ranges, then the - // word spaces alone; composed lines may shrink word spaces (never below zero). - let line: Vec = (i..body).map(line_flex).collect(); - let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); - fit = fit_line(&line, room - w, spaces, floor); - } else if room > w { - // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let line: Vec = (i..body).map(line_flex).collect(); - fit = fit_line(&line, room - w, 0, 0.0); - let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); - let rest = room - w - used; - let gaps = (i..body) - .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) - .count(); - if gaps > 0 && rest > EPS { - per_gap = rest / gaps as f64; - } - } - } - let start_x = if justify { - ax0 - opt_l - } else if regions.is_none() { - match align { - 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, - _ => -ind_r + opt_r - w, - } - } else { - match align { - 0 => ax0 - opt_l, - 1 => ax0 - opt_l + (room - w) * 0.5, - _ => ax1 + opt_r - w, - } - }; - let li = cx.out.lines.len(); - let glyph_start = cx.out.glyphs.len(); - // Character Alignment lines smaller characters up with the line's largest em box. - let line_em = sg.get(i..end).map_or(0.0, |line| line.iter().map(glyph_em).fold(0.0, f64::max)); - let mut x_end = start_x; - // Tab stops are measured from the frame's left edge (point type: the origin). - let tab_origin = if regions.is_some() { x0 } else { 0.0 }; - // A line with tabs is measured as if it started at the left (see `set_width`), then - // aligned as a whole: its tabs stop where they were measured, however it is aligned. - let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; - // RTL's logical trailing spaces precede the visible content. Keep them outside - // the aligned content extent, just as LTR trailing spaces extend to its right. - let leading_space_width: f64 = - order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); - let mut x = start_x - leading_space_width; - // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. - let mut marker_glyphs: Option> = None; - let mut marker_tab = 0; - for (oi, &offset) in order.iter().enumerate() { - let j = i + offset; - let Some(g) = sg.get(j) else { continue }; - let mut adv = g.adv; - if j + 1 == trimmed { - adv -= end_trim; - } - // Glyph scaling a justified line adds to the glyph (1: none). - let mut scale = 1.0; - let mut leader = ""; - if g.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if g.len > 0 { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); - } - } else if j < body { - let f = line_flex(j); - if is_word_space(g) { - adv += fit.space(&f); - } else { - adv += fit.letter(&f) + fit.glyph(&f); - scale = fit.glyph_scale(&f); - } - if g.is_space() { - adv += per_space; - } else if per_cjk != 0.0 { - if cjk_gap(j) { - adv += per_cjk; - } - } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { - // Between glyphs, never inside a tate-chu-yoko block (one cell). - adv += per_gap; - } - } - // The kashida before a cluster in the text is drawn at the cluster's right, between - // it and the letter before it, and the cluster's last glyph advances over it: the - // caret, selection and hit testing take it as part of the cluster. - let kashida = kashidas - .binary_search_by_key(&g.byte, |k| k.0) - .ok() - .and_then(|at| kashidas.get(at)) - .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) - .map(|k| k.1); - adv += kashida.unwrap_or(0.0); - let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); - let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; - if j < marker { - let at = cx.out.glyphs.len(); - marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); - if j + 1 == marker { - marker_tab = at; - } - } - cx.emit(g, place, Point::new(x, y), 0.0, adv, li); - if let Some(out) = cx.out.glyphs.last_mut() { - out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; - } - if !leader.is_empty() { - // The leader belongs to the tab's glyph: the gap stays one character for the - // caret, selection and hit testing. - let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); - if let Some(tab) = cx.out.glyphs.last_mut() { - tab.outline.extend(dots); - } - } - if let Some(len) = kashida { - cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); - } - x += adv; - if j < trimmed { - x_end = x; - } - } - if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) { - cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); - } - if let Some(h) = &hyphen { - // The hyphen follows the last non-space glyph. - let hx = x_end; - cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); - x_end = hx + h.adv; - } - cx.out.lines.push(LineInfo { - rtl: rtl && !cx.vertical, - baseline, - x0: start_x, - x1: x_end, - ascent: m.asc, - descent: m.desc, - first_baseline: if pen.model == LeadingModel::EmBoxTop { m.top.max(pen.fb_min) } else { m.first_baseline(pen.fb, pen.fb_min) }, - start: if i < n { sg[i].byte } else { pr.start }, - end: if last_of_para { pr.end } else { sg[end].byte }, - glyph_start, - glyph_end: cx.out.glyphs.len(), - avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, - region: if regions.is_some() { pen.ri } else { 0 }, - }); - pen.settled(baseline, m); - // Further spans of this line band share the settled baseline. - for q in &mut pen.queued { - q.0 = baseline; - } - li_para += 1; - i = end; - if i >= n { + emitted_empty_continuation = paragraph.sg.last().is_some_and(|g| g.ch == '\u{2028}'); + if !emitted_empty_continuation { break; } } - pen.pending += para.space_after; + } + pen.pending += paragraph.para.space_after; + true +} + +fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { + let para = paragraph.para; + let sg = ¶graph.sg; + let n = sg.len(); + let (ax0, ax1) = + if paragraph.regions.is_some() { (position.x0 + position.ind_l, position.x1 - position.ind_r) } else { (position.x0, position.x1) }; + let width = ax1 - ax0; + let mut trimmed = position.end; + while trimmed > index && (sg[trimmed - 1].is_space() || sg[trimmed - 1].ch == '\u{2028}') { + trimmed -= 1; + } + let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); + // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and + // no Japanese–Latin space is left at the end of a line. + let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| { + let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; + half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0) + }); + let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; + let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); + let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); + let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); + // The glyphs before `stop` in visual order, each with the advance it is set with. A list + // marker does not hang: it is counted with no width. + let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { + let indices: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < stop).collect(); + let glyphs = indices + .iter() + .filter_map(|&j| { + sg.get(j).map(|g| { + ( + g, + if j < paragraph.marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + }, + ) + }) + }) + .collect(); + (indices, glyphs) + }; + // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. + let share = (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); + // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: + // when it doesn't fit, counting what hangs at the line's start as room; Forced: + // always). The rest of the line is aligned and justified without it, and it follows + // the line's last character. + let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); + let hang = (paragraph.regions.is_some() && !paragraph.rtl && hyphen.is_none() && trimmed > index + 1) + .then(|| sg.get(trimmed - 1)) + .flatten() + .filter(|g| hangs(g)) + .map(|g| g.adv - end_trim) + .filter(|_| match para.burasagari { + Burasagari::None => false, + Burasagari::Standard => measured_width > width + room_at_start() + EPS, + Burasagari::Forced => true, + }); + let body = if hang.is_some() { trimmed - 1 } else { trimmed }; + let render_width = measured_width - hang.unwrap_or(0.0); + // The glyphs before the line's trailing spaces (and its burasagari mark), in the order + // they are drawn. + let (drawn, mut visible) = visible(body); + // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins + // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much + // wider at each end. A left-aligned line then hangs only at its left, a right-aligned + // line hangs only at its right, and centered and justified lines hang at both; Auto + // aligns a right-to-left line at its right. A right-to-left line is read from right to + // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its + // left. Justification adds no space after a punctuation mark hanging at the left, nor + // before one hanging at the right (`held`). A letter that hangs by part of its width + // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so + // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph + // attribute) hangs the same punctuation by the same amounts, and no letters; with + // Optical Margin Alignment on as well, the letters hang too. + let (opt_l, opt_r, held, hung) = if let Some(share) = share { + if let Some(hyphen) = &hyphen { + visible.push((hyphen, hyphen.adv)); + } + let (left, right, left_count, right_count) = + if paragraph.rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; + let (right, right_count) = if hang.is_some() { (0.0, 0) } else { (right, right_count) }; + let visible_count = visible.len(); + let punctuation = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); + let held = drawn + .iter() + .enumerate() + .filter(|&(k, _)| (k < left_count && punctuation(k)) || (right_count > 0 && k + 1 + right_count >= visible_count && punctuation(k + 1))) + .map(|(_, &j)| j) + .collect(); + let hung = drawn + .iter() + .enumerate() + .filter(|&(k, _)| k < left_count || (right_count > 0 && k + right_count >= visible_count)) + .map(|(_, &j)| j) + .collect(); + (left, right, held, hung) + } else { + (0.0, 0.0, Vec::new(), Vec::new()) + }; + // Justification adds nothing to the list marker's glyphs. + let mut held = held; + held.extend(index..paragraph.marker.min(body)); + let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}'); + LineGeometry { + position, + index, + trimmed, + body, + end_trim, + hyphen, + ax0, + ax1, + width, + render_width, + order, + drawn, + opt_l, + opt_r, + held, + hung, + last_of_para, + } +} + +fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option { + glyph.checked_sub(index).and_then(|offset| after.get(offset).copied().flatten()) +} + +// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the +// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. +fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph]) -> bool { + !held.contains(&glyph) + && drawn_after(index, glyph, after) + .and_then(|next| sg.get(glyph).zip(sg.get(next))) + .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) +} + +// What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph +// scaling of a `hung` one. +fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex { + if held.contains(&glyph) { + return Flex::default(); + } + let mut value = flex.get(glyph).copied().unwrap_or_default(); + if hung.contains(&glyph) { + value.glyph = (0.0, 0.0); + } + value +} + +fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { + let para = paragraph.para; + let sg = ¶graph.sg; + let (held, hung) = (&line.held, &line.hung); + let room = line.width + line.opt_l + line.opt_r; + let (mut align, mut justify) = match para.justify { + Justify::Auto => (if paragraph.rtl { 2 } else { 0 }, false), + Justify::Left => (0, false), + Justify::Center => (1, false), + Justify::Right => (2, false), + Justify::JustifyLeft => (0, !line.last_of_para), + Justify::JustifyCenter => (1, !line.last_of_para), + Justify::JustifyRight => (2, !line.last_of_para), + Justify::JustifyAll => (0, true), + }; + justify &= paragraph.regions.is_some(); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count(); + // A gap that justification adds between two glyphs goes into the advance of the one + // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a + // right-to-left line comes before it in the text. + let mut after: Vec> = vec![None; line.body.saturating_sub(line.index)]; + for (&a, &b) in line.drawn.iter().zip(line.drawn.iter().skip(1)) { + if let Some(slot) = after.get_mut(a - line.index) { + *slot = Some(b); + } + } + let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + // Single Word Justification: a justified line holding one word (no word space) is + // spread between its letters (Full Justify) or aligned. + let single_word = justify && spaces == 0 && cjk_gaps == 0; + (align, justify) = match (single_word, para.justification.single_word) { + (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), + (true, SingleWordJustify::AlignLeft) => (0, false), + (true, SingleWordJustify::AlignCenter) => (1, false), + (true, SingleWordJustify::AlignRight) => (2, false), + }; + // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, + // and the word spaces and gaps take what is left. + let kashidas = if justify && !vertical && room > line.render_width + EPS { + kashida::plan(text, sg, line.index..line.body, para.kashida, room - line.render_width) + } else { + Vec::new() + }; + let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); + let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); + let mut fit = LineFit::default(); + if justify && (room - width).abs() > EPS { + if cjk_gaps > 0 && room > width { + // Spread over the CJK gaps and the word spaces alike. + per_space = (room - width) / (cjk_gaps + spaces) as f64; + per_cjk = per_space; + } else if spaces > 0 { + // Word spacing, letter spacing and glyph scaling within their ranges, then the + // word spaces alone; composed lines may shrink word spaces (never below zero). + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + fit = fit_line(&flex, room - width, spaces, floor); + } else if room > width { + // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + fit = fit_line(&flex, room - width, 0, 0.0); + let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); + let rest = room - width - used; + let gaps = (line.index..line.body) + .filter(|&j| { + !held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) + }) + .count(); + if gaps > 0 && rest > EPS { + per_gap = rest / gaps as f64; + } + } + } + let position = &line.position; + let start_x = if justify { + line.ax0 - line.opt_l + } else if paragraph.regions.is_none() { + match align { + 0 => position.ind_l - line.opt_l, + 1 => (position.ind_l - line.opt_l - position.ind_r + line.opt_r - width) * 0.5, + _ => -position.ind_r + line.opt_r - width, + } + } else { + match align { + 0 => line.ax0 - line.opt_l, + 1 => line.ax0 - line.opt_l + (room - width) * 0.5, + _ => line.ax1 + line.opt_r - width, + } + }; + LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } +} + +fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { + let para = paragraph.para; + let sg = ¶graph.sg; + let index = line.index; + let trimmed = line.trimmed; + let body = line.body; + let order = &line.order; + let start_x = justification.start_x; + let (ax0, ax1) = (line.ax0, line.ax1); + let position = &line.position; + let line_index = cx.out.lines.len(); + let glyph_start = cx.out.glyphs.len(); + // Character Alignment lines smaller characters up with the line's largest em box. + let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max)); + // Tab stops are measured from the frame's left edge (point type: the origin). + let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 }; + // A line with tabs is measured as if it started at the left (see `set_width`), then + // aligned as a whole: its tabs stop where they were measured, however it is aligned. + let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l }; + // RTL's logical trailing spaces precede the visible content. Keep them outside + // the aligned content extent, just as LTR trailing spaces extend to its right. + let leading_space_width: f64 = + order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum(); + let mut x = start_x - leading_space_width; + let mut x_end = start_x; + // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; + for (order_index, &offset) in order.iter().enumerate() { + let glyph_index = index + offset; + let Some(glyph) = sg.get(glyph_index) else { continue }; + let mut advance = glyph.adv; + if glyph_index + 1 == trimmed { + advance -= line.end_trim; + } + // Glyph scaling a justified line adds to the glyph (1: none). + let mut scale = 1.0; + let mut leader = ""; + if glyph.ch == '\t' { + // The tab after a list marker keeps the width `prepend_marker` gave it. + if glyph.len > 0 { + (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); + } + } else if glyph_index < body { + let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); + if is_word_space(glyph) { + advance += justification.fit.space(&flex); + } else { + advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); + scale = justification.fit.glyph_scale(&flex); + } + if glyph.is_space() { + advance += justification.per_space; + } else if justification.per_cjk != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk; + } + } else if !line.held.contains(&glyph_index) + && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) + { + // Between glyphs, never inside a tate-chu-yoko block (one cell). + advance += justification.per_gap; + } + } + // The kashida before a cluster in the text is drawn at the cluster's right, between + // it and the letter before it, and the cluster's last glyph advances over it: the + // caret, selection and hit testing take it as part of the cluster. + let kashida = justification + .kashidas + .binary_search_by_key(&glyph.byte, |item| item.0) + .ok() + .and_then(|at| justification.kashidas.get(at)) + .filter(|_| order.get(order_index + 1).and_then(|&offset| sg.get(index + offset)).is_none_or(|next| next.byte != glyph.byte)) + .map(|item| item.1); + advance += kashida.unwrap_or(0.0); + let y = line.position.baseline - align_shift(glyph, cx.style_at(glyph.byte).char_align, line_em, cx.vertical); + let transform = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if glyph_index < paragraph.marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range| range.start.min(at)..range.end.max(at + 1))); + if glyph_index + 1 == paragraph.marker { + marker_tab = at; + } + } + cx.emit(glyph, transform, Point::new(x, y), 0.0, advance, line_index); + if let Some(out) = cx.out.glyphs.last_mut() { + out.scale = paragraph.flex.get(glyph_index).map_or(1.0, |flex| flex.scale) * scale; + } + if !leader.is_empty() { + // The leader belongs to the tab's glyph: the gap stays one character for the + // caret, selection and hit testing. + let dots = leader_outline(cx.db, glyph, leader, tab_origin, x, x + advance, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); + } + } + if let Some(length) = kashida { + cx.emit_kashida(glyph, para.kashida, length, Point::new(x + advance - length, y), line_index); + } + x += advance; + if glyph_index < trimmed { + x_end = x; + } + } + if let (Some(glyphs), Some(text)) = (marker_glyphs, paragraph.marker_text) { + cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + if let Some(hyphen) = &line.hyphen { + // The hyphen follows the last non-space glyph. + let x = x_end; + cx.emit(hyphen, Affine::translate((x, position.baseline)), Point::new(x, position.baseline), 0.0, hyphen.adv, line_index); + x_end = x + hyphen.adv; + } + cx.out.lines.push(LineInfo { + rtl: paragraph.rtl && !cx.vertical, + baseline: position.baseline, + x0: start_x, + x1: x_end, + ascent: position.m.asc, + descent: position.m.desc, + first_baseline: if pen.model == LeadingModel::EmBoxTop { + position.m.top.max(pen.fb_min) + } else { + position.m.first_baseline(pen.fb, pen.fb_min) + }, + start: if index < sg.len() { sg[index].byte } else { paragraph.pr.end }, + end: if position.end >= sg.len() { paragraph.pr.end } else { sg[position.end].byte }, + glyph_start, + glyph_end: cx.out.glyphs.len(), + avail: if paragraph.regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, + region: if paragraph.regions.is_some() { pen.ri } else { 0 }, + }); + pen.settled(position.baseline, position.m); + let forced_break = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); + if forced_break { + pen.queued.clear(); + } else { + // Further spans of this line band share the settled baseline. + for queued in &mut pen.queued { + queued.0 = position.baseline; + } } } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 7bdeba66d..ddf4e56f3 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -2,7 +2,10 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{Metrics, optical_hangs, optical_hangs_rtl, optical_share, roman_share}; use super::flow::list_markers; use super::region::in_range; -use super::*; +use super::{ + Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range, + SGlyph, Shape, TextObject, Vec2, style_metrics, +}; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index d3f953359..e0423e1dd 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(test)] +use super::PathData; +use super::{BezPath, LayoutOptions, PathEl, Point, Rect, Shape}; /// A flattened text wrap shape: (polygons, offset, invert). pub(super) type FlatWrap = (Vec>, f64, bool); diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index c09786bbc..54681da89 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -1,5 +1,5 @@ use super::driver::layout; -use super::*; +use super::{Affine, CharStyle, Ctx, EPS, Point, PositionedGlyph, RubyAlign, RubyPosition, Shape, TextKind, TextObject}; /// Ruby (furigana): lays each reading out over its base text in line space, grows the line's /// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. @@ -11,37 +11,54 @@ use super::*; /// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the /// base's width. Type on a path is left out (it has no above/below). pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { - use std::collections::BTreeMap; - - let db = cx.db; - let text_len = cx.text.len(); - let n_lines = cx.out.lines.len(); - if n_lines == 0 || cx.out.glyphs.is_empty() { + let line_count = cx.out.lines.len(); + if line_count == 0 || cx.out.glyphs.is_empty() { return; } - let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); - let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); - let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); - // One piece of base text on one line, with the part of the reading that belongs to it. - struct Piece { - line: usize, - x0: f64, - x1: f64, - run: usize, - byte: usize, - reading: String, - style: CharStyle, - position: RubyPosition, - align: RubyAlign, - base_size: f64, - overhang: bool, + let base_baselines = cx.out.lines.iter().map(|line| line.baseline).collect::>(); + let old_ascent = cx.out.lines.iter().map(|line| line.ascent).collect::>(); + let old_descent = cx.out.lines.iter().map(|line| line.descent).collect::>(); + let pieces = ruby_pieces(cx, t); + // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how + // far above/below the baseline its ink reaches. + let placed = place_ruby_pieces(cx, &pieces, &base_baselines, &old_descent); + if !placed.is_empty() { + grow_ruby_lines(cx, t, placed, &old_ascent, &old_descent); } - let mut pieces: Vec = Vec::new(); +} + +// One piece of base text on one line, with the part of the reading that belongs to it. +struct RubyPiece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, +} + +struct PlacedRuby { + line: usize, + glyphs: Vec, + ascent: f64, + descent: f64, +} + +fn ruby_pieces(cx: &Ctx<'_>, t: &TextObject) -> Vec { + use std::collections::BTreeMap; + + let text_len = cx.text.len(); // The glyphs by their byte, to find a span's without a scan of them all for each span. let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); by_byte.sort_by_key(|&i| byte_of(i)); let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); + let mut pieces = Vec::new(); for span in &t.ruby { let (start, end) = (span.start.min(text_len), span.end.min(text_len)); if start >= end || span.text.trim().is_empty() { @@ -56,225 +73,275 @@ pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { // In glyph order, as the scan found them. hits.sort_unstable(); for i in hits { - let Some(g) = cx.out.glyphs.get(i) else { continue }; - let b = g.byte.saturating_add(g.len.max(1)); - if g.byte < end && b > start && cx.out.marker_of(i).is_none() { - by_line.entry(g.line).or_default().push(i); + let Some(glyph) = cx.out.glyphs.get(i) else { continue }; + let glyph_end = glyph.byte.saturating_add(glyph.len.max(1)); + if glyph.byte < end && glyph_end > start && cx.out.marker_of(i).is_none() { + by_line.entry(glyph.line).or_default().push(i); } } let total: usize = by_line.values().map(Vec::len).sum(); if total == 0 { continue; } - let reading: Vec = span.text.trim().chars().collect(); - let ratio = span.safe_size_percent() / 100.0; - // Split the reading by each line's share of the base glyphs: cumulative fractions keep - // every character once and in order. - let mut taken = 0; - let mut done = 0; - let lines: Vec = by_line.keys().copied().collect(); - for line in &lines { - let count = by_line.get(line).map_or(0, Vec::len); - done += count; - let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; - let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); - taken = upto; - if part.is_empty() { - continue; - } - let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); - let mut x0 = f64::INFINITY; - let mut x1 = f64::NEG_INFINITY; - let mut base_size: f64 = 0.0; - let mut run = 0; - let mut byte = start; - for (j, &gi) in glyphs.iter().enumerate() { - let Some(g) = cx.out.glyphs.get(gi) else { continue }; - x0 = x0.min(g.origin.x); - x1 = x1.max(g.origin.x + g.advance); - base_size = base_size.max(cx.style_at(g.byte).size); - if j == 0 { - run = g.run; - byte = g.byte; - } - } - if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { - continue; - } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; - style.leading = None; - style.baseline_shift = 0.0; - style.tracking = 0.0; - style.kerning = None; - pieces.push(Piece { - line: *line, - x0, - x1, - run, - byte, - reading: part, - style, - position: span.position, - align: span.align, - base_size, - overhang: span.overhang, - }); + collect_ruby_pieces_for_span(cx, span, start, total, &by_line, &mut pieces); + } + pieces +} + +fn collect_ruby_pieces_for_span( + cx: &Ctx<'_>, + span: &vectorcraft_doc::text::RubySpan, + start: usize, + total: usize, + by_line: &std::collections::BTreeMap>, + pieces: &mut Vec, +) { + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + // Split the reading by each line's share of the base glyphs: cumulative fractions keep + // every character once and in order. + let mut taken = 0; + let mut done = 0; + for (&line, glyph_indices) in by_line { + done += glyph_indices.len(); + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + if let Some(piece) = ruby_piece_for_line(cx, line, start, &part, ratio, glyph_indices, span) { + pieces.push(piece); } } - // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how - // far above/below the baseline its ink reaches. - let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) - for piece in &pieces { - let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; - let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); - let lay = layout(db, &mini); - let Some(first_line) = lay.lines.first() else { continue }; - let mini_base = first_line.baseline; - let mini_asc = first_line.ascent; - let mini_desc = first_line.descent; - let base_w = (piece.x1 - piece.x0).max(0.0); - // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the - // base's em box bottom (the line's descent below the baseline). - let ruby_y = if piece.position == RubyPosition::Above { - baseline - piece.base_size - mini_desc - } else { - baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc - }; - let dest = |width: f64| match piece.align { - RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, - RubyAlign::Start => piece.x0, - RubyAlign::End => piece.x1 - width, - }; - let mut glyphs: Vec = Vec::new(); - if cx.vertical { - // Each reading character in its own cell along the column, upright once the lines turn. - let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); - let mut clusters: Vec> = Vec::new(); - for g in &lay.glyphs { - match clusters.last_mut() { - Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), - _ => clusters.push(vec![g]), - } - } - let mut ruby_w = clusters.len() as f64 * cell; - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - for (ci, cluster) in clusters.iter().enumerate() { - let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); - let mut box_: Option = None; - for g in cluster { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - box_ = Some(box_.map_or(b, |a| a.union(b))); - } - let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); - let mut m = - Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in cluster { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - } else { - // The reading set along the line: the line's extent, centred or aligned to the base. - let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in &lay.glyphs { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - // How far the reading reaches above and below the base line's baseline (its ink). - let mut ascent: f64 = 0.0; - let mut descent: f64 = 0.0; - for g in &glyphs { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - ascent = ascent.max(baseline - b.y0); - descent = descent.max(b.y1 - baseline); - } - if !glyphs.is_empty() { - placed.push((piece.line, glyphs, ascent, descent)); +} + +fn ruby_piece_for_line( + cx: &Ctx<'_>, + line: usize, + start: usize, + reading: &str, + ratio: f64, + glyph_indices: &[usize], + span: &vectorcraft_doc::text::RubySpan, +) -> Option { + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (index, &glyph_index) in glyph_indices.iter().enumerate() { + let Some(glyph) = cx.out.glyphs.get(glyph_index) else { continue }; + x0 = x0.min(glyph.origin.x); + x1 = x1.max(glyph.origin.x + glyph.advance); + base_size = base_size.max(cx.style_at(glyph.byte).size); + if index == 0 { + run = glyph.run; + byte = glyph.byte; } } - if placed.is_empty() { - return; + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + return None; } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + Some(RubyPiece { + line, + x0, + x1, + run, + byte, + reading: reading.to_string(), + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }) +} + +fn place_ruby_pieces(cx: &Ctx<'_>, pieces: &[RubyPiece], baselines: &[f64], old_descent: &[f64]) -> Vec { + pieces.iter().filter_map(|piece| place_ruby_piece(cx, piece, baselines, old_descent)).collect() +} + +fn place_ruby_piece(cx: &Ctx<'_>, piece: &RubyPiece, baselines: &[f64], old_descent: &[f64]) -> Option { + let baseline = baselines.get(piece.line).copied()?; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let layout = layout(cx.db, &mini); + let first_line = layout.lines.first()?; + let base_width = (piece.x1 - piece.x0).max(0.0); + let ruby_y = ruby_baseline(piece, baseline, first_line.ascent, first_line.descent, old_descent); + let glyphs = if cx.vertical { + place_vertical_ruby(piece, &layout.glyphs, first_line.ascent, first_line.descent, base_width, ruby_y) + } else { + place_horizontal_ruby(piece, &layout.glyphs, first_line, base_width, ruby_y) + }; + let (ascent, descent) = ruby_ink_extents(&glyphs, baseline); + (!glyphs.is_empty()).then_some(PlacedRuby { line: piece.line, glyphs, ascent, descent }) +} + +fn ruby_baseline(piece: &RubyPiece, baseline: f64, reading_ascent: f64, reading_descent: f64, old_descent: &[f64]) -> f64 { + // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the + // base's em box bottom (the line's descent below the baseline). + if piece.position == RubyPosition::Above { + baseline - piece.base_size - reading_descent + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + reading_ascent + } +} + +fn ruby_destination(piece: &RubyPiece, base_width: f64, reading_width: f64) -> f64 { + match piece.align { + RubyAlign::Center => piece.x0 + (base_width - reading_width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - reading_width, + } +} + +fn place_vertical_ruby(piece: &RubyPiece, reading: &[PositionedGlyph], asc: f64, desc: f64, base_width: f64, ruby_y: f64) -> Vec { + // Each reading character in its own cell along the column, upright once the lines turn. + let cell = (asc + desc).max(piece.style.size * 0.5); + let clusters = ruby_clusters(reading); + let (ruby_width, squeeze) = ruby_width(clusters.len() as f64 * cell, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let mut glyphs = Vec::new(); + for (index, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (index as f64 + 0.5) * cell, ruby_y); + let centre = ruby_cluster_center(cluster, anchor); + let transform = squeeze_transform( + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)), + x, + squeeze, + ); + for glyph in cluster { + glyphs.push(ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)); + } + } + glyphs +} + +fn ruby_clusters(glyphs: &[PositionedGlyph]) -> Vec> { + let mut clusters: Vec> = Vec::new(); + for glyph in glyphs { + match clusters.last_mut() { + Some(cluster) if cluster.last().is_some_and(|previous| previous.byte == glyph.byte && previous.len == glyph.len) => cluster.push(glyph), + _ => clusters.push(vec![glyph]), + } + } + clusters +} + +fn ruby_cluster_center(cluster: &[&PositionedGlyph], anchor: Point) -> Point { + let bounds = cluster + .iter() + .filter(|glyph| !glyph.outline.elements().is_empty()) + .map(|glyph| glyph.outline.bounding_box()) + .reduce(|left, right| left.union(right)); + bounds.map_or(anchor, |bounds| Point::new((bounds.x0 + bounds.x1) * 0.5, (bounds.y0 + bounds.y1) * 0.5)) +} + +fn place_horizontal_ruby( + piece: &RubyPiece, + reading: &[PositionedGlyph], + line: &super::LineInfo, + base_width: f64, + ruby_y: f64, +) -> Vec { + // The reading set along the line: the line's extent, centred or aligned to the base. + let reading_width = (line.x1 - line.x0).max(0.0); + let (ruby_width, squeeze) = ruby_width(reading_width, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let transform = squeeze_transform(Affine::translate((x - line.x0, ruby_y - line.baseline)), x, squeeze); + reading.iter().map(|glyph| ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)).collect() +} + +fn ruby_width(reading_width: f64, base_width: f64, overhang: bool) -> (f64, f64) { + if !overhang && reading_width > base_width + EPS && reading_width > EPS { (base_width, base_width / reading_width) } else { (reading_width, 1.0) } +} + +fn squeeze_transform(transform: Affine, x: f64, squeeze: f64) -> Affine { + if squeeze == 1.0 { + transform + } else { + Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * transform + } +} + +fn ruby_ink_extents(glyphs: &[PositionedGlyph], baseline: f64) -> (f64, f64) { + // How far the reading reaches above and below the base line's baseline (its ink). + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for glyph in glyphs.iter().filter(|glyph| !glyph.outline.elements().is_empty()) { + let bounds = glyph.outline.bounding_box(); + ascent = ascent.max(baseline - bounds.y0); + descent = descent.max(bounds.y1 - baseline); + } + (ascent, descent) +} + +fn grow_ruby_lines(cx: &mut Ctx<'_>, t: &TextObject, placed: Vec, old_ascent: &[f64], old_descent: &[f64]) { + let line_count = cx.out.lines.len(); // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they // need in the block direction (the first line too for area type, so the ruby stays inside the // frame; point type's first baseline is its anchor and stays). - let mut growth = vec![0.0_f64; n_lines]; - for (line, _, ascent, descent) in &placed { - if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { - let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); - if grown > 0.0 { - let g = growth.get_mut(*line); - if let Some(slot) = g { - *slot = slot.max(grown); - } - } + let mut growth = vec![0.0_f64; line_count]; + for ruby in &placed { + if let (Some(asc), Some(desc), Some(slot)) = (old_ascent.get(ruby.line), old_descent.get(ruby.line), growth.get_mut(ruby.line)) { + let grown = (asc.max(ruby.ascent) + desc.max(ruby.descent)) - (asc + desc); + *slot = slot.max(grown.max(0.0)); } } // A line's ruby grows above it: the line moves on in the block direction by its own growth, // so the line before it keeps its room (for area type the first line moves too, keeping the // ruby inside the frame; point type's first baseline is its anchor and stays). let area = matches!(t.kind, TextKind::Area { .. }); - let mut shift = vec![0.0_f64; n_lines]; - let mut cum = 0.0; - for i in 0..n_lines { - if area || i > 0 { - cum += growth.get(i).copied().unwrap_or(0.0); - if let Some(s) = shift.get_mut(i) { - *s = cum; - } + let mut shift = vec![0.0_f64; line_count]; + let mut cumulative = 0.0; + for (index, amount) in growth.iter().copied().enumerate() { + if area || index > 0 { + cumulative += amount; + shift[index] = cumulative; } } // Grow the lines' boxes to fit their ruby, then move them on in the block direction. - for (line, _, ascent, descent) in &placed { - if let Some(l) = cx.out.lines.get_mut(*line) { - l.ascent = l.ascent.max(*ascent); - l.descent = l.descent.max(*descent); + for ruby in &placed { + if let Some(line) = cx.out.lines.get_mut(ruby.line) { + line.ascent = line.ascent.max(ruby.ascent); + line.descent = line.descent.max(ruby.descent); } } - for (i, line) in cx.out.lines.iter_mut().enumerate() { - line.baseline += shift.get(i).copied().unwrap_or(0.0); + for (index, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(index).copied().unwrap_or(0.0); } - for g in &mut cx.out.glyphs { - let s = shift.get(g.line).copied().unwrap_or(0.0); - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - g.xf = Affine::translate((0.0, s)) * g.xf; + shift_base_glyphs(cx, &shift); + append_ruby_glyphs(cx, placed, &shift); +} + +fn shift_base_glyphs(cx: &mut Ctx<'_>, shift: &[f64]) { + for glyph in &mut cx.out.glyphs { + let amount = shift.get(glyph.line).copied().unwrap_or(0.0); + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); + glyph.xf = Affine::translate((0.0, amount)) * glyph.xf; } } - for (line, glyphs, _, _) in placed { - let s = shift.get(line).copied().unwrap_or(0.0); - for mut g in glyphs { - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); +} + +fn append_ruby_glyphs(cx: &mut Ctx<'_>, placed: Vec, shift: &[f64]) { + for ruby in placed { + let amount = shift.get(ruby.line).copied().unwrap_or(0.0); + for mut glyph in ruby.glyphs { + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); } - g.line = line; - cx.out.ruby.push(g); + glyph.line = ruby.line; + cx.out.ruby.push(glyph); } } } @@ -300,5 +367,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, } } diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index a8ffad796..0953ae656 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -1,6 +1,6 @@ use super::flow::flow; use super::region::Region; -use super::*; +use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, is_cjk, punct}; /// System Layout on or Fractional Widths off: each character that takes space advances by a whole /// number of points, at least one, so small type does not collapse onto itself. A zero advance (a @@ -23,7 +23,7 @@ pub(super) fn whole_character_widths(glyphs: &mut [SGlyph]) { /// before the automatic digit rule. /// pub(super) fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) { - let manual: Vec = g.iter().map(|gl| style(gl).1).collect(); + let manual: Vec = g.iter().map(|gl| gl.ch != '\u{2028}' && style(gl).1).collect(); let digits: Vec = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect(); let mut i = 0; while i < g.len() { @@ -242,28 +242,8 @@ pub(super) fn stands_upright(g: &SGlyph) -> bool { pub(super) fn finish_bounds(out: &mut TextLayout) { let xf = out.line_xf; - let mut b: Option = None; + let mut b: Option = out.ink_bounds(); let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r))); - for g in &out.glyphs { - if !g.outline.elements().is_empty() { - add(g.outline.bounding_box()); - } - if let Some((image, xf)) = g.placed_image() { - add(xf.transform_rect_bbox(image.rect())); - } - if let Some(b) = g.colour.as_ref().and_then(|c| c.geometric_bounds()) { - add(g.xf.transform_rect_bbox(b)); - } - } - // Ruby: covered by its line's ascent, but its ink can overhang sideways past the line. - for g in &out.ruby { - if !g.outline.elements().is_empty() { - add(g.outline.bounding_box()); - } - } - for i in &out.inlines { - add(i.bounds); - } if !out.on_path { for l in &out.lines { add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent))); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4299e61d2..8524a5962 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -43,7 +43,7 @@ pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc; pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; -use kurbo::{Affine, BezPath, Point, Rect, Vec2}; +use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; @@ -136,6 +136,9 @@ pub struct PositionedGlyph { /// the path ([`GlyphBend`]) so it meets its neighbours, and `xf` is only the straight glyph /// nearest it (for the caret, hit testing and tools). `None`: the glyph is drawn through `xf`. pub bend: Option, + /// Font units → font units: `xf * unturned` places the glyph as it would be placed without + /// its Character Rotation. The identity for a glyph without one. + pub unturned: Affine, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its @@ -338,6 +341,21 @@ impl TextLayout { }; self.lines.iter().filter(|l| l.x1 > l.x0).map(line).collect() } + /// The ink of the type in text space: its glyphs (and ruby) as drawn, colour glyphs, images and + /// inline graphics included, without the lines' em boxes that [`Self::bounds`] also covers. + /// What Align to Glyph Bounds aligns. `None` when nothing draws (only spaces, or no text). + pub fn ink_bounds(&self) -> Option { + let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| g.outline.bounding_box()); + let glyphs = self.glyphs.iter().flat_map(|g| { + let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect())); + let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b)); + [outlined(g), image, colour] + }); + // Ruby is covered by its line's ascent, but its ink can overhang sideways past the line. + let ruby = self.ruby.iter().map(outlined); + let inlines = self.inlines.iter().map(|i| Some(i.bounds)); + glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b)) + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index e833d95db..2c79099b4 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -5,12 +5,14 @@ use std::sync::Arc; use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; use skrifa::MetadataProvider; +use skrifa::charmap::Charmap; use skrifa::instance::Size; use vectorcraft_doc::text::INLINE_CHAR; -use vectorcraft_doc::{CharStyle, InlineArt}; +use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; use crate::fontdb::{FontDb, FontFace}; @@ -132,7 +134,10 @@ impl SGlyph { } /// A line may break after this glyph. pub fn break_after(&self) -> bool { - self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN) || is_cjk(self.ch) + self.is_space() + || self.word_end + || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') + || is_cjk(self.ch) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { @@ -393,24 +398,23 @@ pub(crate) fn shape_range( let small_caps = st.small_caps.is_some() && !st.all_caps; // Split into segments by font coverage (and case, for Small Caps). let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) }; - let mut cache: std::collections::HashMap> = std::collections::HashMap::new(); + let mut cache: std::collections::HashMap<&str, Arc> = std::collections::HashMap::new(); let mut script = Script::Common; - for (i, c) in text[a..b].char_indices() { + // A grapheme cluster (a letter and its marks, an emoji sequence) is set in one face. + for (i, cluster) in text[a..b].grapheme_indices(true) { let i = a + i; // Digits are drawn in the style's numeral system: the font must cover that digit. - let c = st.digits.map(c); + let Some(c) = cluster.chars().next().map(|c| st.digits.map(c)) else { continue }; let level = level_at(i); // Whitespace the primary font doesn't map goes to the fallback face like any other // character (#1844): an ideographic space set at zero width would remove an indent. - // What the layout handles itself (tab, line and paragraph breaks) stays. - let covered = c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some()); - let face = if covered { + let face = if drawn_chars(cluster, st.digits).all(|c| in_face(pmap.as_ref(), c)) { primary.clone() - } else if let Some(f) = cache.get(&c) { + } else if let Some(f) = cache.get(cluster) { f.clone() } else { - let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone()); - cache.insert(c, f.clone()); + let f = fallback_face(db, &primary, pmap.as_ref(), cluster, st.digits); + cache.insert(cluster, f.clone()); f }; let small = small_caps && c.is_lowercase(); @@ -516,6 +520,32 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) } +/// The characters of a grapheme cluster that need a glyph of their own, in the numeral system +/// `digits`: not what the layout handles itself (tab, line and paragraph breaks), joiners or +/// variation selectors. +fn drawn_chars(cluster: &str, digits: Digits) -> impl Iterator + '_ { + cluster + .chars() + .map(move |c| digits.map(c)) + .filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{2028}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))) +} + +/// Does the face with charmap `map` have a glyph for `c` (any face does when it has no charmap)? +fn in_face(map: Option<&Charmap<'_>>, c: char) -> bool { + map.is_none_or(|m| m.map(c).is_some()) +} + +/// The face of a grapheme cluster the primary face (charmap `pmap`) lacks a character of: the +/// first fallback face with all of them, so that a letter's marks are shaped with it and attach to +/// it (#4491); when there is none, the face its first character takes on its own. +fn fallback_face(db: &FontDb, primary: &Arc, pmap: Option<&Charmap<'_>>, cluster: &str, digits: Digits) -> Arc { + let fallback = |c| db.fallback_for_style(c, primary.id(), primary.weight, primary.italic); + drawn_chars(cluster, digits) + .find_map(|c| fallback(c).filter(|f| drawn_chars(cluster, digits).all(|c| f.covers(c)))) + .or_else(|| drawn_chars(cluster, digits).next().filter(|&c| !in_face(pmap, c)).and_then(fallback)) + .unwrap_or_else(|| primary.clone()) +} + /// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and /// Bopomofo, shaped together (splitting them would cost a shaper call per change of script). fn shaping_script(c: char) -> Script { @@ -811,7 +841,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); let mut adv = xa as f64 * k * hs; - if ch == SOFT_HYPHEN { + if ch == SOFT_HYPHEN || ch == '\u{2028}' { adv = 0.0; } else if last_in_cluster { let tracking = gst.tracking / 1000.0 * size; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 79ce4f566..7ed2eb48c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -25,6 +25,7 @@ mod typo; mod variable; mod versions; mod vertical; +mod vietnamese; /// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index af67365b0..1ec51d15a 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -163,6 +163,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, }); x += advance; } diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index c06396a1e..345340b52 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1339,3 +1339,58 @@ fn thai_words_are_words_to_the_caret() { assert_eq!(edit::word_at(s, s.len() - 1), end + 1..s.len()); assert_eq!(edit::next_word("hello world", 0), 5); } + +#[test] +fn soft_return_breaks_line_without_new_paragraph() { + let mut t = TextObject::point(Point::ZERO, "Line 1\u{2028}Line 2", style(12.0)); + t.para.space_before = 20.0; + t.para.space_after = 20.0; + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2, "broken into two lines"); + let l0 = &lay.lines[0]; + let l1 = &lay.lines[1]; + let line_gap = l1.baseline - l0.baseline; + assert!((line_gap - 12.0 * 1.2).abs() < 1.0, "normal line leading: {line_gap}"); + assert_eq!(&t.plain_text()[l0.start..l0.end], "Line 1\u{2028}"); + assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2"); +} + +#[test] +fn soft_return_in_justified_text_does_not_spread() { + let text = "Short\u{2028}A much longer second line of text inside the frame."; + let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft); + let lay = layout(db(), &t); + assert!(lay.lines.len() >= 2); + let l0 = &lay.lines[0]; + assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0); +} + +#[test] +fn soft_return_in_list_does_not_repeat_marker() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut t = area("First line\u{2028}Continuation line", style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2); + let l0_glyphs = &lay.glyphs[lay.lines[0].glyph_start..lay.lines[0].glyph_end]; + let l1_glyphs = &lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end]; + assert!(l0_glyphs.iter().any(|g| g.len == 0), "line 0 has bullet marker"); + assert!(!l1_glyphs.iter().any(|g| g.len == 0), "continuation line does not have bullet marker"); +} + +#[test] +fn trailing_soft_return_creates_empty_continuation_line() { + let t = TextObject::point(Point::ZERO, "Line 1\u{2028}", style(12.0)); + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2, "trailing soft return produces a second empty line"); + assert_eq!(lay.line_of(t.plain_text().len()), 1, "caret at end sits on the new line"); +} + +#[test] +fn soft_return_breaks_line_even_inside_no_break() { + let mut st = style(12.0); + st.no_break = true; + let t = area("First\u{2028}Second", st, Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left); + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2, "soft return honors line break despite no_break"); +} diff --git a/crates/text/src/tests/vietnamese.rs b/crates/text/src/tests/vietnamese.rs new file mode 100644 index 000000000..29f98d072 --- /dev/null +++ b/crates/text/src/tests/vietnamese.rs @@ -0,0 +1,123 @@ +//! Vietnamese letters with two diacritics (Ể, Ứ, Ự, Ẩ…) draw both marks on their base letter +//! (#4491), whichever form the text comes in: precomposed (NFC), decomposed (NFD), or as +//! Vietnamese keyboards type it (a precomposed vowel and a combining tone mark), in a font with +//! the precomposed letters, one that builds them from its marks (GPOS mark-to-base and +//! mark-to-mark), and one that has to borrow the marks from a fallback font. + +use kurbo::{Point, Rect, Shape}; +use skrifa::MetadataProvider; +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharStyle, TextObject}; +use write_fonts::FontBuilder; +use write_fonts::types::Tag; + +use crate::test_fonts::name_table; +use crate::{FontDb, TextLayout, layout}; + +/// Letters as (precomposed, decomposed, keyboard) text: the words "HỌC ĐỂ ỨNG DỤNG" and more +/// letters with two marks, capital and small. +const LETTERS: [[&str; 3]; 20] = [ + ["H", "H", "H"], + ["\u{1ECC}", "O\u{323}", "O\u{323}"], + ["C", "C", "C"], + ["\u{110}", "\u{110}", "\u{110}"], + ["\u{1EC2}", "E\u{302}\u{309}", "\u{CA}\u{309}"], + ["\u{1EE8}", "U\u{31B}\u{301}", "\u{1AF}\u{301}"], + ["N", "N", "N"], + ["G", "G", "G"], + ["D", "D", "D"], + ["\u{1EE4}", "U\u{323}", "U\u{323}"], + ["\u{1EF0}", "U\u{31B}\u{323}", "\u{1AF}\u{323}"], + ["\u{1EA8}", "A\u{302}\u{309}", "\u{C2}\u{309}"], + ["\u{1EBE}", "E\u{302}\u{301}", "\u{CA}\u{301}"], + ["\u{1ED8}", "O\u{302}\u{323}", "\u{D4}\u{323}"], + ["\u{1EDC}", "O\u{31B}\u{300}", "\u{1A0}\u{300}"], + ["\u{1EC3}", "e\u{302}\u{309}", "\u{EA}\u{309}"], + ["\u{1EE9}", "u\u{31B}\u{301}", "\u{1B0}\u{301}"], + ["\u{1EF1}", "u\u{31B}\u{323}", "\u{1B0}\u{323}"], + ["\u{1EA9}", "a\u{302}\u{309}", "\u{E2}\u{309}"], + ["\u{1EAB}", "a\u{306}\u{303}", "\u{103}\u{303}"], +]; + +const SIZE: f64 = 100.0; +const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + +fn text(form: usize) -> String { + LETTERS.iter().filter_map(|l| l.get(form).copied()).collect::>().join(" ") +} + +/// Each letter's ink, relative to the pen position of its first glyph. +fn letter_inks(db: &FontDb, family: &str, text: &str) -> Vec { + let st = CharStyle { font_family: family.into(), font_style: "Regular".into(), size: SIZE, ..CharStyle::default() }; + let l: TextLayout = layout(db, &TextObject::point(Point::ZERO, text, st)); + text.grapheme_indices(true) + .filter(|(_, g)| *g != " ") + .map(|(start, g)| { + let glyphs: Vec<_> = l.glyphs.iter().filter(|gl| (start..start + g.len()).contains(&gl.byte)).collect(); + assert!(!glyphs.is_empty(), "{g:?} has glyphs"); + let pen = glyphs[0].origin.x; + let ink = glyphs.iter().map(|gl| gl.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!(ink.area() > 0.0, "{g:?} draws"); + assert!(glyphs.iter().all(|gl| gl.gid != 0), "{g:?} has no missing glyph"); + ink - kurbo::Vec2::new(pen, 0.0) + }) + .collect() +} + +/// Every letter of `form` (drawn in `family` of `db`) has the ink of the precomposed letter in +/// Source Sans 3: its marks are on the letter, not beside it. +fn assert_attached(db: &FontDb, family: &str, form: usize) { + let want = letter_inks(FontDb::global(), "Source Sans 3", &text(0)); + let got = letter_inks(db, family, &text(form)); + assert_eq!(want.len(), got.len()); + for ((w, g), letter) in want.iter().zip(&got).zip(LETTERS) { + let off = [w.x0 - g.x0, w.x1 - g.x1, w.y0 - g.y0, w.y1 - g.y1].into_iter().fold(0.0_f64, |m, d| m.max(d.abs())); + assert!(off < 0.08 * SIZE, "{family}, form {form}: {:?} drawn {g:?}, precomposed {w:?}", letter[form]); + } +} + +#[test] +fn stacked_marks_sit_on_their_letter_in_every_form() { + for form in 0..3 { + assert_attached(FontDb::global(), "Source Sans 3", form); + } +} + +/// Source Sans 3 renamed `family`, with only the characters `keep` takes in its cmap. +fn source_sans_with(family: &str, keep: impl Fn(u32) -> bool) -> Vec { + let font = skrifa::FontRef::new(SOURCE_SANS).unwrap(); + let map: Vec<(u32, u16)> = font.charmap().mappings().filter(|(c, _)| keep(*c)).map(|(c, g)| (c, u16::try_from(g.to_u32()).unwrap())).collect(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"cmap"), crate::embed::cmap(&map).unwrap()); + builder.add_raw(Tag::new(b"name"), name_table(&[(1, family), (2, "Regular"), (4, family), (6, "Viettest-Regular")]).unwrap()); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + builder.build() +} + +/// A font without the precomposed letters builds them from its base letters and marks. +#[test] +fn stacked_marks_are_positioned_mark_to_mark() { + const FAMILY: &str = "Viettest Marks"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x1E00 && !(0xC0..0x250).contains(&c))), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} + +/// A font with the base letters but none of the marks takes the whole letter, marks included, +/// from a font that has them. +#[test] +fn letters_whose_marks_a_font_lacks_fall_back_whole() { + const FAMILY: &str = "Viettest Bare"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x80)), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} diff --git a/crates/three-d/src/caps.rs b/crates/three-d/src/caps.rs index 5cb17a366..ab604589d 100644 --- a/crates/three-d/src/caps.rs +++ b/crates/three-d/src/caps.rs @@ -1,8 +1,8 @@ //! Curved projected rings shared by a cap and its adjoining body faces. -use vectorcraft_geom::{CubicBez, Shape}; +use vectorcraft_geom::{CubicBez, ParamCurve, Shape}; -use super::*; +use super::{BezPath, Face, PathData, Point, Rect, Revolve, V3, Vec2, eye_distance, perspective_scale, project_face}; const CURVE_TOLERANCE: f64 = 0.001; const MAX_RING_CURVES: usize = 32_768; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c9527edaf..c78f2a38b 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,5 @@ use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area}; -use super::*; +use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index be5aec370..15da644a9 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,7 @@ use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; -use super::*; +use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 296b659df..02d258531 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,6 +1,6 @@ use super::build::segment_distance; use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; -use super::*; +use super::{FillRule, PathData, Point, Rect, Vec2}; /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is /// found among the buckets around it rather than among all the edges. diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 57335e4c6..b29493d96 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FillRule, ParamCurve, PathData, Point, Rect}; /// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel /// bands and distance tests. diff --git a/crates/tools/src/bbox.rs b/crates/tools/src/bbox.rs index 385744d09..b8723f909 100644 --- a/crates/tools/src/bbox.rs +++ b/crates/tools/src/bbox.rs @@ -91,9 +91,11 @@ pub fn scale_for_drag(r: Rect, handle: Handle, p: Point, proportional: bool, fro let mut sy = if ay && d0.y.abs() > 1e-9 { d1.y / d0.y } else { 1.0 }; if proportional { if handle.is_corner() { - let s = if sx.abs() > sy.abs() { sx } else { sy }; - sx = s.abs() * sx.signum(); - sy = s.abs() * sy.signum(); + // Follow the axis the pointer moved further along (a drag along one axis must still + // resize), and mirror both axes together when it crosses the origin. + let s = if (sx - 1.0).abs() >= (sy - 1.0).abs() { sx } else { sy }; + sx = s; + sy = s; } else if ax { sy = sx.abs(); } else { @@ -151,6 +153,25 @@ mod tests { assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 100.0)); } + #[test] + fn proportional_corner_follows_a_drag_along_one_axis() { + let scale = |p: Point, from_center: bool| { + let a = scale_for_drag(R, Handle::BottomRight, p, true, from_center).as_coeffs(); + (a[0], a[3]) + }; + for from_center in [false, true] { + let o = if from_center { R.center() } else { Point::new(0.0, 0.0) }; + let (hx, hy) = (100.0 - o.x, 50.0 - o.y); + // Horizontal drag, then vertical drag: the size follows the pointer, uniformly. + for (t, want) in [(0.5, 0.5), (0.25, 0.25), (-0.5, -0.5), (-1.0, -1.0)] { + for p in [Point::new(o.x + hx * t, 50.0), Point::new(100.0, o.y + hy * t)] { + let (sx, sy) = scale(p, from_center); + assert!((sx - want).abs() < 1e-9 && (sy - want).abs() < 1e-9, "{p:?} center={from_center}: got ({sx}, {sy}), want {want}"); + } + } + } + } + #[test] fn scale_side_and_center() { let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 999.0), false, false); diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 2ed8ff6d6..5903ac5b3 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -38,9 +38,9 @@ use live_paint::LpHover; use live_paint::lp_color; pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; pub use lookup::{ - EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, - leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, - visible_edge_near, visible_face_at, + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, + is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, + sources, visible_edge_near, visible_face_at, }; pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots}; /// Small, Medium and Large gap lengths in points. @@ -142,8 +142,8 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option, forward: boo /// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges /// (outline pieces between the points where paths meet) it passes over. With Shift held at the /// press, the drag draws a rectangular marquee and touches the regions it reaches (see -/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Its options are -/// [`ShapeBuilderOptions`]. +/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Escape during a +/// drag cancels it. Its options are [`ShapeBuilderOptions`]. #[derive(Default)] pub struct ShapeBuilderTool { cache: RegionCache, @@ -461,15 +461,23 @@ impl Tool for ShapeBuilderTool { } /// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch - /// before or after the current fill. + /// before or after the current fill. Escape during a drag cancels it: the release after it + /// merges and deletes nothing. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right) + (self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right)) || (self.dragging && key == ToolKey::Escape) } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if !self.claims_key(cx, key) { return vec![]; } + if key == ToolKey::Escape { + self.dragging = false; + self.points.clear(); + self.touched = Touched::default(); + (self.marquee, self.hover, self.far) = (None, None, false); + return vec![]; + } let list = color_swatches(cx.doc); let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else { return vec![]; diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs index 27487e4c3..f34a29000 100644 --- a/crates/tools/src/builder/cache.rs +++ b/crates/tools/src/builder/cache.rs @@ -1,6 +1,6 @@ use super::lookup::{drawn_outlines, faces_of, shape_builder_shapes, shapes_for}; use super::map::{BuilderMap, sorted_roots}; -use super::*; +use super::{Arc, Document, NodeId, PathData, Point, Receiver, ToolContext, TryRecvError, po}; /// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids /// and whether filled open paths count as closed. diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 667a6e2e3..607584bb9 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,7 +1,10 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at}; +use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; use super::map::{HIGHLIGHT_RED, sorted_roots}; -use super::*; +use super::{ + Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, + PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains, +}; // ---------- Live Paint Bucket ---------- @@ -20,9 +23,11 @@ pub(super) enum LpHover { /// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under /// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside /// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the -/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. -fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { - if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. Only +/// the groups for which `takes` is true are looked at: [`lp_visible`] for the Live Paint Bucket and +/// [`lp_selectable`] for Live Paint Selection. +fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> Option<(NodeId, Option<&'a Arc>)> { + if let Some(g) = live_paint_at_where(doc, p, takes).and_then(|id| doc.node(id)) { return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); } if !edge { @@ -34,7 +39,7 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, && let Some(e) = visible_edge_near(n, p, tol) && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) && best.is_none_or(|b| d <= b.0) - && doc.is_visible(n.id) + && takes(n.id) { best = Some((d, n.id, e)); } @@ -42,11 +47,25 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, best.map(|(_, g, e)| (g, Some(e))) } -/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else -/// with `cache` the region of the selection under `p`. A large selection's regions are found on -/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. -fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { - if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { +/// Whether the Live Paint Bucket takes Live Paint group `id`: it is visible, with every layer and +/// group around it. +fn lp_visible(cx: &ToolContext, id: NodeId) -> bool { + cx.doc.is_visible(id) +} + +/// Whether the Live Paint Selection tool takes Live Paint group `id`: it is editable +/// ([`Document::is_editable`]: visible, and not locked, on a locked layer or on a locked artboard), +/// and in isolation mode it is the isolated object or inside it ([`crate::direct::in_scope`]). +fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool { + cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id) +} + +/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`], among +/// the groups `takes` takes), else with `cache` the region of the selection under `p`. A large +/// selection's regions are found on another thread ([`RegionCache::ready`]), and until they are, no +/// region is highlighted. +fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, takes) { return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); } let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); @@ -93,14 +112,14 @@ impl Tool for LivePaintBucketTool { match ev.kind { PointerKind::Move => { self.at = Some((p, ev.mods.shift)); - self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); + self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)); vec![] } PointerKind::Down => { self.hover = LpHover::None; let point = json!([p.x, p.y]); if ev.mods.shift { - return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { Some((g, Some(_))) => { vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] } @@ -141,7 +160,7 @@ impl Tool for LivePaintBucketTool { loop { self.cache.settle(wait); if let Some((p, shift)) = self.at { - self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); + self.hover = lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)); } if !wait || !self.cache.waiting() { break; @@ -158,12 +177,18 @@ pub const LP_SELECT_WIDTH: f64 = 4.0; /// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); -/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool -/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight -/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select -/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills -/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click -/// selects no face or edge. +/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws +/// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection; +/// with Shift held at the press, each of them leaves the selection if it is selected and joins it +/// otherwise. A click and a marquee change the selection on release, so Escape between the press +/// and the release (which removes the marquee) leaves the selection as it was. The tool does not +/// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer +/// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options +/// (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight `color` +/// (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With +/// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee +/// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click +/// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge. pub struct LivePaintSelectionTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -171,11 +196,44 @@ pub struct LivePaintSelectionTool { strokes: bool, color: [u8; 3], width: f64, + /// The drag in progress, from the press to the release. + drag: Option, +} + +/// A press and drag of the Live Paint Selection tool. +#[derive(Clone, Copy, Debug)] +struct LpDrag { + /// Where the press was. + start: Point, + /// Where the pointer is. + cur: Point, + /// Shift was held at the press. + shift: bool, + /// The face or edge under the press, which a click selects (or with Shift toggles). + hit: Option, +} + +impl LpDrag { + /// The marquee from the press to the pointer, None while the drag is shorter than + /// [`CLICK_TOL_PX`] screen pixels both across and down (a click). + fn marquee(&self, cx: &ToolContext) -> Option { + let r = Rect::from_points(self.start, self.cur); + let tol = cx.tol(CLICK_TOL_PX); + (r.width() >= tol || r.height() >= tol).then_some(r) + } } impl Default for LivePaintSelectionTool { fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } + Self { + cache: RegionCache::live_paint(), + hover: LpHover::None, + fills: true, + strokes: true, + color: HIGHLIGHT_RED, + width: LP_SELECT_WIDTH, + drag: None, + } } } @@ -191,6 +249,30 @@ impl LivePaintSelectionTool { } } +/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and +/// edges that are not hidden of the Live Paint groups the tool takes ([`lp_selectable`]), in paint +/// order. A face is touched when its outline has an anchor inside `r` or crosses `r`'s sides, or +/// when it holds `r`'s center. An edge is touched when it has an anchor inside `r` or crosses +/// `r`'s sides. A face or edge partly inside `r` is touched. +fn lp_parts_in(cx: &ToolContext, r: Rect, faces: bool, edges: bool) -> Vec { + let mut out = vec![]; + cx.doc.walk(|n| { + if !is_live_paint(n) || !lp_selectable(cx, n.id) { + return; + } + for c in n.children().into_iter().flatten().filter(|c| c.visible) { + let Some(pd) = c.path_data() else { continue }; + let face = faces && c.name.as_deref() == Some(FACE_NAME); + let edge = edges && c.name.as_deref() == Some(EDGE_NAME); + let touched = vectorcraft_geom::hit::intersects_rect(pd, r) || (face && path_contains(pd, FillRule::NonZero, r.center())); + if (face || edge) && touched { + out.push(c.id); + } + } + }); + out +} + /// A highlight color option: `#rrggbb` or a layer color's name (any case). pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> { let s = v.as_str()?.trim(); @@ -212,26 +294,70 @@ impl Tool for LivePaintSelectionTool { match ev.kind { PointerKind::Move => { // Only the parts of Live Paint groups highlight, so no map of the selection is built. - self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { + self.hover = match edge.map(|e| lp_hover(cx, None, p, e, &|id| lp_selectable(cx, id))) { Some(h @ LpHover::Part(_)) => h, _ => LpHover::None, }; vec![] } PointerKind::Down => { - let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); - match (hit, ev.mods.shift) { - (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], - (None, true) => vec![], - (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id))); + let hit = part.and_then(|(_, part)| part).map(|n| n.id); + self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit }); + vec![] + } + PointerKind::Drag => { + if let Some(d) = &mut self.drag { + d.cur = p; + self.hover = LpHover::None; + } + vec![] + } + PointerKind::Up => { + let Some(d) = self.drag.take() else { return vec![] }; + let d = LpDrag { cur: p, ..d }; + let Some(r) = d.marquee(cx) else { + // A click selects what the press picked. + return match (d.hit, d.shift) { + (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], + (None, true) => vec![], + (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + }; + }; + let ids = lp_parts_in(cx, r, self.fills, self.strokes); + match (ids.is_empty(), d.shift) { + (true, true) => vec![], + (true, false) => vec![Action::Exec("select.none".into(), json!({}))], + (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], + (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + // Escape during a press or drag drops it; the release then selects nothing. + if key == ToolKey::Escape { + self.drag = None; + } + vec![] + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.drag = None; + vec![] + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + if let Some(r) = self.drag.and_then(|d| d.marquee(cx)) { + return vec![Overlay::Marquee(r)]; + } lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 30475be34..99aeeb7aa 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -106,9 +106,14 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to /// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { + live_paint_at_where(doc, p, |id| doc.is_visible(id)) +} + +/// [`live_paint_at`] among the Live Paint groups for which `takes` is true. +pub(super) fn live_paint_at_where(doc: &Document, p: Point, takes: impl Fn(NodeId) -> bool) -> Option { let mut hit = None; doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { + if is_live_paint(n) && visible_face_at(n, p).is_some() && takes(n.id) { hit = Some(n.id); } }); @@ -121,28 +126,48 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { collect_leaves(n, false, out); } -/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { - if !n.visible { +/// [`leaf_shapes`]; with `builder` (the Shape Builder's inputs), a Live Paint group contributes +/// only its filled faces and its stroked edges, and locked objects contribute nothing. +fn collect_leaves<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { + collect_members(n, builder, out); + if builder { + out.retain(|m| m.visible && !m.locked); + } +} + +/// The leaves the Shape Builder takes from `n` ([`collect_leaves`]), in paint order, with each +/// hidden or locked object it skips in its place. +pub fn builder_members(n: &Node) -> Vec<&Node> { + let mut out = vec![]; + collect_members(n, true, &mut out); + out +} + +/// [`collect_leaves`] with, for `builder`, each hidden or locked object in place of what it holds. +fn collect_members<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { + if !n.visible && !builder { + return; + } + if builder && (!n.visible || n.locked) { + out.push(n); return; } if n.extras().shaper.is_some() { for c in n.children().into_iter().flatten().skip(1) { - collect_leaves(c, filled_faces, out); + collect_members(c, builder, out); } return; } match &n.kind { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), - NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); + NodeKind::Group { .. } if builder && is_live_paint(n) => { + out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || !f.appearance.fill_paint().is_none()).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || !e.appearance.stroke_paint().is_none()).map(|e| &**e)); } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { - collect_leaves(c, filled_faces, out); + collect_members(c, builder, out); } } _ => {} @@ -191,13 +216,15 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, V shapes_of(doc, roots, false) } -/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { +/// [`shapes_for`]; with `builder`, the leaves [`collect_leaves`] takes for the Shape Builder, and +/// nothing from a root in a locked or hidden group or layer. +fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec, Vec<&'a Node>) { let mut lv = vec![]; for id in roots { - if let Some(n) = doc.node(*id) { - collect_leaves(n, filled_faces, &mut lv); + if let Some(n) = doc.node(*id) + && (!builder || doc.is_editable(*id)) + { + collect_leaves(n, builder, &mut lv); } } let mut shapes = vec![]; @@ -219,7 +246,8 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Ve /// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions /// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group /// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces -/// and its edges without a stroke are left out. +/// and its edges without a stroke are left out. Locked objects, and objects in a locked group or +/// layer, give nothing. pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_of(doc, roots, true); for (s, l) in shapes.iter_mut().zip(&leaves) { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index a0a6bfb65..aeb753b4c 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -60,6 +60,40 @@ fn shape_builder_drag_emits_merge() { assert!(v["points"].as_array().unwrap().len() >= 2); } +/// Escape during a merge drag, an Alt erase drag or a Shift marquee cancels it: the tool takes +/// the key, nothing stays highlighted, and the release merges and deletes nothing. Enter leaves +/// the drag alone, and with no drag Escape is not the tool's. +#[test] +fn escape_cancels_a_shape_builder_drag() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let shift = Mods { shift: true, ..Mods::default() }; + for (what, mods) in [("merge", Mods::default()), ("erase", alt), ("marquee", shift)] { + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0).with_mods(mods)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 60.0).with_mods(mods)); + assert!(!t.touched.is_empty(), "{what}"); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert!(t.claims_key(&cx, ToolKey::Escape), "{what}: Escape is the tool's during the drag"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "{what}: no interaction began, so none is canceled"); + assert!(!t.busy(), "{what}"); + assert!(t.overlays(&cx).is_empty(), "{what}: nothing stays highlighted"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 125.0, 70.0).with_mods(mods)).is_empty(), "{what}"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 125.0, 70.0).with_mods(mods)).is_empty(), "{what}: the release merges nothing"); + assert!(!t.claims_key(&cx, ToolKey::Escape), "{what}: with no drag, Escape is not the tool's"); + // The next drag merges as before. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{what}: {acts:?}"); + } +} + #[test] fn shape_builder_rebuilds_its_map_for_a_new_revision_at_the_same_address() { let (mut d, a, b) = two_rects(); @@ -824,6 +858,13 @@ fn live_paint_selection_hover_builds_no_map_of_the_selection() { assert!(!t.waiting()); } +/// A Live Paint Selection click pressed with `down`: the press does nothing, and the release does +/// what is returned. +fn lp_click(t: &mut LivePaintSelectionTool, cx: &ToolContext, down: PointerEvent) -> Vec { + assert!(t.pointer(cx, &down).is_empty(), "the press changes nothing"); + t.pointer(cx, &PointerEvent { kind: PointerKind::Up, ..down }) +} + #[test] fn live_paint_selection_selects_faces() { let (d, _gid, f2, e) = live_group_doc(); @@ -831,12 +872,12 @@ fn live_paint_selection_selects_faces() { let p = paint(); let cx = cx(&d, &s, &p); let mut t = LivePaintSelectionTool::default(); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]); let alt = Mods { alt: true, ..Default::default() }; - let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 300.0, 300.0)); assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]); } @@ -853,15 +894,15 @@ fn live_paint_selection_options_choose_what_a_click_selects() { assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0})); // Select Fills off: a click inside a face selects nothing, and a click by an edge selects it. t.set_option("selectFills", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0)), none); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); // Select Strokes off: Alt-click by an edge selects the face under the pointer. t.set_option("selectFills", &json!(true)); t.set_option("selectStrokes", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt)); let ov = t.overlays(&cx); let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; @@ -869,12 +910,12 @@ fn live_paint_selection_options_choose_what_a_click_selects() { // Both off: nothing is selected or highlighted. t.set_option("selectFills", &json!(false)); for m in [Mods::default(), alt] { - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m)); assert!(t.overlays(&cx).is_empty()); } let shift = Mods { shift: true, ..Default::default() }; - assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); + assert!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); } #[test] @@ -894,15 +935,156 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() let ov = t.overlays(&cx); let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); } // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +/// Presses at `from` with `m`, drags to `to` and releases there; returns what the release does +/// (the press and the drag do nothing). +fn lp_drag(t: &mut LivePaintSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec { + assert!(t.pointer(cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty()); + assert!(t.pointer(cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty()); + t.pointer(cx, &ev(PointerKind::Up, to.0, to.1).with_mods(m)) +} + +#[test] +fn live_paint_selection_marquee_selects_the_faces_and_edges_it_touches() { + let mut d = Document::new(500.0, 500.0); + let (_, [left, top, right, bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id; + live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let mut t = LivePaintSelectionTool::default(); + // While the drag is on, the tool is busy and draws the marquee. + t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert!(t.busy()); + assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::new(-10.0, -10.0, 50.0, 50.0))]); + // Partly inside the marquee: the face and its left and top sides, not the other group. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)), set(&[face, left, top])); + assert!(!t.busy()); + // A marquee inside the face takes the face. + assert_eq!(lp_drag(&mut t, &cx, (20.0, 20.0), (40.0, 40.0), Mods::default()), set(&[face])); + // Across the right side, which ends outside the marquee, and the bottom-right corner. + assert_eq!(lp_drag(&mut t, &cx, (90.0, 40.0), (110.0, 150.0), Mods::default()), set(&[face, right, bottom])); + // A marquee that touches nothing selects nothing. + assert_eq!(lp_drag(&mut t, &cx, (120.0, 120.0), (180.0, 180.0), Mods::default()), vec![Action::Exec("select.none".into(), json!({}))]); + // A drag shorter than 3 pixels is a click: the release selects the face under the press. + assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (52.0, 51.0), Mods::default()), set(&[face])); + // Select Fills off takes no faces, and Select Strokes off no edges. + t.set_option("selectFills", &json!(false)); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[left, top])); + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[face])); +} + +#[test] +fn live_paint_selection_shift_marquee_adds_and_removes() { + let mut d = Document::new(500.0, 500.0); + let (_, [left, top, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + // Each face and edge the marquee touches leaves the selection or joins it. + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), shift), toggle(&[face, left, top])); + // From a press on the face, the face is toggled once, with the rest of what the marquee touches. + assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (60.0, -10.0), shift), toggle(&[face, top])); + // From a press by the right side (which a Shift-click there toggles), a marquee that touches + // nothing changes nothing. + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + assert_eq!(lp_drag(&mut t, &cx, (102.0, 50.0), (102.0, 50.0), alt_shift), vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); + assert!(lp_drag(&mut t, &cx, (102.0, 50.0), (150.0, 60.0), alt_shift).is_empty()); +} + +#[test] +fn live_paint_selection_escape_cancels_the_marquee() { + let mut d = Document::new(500.0, 500.0); + live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + // Pressed outside the group and on its face, with and without Shift, and then dragged or not. + for (m, from, to) in [ + (Mods::default(), (-10.0, -10.0), (50.0, 50.0)), + (Mods { shift: true, ..Default::default() }, (-10.0, -10.0), (50.0, 50.0)), + (Mods::default(), (50.0, 50.0), (80.0, 80.0)), + (Mods { shift: true, ..Default::default() }, (50.0, 50.0), (80.0, 80.0)), + (Mods::default(), (50.0, 50.0), (50.0, 50.0)), + ] { + let mut t = LivePaintSelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty()); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert!(t.overlays(&cx).is_empty(), "no marquee after Escape"); + // The rest of the drag and the release select nothing. + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0).with_mods(m)); + assert!(t.overlays(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0).with_mods(m)).is_empty(), "{m:?} {from:?}"); + } +} + +#[test] +fn live_paint_selection_skips_locked_groups_and_art_outside_isolation() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, back_bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |d: &Document, g: NodeId| faces(d.node(g).unwrap())[0].id; + let back_face = face(&d, back); + d.node_mut(front).unwrap().locked = true; + let s = Selection::default(); + let p = paint(); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let none = vec![Action::Exec("select.none".into(), json!({}))]; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + let mut t = LivePaintSelectionTool::default(); + { + let cx = cx(&d, &s, &p); + // Where the faces overlap, the back group's face under the locked one is highlighted and picked. + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), set(&[back_face])); + // Over the locked group alone, nothing is highlighted or picked. + t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), None); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none); + // A marquee across both groups takes only the back group's face and edges. + assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom])); + } + // With the back group isolated, the front group is not picked either. + d.node_mut(front).unwrap().locked = false; + { + let cx = ToolContext { isolation: Some(back), ..cx(&d, &s, &p) }; + t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), None); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none); + assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom])); + } + // On a locked layer, no group is picked. + let layer = d.layers[0].id; + d.node_mut(layer).unwrap().locked = true; + let cx = cx(&d, &s, &p); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), none); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (200.0, 200.0), Mods::default()), none); } #[test] @@ -931,7 +1113,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), None => Action::Exec("select.none".into(), json!({})), }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); @@ -974,7 +1156,7 @@ fn live_paint_tools_skip_hidden_edges() { Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), None => Action::Exec("select.none".into(), json!({})), }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); @@ -1010,19 +1192,19 @@ fn live_paint_tools_skip_hidden_faces() { let mut sel = LivePaintSelectionTool::default(); sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); // Over the hidden face alone, neither tool highlights or picks a face. bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); assert_eq!(part(&bucket.overlays(&cx)), None); assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), None); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side // as it does outside every face. sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); - let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + let picked = lp_click(&mut sel, &cx, ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); } diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 298714b50..f1973dfd5 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform}; /// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] @@ -63,6 +63,50 @@ impl ScreenFrame { } } +/// The Character panel's Snap to Glyph options (`view.snapToGlyph.options`): which guides View › +/// Snap to Glyph gives. All on by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GlyphSnap { + /// The baseline of a single line of type. + pub baseline: bool, + /// The x-height: the top of its lowercase letters. + pub x_height: bool, + /// Glyph Bounds: each glyph's top, bottom, left and right edges. + pub bounds: bool, + /// Proximity Guides: across the baseline at both edges of each character's width. + pub proximity: bool, + /// Angular Guides: along a glyph's slanted straight segments, and every guide of rotated type + /// (which runs at its angle). + pub angular: bool, + /// Anchor Point: the anchor points of each glyph's outline. + pub anchors: bool, +} + +impl Default for GlyphSnap { + fn default() -> Self { + Self { baseline: true, x_height: true, bounds: true, proximity: true, angular: true, anchors: true } + } +} + +impl GlyphSnap { + /// These options with the ones `p` names (camelCase booleans), or why `p` can't set them. + pub fn merged(self, p: &serde_json::Value) -> Result { + let mut o = serde_json::to_value(self).map_err(|e| e.to_string())?; + match (p, o.as_object_mut()) { + (serde_json::Value::Null, _) => {} + (serde_json::Value::Object(given), Some(cur)) => { + for (k, v) in given { + let slot = cur.get_mut(k).ok_or_else(|| format!("unknown Snap to Glyph option `{k}`"))?; + *slot = serde_json::Value::Bool(v.as_bool().ok_or_else(|| format!("`{k}` must be true or false"))?); + } + } + _ => return Err("the Snap to Glyph options must be an object".into()), + } + serde_json::from_value(o).map_err(|e| e.to_string()) + } +} + /// Read-only context a tool sees. pub struct ToolContext<'a> { pub doc: &'a Document, @@ -96,8 +140,11 @@ pub struct ToolContext<'a> { pub snap_tangent: bool, /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. pub snap_perpendicular: bool, - /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). + /// View → Snap to Glyph: drawn points snap to live-text glyph bounds, and on a single line of type + /// to its baseline, x-height, character widths and glyph anchor points (with Smart Guides). pub snap_to_glyph: bool, + /// Which of Snap to Glyph's guides there are (the Character panel's Snap to Glyph options). + pub glyph_snap: GlyphSnap, /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. pub corner_widgets: bool, /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 7661f3d72..513f8ceb1 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Vec2}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Cursor { diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index fbca7f0af..dd3feae0b 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -163,7 +163,7 @@ id = "PenAdd" group = "Pen" shown_when = [ "The Pen over a segment of a selected path with Auto Add/Delete on (Shift off), or over a selected blend's spine.", - "The Add Anchor Point tool everywhere (and the Delete Anchor Point tool with Alt).", + "The Add Anchor Point tool everywhere (and the Delete Anchor Point tool and the Scissors tool with Alt).", "The Curvature tool over a segment.", "Stands in for MeshAdd and PuppetPin until they're drawn.", ] @@ -622,14 +622,14 @@ hotspot = "The blade's point." [[cursor]] id = "Scissors" group = "Drawing and editing tools" -shown_when = ["The Scissors tool over a path segment: a click splits the path there."] +shown_when = ["The Scissors tool over a path segment, Alt up: a click splits the path there."] hotspot = "Where the blades cross, exact enough to cut where aimed." look = "Scissors. ScissorsNotAllowed is this drawing with a badge." [[cursor]] id = "ScissorsNotAllowed" group = "Drawing and editing tools" -shown_when = ["The Scissors tool away from every path segment: a click does nothing."] +shown_when = ["The Scissors tool away from every path segment, Alt up: a click does nothing."] hotspot = "The same point as Scissors." look = "The Scissors with a small not-allowed badge, so the cursor keeps its size and shape and only the badge says a click won't cut." diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 77a96e8b2..cb0a958f6 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -3,8 +3,9 @@ //! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the //! segment independently of its endpoint anchors, drag to move selected anchors (the one pressed on snaps to //! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45° -//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a -//! direction handle to +//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it; an Alt-click +//! on a path selected whole adds its group, and each further Alt-click adds the next group up, as +//! Group Selection's clicks do), drag a direction handle to //! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap //! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are //! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners @@ -15,7 +16,10 @@ //! objects entirely inside it (members of groups too), whole. //! In isolation mode both pick only the isolated object and what is inside it: anchors, segments, //! paths and groups outside it stay put. -//! Both move objects selected as a whole as the Selection tool does, snapping alike. +//! Both move objects selected as a whole as the Selection tool does, snapping alike. When the +//! selection also holds direct-selected anchors or segments, a drag from an object moves those +//! anchors and the objects selected whole, as an anchor drag does, and while Alt is held it +//! copies the selected objects. //! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]). //! A Cmd/Ctrl+Shift double-click with either one releases the ruler guide or guide path under the //! pointer ([`release_guide_at`]). @@ -23,8 +27,11 @@ //! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes //! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient //! mesh's point last clicked). Dragging a corner or an edge of area -//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size; dragging -//! an anchor of type on a path's path reshapes the path (`text.reshapePath`): the text reflows along it. +//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. A click +//! on a frame anchor or a marquee selects frame anchors (Shift toggles them). While the selection +//! holds only area type with selected frame anchors, those show filled, a drag of one of them moves +//! them all, and the arrow keys nudge them; otherwise the type is selected whole. Dragging an anchor of type on +//! a path's path reshapes the path (`text.reshapePath`): the text reflows along it. //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors @@ -32,9 +39,10 @@ //! moves its anchors. use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test}; +use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at}; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2}; @@ -121,16 +129,34 @@ enum State { }, /// Dragging a mesh point or handle ([`MeshEdit`]). Mesh, - /// Dragging anchors of area type's frame (a corner, or the two ends of an edge), or an anchor - /// of type on a path's path. + /// Dragging anchors of area type's frames (a corner, the two ends of an edge, or the selected + /// frame anchors), or an anchor of type on a path's path: each object with its anchors. TypeArea { - id: NodeId, - anchors: Vec, + items: FrameAnchors, start: Point, began: bool, }, } +/// Anchors of the frames of area type (or of the path of type on a path), each object with its +/// anchors. +type FrameAnchors = Vec<(NodeId, Vec)>; + +/// What [`DirectSelectionTool::notify`] hears after a press or a marquee selected frame anchors: +/// the selected objects to keep them with. +const FRAME_PICKED: &str = "frameAnchors"; + +/// The params of `text.reshapeArea` or `text.reshapePath` moving `items` by `d`. +fn reshape_params(items: &[(NodeId, Vec)], d: Vec2) -> Value { + match items { + [(id, anchors)] => json!({"id": id.0, "anchors": anchors_json(anchors), "dx": d.x, "dy": d.y}), + _ => { + let items: Vec = items.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); + json!({"items": items, "dx": d.x, "dy": d.y}) + } + } +} + /// How far (pt) a snap must move a dragged point for the arrow to say it snapped. const SNAPPED: f64 = 1e-9; @@ -158,6 +184,13 @@ pub struct DirectSelectionTool { handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. move_snap: Option, + /// What the point holding a move of objects snaps to while the move takes direct-selected + /// anchors along (`path.moveAnchors`): each move of objects sets it when it begins, to the + /// targets of an anchor drag when anchors are selected, else to `None`. + partial_snap: Option, + /// With Snap to Grid or Snap to Pixel on, what such a move from a fill snaps instead of the + /// point it holds: the box around the selected anchors and the objects selected whole. + partial_grid: Option, /// The drag snapped to a point or a guide at its last step: the arrow turns hollow. snapped: bool, /// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is. @@ -168,6 +201,18 @@ pub struct DirectSelectionTool { click: Option<(NodeId, usize, usize)>, /// A click on a whole path picks the segment on release; a drag still moves the whole path. click_segment: Option<(NodeId, usize, usize)>, + /// An Alt-click on a path selected whole adds this enclosing group on release, as a Group + /// Selection click does; an Alt-drag still copies the selection. + click_group: Option, + /// The selected anchors of area type's frames, picked by a click or a marquee: they show + /// filled, a drag of one moves them all, and the arrow keys nudge them. They stay selected + /// while the document and its selection are at `frame_revision`, the revision after they were + /// picked or the tool last moved them. + frame: BTreeMap>, + frame_revision: (u64, u64), + /// The already selected frame anchor the press landed on without Shift: a click (no drag) + /// selects it alone, as a drag moves every selected frame anchor with it. + frame_click: Option<(NodeId, AnchorRef)>, } impl DirectSelectionTool { @@ -182,14 +227,75 @@ impl DirectSelectionTool { copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, + partial_snap: None, + partial_grid: None, snapped: false, hover: None, guide: GuideEdit::default(), click: None, click_segment: None, + click_group: None, + frame: BTreeMap::new(), + frame_revision: (0, 0), + frame_click: None, } } + /// The selected frame anchors that still apply, each object with its anchors. They apply while + /// nothing else changed the document or the selection since they were picked (a drag of them + /// changes it as it goes), and only while the selection holds nothing else: every selected + /// object is editable area type with selected frame anchors, and no path anchors or ruler + /// guides are selected. Otherwise the type is selected whole. + fn frame_anchors(&self, cx: &ToolContext) -> FrameAnchors { + let dragging = matches!(self.state, State::TypeArea { began: true, .. }); + if self.group + || self.frame.is_empty() + || (!dragging && cx.revision != self.frame_revision) + || !cx.selection.anchors.is_empty() + || !cx.selection.guides.is_empty() + { + return vec![]; + } + let editable = cx.doc.editable(); + let v: FrameAnchors = self + .frame + .iter() + .filter_map(|(id, set)| { + let n = cx + .doc + .node(*id) + .filter(|n| cx.selection.contains(*id) && is_area_type(n) && editable(*id) && in_scope(cx.doc, cx.isolation, *id))?; + let frame = editable_path(n)?; + let v: Vec = + set.iter().copied().filter(|(si, ai)| frame.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())).collect(); + (!v.is_empty()).then_some((*id, v)) + }) + .collect(); + if v.len() == cx.selection.objects.len() { v } else { vec![] } + } + + /// The move an arrow key nudges the selected frame anchors by (General › Keyboard Increment, + /// ten times with Shift, along General › Constrain Angle's axes), with those anchors. + fn frame_nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<(FrameAnchors, Vec2)> { + if !matches!(self.state, State::Idle) { + return None; + } + let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 }; + let d = match key { + ToolKey::Left => Point::new(-step, 0.0), + ToolKey::Right => Point::new(step, 0.0), + ToolKey::Up => Point::new(0.0, -step), + ToolKey::Down => Point::new(0.0, step), + _ => return None, + }; + let items = self.frame_anchors(cx); + if items.is_empty() { + return None; + } + let d = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(d, |axes| axes * d); + Some((items, d.to_vec2())) + } + /// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct /// Selection takes no letter. fn claims_letter(&self, key: ToolKey) -> bool { @@ -199,7 +305,7 @@ impl DirectSelectionTool { /// Whether `id` may be picked while `scope` is isolated: it is that object, or it sits inside it. /// With no isolation, everything may be picked. -fn in_scope(doc: &vectorcraft_doc::Document, scope: Option, id: NodeId) -> bool { +pub(crate) fn in_scope(doc: &vectorcraft_doc::Document, scope: Option, id: NodeId) -> bool { scope.is_none_or(|scope| doc.ancestry(id).is_some_and(|anc| anc.contains(&scope))) } @@ -432,6 +538,23 @@ struct SegmentHit { curved: bool, } +/// The group a Group Selection click on a selected object adds, given the object's `ancestry` +/// (its layer and sublayers first, the object last). It walks up from the object and returns the +/// first ancestor below the layer and its sublayers that is not yet selected. The walk stops at the +/// isolated object, since the next group up is outside isolation mode, and at any layer or +/// sublayer, including one inside a group. +fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { + // Skip the layer and its sublayers: they are not objects. + let layers = ancestry.iter().take_while(|id| cx.doc.node(**id).is_some_and(Node::is_layer)).count().max(1); + ancestry + .iter() + .skip(layers) + .rev() + .copied() + .take_while(|id| in_scope(cx.doc, cx.isolation, *id) && !cx.doc.node(*id).is_some_and(Node::is_layer)) + .find(|id| !cx.selection.contains(*id)) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -453,11 +576,16 @@ impl Tool for DirectSelectionTool { fn options(&self) -> Value { self.mesh.options() } - /// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, a - /// marquee) cancels it: what it moved goes back and the tool waits for the next press (#1743). + /// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, mesh + /// points, corner widgets, the brackets of type on a path, a marquee) cancels it: what it moved + /// goes back and the tool waits for the next press (#1743). /// With Group Selection, E during a marquee drag makes the marquee select only the objects /// entirely inside it, whole, and E pressed again makes it select what it selects by default. - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + /// The arrow keys nudge the selected frame anchors of area type. + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if let Some((items, d)) = self.frame_nudge(cx, key, mods) { + return vec![Action::Exec("text.reshapeArea".into(), reshape_params(&items, d)), Action::Notify(FRAME_PICKED.into())]; + } if self.claims_letter(key) && let State::Marquee { enclosed, .. } = &mut self.state { @@ -467,17 +595,26 @@ impl Tool for DirectSelectionTool { if key == ToolKey::Escape && self.guide.busy() { return self.guide.cancel(); } - if key != ToolKey::Escape || matches!(self.state, State::Idle | State::Mesh | State::Corner(_) | State::Bracket(_)) { + if key != ToolKey::Escape || matches!(self.state, State::Idle) { return vec![]; } + // A mesh point drag ends here too; the `Action::Cancel` below undoes it. + self.mesh.cancel(); + // The frame anchors a cancelled drag held stay selected. + let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty(); self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); - (self.click, self.click_segment) = (None, None); - vec![Action::Cancel] + (self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None); + if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] } } - fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - self.claims_letter(key) + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + self.claims_letter(key) || self.frame_nudge(cx, key, Mods::default()).is_some() + } + fn notify(&mut self, cx: &ToolContext, what: &str) { + if what == FRAME_PICKED { + self.frame_revision = cx.revision; + } } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.guide.pointer(cx, ev) { @@ -487,66 +624,9 @@ impl Tool for DirectSelectionTool { // Anchors and handle ends are picked from a little further than segments and objects. let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (ev.kind, self.state.clone()) { - (PointerKind::Down, _) if self.group => { - if let Some(out) = self.guide.press(cx, ev) { - self.state = State::Idle; - return out; - } - let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else { - self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; - return vec![]; - }; - // A compound path's paths are picked one at a time, below the compound path (#2441). - if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { - h.ancestry.push(part); - h.leaf = part; - } - // Walk up from the leaf: select the first ancestor not yet selected (below the layer). - // Stop at the isolated object: the next group up is outside isolation mode. - let chain: Vec = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect(); - let target = if cx.selection.contains(h.leaf) { - chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) - } else { - h.leaf - }; - let mut deselect = None; - let mut out = vec![]; - if ev.mods.shift { - // Shift on a selected member deselects what was hit (a compound path's part), - // not the next ancestor a plain click would add (#2441). - if cx.selection.contains(h.leaf) { - deselect = Some(h.leaf); - } else { - out.push(Action::Exec("select.toggle".into(), json!({"id": target.0}))); - } - } else if !cx.selection.contains(target) { - if cx.selection.contains(h.leaf) { - out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]}))); - } else { - out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]}))); - } - } - self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect }; - out - } + (PointerKind::Down, _) if self.group => self.press_group(cx, ev, p, tol), (PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(), - (PointerKind::DoubleClick, _) if !self.group => { - self.state = State::Idle; - if let Some(a) = corners::double_click(cx, p, true) { - return vec![a]; - } - if let Some(a) = release_guide_at(cx, p, ev.mods) { - return vec![a]; - } - if crate::rulerguide::guide_at(cx, p).is_some() { - return vec![]; - } - // A double-click edits what is drawn: not a clipping path's outline. - if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { - return vec![Action::SwitchTool("type".into())]; - } - vec![] - } + (PointerKind::DoubleClick, _) if !self.group => self.double_click(cx, ev, p), (PointerKind::Move, State::Idle) => { self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) }; vec![] @@ -554,6 +634,8 @@ impl Tool for DirectSelectionTool { (PointerKind::Down, _) => { self.click = None; self.click_segment = None; + self.click_group = None; + self.frame_click = None; if let Some(c) = CornerDrag::hit(cx, ev, true) { self.state = State::Corner(c); return vec![]; @@ -588,39 +670,11 @@ impl Tool for DirectSelectionTool { return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) { - if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) { - return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); - } - // Alt picks the whole path (and Alt-drag copies it), as on a segment. - if ev.mods.alt { - self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; - let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); - return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; - } - // A path with all its anchors selected is stored whole (no partial entry). - let already = cx.selection.contains(id) - && !cx.selection.segments.contains_key(&id) - && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); - // Paths all selected whole move as objects, held by this anchor, so Alt can turn - // the copy on and off during the drag as with the Selection tool. - self.state = if already && cx.selection.anchors.is_empty() { - State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } - } else { - State::MoveAnchors { start: p, grab, began: false } - }; - if ev.mods.shift { - return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; - } - // Pressed among other selected anchors: a click selects this one alone (the - // drag, which moves them all, keeps them). - self.click = already.then_some((id, si, ai)); - if !already { - return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]; - } - return vec![]; + return self.press_anchor(cx, ev, p, (id, si, ai, grab)); } if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) { - return self.press_type_area(cx, id, anchors, p, ev.mods.shift); + self.frame.clear(); + return self.press_type_area(cx, id, vec![(id, anchors)], p, ev.mods.shift); } // A curved segment of a selected blend's spine reshapes, as a path's does. if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) { @@ -631,79 +685,16 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; return out; } - if let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) { - if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { - h.ancestry.push(part); - h.leaf = part; - } - // A segment pick keeps its endpoints available for movement without selecting - // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks - // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); - if !ev.mods.alt - && matches!(h.kind, HitKind::Stroke | HitKind::Outline) - && let Some(s) = segment_at(cx, h.leaf, p) - { - if whole && !ev.mods.shift { - self.click_segment = Some((h.leaf, s.si, s.seg)); - } else { - let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg))); - self.state = if s.curved && !ev.mods.shift { - State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } - } else { - // Held by the segment itself, so the segment is what lands on a - // ruler guide or lines up, not the pressed spot beside it (#3701). - State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } - }; - if ev.mods.shift { - return vec![Action::Exec( - "select.segments".into(), - json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}), - )]; - } - if selected { - return vec![]; - } - return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))]; - } - } - // Clicking the fill selects the whole leaf path (all anchors). - self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; - if ev.mods.shift { - return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; - } - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { - return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; - } - return vec![]; + if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { + return self.press_object(cx, ev, p, tol, h); + } + if let Some(out) = self.press_hidden(cx, ev, p) { + return out; } self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; vec![] } - (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => { - let mut out = vec![]; - if !began { - if p.distance(start) < cx.tol(3.0) { - return out; - } - out.push(Action::Begin("Move".into())); - self.state = State::MoveAnchors { start, grab, began: true }; - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - } - let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); - let unsnapped = d; - // The grabbed anchor snaps and the others follow it. Shift keeps the move at its - // angle, and the anchor slides along it onto what Smart Guides find there (#886). - self.guides.clear(); - if let Some(snap) = &self.anchor_snap { - let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); - let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); - (d, self.guides) = (q - grab, guides); - } - self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); - out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); - out - } + (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => self.drag_move_anchors(cx, ev, p, (start, grab, began)), (PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => { let mut out = vec![]; if !began { @@ -720,43 +711,10 @@ impl Tool for DirectSelectionTool { )); out } - (PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => { + (PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => self.drag_move_object(cx, ev, p, (start, grab, hold, began)), + (PointerKind::Drag, State::TypeArea { items, start, began }) => { let mut out = vec![]; - if !began { - if p.distance(start) < cx.tol(3.0) { - return out; - } - out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); - self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; - if grab.is_some() { - // Alt can go down or up at any time: gather both while nothing has moved. - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); - } else { - self.move_snap = Some(MoveSnap::new(cx)); - } - } - let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); - let unsnapped = d; - self.guides.clear(); - let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; - if let (Some(grab), Some(snap)) = (grab, snap) { - let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); - let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); - (d, self.guides) = (q - grab, guides); - } else if let Some(snap) = &self.move_snap { - (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; - } - self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); - out.push(Action::Preview( - "object.transform".into(), - json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), - )); - out - } - (PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => { - let mut out = vec![]; - let on_path = cx.doc.node(id).is_some_and(is_path_type); + let on_path = items.first().and_then(|(id, _)| cx.doc.node(*id)).is_some_and(is_path_type); if !began { if p.distance(start) < cx.tol(3.0) { return out; @@ -765,8 +723,8 @@ impl Tool for DirectSelectionTool { } let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let cmd = if on_path { "text.reshapePath" } else { "text.reshapeArea" }; - out.push(Action::Preview(cmd.into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y}))); - self.state = State::TypeArea { id, anchors, start, began: true }; + out.push(Action::Preview(cmd.into(), reshape_params(&items, d))); + self.state = State::TypeArea { items, start, began: true }; out } (PointerKind::Drag, State::Handle { id, si, ai, out }) => { @@ -846,71 +804,13 @@ impl Tool for DirectSelectionTool { | State::SpinePoint { began, .. } | State::SpineSegment { began, .. } | State::TypeArea { began, .. }, - ) => { - let deselect = match self.state { - State::MoveObject { deselect, .. } => deselect, - _ => None, - }; - self.state = State::Idle; - self.guides.clear(); - (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); - match (began, deselect, self.click.take(), self.click_segment.take()) { - (true, ..) => vec![Action::Commit], - (false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))], - (false, None, Some((id, si, ai)), _) => { - vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))] - } - (false, None, None, Some((id, si, seg))) => { - vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))] - } - (false, None, None, None) => vec![], - } - } + ) => self.release_move(cx, began), (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { self.state = State::Idle; self.guides.clear(); vec![Action::Commit] } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Idle; - let r = Rect::from_points(start, p); - if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { - return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; - } - // With Group Selection's enclosed marquee, the tool selects the objects inside it - // (members of groups too), each whole, as a click selects one. - if enclosed { - let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); - let editable = cx.doc.editable(); - touched.retain(|id| editable(*id)); - let ids: Vec = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect(); - let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids })); - return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect(); - } - // Anchors inside the rect, on editable paths inside isolation mode. - let mut sel: Vec<(NodeId, Vec)> = vec![]; - walk_pickable(cx.doc, cx.isolation, |n| { - if let NodeKind::Path { path, .. } = &n.kind { - let v: Vec = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); - if !v.is_empty() { - sel.push((n.id, v)); - } - } - }); - // Objects without anchors to pick (type, images, symbols…) are selected whole when - // the marquee touches them, as with the Selection tool (#927). - for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) { - let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); - if whole && !sel.iter().any(|(s, _)| *s == id) { - sel.push((id, vec![])); - } - } - let editable = cx.doc.editable(); - sel.retain(|(id, _)| editable(*id)); - let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); - let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); - std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect() - } + (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => self.release_marquee(cx, p, (start, toggle, enclosed)), _ => vec![], } } @@ -922,7 +822,7 @@ impl Tool for DirectSelectionTool { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx)); if !self.group { - out.extend(frame_overlays(cx)); + out.extend(frame_overlays(cx, &self.frame_anchors(cx))); out.extend(pathtype::overlays(cx)); } if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) { @@ -1035,22 +935,60 @@ fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> { } /// The frame anchors of the selected area type and the path anchors of the selected type on a -/// path, which Direct Selection drags. -fn frame_overlays(cx: &ToolContext) -> Vec { +/// path, which Direct Selection drags; the `selected` frame anchors are filled. +fn frame_overlays(cx: &ToolContext, selected: &[(NodeId, Vec)]) -> Vec { let mut out = vec![]; for id in &cx.selection.objects { let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n) || is_path_type(n)).and_then(editable_path) else { continue }; let color = cx.doc.layer_color(*id); - out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 })); + let picked = selected.iter().find(|(s, _)| s == id).map(|(_, v)| v.as_slice()).unwrap_or_default(); + out.extend(frame.anchors().map(|(si, ai, a)| Overlay::Anchor { p: a.p, color, filled: picked.contains(&(si, ai)), size: 5.0 })); } out } impl DirectSelectionTool { + /// Press on a frame anchor of area type: Shift toggles it among the selected frame anchors; + /// without Shift, one not among them becomes the only one, and one among them becomes the only + /// one when the press is released without a drag. A drag moves them all. + fn press_frame_anchor(&mut self, cx: &ToolContext, id: NodeId, anchor: AnchorRef, p: Point, shift: bool) -> Vec { + let mut picked: BTreeMap> = + self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect(); + let on = picked.get(&id).is_some_and(|set| set.contains(&anchor)); + if shift { + let set = picked.entry(id).or_default(); + if on { + set.remove(&anchor); + } else { + set.insert(anchor); + } + if set.is_empty() { + picked.remove(&id); + } + } else if !on { + picked = BTreeMap::from([(id, BTreeSet::from([anchor]))]); + } + // The selected objects follow: the type is selected while it has selected frame anchors, + // and a click that picks a new anchor without Shift selects that type alone. + let mut out = vec![]; + let any = picked.contains_key(&id); + if !shift && !on && cx.selection.objects != [id] { + out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]}))); + } else if shift && cx.selection.contains(id) != any { + out.push(Action::Exec("select.toggle".into(), json!({"id": id.0}))); + } + let items: FrameAnchors = picked.iter().map(|(id, set)| (*id, set.iter().copied().collect())).collect(); + self.state = if items.is_empty() { State::Idle } else { State::TypeArea { items, start: p, began: false } }; + self.frame = picked; + self.frame_click = (on && !shift).then_some((id, anchor)); + out.push(Action::Notify(FRAME_PICKED.into())); + out + } + /// Press on area type's frame anchors (a corner, or an edge's two ends) or an anchor of type - /// on a path's path: select the text and get ready to drag them. - fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec, p: Point, shift: bool) -> Vec { - self.state = State::TypeArea { id, anchors, start: p, began: false }; + /// on a path's path, of `id`: select the text and get ready to drag `items`. + fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, items: FrameAnchors, p: Point, shift: bool) -> Vec { + self.state = State::TypeArea { items, start: p, began: false }; if cx.selection.contains(id) { vec![] } else if shift { @@ -1077,3 +1015,366 @@ impl DirectSelectionTool { #[cfg(test)] mod tests; + +impl DirectSelectionTool { + /// A press where a clipping mask hides the art of a selected object (picked in the Layers + /// panel, say) grabs the selection by it, as a press on its visible part does; `None` + /// elsewhere, and with Shift (a marquee then adds to the selection). + fn press_hidden(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option> { + if ev.mods.shift { + return None; + } + selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects)?; + self.state = State::MoveObject { start: p, grab: None, hold: p, began: false, deselect: None }; + Some(vec![]) + } + fn press_group(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64) -> Vec { + if let Some(out) = self.guide.press(cx, ev) { + self.state = State::Idle; + return out; + } + let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else { + if let Some(out) = self.press_hidden(cx, ev, p) { + return out; + } + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; + return vec![]; + }; + // A compound path's paths are picked one at a time, below the compound path (#2441). + if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { + h.ancestry.push(part); + h.leaf = part; + } + let target = if cx.selection.contains(h.leaf) { next_group(cx, &h.ancestry).unwrap_or(h.leaf) } else { h.leaf }; + let mut deselect = None; + let mut out = vec![]; + if ev.mods.shift { + // Shift on a selected member deselects what was hit (a compound path's part), + // not the next ancestor a plain click would add (#2441). + if cx.selection.contains(h.leaf) { + deselect = Some(h.leaf); + } else { + out.push(Action::Exec("select.toggle".into(), json!({"id": target.0}))); + } + } else if !cx.selection.contains(target) { + if cx.selection.contains(h.leaf) { + out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]}))); + } else { + out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]}))); + } + } + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect }; + out + } + fn double_click(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Vec { + self.state = State::Idle; + if let Some(a) = corners::double_click(cx, p, true) { + return vec![a]; + } + if let Some(a) = release_guide_at(cx, p, ev.mods) { + return vec![a]; + } + if crate::rulerguide::guide_at(cx, p).is_some() { + return vec![]; + } + // A double-click edits what is drawn: not a clipping path's outline. + let hit = hit_test(cx.doc, p, cx.hit_options()); + if hit.as_ref().and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { + return vec![Action::SwitchTool("type".into())]; + } + // Outside the isolated art it leaves isolation mode, as with the Selection tool (#4466). + if hit.is_none() && cx.isolation.is_some() { + return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; + } + vec![] + } + fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec { + if cx.doc.node(id).is_some_and(is_area_type) { + return self.press_frame_anchor(cx, id, (si, ai), p, ev.mods.shift); + } + if cx.doc.node(id).is_some_and(is_path_type) { + return self.press_type_area(cx, id, vec![(id, vec![(si, ai)])], p, ev.mods.shift); + } + // Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path + // already selected whole, an Alt-click adds the next enclosing group on release. + if ev.mods.alt { + self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; + let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); + if whole && !ev.mods.shift { + self.click_group = cx.doc.ancestry(id).and_then(|a| next_group(cx, &a)); + } + return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; + } + // A path with all its anchors selected is stored whole (no partial entry). + let already = + cx.selection.contains(id) && !cx.selection.segments.contains_key(&id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); + // Paths all selected whole move as objects, held by this anchor, so Alt can turn + // the copy on and off during the drag as with the Selection tool. + self.state = if already && cx.selection.anchors.is_empty() { + State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } + } else { + State::MoveAnchors { start: p, grab, began: false } + }; + if ev.mods.shift { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; + } + // Pressed among other selected anchors: a click selects this one alone (the + // drag, which moves them all, keeps them). + self.click = already.then_some((id, si, ai)); + if !already { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]; + } + vec![] + } + fn press_object(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64, mut h: vectorcraft_doc::hit::Hit) -> Vec { + if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { + h.ancestry.push(part); + h.leaf = part; + } + // A segment pick keeps its endpoints available for movement without selecting + // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks + // the whole path (and Alt-drag copies it), as with Group Selection. + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + if !ev.mods.alt + && matches!(h.kind, HitKind::Stroke | HitKind::Outline) + && let Some(s) = segment_at(cx, h.leaf, p) + { + if whole && !ev.mods.shift { + self.click_segment = Some((h.leaf, s.si, s.seg)); + } else { + let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg))); + self.state = if s.curved && !ev.mods.shift { + State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } + } else { + // Held by the segment itself, so the segment is what lands on a + // ruler guide or lines up, not the pressed spot beside it (#3701). + State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } + }; + if ev.mods.shift { + return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}))]; + } + if selected { + return vec![]; + } + return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))]; + } + } + // Clicking the fill selects the whole leaf path (all anchors). With Alt, a click + // on a path already selected whole adds the next enclosing group on release, + // as Group Selection's click does; each further Alt-click climbs one level. + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + if ev.mods.alt && whole && !ev.mods.shift { + self.click_group = next_group(cx, &h.ancestry); + } + if ev.mods.shift { + return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; + } + self.frame.clear(); + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; + } + vec![] + } + fn drag_move_anchors(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (start, grab, began): (Point, Point, bool)) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Move".into())); + self.state = State::MoveAnchors { start, grab, began: true }; + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + } + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); + let unsnapped = d; + // The grabbed anchor snaps and the others follow it. Shift keeps the move at its + // angle, and the anchor slides along it onto what Smart Guides find there (#886). + self.guides.clear(); + if let Some(snap) = &self.anchor_snap { + let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + (d, self.guides) = (q - grab, guides); + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); + out + } + fn drag_move_object( + &mut self, + cx: &ToolContext, + ev: &PointerEvent, + p: Point, + (start, grab, hold, began): (Point, Option, Point, bool), + ) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + // With direct-selected anchors or segments in the selection, the drag moves + // them and the objects selected whole (`path.moveAnchors`), as an anchor drag + // does, while Alt is up; the other anchors of a partly selected path stay in + // place. The history names the move or the copy at the end. + self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); + self.partial_grid = + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + let copy = ev.mods.alt && self.partial_snap.is_none(); + out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() })); + self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; + if grab.is_some() { + // Alt can go down or up at any time: gather both while nothing has moved. + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + } else { + self.move_snap = Some(MoveSnap::new(cx)); + } + } + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); + let unsnapped = d; + self.guides.clear(); + if !ev.mods.alt + && let Some(snap) = &self.partial_snap + { + let held = grab.unwrap_or(hold); + if let Some(grid) = &self.partial_grid { + (d, self.guides) = if ev.mods.shift { grid.snap_along(cx, held, d, false) } else { grid.snap(cx, held, d, false) }; + } else { + let shift = ev.mods.shift.then(|| Leave::segment(cx, held, true)); + let (q, guides) = snap.snap_from(cx, held + (p - start), shift.as_ref()); + (d, self.guides) = (q - held, guides); + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); + return out; + } + let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; + if let (Some(grab), Some(snap)) = (grab, snap) { + let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + (d, self.guides) = (q - grab, guides); + } else if let Some(snap) = &self.move_snap { + (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), + )); + out + } + fn release_move(&mut self, cx: &ToolContext, began: bool) -> Vec { + let deselect = match self.state { + State::MoveObject { deselect, .. } => deselect, + _ => None, + }; + // The frame anchors a drag moved stay selected. + let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty(); + self.state = State::Idle; + self.guides.clear(); + (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + let group = self.click_group.take(); + // A click on one of several selected frame anchors selects it alone. + if let (false, Some((id, anchor))) = (began, self.frame_click.take()) { + self.frame = BTreeMap::from([(id, BTreeSet::from([anchor]))]); + let mut out = vec![]; + if cx.selection.objects != [id] { + out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]}))); + } + out.push(Action::Notify(FRAME_PICKED.into())); + return out; + } + match (began, deselect, self.click.take(), self.click_segment.take()) { + (true, ..) if frame => vec![Action::Commit, Action::Notify(FRAME_PICKED.into())], + (true, ..) => vec![Action::Commit], + (false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))], + (false, None, Some((id, si, ai)), _) => { + vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))] + } + (false, None, None, Some((id, si, seg))) => { + vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))] + } + (false, None, None, None) => group.map(|g| Action::Exec("select.add".into(), json!({"ids": [g.0]}))).into_iter().collect(), + } + } + fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec { + self.state = State::Idle; + let r = Rect::from_points(start, p); + if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { + if toggle { + return vec![]; + } + self.frame.clear(); + return vec![Action::Exec("select.none".into(), json!({}))]; + } + // With Group Selection's enclosed marquee, the tool selects the objects inside it + // (members of groups too), each whole, as a click selects one. + if enclosed { + let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); + let editable = cx.doc.editable(); + touched.retain(|id| editable(*id)); + let ids: Vec = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect(); + let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids })); + return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect(); + } + // Anchors inside the rect, on editable paths inside isolation mode. + let mut sel: Vec<(NodeId, Vec)> = vec![]; + walk_pickable(cx.doc, cx.isolation, |n| { + if let NodeKind::Path { path, .. } = &n.kind { + let v: Vec = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); + if !v.is_empty() { + sel.push((n.id, v)); + } + } + }); + let editable = cx.doc.editable(); + // Direct Selection: the frame anchors of area type inside the rect are selected + // (with Shift, toggled), and the type with them, whole. + let mut framed: Vec<(NodeId, BTreeSet)> = vec![]; + if !self.group { + walk_pickable(cx.doc, cx.isolation, |n| { + if is_area_type(n) + && let Some(frame) = editable_path(n) + { + let inside: BTreeSet = frame.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); + if !inside.is_empty() { + framed.push((n.id, inside)); + } + } + }); + framed.retain(|(id, _)| editable(*id)); + } + let mut marked: BTreeMap> = + if toggle { self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect() } else { BTreeMap::new() }; + for (id, inside) in &framed { + let set = marked.entry(*id).or_default(); + *set = if toggle { set.symmetric_difference(inside).copied().collect() } else { inside.clone() }; + let any = !set.is_empty(); + if !any { + marked.remove(id); + } + // With Shift the type is toggled only when it gains its first selected frame + // anchor or loses its last one. + if !toggle || cx.selection.contains(*id) != any { + sel.push((*id, vec![])); + } + } + // Objects without anchors to pick (type, images, symbols…) are selected whole when + // the marquee touches them, as with the Selection tool (#927). + for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) { + let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) { + sel.push((id, vec![])); + } + } + sel.retain(|(id, _)| editable(*id)); + self.frame = marked; + let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); + let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); + let mut out: Vec = std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect(); + if !self.frame.is_empty() { + out.push(Action::Notify(FRAME_PICKED.into())); + } + out + } +} diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 311efe223..d19108509 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -2,6 +2,40 @@ use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; +/// Direct Selection and Group Selection grab a selected clipped object (picked in the Layers +/// panel) where its mask hides it, as on its visible part: a drag moves it. With nothing of it +/// selected there, a press starts a marquee. +#[test] +fn a_press_where_a_mask_hides_a_selected_object_drags_it() { + let (d, _, _, art) = doc_with_masked_square(); + let p = paint(); + let mut picked = Selection::default(); + picked.add(art); + let none = Selection::default(); + // (370, 450): on the art, outside the mask (400..500). + for group in [false, true] { + let c = cx(&d, &picked, &p); + let mut t = DirectSelectionTool::new(group); + let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select."))), "{down:?} (group: {group})"); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0)); + assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?} (group: {group})"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 390.0, 470.0)); + // With Shift, a marquee. + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = DirectSelectionTool::new(group); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0).with_mods(shift)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0).with_mods(shift)); + assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_))), "a marquee (group: {group})"); + let c = cx(&d, &none, &p); + let mut t = DirectSelectionTool::new(group); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})"); + } +} + /// #414: a ruler guide is picked over the art, but an anchor on it is picked first. #[test] fn direct_and_group_selection_pick_ruler_guides() { @@ -250,13 +284,13 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() { // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... assert_eq!( t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), - vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]})), Action::Notify(FRAME_PICKED.into())] ); // ...and drags that corner. let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); - assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]); // The top edge drags both its ends; a click alone changes nothing. let mut s = Selection::default(); s.add(text); @@ -265,10 +299,146 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() { let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); + assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]); assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); - // The selected frame's corners show. - assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); + // The selected frame's corners show, the one clicked filled. + assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 3); + assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { p, filled: true, .. } if *p == Point::new(300.0, 340.0))).count(), 1); +} + +/// The frame anchor squares of the selected area type that `t` draws: (filled, hollow). +fn frame_squares(t: &DirectSelectionTool, c: &ToolContext) -> (Vec, Vec) { + let mut out = (vec![], vec![]); + for o in t.overlays(c) { + if let Overlay::Anchor { p, filled, size: 5.0, .. } = o { + if filled { out.0.push(p) } else { out.1.push(p) } + } + } + out +} + +/// A Direct Selection marquee selects the frame anchors of area type inside it, which show +/// filled: a drag of one moves them all, the arrow keys nudge them, and Shift toggles them. +#[test] +fn a_marquee_selects_frame_anchors_of_area_type() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let none = Selection::default(); + let cx1 = cx(&d, &none, &p); + let mut t = DirectSelectionTool::new(false); + let ev = |k, x, y| PointerEvent::new(k, x, y); + let shift = |k, x, y| PointerEvent::new(k, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + // A marquee round the 120 × 40 frame's right-hand corners, (420, 300) and (420, 340). + assert!(t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0)).is_empty()); + t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0)); + assert_eq!( + t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)), + vec![ + Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"})), + Action::Notify(FRAME_PICKED.into()), + ] + ); + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + t.notify(&cx2, FRAME_PICKED); + let (filled, hollow) = frame_squares(&t, &cx2); + assert_eq!(filled, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]); + assert_eq!(hollow, vec![Point::new(300.0, 300.0), Point::new(300.0, 340.0)]); + // A drag of one of them moves both. + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]); + let a = t.pointer(&cx2, &ev(PointerKind::Drag, 440.0, 360.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 1], [0, 2]], "dx": 20.0, "dy": 20.0}))); + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 440.0, 360.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]); + // The arrow keys nudge them by the keyboard increment, ten times that with Shift. + let nudged = |anchors: Value, dx: f64, dy: f64| { + vec![ + Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": anchors, "dx": dx, "dy": dy})), + Action::Notify(FRAME_PICKED.into()), + ] + }; + assert!(t.claims_key(&cx2, ToolKey::Right) && !t.claims_key(&cx2, ToolKey::Enter)); + assert_eq!(t.key(&cx2, ToolKey::Right, Mods::default()), nudged(json!([[0, 1], [0, 2]]), 1.0, 0.0)); + assert_eq!(t.key(&cx2, ToolKey::Up, Mods { shift: true, ..Mods::default() }), nudged(json!([[0, 1], [0, 2]]), 0.0, -10.0)); + // Any other change of the document or the selection (Deselect, then the same type selected + // again) leaves the type selected whole: the arrow keys are no longer the tool's. + let mut later = cx(&d, &s, &p); + later.revision = (0, 1); + assert!(frame_squares(&t, &later).0.is_empty()); + assert!(!t.claims_key(&later, ToolKey::Right)); + // Shift-click takes one out; the other still nudges. + assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 420.0, 300.0)), vec![Action::Notify(FRAME_PICKED.into())]); + assert!(t.pointer(&cx2, &shift(PointerKind::Up, 420.0, 300.0)).is_empty()); + assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]); + assert_eq!(t.key(&cx2, ToolKey::Down, Mods::default()), nudged(json!([[0, 2]]), 0.0, 1.0)); + // A Shift-marquee round the bottom corners toggles them: (300, 340) joins, (420, 340) leaves. + t.pointer(&cx2, &shift(PointerKind::Down, 280.0, 330.0)); + t.pointer(&cx2, &shift(PointerKind::Drag, 440.0, 360.0)); + let a = t.pointer(&cx2, &shift(PointerKind::Up, 440.0, 360.0)); + assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": [], "mode": "toggle"})), "the type stays selected"); + assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(300.0, 340.0)]); + // When the selection changes, the frame anchors are no longer selected. + assert!(frame_squares(&t, &cx1).0.is_empty()); + assert!(!t.claims_key(&cx1, ToolKey::Right) && t.key(&cx1, ToolKey::Right, Mods::default()).is_empty()); + // A marquee that also reaches a path's anchor (the rectangle's corner at (200, 200)) selects + // the type whole, as before: the arrow keys move the path's anchor and the type alike. + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + t.pointer(&cx1, &ev(PointerKind::Down, 440.0, 360.0)); + t.pointer(&cx1, &ev(PointerKind::Drag, 190.0, 190.0)); + let a = t.pointer(&cx1, &ev(PointerKind::Up, 190.0, 190.0)); + let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); + assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))); + let mut mixed = Selection::default(); + mixed.add(rect); + mixed.anchors.insert(rect, [(0, 2)].into()); + mixed.add(text); + let cx3 = cx(&d, &mixed, &p); + t.notify(&cx3, FRAME_PICKED); + assert!(frame_squares(&t, &cx3).0.is_empty()); + assert!(!t.claims_key(&cx3, ToolKey::Right)); + // Group Selection's marquee selects the type whole, without its frame anchors. + let mut g = DirectSelectionTool::new(true); + g.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0)); + g.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0)); + assert_eq!( + g.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)), + vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"}))] + ); +} + +/// A click without a drag on one of several selected frame anchors selects it alone, as a click on +/// a selected path anchor does; a drag of it still moves them all. +#[test] +fn a_click_on_a_selected_frame_anchor_selects_it_alone() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let none = Selection::default(); + let cx1 = cx(&d, &none, &p); + let mut t = DirectSelectionTool::new(false); + let ev = |k, x, y| PointerEvent::new(k, x, y); + // A marquee round the right-hand corners, (420, 300) and (420, 340). + t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0)); + t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0)); + t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)); + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + t.notify(&cx2, FRAME_PICKED); + assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]); + // A click on (420, 340) keeps both until the release, then selects it alone. + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]); + assert_eq!(frame_squares(&t, &cx2).0.len(), 2, "both, for a drag"); + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]); + t.notify(&cx2, FRAME_PICKED); + assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]); + assert_eq!( + t.key(&cx2, ToolKey::Right, Mods::default()), + vec![ + Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 1.0, "dy": 0.0})), + Action::Notify(FRAME_PICKED.into()), + ] + ); } /// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last @@ -526,6 +696,141 @@ fn isolation_mode_leaves_art_outside_the_isolated_group() { assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}"); } +/// #4466: in isolation mode a Direct Selection double-click outside the isolated art (on the +/// object beside it, or on nothing) leaves isolation mode, as the Selection tool's does; on the +/// isolated art it doesn't. +#[test] +fn a_double_click_outside_isolation_leaves_it() { + let (d, _, _, inner, _) = nested_group_doc(); + let p = paint(); + let none = Selection::default(); + let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) }; + let double = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::DoubleClick, x, y)); + let exit = vec![Action::Exec("object.exitIsolation".into(), json!({}))]; + assert_eq!(double(350.0, 350.0), exit, "on the object beside the isolated group"); + assert_eq!(double(700.0, 700.0), exit, "on nothing"); + assert!(double(150.0, 150.0).is_empty(), "on the isolated art"); + assert!(DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::DoubleClick, 700.0, 700.0)).is_empty()); +} + +/// With Direct Selection, an Alt-click on a path selected whole adds its group on release, and +/// each further Alt-click adds the next group up, as Group Selection's clicks do, on an anchor, +/// a segment and the fill alike. The climb stops below the layer and at the isolated group. +#[test] +fn alt_clicks_on_a_path_selected_whole_add_its_groups() { + let (d, inside, _, inner, outer) = nested_group_doc(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let click = |cx: &ToolContext, x: f64, y: f64| { + let mut t = DirectSelectionTool::new(false); + let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)); + assert!(down.is_empty(), "({x}, {y}): {down:?}"); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt)) + }; + for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] { + let mut s = Selection::default(); + s.set([inside]); + assert_eq!(click(&cx(&d, &s, &p), x, y), add(inner), "({x}, {y}): the path's group"); + s.set([inside, inner]); + assert_eq!(click(&cx(&d, &s, &p), x, y), add(outer), "({x}, {y}): the group around it"); + s.set([inside, inner, outer]); + assert!(click(&cx(&d, &s, &p), x, y).is_empty(), "({x}, {y}): the layer is not added"); + s.set([inside, inner]); + let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; + assert!(click(&isolated, x, y).is_empty(), "({x}, {y}): not the group outside isolation"); + } +} + +/// In a sublayer, a Group Selection click and a Direct Selection Alt-click climb to the group +/// and stop there: the sublayer is not added. +#[test] +fn group_clicks_in_a_sublayer_stop_below_the_sublayer() { + use vectorcraft_doc::{Appearance, Document, LayerColor, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let click = |s: &Selection, group: bool, mods: Mods| { + let c = cx(&d, s, &p); + let mut t = DirectSelectionTool::new(group); + let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods)); + let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods)); + [down, up].concat() + }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let mut s = Selection::default(); + s.set([a]); + assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group"); + assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group"); + s.set([a, g]); + assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer"); + assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer"); +} + +/// When a group holds a sublayer, the climb stops at the sublayer: it adds neither the sublayer +/// nor the group above it. +#[test] +fn group_clicks_stop_at_a_sublayer_inside_a_group() { + use vectorcraft_doc::{Appearance, Document, LayerColor, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::group(outer, vec![])).unwrap(); + d.insert(Some(outer), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let click = |s: &Selection, group: bool, mods: Mods| { + let c = cx(&d, s, &p); + let mut t = DirectSelectionTool::new(group); + let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods)); + let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods)); + [down, up].concat() + }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let mut s = Selection::default(); + s.set([a]); + assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group"); + assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group"); + s.set([a, g]); + assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer"); + assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer"); +} + +/// An Alt-drag from a path selected whole inside a group copies the path and adds no group, and +/// Alt+Shift-click on a segment or the fill still toggles the path, adding no group either. +#[test] +fn alt_drags_and_alt_shift_clicks_on_a_path_in_a_group_add_no_group() { + let (d, inside, ..) = nested_group_doc(); + let p = paint(); + let mut s = Selection::default(); + s.set([inside]); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y).with_mods(alt)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, y).with_mods(alt)), vec![Action::Commit]); + } + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + for (x, y) in [(150.0, 100.0), (150.0, 150.0)] { + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt_shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": inside.0}))], "({x}, {y})"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt_shift)).is_empty(), "({x}, {y})"); + } +} + /// #927: a marquee across type selects the type object whole, beside the anchors it holds. #[test] fn marquee_selects_type_it_touches() { @@ -541,7 +846,8 @@ fn marquee_selects_type_it_touches() { t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]); + // The frame's corners are inside too: they are selected with the type. + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"})), Action::Notify(FRAME_PICKED.into())]); // Away from the type, only anchors. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); @@ -661,6 +967,41 @@ fn escape_cancels_a_drag() { assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); } +/// Escape also cancels a corner widget drag and a drag of the brackets of type on a path, and +/// the release after it does nothing. After a press on a widget that has not moved, Escape drops +/// the press, so its release selects no corner. +#[test] +fn escape_cancels_a_corner_widget_or_bracket_drag() { + let p = paint(); + let (d, rect) = doc_with_rect(); + let mut s = Selection::default(); + s.set([rect]); + let corners = cx(&d, &s, &p); + let (d2, text) = doc_with_path_type(); + let mut s2 = Selection::default(); + s2.add(text); + let brackets = cx(&d2, &s2, &p); + // (what, its context, the press, the drag's first step) + for (what, c, from, to) in [ + ("corner widget", &corners, Point::new(110.0, 110.0), Point::new(120.0, 120.0)), + ("bracket", &brackets, Point::new(400.0, 290.0), Point::new(340.0, 290.0)), + ] { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Down, from.x, from.y)).is_empty(), "{what}"); + let a = t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert!(matches!(a.first(), Some(Action::Begin(_))), "{what}: {a:?}"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.x - 5.0, to.y)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Up, to.x - 5.0, to.y)).is_empty(), "{what}: the release does nothing"); + } + let mut t = DirectSelectionTool::new(false); + t.pointer(&corners, &PointerEvent::new(PointerKind::Down, 110.0, 110.0)); + assert_eq!(t.key(&corners, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&corners, &PointerEvent::new(PointerKind::Up, 110.0, 110.0)).is_empty(), "the click selects no corner"); +} + #[test] fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { let (d, arch) = arch_doc(); @@ -963,6 +1304,92 @@ fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); } +/// A drag from the fill of a path selected whole, while another path has some anchors selected, +/// moves the selected anchors and the paths selected whole (`path.moveAnchors`), as a drag of +/// an anchor does, with Direct Selection and Group Selection alike. Alt, pressed or released at +/// any time during the drag, turns the copy of the selected objects on and off, and a selection +/// of whole paths only still moves as objects. +#[test] +fn a_drag_from_a_fill_with_anchors_selected_moves_the_anchors() { + let (d, a, b) = two_squares(); + let p = paint(); + let mut mixed = anchors_of(b, &[(0, 0)]); + mixed.objects.insert(0, a); + let mut whole = Selection::default(); + whole.set([a, b]); + let alt = Mods { alt: true, ..Default::default() }; + for group in [false, true] { + let c = cx(&d, &mixed, &p); + let mut t = DirectSelectionTool::new(group); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)).is_empty(), "group: {group}"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0)); + assert_eq!( + out, + vec![Action::Begin("Move".into()), Action::Preview("path.moveAnchors".into(), json!({"dx": 21.0, "dy": 3.0}))], + "group: {group}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0)), vec![Action::Commit]); + // Alt held at the start, released and pressed again: a copy, a move, a copy. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(alt)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0).with_mods(alt)); + assert!(matches!(out.last(), Some(Action::Preview(c, v)) if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}"); + assert_eq!(out.first(), Some(&Action::Begin("Move".into())), "the history names it by the last preview"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 172.0, 153.0)); + assert_eq!(out, vec![Action::Preview("path.moveAnchors".into(), json!({"dx": 22.0, "dy": 3.0}))], "group: {group}"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 173.0, 153.0).with_mods(alt)); + assert!(matches!(out.as_slice(), [Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 173.0, 153.0).with_mods(alt)); + let c = cx(&d, &whole, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0)); + assert!(matches!(out.last(), Some(Action::Preview(c, _)) if c == "object.transform"), "group: {group} {out:?}"); + } +} + +/// A drag from the fill of A, selected whole, while B's top-left anchor is selected snaps the +/// point it holds as a dragged anchor does: near an anchor of B that stays put, it lands on it. +/// With Snap to Grid on, the box around A and B's selected anchor, (100, 100)–(300, 300), lands an +/// edge on the 9 pt grid instead: moved by (21, 3), its right side and bottom (321, 303) are +/// nearest the gridlines 324 and 306, and with Shift the level move by 13 lands the right side on +/// 315. +#[test] +fn a_drag_from_a_fill_with_anchors_selected_snaps() { + let (d, a, b) = two_squares(); + let p = paint(); + let mut mixed = anchors_of(b, &[(0, 0)]); + mixed.objects.insert(0, a); + let none = Mods::default(); + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + let c = cx(&d, &mixed, &p); + let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (298.0, 401.0), none); + assert_eq!((v, labels), (json!({"dx": 150.0, "dy": 250.0}), vec!["anchor".to_string()]), "group: {group}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 298.0, 401.0)); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &mixed, &p) }; + assert_eq!(drag(&mut t, &c, (150.0, 150.0), (171.0, 153.0), none).0, json!({"dx": 24.0, "dy": 6.0}), "group: {group}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0)); + let (v, _) = drag(&mut t, &c, (150.0, 150.0), (163.0, 151.0), Mods { shift: true, ..none }); + let (x, y) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); + assert!((x - 15.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 163.0, 151.0)); + } +} + +/// A path inside a selected group moves whole with the group, even with some of its anchors +/// direct-selected, so a drag from a fill does not snap to its other anchors: here to the +/// bottom-right corner (400, 400) of the path at (300, 300)–(400, 400). +#[test] +fn a_drag_from_a_fill_skips_the_anchors_of_a_path_in_a_selected_group() { + let (d, _, outside, _, outer) = nested_group_doc(); + let p = paint(); + let mut sel = anchors_of(outside, &[(0, 0)]); + sel.objects.insert(0, outer); + let c = cx(&d, &sel, &p); + let mut t = DirectSelectionTool::new(true); + let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (398.0, 401.0), Mods::default()); + assert_eq!((v, labels), (json!({"dx": 248.0, "dy": 251.0}), Vec::::new())); +} + /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors /// of its own path, and with several selected the one pressed on snaps, the others following. #[test] diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index 9f3583e2a..3e23b769d 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -5,7 +5,8 @@ //! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a //! pure function of the parameters (replay reproduces it exactly). Holding the brush still with //! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`] -//! time: one more dab there each time. +//! time: one more dab there each time. Escape during a stroke cancels it, and Escape during an +//! Alt-drag that sizes the brush puts the brush size back. //! //! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]: //! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it @@ -19,7 +20,7 @@ use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, Rect, SubPath, Vec2}; use super::ellipse_path; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LiquifyKind { @@ -813,6 +814,27 @@ impl Tool for LiquifyTool { } } } + /// The tool claims Escape during an Alt-drag that sizes the brush. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + key == ToolKey::Escape && self.sizing.is_some() + } + /// Escape during a stroke cancels it: the art goes back to how the press found it. During an + /// Alt-drag sizing the brush, Escape puts the brush size back. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape { + return vec![]; + } + if let Some(s) = self.sizing.take() { + (self.params.width, self.params.height) = (s.width, s.height); + return vec![]; + } + if !std::mem::take(&mut self.active) { + return vec![]; + } + self.points.clear(); + (self.held, self.previewed) = (0.0, 0); + vec![Action::Cancel] + } fn busy(&self) -> bool { self.active } @@ -960,4 +982,41 @@ mod tests { let r = LiquifyParams::from_json(&json!({"tool": "warp", "diameter": 40, "intensity": 80})).unwrap(); assert_eq!((r.width, r.height, r.intensity), (40.0, 40.0, 0.8)); } + + #[test] + fn escape_cancels_a_stroke_or_a_brush_size_drag() { + use crate::testutil::{cx, doc_with_rect, paint}; + let (d, id) = doc_with_rect(); + let s = vectorcraft_doc::Selection { objects: vec![id], ..Default::default() }; + let p = paint(); + let c = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let ev = |kind, x, y, mods| PointerEvent::new(kind, x, y).with_mods(mods); + for tool in ["warp", "twirl"] { + let mut t = LiquifyTool::new(tool); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}: no stroke, Escape is the shortcut's"); + assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}"); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 190.0, 150.0, Mods::default()))[0], Action::Begin(_)), "{tool}"); + t.pointer(&c, &ev(PointerKind::Drag, 220.0, 150.0, Mods::default())); + assert!(t.busy(), "{tool}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{tool}"); + assert!(!t.busy() && !t.wants_ticks(), "{tool}"); + // The rest of the stroke does nothing. + assert!(t.tick(&c, 1.0).is_empty(), "{tool}"); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 240.0, 150.0, Mods::default())).is_empty(), "{tool}"); + assert!(t.pointer(&c, &ev(PointerKind::Up, 240.0, 150.0, Mods::default())).is_empty(), "{tool}"); + // Alt-drag sizes the brush; Escape puts the size back and the rest of that drag does nothing. + let size = (t.params.width, t.params.height); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0, alt)); + t.pointer(&c, &ev(PointerKind::Drag, 180.0, 170.0, alt)); + assert_ne!((t.params.width, t.params.height), size, "{tool}"); + assert!(t.claims_key(&c, ToolKey::Escape), "{tool}"); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}"); + assert_eq!((t.params.width, t.params.height), size, "{tool}"); + assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}"); + t.pointer(&c, &ev(PointerKind::Drag, 200.0, 200.0, alt)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 200.0, alt)).is_empty(), "{tool}"); + assert_eq!((t.params.width, t.params.height), size, "{tool}"); + } + } } diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index aeaef8461..9a9863e55 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1094,6 +1094,29 @@ mod tests { assert!(!grid_overlays(&d, 1.0).is_empty()); } + #[test] + fn escape_cancels_a_grid_tool_drag() { + let mut d = Document::new(800.0, 600.0); + grid(2).store(&mut d); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::effective(&d); + let mut t = PerspectiveGridTool::default(); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's"); + let widget = g.plane_widgets()[0].1; + for (what, at, begin) in [("handle", Point::new(g.vp_left, g.horizon), "Edit Perspective Grid"), ("plane", widget, "Move Plane")] { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)), vec![Action::Begin(begin.into())], "{what}"); + assert!(!t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 40.0, at.y + 10.0)).is_empty(), "{what}"); + assert!(t.key(&c, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}"); + assert!(!t.busy(), "{what}"); + // The rest of the drag does nothing. + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 60.0, at.y)).is_empty(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x - 60.0, at.y)).is_empty(), "{what}"); + } + } + #[test] fn depth_moves_along_the_plane_normal() { // A grid with a viewing angle foreshortens each axis by its own distance. @@ -1186,6 +1209,41 @@ mod tests { assert!(!t.busy()); } + #[test] + fn escape_cancels_a_perspective_selection_drag() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::current(&d); + let (_, _, rect) = g.plane_bounds(&d, &[id]).unwrap(); + let h = g.homography(Plane::Right).unwrap(); + let (corner, mid) = (h.apply(Point::new(rect.x1, rect.y1)).unwrap(), h.apply(rect.center()).unwrap()); + let widget = g.plane_widgets()[0].1; + let mut t = PerspectiveSelectionTool::default(); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's"); + for (what, at) in [("resize", corner), ("move", mid), ("plane", widget)] { + t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0)); + assert!(acts.iter().any(|a| matches!(a, Action::Preview(..))), "{what}: {acts:?}"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}"); + assert!(!t.busy(), "{what}"); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{what}: the measurement goes"); + // The rest of the drag does nothing. + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 40.0, at.y)).is_empty(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 40.0, at.y)).is_empty(), "{what}"); + } + // A press that has not moved past the drag threshold has nothing to cancel. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y)); + assert!(t.busy()); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y)).is_empty(), "no move begins"); + } + #[test] fn resize_cursors_follow_the_handle_on_screen() { assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 9e563f00c..541e7a8f9 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Action, Affine, BoxHandle, Cursor, Handle, Homography, Mods, NodeId, NodeKind, Overlay, PerspectiveGrid, Plane, PlaneDrag, Point, PointerEvent, + PointerKind, Press, Rect, Tool, ToolContext, ToolKey, Vec2, cell_steps, drag_handle, handles, json, scale_for_drag, +}; #[derive(Default)] pub struct PerspectiveGridTool { @@ -89,6 +92,15 @@ impl Tool for PerspectiveGridTool { } .turned(cx.view_angle()) } + /// Escape during a handle or plane widget drag cancels it: the grid and the plane go back to + /// the start of the drag. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape || !self.busy() { + return vec![]; + } + (self.drag, self.plane) = (None, None); + vec![Action::Cancel] + } fn busy(&self) -> bool { self.drag.is_some() || self.plane.is_some() } @@ -267,7 +279,16 @@ impl Tool for PerspectiveSelectionTool { } } } + /// Escape during a move, a resize or a plane widget drag cancels it: the objects and the plane + /// go back to the start of the drag. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if key == ToolKey::Escape && self.busy() { + self.measure = None; + return match std::mem::take(&mut self.drag) { + SelDrag::Move { began: false, .. } => vec![], + _ => vec![Action::Cancel], + }; + } if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { *perp = !*perp; return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index 9c4b11f91..f14137838 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -5,7 +5,8 @@ //! adds a pin and selects it; Shift-click adds pins to the selection (or takes them out); dragging //! a pin moves every selected pin and warps the art; Alt-dragging near (not on) a selected pin //! turns the art around it, and that pin keeps the turn; Delete/Backspace remove the selected pins. -//! A click off the mesh places nothing and says so. +//! A click off the mesh places nothing and says so. Escape during a drag cancels it: the art, its +//! pins and the selected pins go back to how the press found them. //! //! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every //! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and @@ -224,6 +225,8 @@ pub struct PuppetWarpTool { /// Selected pins (indices into the pins). selected: Vec, drag: Option, + /// The selected pins before the press that began the drag (Escape puts them back). + before_drag: Vec, /// The pins as the drag began (each preview starts from them). start: Option>, /// The pointer and the modifiers held while hovering (for the turn ring). @@ -245,6 +248,7 @@ impl Default for PuppetWarpTool { ids: vec![], selected: vec![], drag: None, + before_drag: vec![], start: None, hover: None, off_mesh: None, @@ -368,14 +372,17 @@ impl Tool for PuppetWarpTool { && let Some(q) = set.pins.get(i) { self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 }); + self.before_drag = self.selected.clone(); self.start = Some(c.clone()); return vec![Action::Begin("Puppet Warp".into())]; } if let Some(i) = Self::pin_at(cx, set, p) { + let before = self.selected.clone(); if !self.pick(i, ev.mods.shift) { return vec![]; } self.drag = Some(Gesture::Move { from: p, to: p }); + self.before_drag = before; self.start = Some(c.clone()); return vec![Action::Begin("Puppet Warp".into())]; } @@ -426,7 +433,14 @@ impl Tool for PuppetWarpTool { && cx.selection.objects == self.ids && self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len())) } + /// Escape during a pin drag or a turn cancels it: the art and its pins go back to the start of + /// the drag, and the pins selected before the press are selected again. fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.drag.take().is_some() { + self.start = None; + self.selected = std::mem::take(&mut self.before_drag); + return vec![Action::Cancel, Action::Notify("pins".into())]; + } if !self.claims_key(cx, key) { return vec![]; } @@ -572,6 +586,11 @@ mod tests { self.run(p); } Action::Commit => self.base = None, + Action::Cancel => { + if let Some(b) = self.base.take() { + self.doc = b; + } + } Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p), Action::Notify(w) => { let p = paint(); @@ -678,6 +697,32 @@ mod tests { assert_eq!(r.bounds(), before); } + #[test] + fn escape_cancels_a_pin_drag_or_a_turn_and_selects_the_pins_selected_before_it() { + let mut r = Rig::new(); + r.click(110.0, 110.0, Mods::default()); + r.click(190.0, 110.0, Mods::default()); + let b = r.pins().len() - 1; + assert_eq!(r.tool.selected, vec![b]); + assert!(r.key(ToolKey::Escape).is_empty(), "no drag: Escape is the shortcut's"); + let (pins, art) = (r.pins(), r.doc.node(r.sel.objects[0]).cloned().unwrap()); + // A press on the unselected pin selects it and drags it; Escape puts everything back. + for (what, press, to, mods) in [("move", (110.0, 110.0), (130.0, 140.0), Mods::default()), ("turn", (205.0, 110.0), (190.0, 125.0), alt())] { + assert_eq!(r.ev(PointerKind::Down, press.0, press.1, mods), vec![Action::Begin("Puppet Warp".into())], "{what}"); + r.ev(PointerKind::Drag, to.0, to.1, mods); + assert_ne!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: the drag warps the art"); + assert_eq!(r.key(ToolKey::Escape), vec![Action::Cancel, Action::Notify("pins".into())], "{what}"); + assert_eq!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: Escape puts the art back"); + assert_eq!(r.pins(), pins, "{what}: and the pins"); + assert_eq!(r.tool.selected, vec![b], "{what}: and the pins selected before the press"); + assert!(!r.tool.busy(), "{what}"); + // The rest of the drag does nothing. + assert!(r.ev(PointerKind::Drag, to.0 + 10.0, to.1, mods).is_empty(), "{what}"); + assert!(r.ev(PointerKind::Up, to.0 + 10.0, to.1, mods).is_empty(), "{what}"); + assert_eq!(r.pins(), pins, "{what}"); + } + } + #[test] fn alt_drag_near_a_selected_pin_turns_the_art_around_it() { let mut r = Rig::new(); diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index aa43a46e1..27592035b 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -14,6 +14,8 @@ //! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids, //! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`. //! +//! Escape deselects the selected width points and sends no command. +//! //! A compound path's stroke is the compound's: its members are edited through it, and the width //! points show on every subpath of the stroke (one profile runs along each). The cursor says what a //! drag does: add a width point on a stroke, or move or widen the one under it. @@ -391,14 +393,19 @@ impl Tool for WidthTool { }, } } - /// Delete/Backspace remove the selected width points; with none selected they are the - /// shortcut's (Clear deletes the selected objects). + /// Delete/Backspace remove the selected width points and Escape deselects them; with none + /// selected the keys do what they do with other tools (Clear deletes the selected objects, and + /// Escape leaves isolation mode, for one). fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { - matches!(key, ToolKey::Delete | ToolKey::Backspace) + matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Escape) && self.drag.is_none() && self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty()) } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.drag.is_none() { + self.selected = None; + return vec![]; + } if !matches!(key, ToolKey::Delete | ToolKey::Backspace) { return vec![]; } @@ -650,6 +657,40 @@ mod tests { ); } + #[test] + fn escape_deselects_the_width_points() { + let (d, id) = doc_three_points(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = WidthTool::default(); + let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() }); + // With nothing selected the tool leaves Escape to the app. + assert!(!t.claims_key(&c, ToolKey::Escape)); + // Select the points at t = 0.25 and 0.75: Escape is the tool's and deselects both, sending + // nothing to the document. + t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none)); + t.pointer(&c, &ev(PointerKind::Up, 150.0, 200.0, none)); + t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, shift)); + t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, shift)); + assert_eq!(t.selected_indices(&c, id), vec![1, 3]); + t.pointer(&c, &ev(PointerKind::Move, 200.0, 202.0, none)); + let thick = |t: &WidthTool| t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Path { width, .. } if *width > 2.0)).count(); + assert_eq!(thick(&t), 2, "the two selected diamonds are drawn selected"); + assert!(t.claims_key(&c, ToolKey::Escape)); + assert!(t.key(&c, ToolKey::Escape, none).is_empty()); + assert!(t.selected_indices(&c, id).is_empty()); + assert_eq!(thick(&t), 0, "no diamond is drawn selected"); + // Delete then removes nothing, and Escape is the app's again. + assert!(!t.claims_key(&c, ToolKey::Delete) && !t.claims_key(&c, ToolKey::Escape)); + assert!(t.key(&c, ToolKey::Delete, none).is_empty()); + // During a drag the dragged point stays selected. + t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0, none)); + t.pointer(&c, &ev(PointerKind::Drag, 210.0, 200.0, none)); + assert!(!t.claims_key(&c, ToolKey::Escape)); + t.key(&c, ToolKey::Escape, none); + assert!(t.selected.is_some()); + } + #[test] fn width_points_show_on_every_subpath() { let (d, c, _) = doc_compound(); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 22f961c0e..be2019a38 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -17,7 +17,9 @@ use vectorcraft_pathops::{Cap, Join}; use crate::{Action, Tool, ToolContext}; pub use anchor::AnchorTool; -pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_smooth}; +pub use curvature::{ + CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_remove, curvature_smooth, +}; pub use family::FamilyTool; pub use gesture::GestureTool; @@ -36,6 +38,16 @@ pub fn min_fidelity(id: &str) -> f64 { if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY } } +/// The Blob Brush's or the Eraser's width at pen `pressure` (0..1) with Size set to Pressure: `size` − +/// `variation` at the lightest pressure to `size` + `variation` at the heaviest, at least +/// [`MIN_BRUSH_SIZE`]. +pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { + (size + variation * (2.0 * pressure.clamp(0.0, 1.0) - 1.0)).max(MIN_BRUSH_SIZE) +} + +/// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum). +pub const MIN_BRUSH_SIZE: f64 = 0.1; + /// Create a drawing tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index bf3878abf..58664704a 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -10,7 +10,7 @@ //! still held: at 45° steps from where it was). Space held during a convert- or handle-drag moves //! the anchor, handles and all, as while drawing with the Pen. Blend spine points and handles //! follow the same conversions (#2544). -//! Scissors: click a path to split it there. +//! Scissors: click a path to split it there (Alt = add, as the Add Anchor Point tool does). use serde_json::json; use vectorcraft_doc::NodeId; @@ -19,7 +19,7 @@ use vectorcraft_geom::Point; use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor}; use crate::direct::hit_handle; use crate::guides::HandleSnap; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use vectorcraft_geom::constrain_angle_from; #[derive(Clone, Copy, Debug)] @@ -296,6 +296,30 @@ impl Tool for AnchorTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) } + /// Escape during an Anchor Point drag (an anchor pulled into a smooth point or moved, a + /// handle, a segment, a blend spine's point, handle or segment) cancels it: what it moved goes + /// back and the release does nothing. Escape after a press that has not moved yet drops the + /// press, so its release converts nothing. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape || matches!(self.state, State::Idle) { + return vec![]; + } + let began = matches!( + self.state, + State::Convert { began: true, .. } + | State::Move { began: true, .. } + | State::SpineMove { began: true, .. } + | State::Handle { began: true, .. } + | State::SpineConvert { began: true, .. } + | State::SpineHandle { began: true, .. } + | State::Reshape { began: true, .. } + ); + self.state = State::Idle; + self.guides.clear(); + self.measure = None; + self.space_resume = None; + if began { vec![Action::Cancel] } else { vec![] } + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; // Anchors and handle ends are picked from a little further than segments. @@ -316,6 +340,9 @@ impl Tool for AnchorTool { } } ("scissors", PointerKind::Down) => { + if ev.mods.alt { + return Self::add(cx, p); + } if let Some((id, si, ai)) = hit_anchor(cx, p, point) { return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))]; } @@ -554,6 +581,7 @@ impl Tool for AnchorTool { "addAnchor" => Cursor::PenDelete, "deleteAnchor" if !m.alt => Cursor::PenDelete, "deleteAnchor" => Cursor::PenAdd, + "scissors" if m.alt => Cursor::PenAdd, "scissors" => { if hit_segment(cx, p, cx.pick_tol()).is_some() { Cursor::Scissors diff --git a/crates/tools/src/draw2/anchor/tests.rs b/crates/tools/src/draw2/anchor/tests.rs index 31e4108aa..f6986cf7e 100644 --- a/crates/tools/src/draw2/anchor/tests.rs +++ b/crates/tools/src/draw2/anchor/tests.rs @@ -301,6 +301,34 @@ fn scissors_splits_at_segment() { ); } +/// With Alt held the Scissors tool acts as the Add Anchor Point tool: a click sends what the Add +/// Anchor Point tool's click sends (an anchor inserted on a segment, nothing away from every path) +/// and never splits, and the cursor is the Add Anchor Point tool's everywhere. Without Alt it cuts +/// again. +#[test] +fn scissors_with_alt_adds_an_anchor() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("scissors"); + let mut add = AnchorTool::new("addAnchor"); + let alt = Mods { alt: true, ..Mods::default() }; + for (x, y) in [(200.0, 150.0), (150.0, 101.0), (200.0, 100.0), (400.0, 400.0)] { + let down = PointerEvent::new(PointerKind::Down, x, y); + let a = t.pointer(&cx, &down.with_mods(alt)); + assert_eq!(a, add.pointer(&cx, &down), "({x}, {y})"); + assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "path.split")), "({x}, {y})"); + assert_eq!(t.cursor(&cx, Point::new(x, y), alt), Cursor::PenAdd, "({x}, {y})"); + } + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 1)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0)); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.split" && v["id"] == id.0 && v["segment"] == 1)); + assert_eq!(t.cursor(&cx, Point::new(200.0, 150.0), Mods::default()), Cursor::Scissors); + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::ScissorsNotAllowed); +} + /// The Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchors and /// segments only on the art a click can pick: not on a Shaper Group's original shapes unless /// they are isolated (Construction mode), and in isolation mode only on the isolated object. @@ -509,6 +537,58 @@ fn anchor_point_space_mid_drag_moves_the_anchor() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 370.0)), vec![Action::Commit]); } +/// Escape during a drag cancels it, whatever the drag does, and the release after it does +/// nothing; Enter leaves the drag alone. After a press that has not moved, Escape drops the press, +/// so its release does not make the smooth anchor a corner. +#[test] +fn escape_cancels_an_anchor_point_drag() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); + let path = vectorcraft_geom::PathData::single(sp); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let space = Mods { space: true, ..Mods::default() }; + // (what, the press, its modifiers, Space held during the second drag step) + for (what, x, y, mods, spaced) in [ + ("convert", 100.0, 300.0, Mods::default(), false), + ("handle", 240.0, 320.0, Mods::default(), false), + ("segment", 150.0, 200.0, Mods::default(), false), + ("shift move", 200.0, 300.0, shift, false), + ("space move", 100.0, 300.0, Mods::default(), true), + ] { + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(mods)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y + 30.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(..)]), "{what}: {a:?}"); + let step = if spaced { space } else { Mods::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 30.0, y + 40.0).with_mods(step)); + let moved = matches!(&a[..], [Action::Preview(c, _)] if c == "path.moveAnchor"); + assert_eq!(moved, spaced || what == "shift move", "{what}: {a:?}"); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y + 50.0)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y + 50.0)).is_empty(), "{what}: the release does nothing"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + } + // A click on the smooth anchor makes it a corner; Escape between press and release drops it. + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "nothing began, so nothing is canceled"); + assert!(!t.busy()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0)).is_empty()); + // With nothing pressed, Escape is not the tool's. + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); +} + #[test] fn anchor_point_shift_drag_honors_measurement_labels() { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 63d4d7e35..8301c108c 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -9,7 +9,8 @@ //! It edits any selected path too, whatever drew it, keeping its shape except where edited //! (#798). A click on one of its points makes that the current point (direct-selected); a drag //! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or -//! double-click toggles it between smooth and corner and Backspace/Delete removes it. A click on +//! double-click toggles it between smooth and corner and Backspace/Delete removes it, the curve +//! where it was then following the remaining points ([`curvature_remove`]). A click on //! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a //! drag moves the new point at once. While the current point is an end of an open path, clicks go //! on from it ([`curvature_extend`]): the old segments keep their shape, the new ones curve @@ -20,7 +21,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, Selection}; use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2}; -use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, remove_anchor, segment_in, smooth_handles}; +use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, segment_in, smooth_handles}; use crate::guides::{DrawSnap, Leave, Targets}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -212,6 +213,106 @@ pub fn curvature_smooth(sp: &mut SubPath, i: usize) -> bool { } } +/// Remove anchor `i` of `sp` as the Curvature tool deletes a point: the curve where it was follows +/// the remaining points. Each neighbor that curves by the tool's rule (a smooth anchor, or an end +/// of an open path with a handle into its segment) takes new handles from its new neighbors +/// ([`smooth_handles`]; at an end, the one [`end_handle`] into the curve), and so does an end beyond +/// one of them whose handle mirrors its tangent. A corner neighbor keeps its handles' directions, +/// the one on the new segment scaled with its chord, as [`curvature_move`] scales them. With two +/// anchors left the subpath is open, and those that curve by the tool's rule lose their handles, as +/// [`curvature_spline`] draws two points as a line; a corner keeps its handles. On a path the tool +/// drew, the result is the path it draws through the remaining points, except that an Alt-clicked +/// end beside a corner without handles curves (the path stores it like any end there). False, and +/// `sp` unchanged, when there is no anchor `i`. +pub fn curvature_remove(sp: &mut SubPath, i: usize) -> bool { + let n = sp.anchors.len(); + let Some(a) = sp.anchors.get(i).copied() else { return false }; + let closed = sp.closed; + let is_end = |j: usize| !closed && (j == 0 || j + 1 == n); + // An end without a handle beside a corner without handles is stored the same whether it was + // Alt-clicked or not ([`curvature_spline`] gives it no handle either way), so it curves, as an + // end the tool places does by default. Beside a tangent, a missing handle means a corner. + let flags: Vec = sp + .anchors + .iter() + .enumerate() + .map(|(j, b)| { + if !is_end(j) { + return b.kind == AnchorKind::Smooth; + } + let near = sp.anchors.get(if j == 0 { 1 } else { n.saturating_sub(2) }); + b.has_in() || b.has_out() || near.is_none_or(|q| end_handle(b.p, q, j == 0).is_none()) + }) + .collect(); + let curved = |j: usize| flags.get(j).copied().unwrap_or(false); + let [prev, next] = neighbours(sp, i); + // A corner on both sides of the removed point: its handle on the new segment scales with it. + if let (Some(pi), Some(ni)) = (prev, next) + && pi != ni + { + for (j, other, out) in [(pi, ni, true), (ni, pi, false)] { + let to = sp.anchors.get(other).map(|o| o.p); + if curved(j) { + continue; + } + let Some((b, to)) = sp.anchors.get_mut(j).zip(to) else { continue }; + let old = b.p.distance(a.p); + let s = if old > 1e-9 { b.p.distance(to) / old } else { 1.0 }; + let h = if out { &mut b.h_out } else { &mut b.h_in }; + *h = b.p + (*h - b.p) * s; + } + } + let mut again: Vec = [prev, next].into_iter().flatten().filter(|&j| curved(j)).collect(); + // An end beside a smooth neighbor mirrors that neighbor's tangent, which changes. + for j in again.clone() { + if !is_end(j) { + again.extend(neighbours(sp, j).into_iter().flatten().filter(|&k| k != i && is_end(k) && curved(k))); + } + } + sp.anchors.remove(i); + let mut again: Vec = again.into_iter().map(|j| if j > i { j - 1 } else { j }).collect(); + again.sort_unstable(); + again.dedup(); + let m = sp.anchors.len(); + if m < 3 { + sp.closed = false; + } + if !sp.closed { + // A new end has nothing beyond it. + if let Some(e) = sp.anchors.first_mut() { + e.h_in = e.p; + } + if let Some(e) = sp.anchors.last_mut() { + e.h_out = e.p; + } + } + let end = |j: usize| !sp.closed && (j == 0 || j + 1 == m); + let (ends, inner): (Vec, Vec) = again.into_iter().partition(|&j| end(j)); + // The inner anchors first: an end's handle mirrors its neighbor's tangent. + for j in inner { + let [b, c] = neighbours(sp, j).map(|k| k.and_then(|k| sp.anchors.get(k)).map(|x| x.p)); + let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue }; + let fresh = match (b, c) { + (Some(b), Some(c)) => smooth_handles(b, x, c).map(|(h_in, h_out)| Anchor { p: x, h_in, h_out, kind: AnchorKind::Smooth }), + _ => None, + }; + if let Some(y) = sp.anchors.get_mut(j) { + *y = fresh.unwrap_or_else(|| Anchor::corner(x)); + } + } + for j in ends { + let first = j == 0; + let near = if first { sp.anchors.get(1) } else { j.checked_sub(1).and_then(|k| sp.anchors.get(k)) }.copied(); + let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue }; + // Two points are a line. + let h = near.filter(|_| m >= 3).and_then(|q| end_handle(x, &q, first)).unwrap_or(x); + if let Some(y) = sp.anchors.get_mut(j) { + *y = if first { Anchor { h_out: h, ..Anchor::corner(x) } } else { Anchor { h_in: h, ..Anchor::corner(x) } }; + } + } + true +} + /// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`; the old /// end bends into the new segment as `how` says. The new end has no handle on the outer side and, /// unless it is a `corner` (Alt-clicked), the one [`end_handle`] into the curve, so its segment @@ -313,6 +414,12 @@ fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action { Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"})) } +/// Delete anchor `(id, si, ai)` of a selected path, the curve where it was following the remaining +/// points ([`curvature_remove`]). +fn delete_point((id, si, ai): (NodeId, usize, usize)) -> Action { + Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "curvature": true})) +} + /// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner. An end of an open /// path counts as smooth while it has a handle into its segment (the ends the tool curves are /// corners with one handle). @@ -560,7 +667,7 @@ impl Tool for CurvatureTool { } ToolKey::Backspace | ToolKey::Delete if self.claims_key(cx, key) => { if self.active(cx).is_none() { - return current_point(cx).map(remove_anchor).into_iter().collect(); + return current_point(cx).map(delete_point).into_iter().collect(); } let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1); self.pts.remove(i); diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index 10dc9d031..412292ef9 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -244,6 +244,62 @@ fn curvature_smooth_uses_the_tool_rule() { assert_eq!(fold, before); } +/// Removing a point of a path the tool drew leaves the path it draws through the remaining points, +/// open or closed, and two points left are a straight line. +#[test] +fn curvature_remove_curves_through_the_remaining_points() { + let at = |corners: &[usize]| -> Vec<(Point, bool)> { + [(0.0, 0.0), (100.0, -80.0), (220.0, 30.0), (300.0, -60.0), (420.0, 10.0), (500.0, -40.0)] + .iter() + .enumerate() + .map(|(j, &(x, y))| (pt(x, y), corners.contains(&j))) + .collect() + }; + // A corner in the middle, and corners next to both ends (the ends then have no handle). + for pts in [at(&[2]), at(&[1, 4]), at(&[])] { + for closed in [false, true] { + for i in 0..pts.len() { + let mut sp = curvature_spline(&pts, closed); + assert!(curvature_remove(&mut sp, i)); + let rest: Vec<(Point, bool)> = pts.iter().enumerate().filter(|&(j, _)| j != i).map(|(_, &q)| q).collect(); + let want = curvature_spline(&rest, closed); + assert_eq!(sp.closed, want.closed); + for (a, b) in sp.anchors.iter().zip(&want.anchors) { + assert!(a.h_in.distance(b.h_in) < 1e-6 && a.h_out.distance(b.h_out) < 1e-6, "{pts:?} closed {closed}, point {i}: {a:?} != {b:?}"); + } + } + } + } + let pts = at(&[2]); + for closed in [false, true] { + let mut sp = curvature_spline(&pts[..3], closed); + assert!(curvature_remove(&mut sp, 1)); + assert!(!sp.closed && sp.segment_is_line(0), "two points are a line: {sp:?}"); + } + let mut sp = curvature_spline(&pts, false); + let before = sp.clone(); + assert!(!curvature_remove(&mut sp, 9)); + assert_eq!(sp, before); +} + +/// A corner neighbor with handles keeps their directions; its handle on the new segment scales +/// with the segment's chord. +#[test] +fn curvature_remove_keeps_a_corner_neighbors_handle_direction() { + let corner = Anchor { p: pt(100.0, 0.0), h_in: pt(80.0, -20.0), h_out: pt(120.0, -30.0), kind: AnchorKind::Corner }; + let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0)), Anchor::corner(pt(300.0, 0.0))], false); + assert!(curvature_remove(&mut sp, 2)); + let c = sp.anchors[1]; + assert_eq!(c.h_in, corner.h_in); + let (old, new) = (corner.h_out - corner.p, c.h_out - c.p); + assert!(old.cross(new).abs() < 1e-9 && old.dot(new) > 0.0, "{c:?}"); + assert!((new.hypot() / old.hypot() - 2.0).abs() < 1e-9, "the chord doubled: {c:?}"); + // Two anchors left: the corner keeps its handle on the segment that stays. + let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0))], false); + assert!(curvature_remove(&mut sp, 2)); + assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (corner.h_in, corner.p)); +} + /// A curved end counts as smooth for the toggle: Alt-click turns it into a corner. #[test] fn alt_click_on_a_curved_end_makes_it_a_corner() { @@ -345,7 +401,7 @@ fn alt_click_or_double_click_toggles_and_delete_removes() { let s = selected(id, Some(2)); let cx = quiet(&d, &s, &p); assert!(t.claims_key(&cx, ToolKey::Backspace)); - assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]); + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![delete_point((id, 0, 2))]); // Esc lets go of the point. assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 82870b694..9f8c08a59 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -3,8 +3,9 @@ //! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held //! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The //! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too. -//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→ -//! change grid columns / radial dividers. +//! While dragging, ↑/↓ change the arc's slope, the spiral's segments, the grid rows or the +//! concentric dividers; ←/→ change grid columns / radial dividers. C switches the arc between open +//! and closed, and F switches its base between the X and Y axes. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -13,6 +14,9 @@ use crate::guides::{DrawSnap, Leave}; use crate::shape::{box_params, drag_rect, turned_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +/// How much ↑ or ↓ changes the arc's slope while it is dragged out. +const ARC_SLOPE_STEP: f64 = 10.0; + pub struct FamilyTool { id: &'static str, start: Option, @@ -215,6 +219,22 @@ impl Tool for FamilyTool { fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; let changed = match (self.id, key) { + ("arc", ToolKey::Up) => { + self.slope = (self.slope + ARC_SLOPE_STEP).min(100.0); + true + } + ("arc", ToolKey::Down) => { + self.slope = (self.slope - ARC_SLOPE_STEP).max(-100.0); + true + } + ("arc", ToolKey::Letter('c')) => { + self.closed = !self.closed; + true + } + ("arc", ToolKey::Letter('f')) => { + self.base_along = if self.base_along == "y" { "x" } else { "y" }; + true + } ("spiral", ToolKey::Up) => { self.segments = (self.segments + 1).min(1000); true @@ -253,6 +273,10 @@ impl Tool for FamilyTool { vec![] } } + /// The Arc tool takes C and F while an arc is dragged out: C switches it between open and closed, and F switches its base axis. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.id == "arc" && self.began && self.start.is_some() && matches!(key, ToolKey::Letter('c' | 'f')) + } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) { @@ -469,6 +493,53 @@ mod tests { ); } + /// While an arc is dragged out, C switches it between open and closed, F switches its base + /// between the X and Y axes and ↑/↓ change its slope by 10 within −100…100; the arc drawn + /// next keeps them. + #[test] + fn arc_drag_keys_change_its_type_base_and_slope() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = FamilyTool::new("arc"); + let (c, f) = (ToolKey::Letter('c'), ToolKey::Letter('f')); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert!(!t.claims_key(&cx, c), "no drag yet"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + assert!(t.claims_key(&cx, c) && t.claims_key(&cx, f) && !t.claims_key(&cx, ToolKey::Letter('e'))); + let preview = |a: Vec| match &a[..] { + [Action::Preview(c, v)] if c == "shape.arc" => v.clone(), + _ => panic!("{a:?}"), + }; + let v = preview(t.key(&cx, c, Mods::default())); + assert_eq!((v["closed"].clone(), v["baseAlong"].clone(), v["slope"].clone()), (json!(true), json!("x"), json!(50.0))); + let v = preview(t.key(&cx, f, Mods::default())); + assert_eq!((v["closed"].clone(), v["baseAlong"].clone()), (json!(true), json!("y"))); + assert_eq!((v["x"].clone(), v["y"].clone(), v["reference"].clone()), (json!(10.0), json!(10.0), json!(0)), "the reference point stays"); + assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(60.0)); + for _ in 0..30 { + t.key(&cx, ToolKey::Down, Mods::default()); + } + assert_eq!(preview(t.key(&cx, ToolKey::Down, Mods::default()))["slope"], json!(-100.0)); + for _ in 0..30 { + t.key(&cx, ToolKey::Up, Mods::default()); + } + assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(100.0)); + let v = preview(t.key(&cx, c, Mods::default())); + assert_eq!(v["closed"], json!(false), "C again opens it"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + assert!(!t.claims_key(&cx, c), "the drag is over"); + let o = t.options(); + assert_eq!((o["closed"].clone(), o["baseAlong"].clone(), o["slope"].clone()), (json!(false), json!("y"), json!(100.0))); + // The other tools of the family leave C and F to their shortcuts. + let mut g = FamilyTool::new("rectangularGrid"); + g.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + g.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + assert!(!g.claims_key(&cx, c) && !g.claims_key(&cx, f)); + } + /// General › Constrain Angle: grids are drawn along the turned axes, Shift's square too; at 0° /// and whole turns as before. #[test] diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index bdb8e4d84..2a6380aa9 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -9,10 +9,11 @@ //! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil's new path starts and ends on //! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points //! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen -//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The -//! other tools collect the drag polyline (shown as an overlay) and run -//! one command on release. With Shift held, the Blob Brush's points stay on the horizontal or -//! vertical line through the press point. +//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and +//! so do the Blob Brush and the Eraser while their Size varies with Pressure. The other tools +//! collect the drag polyline (shown as an overlay) and run one command on release. With Shift held, the Blob Brush's points stay on the horizontal or +//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag +//! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; @@ -22,12 +23,23 @@ use super::{FEEDBACK, eraser_area, points_json, polyline}; use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +/// How far from its drag the Smooth tool smooths, in screen pixels. The Pencil uses the same +/// radius while Alt switches it to the Smooth tool. +const SMOOTH_RADIUS: f64 = 12.0; + +/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn: +/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and +/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity. +const PREVIEW_FIDELITY_PX: f64 = 0.5; + pub struct GestureTool { - /// How many points the last preview of the stroke had ([`crate::previews_at`]). + /// How many points the last preview of the stroke had ([`crate::previews_at`]); 0 when it was + /// fitted coarser than the stroke kept ([`PREVIEW_FIDELITY_PX`]). previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush only). + /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser + /// with `size_pressure`). pressures: Vec, active: bool, began: bool, @@ -42,10 +54,16 @@ pub struct GestureTool { /// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected /// Paths' distance in points); the stroke redraws the section it ends on (#2850). redraw: Option<(NodeId, f64)>, - /// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity"). + /// Fit tolerance in points (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"). pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, + /// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` − + /// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the + /// heaviest. + pub size_pressure: bool, + /// Blob Brush and Eraser Tool Options' Size Variation, in points. + pub size_variation: f64, /// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, /// The Eraser tip's short axis in percent of its long one (100: round). @@ -66,21 +84,18 @@ pub struct GestureTool { /// preview shows the points joined by straight segments and the release fits them (Pencil /// only, on by default). pub live_fit: bool, - /// Active drag is smoothing with the Pencil tool. + /// Active drag is smoothing with the Pencil or the Blob Brush (Alt held). smoothing: bool, + /// The pointer has moved from the press point, by any distance (a move too short to add a + /// point counts). + moved: bool, /// Active Pencil drag draws a straight line (Shift or Alt held at the press): the stroke is /// the press point and the pointer. straight: bool, /// The straight line is Alt's: the pointer is not constrained to 45° steps, and the line's end /// snaps to Smart Guides as a stroke's end does. unconstrained: bool, - /// Where the pointer was for each Blob Brush point dragged since Shift went down: the last - /// `shift_raw.len()` of `points`. - shift_raw: Vec, - /// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds. - shift_next: Option, - /// The angle of the line the dragged points were last moved onto: while it holds they are on - /// it, and the next drag event need not move them again. + /// Axis selected during the current uninterrupted Shift interval, in degrees. shift_line: Option, /// "Merge only with selection": a Blob Brush stroke merges only with selected paths. pub merge_only_selection: bool, @@ -129,6 +144,8 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, + size_pressure: false, + size_variation: 0.0, angle: 0.0, roundness: 100.0, fill: false, @@ -138,10 +155,9 @@ impl GestureTool { live_preview: true, live_fit: true, smoothing: false, + moved: false, straight: false, unconstrained: false, - shift_raw: vec![], - shift_next: None, shift_line: None, merge_only_selection: false, edit_within: 12.0, @@ -167,14 +183,8 @@ impl GestureTool { /// Add a stroke sample. fn push(&mut self, p: Point, pressure: f32) { - let raw = self.shift_next.take(); - if self.points.is_empty() { - self.shift_raw.clear(); - } else if let Some(raw) = raw { - self.shift_raw.push(raw); - } self.points.push(p); - if self.id == "paintbrush" { + if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) { self.pressures.push(pressure); } } @@ -185,14 +195,16 @@ impl GestureTool { self.id == "pencil" && !(self.alt_smooth && m.alt) && (m.shift || m.alt) } - /// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the - /// horizontal or vertical line through the press point that is nearer in angle to `p` (90° - /// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged - /// since Shift went down move to that line too, each at its pointer position's distance and on - /// that position's side of the press point; a pointer at the press point leaves them on their - /// line. Without Shift the stroke takes `p`. + /// Would Alt held now switch the Blob Brush to the Smooth tool? It does before a press, and + /// during a drag that has not moved from the press point yet. + fn blob_alt_smooths(&self, m: Mods) -> bool { + self.id == "blobBrush" && m.alt && !self.moved + } + + /// Constrain the Pencil line or the current Blob Brush sample. Blob Brush chooses an + /// axis once per uninterrupted Shift interval; samples already drawn never move. + /// A smoothing drag follows the pointer without a Shift constraint. fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point { - self.shift_next = None; if self.straight && self.unconstrained { return p; } @@ -201,37 +213,51 @@ impl GestureTool { { return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle); } - let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift) else { - self.shift_raw.clear(); + let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else { self.shift_line = None; return p; }; - self.shift_next = Some(p); - // The press point is on both lines: the points dragged before stay on theirs. - if (p - start).hypot() < vectorcraft_geom::EPS { - return start; + if self.shift_line.is_none() { + // Raw press-to-pointer displacement in logical screen pixels along the constraint + // axes. Zoom changes the document distance, not the activation tolerance. + let v = p - start; + let v = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(Point::new(v.x, v.y), |axes| axes.inverse() * Point::new(v.x, v.y)); + let x = v.x.abs() * cx.zoom; + let y = v.y.abs() * cx.zoom; + self.shift_line = if x >= 1.0 && y < 0.5 { + Some(cx.constrain_angle) + } else if y >= 1.0 && x < 0.5 { + Some(cx.constrain_angle + 90.0) + } else { + // No unconstrained trail while pending, including an initially diagonal drag. + return self.points.last().copied().unwrap_or(start); + }; } - let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle); - // `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured. - let line = (-d.y).atan2(d.x).to_degrees(); - let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line); - if self.shift_line != Some(line) { - for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) { - *q = on_line(*raw); - } - self.shift_line = Some(line); - } - on_line(p) + start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle)) } /// Forget the stroke. fn clear(&mut self) { + self.moved = false; self.points.clear(); self.pressures.clear(); self.previewed = 0; self.shift_line = None; } + /// Does the tool's Size take Fixed or Pressure (the Blob Brush and the Eraser)? + fn varies_size(&self) -> bool { + matches!(self.id, "blobBrush" | "eraser") + } + + /// The pen pressure of each point of a Blob Brush or Eraser stroke whose Size varies with + /// Pressure, when a pen pressed less than fully anywhere along it (a stroke without pressure + /// keeps its Size). + fn size_pressures(&self) -> Option<&[f32]> { + let pressed = self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0); + (self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + } + /// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere /// along it (a mouse always presses fully). fn stroke_json(&self) -> Value { @@ -339,9 +365,13 @@ impl GestureTool { let pts = &self.points; let has_sel = !cx.selection.is_empty(); let tol = cx.tol(self.edit_within); + let radius = cx.tol(SMOOTH_RADIUS); if self.smoothing { if has_sel && pts.len() >= 2 { - return vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))]; + return vec![Action::Exec( + "path.smoothRegion".into(), + json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}), + )]; } return vec![]; } @@ -366,19 +396,29 @@ impl GestureTool { "blobBrush" => vec![Action::Exec( "path.blob".into(), json!({ - "points": points_json(pts), + "points": self.stroke_json(), "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "fidelity": self.fidelity, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection }), )], "eraser" => vec![Action::Exec( "path.eraseRegion".into(), - json!({"points": points_json(pts), "size": self.size, "angle": self.angle, "roundness": self.roundness}), + json!({ + "points": self.stroke_json(), + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "angle": self.angle, + "roundness": self.roundness + }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], "smooth" if has_sel && pts.len() >= 2 => { - vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))] + vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))] } "pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))], "join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))], @@ -405,7 +445,9 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.near_start = false; - self.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt; + // Alt switches the Pencil to the Smooth tool while Alt key toggles to Smooth Tool is on, and always + // switches the Blob Brush. + self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth)); // Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift // too, the line keeps Shift's 45° steps. self.straight = self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt); @@ -435,10 +477,11 @@ impl Tool for GestureTool { if !self.active { return vec![]; } - let p = self.constrained(cx, p, ev.mods); - if !self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt { + if (!self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt) || self.blob_alt_smooths(ev.mods) { self.smoothing = true; } + self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS); + let p = self.constrained(cx, p, ev.mods); let mut end = p; if self.snaps() && !self.smoothing && !self.straight { // Where the stroke would end: shown, taken on release. @@ -482,8 +525,11 @@ impl Tool for GestureTool { } // With the Pencil's Live preview off a thin line shows instead (`overlays`). if self.previews() && crate::previews_at(self.points.len()) { - self.previewed = self.points.len(); let mut v = self.freehand_params(false); + let fidelity = self.fidelity.max(cx.tol(PREVIEW_FIDELITY_PX)); + v["fidelity"] = json!(fidelity); + // A preview fitted coarser than the stroke kept is stale (`deactivate`). + self.previewed = if fidelity > self.fidelity { 0 } else { self.points.len() }; if !self.live_fit { // Live curve fitting off: the release fits the stroke (`finish`). v["fit"] = json!(false); @@ -496,6 +542,12 @@ impl Tool for GestureTool { if !self.active { return vec![]; } + // A release away from the press point counts as movement. + self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS); + // Alt pressed while the pointer stood still at the press point switches the release too. + if self.blob_alt_smooths(ev.mods) { + self.smoothing = true; + } let p = self.constrained(cx, p, ev.mods); let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p }; self.snap.clear(); @@ -577,6 +629,7 @@ impl Tool for GestureTool { self.active = false; self.near_start = false; self.began = false; + self.smoothing = false; self.straight = false; self.unconstrained = false; self.clear(); @@ -588,19 +641,30 @@ impl Tool for GestureTool { if !self.active || self.points.len() < 2 || (self.began && self.previews()) { return o; } + // A Blob Brush drag that smooths shows the Smooth tool's thin line. let width = match self.id { - "blobBrush" | "eraser" => (self.size * cx.zoom) as f32, + "blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32, _ => 1.0, }; let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK }; // A tip that is not round shows the outline of the area it covers. + // shortcut: at its Size even with Pressure; the release erases at the pen's widths. if self.id == "eraser" && self.roundness < 100.0 { let area = eraser_area(&self.points, self.size, self.angle, self.roundness); o.push(Overlay::Path { path: area.to_bezpath(), color, width: 1.0, dashed: false }); return o; } - o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }); - if cx.measurement_labels && self.id == "blobBrush" { + // With Size set to Pressure, each step of the drag shows as wide as the pen pressed there. + if let Some(pressures) = self.size_pressures() { + for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) { + let pressure = f64::from(p[0] + p[1]) / 2.0; + let width = super::pressure_size(self.size, self.size_variation, pressure) * cx.zoom; + o.push(Overlay::Path { path: polyline(seg), color, width: width as f32, dashed: false }); + } + } else { + o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }); + } + if cx.measurement_labels && self.id == "blobBrush" && !self.smoothing { let last = self.points[self.points.len() - 1]; let length = self.points.windows(2).map(|w| w[0].distance(w[1])).sum(); o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) }); @@ -608,9 +672,9 @@ impl Tool for GestureTool { o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - // A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that - // has begun keeps drawing with Alt). - if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) { + // A drag that smooths, or Alt before a Pencil or Blob Brush stroke has begun to draw (a + // stroke that has begun keeps drawing with Alt). + if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || self.blob_alt_smooths(m) { return Cursor::Smooth; } // Before a press that would draw a straight line: over an end of a selected open path, @@ -667,8 +731,23 @@ impl Tool for GestureTool { }) } "smooth" => json!({"fidelity": self.fidelity}), - "blobBrush" => json!({"size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}), - "eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}), + "blobBrush" => { + json!({ + "fidelity": self.fidelity, + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "keepSelected": self.keep_selected, + "mergeOnlySelection": self.merge_only_selection + }) + } + "eraser" => json!({ + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "angle": self.angle, + "roundness": self.roundness + }), _ => Value::Null, } } @@ -676,6 +755,12 @@ impl Tool for GestureTool { match key { "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0), "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), + "sizeMode" if self.varies_size() => match v.as_str() { + Some("pressure") => self.size_pressure = true, + Some("fixed") => self.size_pressure = false, + _ => {} + }, + "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), "angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0), "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(1.0, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 2daac5705..c3335d1cf 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -122,6 +122,40 @@ fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() { assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); } +/// A stroke whose Fidelity is finer than half a screen pixel previews fitted at half a pixel, which +/// looks the same at a fraction of the fit's cost (#4565); the release, and another tool chosen +/// while it is drawn, fit it at its own Fidelity. A coarser Fidelity previews as it is kept. +#[test] +fn fine_fidelity_previews_at_half_a_pixel() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)]; + let fidelities = |acts: &[Action]| -> Vec { + acts.iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect() + }; + for id in ["pencil", "paintbrush"] { + for (zoom, fidelity, drawn) in [(1.0, 0.01, 0.5), (4.0, 0.01, 0.125), (1.0, 1.5, 1.5)] { + cx.zoom = zoom; + let mut t = GestureTool::new(id); + t.set_option("fidelity", &json!(fidelity)); + let acts = drag(&mut t, &cx, &stroke, Mods::default()); + let (release, during) = acts.split_last().unwrap(); + assert_eq!(fidelities(&during.concat()), [drawn; 3], "{id} at {zoom}x, {fidelity} pt"); + assert_eq!(fidelities(release), [fidelity], "{id} at {zoom}x, {fidelity} pt: the release"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + for &(x, y) in &stroke[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); + } + let acts = t.deactivate(&cx); + let kept = if drawn > fidelity { [fidelity].to_vec() } else { vec![] }; + assert_eq!(fidelities(&acts), kept, "{id} at {zoom}x, {fidelity} pt: another tool chosen"); + assert_eq!(acts.last(), Some(&Action::Commit)); + } + } +} + /// Keep selected is on by default, and every Pencil and Paintbrush preview sends it. #[test] fn pencil_and_paintbrush_send_keep_selected() { @@ -279,14 +313,32 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { "{acts:?}" ); } - // The Eraser keeps Size, Angle and Roundness: a round tip by default. - assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); + // The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default. + assert_eq!( + GestureTool::new("eraser").options(), + json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0}) + ); +} + +/// The Blob Brush has a Fidelity, 1.5 pt by default and 0.01 to 100 pt, and its stroke sends it. +#[test] +fn blob_brush_sends_its_fidelity() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("blobBrush"); + assert_eq!(t.options()["fidelity"], json!(1.5)); + for (set, sent) in [(1.5, 1.5), (6.0, 6.0), (0.001, 0.01), (500.0, 100.0)] { + t.set_option("fidelity", &json!(set)); + assert_eq!(t.options()["fidelity"], json!(sent)); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["fidelity"] == json!(sent)), "{set}: {acts:?}"); + } } /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, -/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points -/// dragged with Shift held move to the other line together, and each point follows the Shift -/// state of its own event. +/// with its axis chosen by the first clear axial movement. Crossing it never moves earlier samples. #[test] fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let (d, _) = doc_with_rect(); @@ -294,46 +346,135 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let p = paint(); let mut cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { - let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(c, "path.blob"); - v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + let mut t = GestureTool::new("blobBrush"); + let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); + for zoom in [0.5, 1.0, 4.0] { + cx.zoom = zoom; + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 4.0 / zoom, 4.0 / zoom, shift)); + assert_eq!(t.points, vec![Point::ZERO]); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.5 / zoom, shift)); + assert_eq!(t.points.len(), 1, "small component has a strict bound"); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.0, shift)); + let first = t.points[1]; + t.pointer(&cx, &ev(PointerKind::Drag, 3.0 / zoom, 10.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.iter().all(|p| p.y.abs() < 1e-9)); + // Release Shift: freehand resumes without relocating the constrained points. + t.pointer(&cx, &ev(PointerKind::Drag, 8.0 / zoom, 12.0 / zoom, Mods::default())); + assert_eq!(t.points.last(), Some(&Point::new(8.0 / zoom, 12.0 / zoom))); + t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 15.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.last().unwrap().x.abs() < 1e-9); + t.pointer(&cx, &ev(PointerKind::Up, 0.0, 15.0 / zoom, shift)); + } + // Exact quarter turns preserve the strict half-pixel boundary in either direction. + for angle in [-450.0, -180.0, -90.0, 90.0, 180.0, 450.0] { + cx.constrain_angle = angle; + cx.zoom = 1.0; + let axes = vectorcraft_geom::constrain_axes(angle).unwrap(); + for (x, y) in [(1.0, 0.5), (0.5, 1.0), (-1.0, -0.5), (-0.5, -1.0)] { + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + let at = axes * Point::new(x, y); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.points, vec![Point::ZERO], "angle {angle}, ({x}, {y}) stays pending"); + assert!(t.shift_line.is_none()); + let at = axes * Point::new(x, 0.0); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.shift_line.is_some(), x.abs() >= 1.0); + t.clear(); + } + } + cx.constrain_angle = 0.0; + // A pending diagonal released without locking makes the ordinary dot at the press. + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0, shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0, shift)); + assert!(matches!(&acts[0], Action::Exec(c, v) if c == "path.blob" && v["points"] == json!([[20.0, 20.0]])), "{acts:?}"); +} + +/// Alt switches the Blob Brush to the Smooth tool: held before the press, or pressed before the +/// drag has moved, the cursor shows the Smooth tool, the drag shows a thin line and its release +/// smooths the selected paths (`path.smoothRegion`) instead of painting. Shift does not +/// constrain a smoothing drag, a stroke that has moved keeps painting when Alt goes down, and +/// with nothing selected the release changes no path. +#[test] +fn blob_brush_alt_switches_to_the_smooth_tool() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(50.0, 50.0); + let smoothed = |acts: &[Vec]| -> Option { + match acts.last().and_then(|a| a.first()) { + Some(Action::Exec(c, v)) if c == "path.smoothRegion" => Some(v.clone()), + _ => None, + } }; let mut t = GestureTool::new("blobBrush"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); - assert_eq!(pts.len(), 3); - assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); - // A pointer that crosses to the vertical line takes the points before it along, each on - // its own side of the press point and at its own distance. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); - assert_eq!(pts.len(), 4, "{pts:?}"); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); - assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); - // Without Shift the stroke follows the pointer. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); - assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); - // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, - // and a later press of Shift leaves the points of the earlier one where they are. - let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); - t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); - let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); - assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); - assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); - assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); - // The axes turn with the Constrain Angle. - cx.constrain_angle = 30.0; - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); - // A pointer back at the press point leaves the stroke on its line. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::BlobBrush); + let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let acts = drag_held(&mut t, &cx, &stroke, alt); + assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); + let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": 1.5})); + // Mid-drag the cursor stays the Smooth tool's when Alt is let go, and the line is thin with + // no length label. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth); + let o = t.overlays(&cx); + assert!(o.iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)), "{o:?}"); + assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { .. })), "{o:?}"); + // After another tool is chosen mid-drag, the Blob Brush shows its own cursor when it is chosen + // again, and so does the Pencil after a drag that Alt key toggles to Smooth Tool made smooth. + assert!(t.deactivate(&cx).is_empty()); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::BlobBrush); + let mut pencil = GestureTool::new("pencil"); + pencil.set_option("altSmooth", &json!(true)); + pencil.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); + pencil.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); + assert!(pencil.deactivate(&cx).is_empty()); + assert_eq!(pencil.cursor(&cx, at, Mods::default()), Cursor::PencilNew); + // Alt pressed before the drag has moved smooths too, and Shift leaves the points where the + // pointer went. + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 170.0).with_mods(alt_shift)); + let acts = [t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 160.0).with_mods(alt_shift))]; + let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!(v["points"], json!([[50.0, 150.0], [150.0, 170.0], [250.0, 160.0]])); + // A stroke that has moved keeps painting with Alt down. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::BlobBrush); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 150.0).with_mods(alt)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(alt)); + assert!(matches!(acts.as_slice(), [Action::Exec(c, v)] if c == "path.blob" && v["points"].as_array().unwrap().len() == 4), "{acts:?}"); + // So does a stroke that has moved too little to add a point. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.25, 150.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::BlobBrush); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(alt)); + assert!(matches!(acts.as_slice(), [Action::Exec(c, _)] if c == "path.blob"), "{acts:?}"); + // Alt pressed while the pointer stands still at the press point does not paint on the release. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 150.0).with_mods(alt)); + assert!(acts.is_empty(), "{acts:?}"); + // A release away from the press point with no drag before it paints when Alt goes down. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(alt)); + assert!(matches!(acts.as_slice(), [Action::Exec(c, _)] if c == "path.blob"), "{acts:?}"); + // With nothing selected an Alt drag sends nothing. + let none = Selection::default(); + let empty = crate::testutil::cx(&d, &none, &p); + let acts = drag_held(&mut t, &empty, &stroke, alt); + assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}"); } /// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° @@ -703,6 +844,34 @@ fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() { assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3)); } +/// While Alt switches the Pencil to the Smooth tool, the Pencil smooths within the Smooth tool's +/// radius at any Edit selected paths Within value, 0 included. +#[test] +fn pencil_alt_smooths_within_the_smooth_tools_radius() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let radius = |acts: &[Vec]| match acts.last().and_then(|a| a.first()) { + Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap(), + a => panic!("expected path.smoothRegion, got: {a:?}"), + }; + let alt = Mods { alt: true, ..Default::default() }; + for zoom in [1.0, 2.0] { + cx.zoom = zoom; + let want = radius(&drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default())); + for within in [0.0, 5.0, 40.0] { + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + t.set_option("editWithin", &json!(within)); + assert_eq!(radius(&drag_held(&mut t, &cx, &pts, alt)), want, "zoom {zoom}, editWithin {within}"); + } + } +} + /// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of /// a selected open path it continues the path from that end to the pointer. #[test] diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 013578edc..123c4b1ef 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,9 +1,11 @@ //! Flare, Reshape, Shaper and Graph tools. //! -//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options), -//! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare -//! Tool Options, which last). Reshape: drag a point of a selected path; the engine's -//! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly. +//! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down +//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click +//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes +//! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a +//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the +//! neighbourhood smoothly. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; @@ -51,9 +53,13 @@ pub struct FlareTool { /// Centre placed, waiting for the end-point click: (centre, diameter). placed: Option<(Point, f64)>, hover: Point, + /// The pointer at the last press or drag, from which Space held moves the flare. + last: Point, began: bool, /// Ray count changed with ↑/↓ during the gesture (None = the options' count). rays: Option, + /// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set. + turn: f64, /// The options' values, in [`FLARE_OPTIONS`] order. values: [f64; 12], /// Whether the flare has rays and rings (the options' Rays and Rings checkboxes). @@ -67,8 +73,10 @@ impl Default for FlareTool { drag: None, placed: None, hover: Point::ZERO, + last: Point::ZERO, began: false, rays: None, + turn: 0.0, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, rings_on: true, @@ -101,12 +109,26 @@ pub fn flare_params(o: &Value, c: Point) -> Value { v } +/// The chosen rotation rate: one degree per document point of vertical pointer travel. +/// This is independent of pointer event frequency and elapsed time. +const RAY_DEGREES_PER_POINT: f64 = 1.0; + +/// Accumulate travel rather than an event-count step. Frozen/moving samples still update +/// the pointer baseline, so releasing a modifier never catches up skipped rotation. +fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 { + if shift || !dy.is_finite() { + return turn; + } + (turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0) +} + impl FlareTool { - /// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count - /// ↑/↓ chose and the rings' end point once clicked. + /// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn, + /// the ray count ↑/↓ chose and the rings' end point once clicked. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); + v["rotation"] = json!(self.turn); if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } @@ -132,12 +154,20 @@ impl Tool for FlareTool { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; + self.turn = 0.0; } + self.last = ev.pos; vec![] } PointerKind::Drag => { - if let Some((c, _)) = self.drag { + if let Some((mut c, _)) = self.drag { + // Space held moves the center with the pointer, so the size and the turn stay. + let dy = ev.pos.y - self.last.y; + crate::shape::space_moves(&mut c, &mut self.last, ev, self.began); self.drag = Some((c, ev.pos)); + if !ev.mods.space { + self.turn = ray_turn(self.turn, dy, ev.mods.shift); + } if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { self.began = true; return vec![ @@ -148,13 +178,17 @@ impl Tool for FlareTool { if self.began { return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; } - } else if let Some((c, d)) = self.placed { + } else if let Some((mut c, d)) = self.placed { + // Space held moves the whole flare, rings' end point included. + crate::shape::space_moves(&mut c, &mut self.last, ev, true); + self.placed = Some((c, d)); return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos)))]; } vec![] } PointerKind::Up => { - if let Some((c, d)) = self.placed.take() { + if let Some((mut c, d)) = self.placed.take() { + crate::shape::space_moves(&mut c, &mut self.last, ev, true); self.began = false; return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos))), Action::Commit]; } @@ -538,6 +572,109 @@ mod tests { assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0)); } + /// The last `shape.flare` preview of `a`. + fn flare_preview(a: &[Action]) -> Value { + match a.iter().rev().find(|a| matches!(a, Action::Preview(..))) { + Some(Action::Preview(c, v)) if c == "shape.flare" => v.clone(), + other => panic!("no flare preview: {other:?}"), + } + } + + /// Equal vertical travel gives equal rotation, regardless of event partitioning. + #[test] + fn flare_drag_turns_the_rays_and_shift_holds_their_turn() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let space = Mods { space: true, ..Default::default() }; + let mut results = vec![]; + for steps in [1, 8, 64] { + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + for i in 1..=steps { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0 + 80.0 * i as f64 / steps as f64)); + } + assert_eq!(t.turn, 80.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 180.0)); + assert_eq!(t.turn, 80.0, "horizontal movement doesn't turn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 150.0)); + assert_eq!(t.turn, 50.0, "reverse travel subtracts its distance"); + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0).with_mods(shift))); + assert_eq!(v["rotation"].as_f64(), Some(50.0)); + assert_eq!(v["diameter"].as_f64(), Some(200.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 220.0)); + assert_eq!(t.turn, 70.0, "release does not catch up frozen movement"); + t.key(&cx, ToolKey::Up, Mods::default()); + assert_eq!(t.turn, 70.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 220.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 200.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 260.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + assert_eq!(flare_preview(&a)["rotation"].as_f64(), Some(70.0)); + results.push(t.turn); + } + assert!(results.windows(2).all(|p| p[0] == p[1])); + // Space held before the first drag update freezes rotation too. + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 140.0).with_mods(space)); + assert_eq!(t.turn, 0.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 150.0)); + assert_eq!(t.turn, 10.0); + } + + /// Space held while dragging out a flare moves it at its current size and turn, in the center + /// drag and in the drag that places the rings' end point; let go, the drag goes on from there. + #[test] + fn space_moves_the_flare_being_drawn() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let space = Mods { space: true, ..Default::default() }; + let step = 30.0; + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 130.0)); + // Space held: the pointer's y grows, but the flare moves without turning. + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 180.0).with_mods(space))); + let at = |v: &Value| (v["cx"].as_f64(), v["cy"].as_f64(), v["diameter"].as_f64()); + let turned = |v: &Value, by: f64| v["rotation"].as_f64().is_some_and(|r| (r - by).abs() < 1e-9); + assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(60.0))); + assert!(turned(&v, step)); + // Space let go: the size follows the pointer again, from the moved center, and so does the turn. + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 190.0))); + assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(80.0))); + assert!(turned(&v, step + 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 190.0)); + // The second drag: Space held moves the flare, its rings' end point with it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 310.0).with_mods(space))); + assert_eq!((at(&v), v["x2"].as_f64(), v["y2"].as_f64()), ((Some(170.0), Some(160.0), Some(80.0)), Some(320.0), Some(310.0))); + assert!(turned(&v, step + 10.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + let v = flare_preview(&a); + assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(170.0), Some(160.0), Some(340.0))); + // A release with Space held away from the last drag moves the flare there too. + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0)); + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0).with_mods(space))); + assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0))); + } + #[test] fn reshape_drag_honors_measurement_labels() { use crate::testutil::*; diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index e793f0424..614df6891 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -18,7 +18,7 @@ use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathSeg, Point, Rect, use vectorcraft_text::{FontDb, layout as layout_text}; use crate::bbox::Handle; -use crate::{Overlay, ScreenFrame, ToolContext}; +use crate::{GlyphSnap, Overlay, ScreenFrame, ToolContext}; mod snap; mod targets; diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 673e9f078..14a36b422 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -1,5 +1,8 @@ use super::targets::{Targets, span}; -use super::*; +use super::{ + Affine, GlyphSnap, GuideStyle, Handle, HitOptions, Line, NodeId, OrientedBox, Overlay, ParamCurve, ParamCurveDeriv, ParamCurveNearest, PathSeg, + Point, Rect, ScreenFrame, ToolContext, Vec2, constrain_angle_from, hit_test, +}; /// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back /// settles where they meet (none when they don't nearby). @@ -292,7 +295,8 @@ struct SnapKey { exclude: Vec, screen: Option, view: [bool; 7], - snap_to_glyph: bool, + /// The Snap to Glyph options while it gives guides. + snap_to_glyph: Option, style: GuideStyle, tolerances: [f64; 2], } @@ -304,7 +308,7 @@ impl SnapKey { exclude: exclude.to_vec(), screen: cx.screen, view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], - snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, + snap_to_glyph: (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap), style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 4d77ac652..edddd1a48 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -1,5 +1,8 @@ use super::snap::{Leave, meeting, seg_direction, touch_params}; -use super::*; +use super::{ + Affine, Arc, BezPath, Document, FontDb, GlyphSnap, GuideStyle, Handle, HashSet, Line, Node, NodeId, NodeKind, OrientedBox, Overlay, ParamCurve, + ParamCurveNearest, PathSeg, Point, Rect, Ruler, Selection, Shape, SubPath, ToolContext, Vec2, layout_text, +}; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum Kind { @@ -44,6 +47,10 @@ pub struct Targets { /// it ends, as the end that stays put and the unit direction towards the dragged one /// ([`Self::for_anchor_drag`]). pub(super) extensions: Vec<(Point, Vec2)>, + /// Snap to Glyph's guides that run at an angle (in rotated type, or along a glyph's slanted + /// segments), as a point on each and its unit direction: a drawn point lands on the nearest + /// ([`Self::for_drawing`]). + pub(super) angled: Vec<(Point, Vec2)>, /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it /// grazes a curve, or meets a segment square ([`Self::touching`]). pub(super) tangent: bool, @@ -156,12 +163,13 @@ impl Targets { /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its /// anchors, centres, edges and path segments, its anchors in line too. The anchors and - /// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets. - pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option, glyphs: bool) -> Self { + /// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets. With + /// `glyphs` (Snap to Glyph on), live text gives the guides those options ask for. + pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option, glyphs: Option) -> Self { let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing); t.push_paths(doc, exclude, true); - if glyphs { - t.push_glyphs(doc, exclude, visible); + if let Some(o) = glyphs { + t.push_glyphs(doc, exclude, visible, o); } t.index() } @@ -206,12 +214,36 @@ impl Targets { self.ys.push((a.y, a, Kind::Anchor)); } - /// Live-text baselines and glyph bounds (View › Snap to Glyph). - fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { + /// A glyph guide through `at` along `dir` (on the page): a line the art lines up with when it + /// runs across or down the page, else (with `angular`, Angular Guides) an angled line a point + /// lands on. + fn push_glyph_line(&mut self, at: Point, dir: Vec2, angular: bool) { + let len = dir.hypot(); + if !(len.is_finite() && len > 1e-12 && at.is_finite()) { + return; + } + let d = dir / len; + if d.y.abs() <= 1e-9 { + self.ys.push((at.y, at, Kind::Glyph)); + } else if d.x.abs() <= 1e-9 { + self.xs.push((at.x, at, Kind::Glyph)); + } else if angular { + self.angled.push((at, d)); + } + } + + /// Live text as View › Snap to Glyph shows it: the glyph bounds of every text object, and for + /// a single line of type also its baseline and x-height, the proximity guides at the edges of + /// each character's advance width, and its glyphs' anchor points. In rotated type these + /// guides run at its angle (angular guides), and type of one glyph also has angular guides + /// along the glyph's slanted straight segments. Each kind of guide is there only while its + /// option `o` is on. + fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option, o: GlyphSnap) { let skip: HashSet = exclude.iter().copied().collect(); let db = FontDb::global(); let kind = Kind::Glyph; let mut budget = BUDGET / 4; + let (mut anchors, mut angular) = (BUDGET, BUDGET / 4); walk_but(&doc.layers, &skip, &mut |n| { if budget == 0 || !n.visible { return; @@ -228,25 +260,89 @@ impl Targets { { return; } + // A paragraph shows glyph bounds only. + let single = lay.lines.len() == 1; + // Angular guides along a glyph's slanted segments: type of one glyph only. + let one_glyph = lay.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count() == 1; + // Text space → the page, and a direction in text space → one on the page. let to_doc = |p: Point| t.xf * p; - for line in &lay.lines { - let at = to_doc(Point::new(line.x0, line.baseline)); - self.ys.push((at.y, at, kind)); + let along = |m: Affine, v: Vec2| (m * v.to_point()) - (m * Point::ZERO); + if single && o.baseline { + let line_doc = t.xf * lay.line_xf; + for line in &lay.lines { + self.push_glyph_line(line_doc * Point::new(line.x0, line.baseline), along(line_doc, Vec2::new(1.0, 0.0)), o.angular); + } } + // The last x-height pushed, to push each run of glyphs alike once. + let mut last_x_height: Option<(Point, Vec2)> = None; for g in &lay.glyphs { if budget == 0 { break; } + // Glyphs that draw nothing (spaces) give no guides. let bb = g.outline.bounding_box(); if !(bb.width() > 0.0 && bb.height() > 0.0) { continue; } - let r = t.xf.transform_rect_bbox(bb); - for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { - self.xs.push((p.x, p, kind)); - self.ys.push((p.y, p, kind)); - } budget -= 1; + // Horizontal type and type on a path: the baseline runs from where the pen stood + // along the glyph's angle (not the glyph's own drawing, which Character Rotation + // turns), up being to its left. + if single + && !lay.vertical + && let Some(face) = db.face_by_id(g.font_id) + { + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let up = Vec2::new(ahead.y, -ahead.x); + let dir = along(t.xf, ahead); + // The x-height of the glyph as placed without its Character Rotation (raised or + // lowered by its baseline shift), measured across the baseline. + let top = g.xf * g.unturned * Point::new(0.0, -face.x_height()); + let at = to_doc(g.origin + up * (top - g.origin).dot(up)); + let same = last_x_height.is_some_and(|(a, d)| { + let (l, k) = (d.hypot(), dir.hypot()); + l > 1e-12 && k > 1e-12 && (d / l - dir / k).hypot() <= 1e-9 && ((at - a).cross(d / l)).abs() <= 1e-6 + }); + if o.x_height && face.x_height() > 0.0 && !same { + self.push_glyph_line(at, dir, o.angular); + last_x_height = Some((at, dir)); + } + // Proximity guides: across the baseline at both edges of the width the layout + // gives the character (its advance with tracking and kerning). + if o.proximity { + for e in [g.origin, g.origin + ahead * g.advance] { + self.push_glyph_line(to_doc(e), along(t.xf, up), o.angular); + } + } + } + if single { + for seg in g.outline.segments() { + if o.anchors && anchors > 0 { + self.points.push((to_doc(seg.start()), Kind::Anchor)); + anchors -= 1; + } + if o.angular + && one_glyph + && angular > 0 + && let PathSeg::Line(l) = seg + { + let (p, q) = (to_doc(l.p0), to_doc(l.p1)); + let d = q - p; + let len = d.hypot(); + if len > 1e-6 && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len { + self.push_glyph_line(p, d, true); + angular -= 1; + } + } + } + } + if o.bounds { + let r = t.xf.transform_rect_bbox(bb); + for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { + self.xs.push((p.x, p, kind)); + self.ys.push((p.y, p, kind)); + } + } } }); } @@ -284,7 +380,11 @@ impl Targets { // With several anchors moving, the one under the pointer is not known here, and the // others' extensions don't run through it. let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::() == 1; - for (id, set) in &sel.anchors { + // A path inside another selected object, such as a path in a selected group, moves whole + // with that object, so none of its anchors or segments stay put. + let selected: HashSet = sel.objects.iter().copied().collect(); + let inside_selected = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().rev().skip(1).any(|p| selected.contains(p))); + for (id, set) in sel.anchors.iter().filter(|(id, _)| !inside_selected(**id)) { let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue }; for (si, sp) in path.subpaths.iter().enumerate() { let moving = |ai: usize| set.contains(&(si, ai)); @@ -396,6 +496,7 @@ impl Targets { self.ys.clear(); self.segments.clear(); self.extensions.clear(); + self.angled.clear(); self } @@ -505,7 +606,11 @@ impl Targets { } // A construction guide through the point it leaves, else a dragged segment's extension: // it lands on the guide, sliding into line with a target crossing it nearby. - if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) { + if let Some((at, dir, q)) = from + .and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))) + .or_else(|| self.extension(p, tol)) + .or_else(|| self.on_angled(p, tol)) + { let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false }; return match self.slide(at, dir, q, tol, false) { Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()), @@ -603,6 +708,18 @@ impl Targets { .map(|(at, d, q, _)| (at, d, q)) } + /// The angled glyph guide ([`Self::angled`]) nearest `p`, within `tol` of it: a point on it, its + /// direction and where `p` lands on it. + fn on_angled(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> { + self.angled + .iter() + .map(|&(at, d)| (at, d, at + d * (p - at).dot(d))) + .map(|(at, d, q)| (at, d, q, q.distance(p))) + .filter(|(.., dist)| *dist <= tol) + .min_by(|a, b| a.3.total_cmp(&b.3)) + .map(|(at, d, q, _)| (at, d, q)) + } + /// The anchor or centre nearest `p`, within `tol`. /// Is a target point (an anchor, a centre) within `tol` of `p`? pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool { @@ -618,7 +735,7 @@ impl Targets { } /// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where - /// the nearest target line crosses it, at most twice `tol` away: where it lands and the + /// the nearest target line or angled glyph guide crosses it, at most twice `tol` away: where it lands and the /// target's own point (where its line starts). fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> { if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 { @@ -643,6 +760,19 @@ impl Targets { } } } + // The angled glyph guides it crosses. + for &(on, e) in &self.angled { + let across = dir.cross(e); + if across.abs() <= 1e-9 * dir.hypot() { + continue; + } + let s = (on - at).cross(e) / across; + let hit = at + dir * s; + let moved = hit.distance(q); + if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) { + best = Some((moved, hit, on)); + } + } best.map(|(_, hit, from)| (hit, from)) } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 517937c74..7ecdc2f1c 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -251,7 +251,7 @@ fn shaper_sources_are_no_targets() { || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) }; assert!(!gives_corner(&Targets::collect(&d, &[], None))); - assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); + assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, None))); assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); assert!(!gives_corner(&Targets::for_handle(&d, face))); assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); @@ -341,7 +341,7 @@ fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() { let (d, _) = doc_with_rect(); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); - let t = Targets::for_drawing(&d, &[], None, false).styled(&c); + let t = Targets::for_drawing(&d, &[], None, None).styled(&c); // The rect is 100..200: its corner, a point on its side, and in line with an anchor. let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); @@ -395,7 +395,7 @@ fn drawn_points_land_on_intersections() { d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; - let t = Targets::for_drawing(&d, &[], None, false).styled(&c).with_rulers(&c); + let t = Targets::for_drawing(&d, &[], None, None).styled(&c).with_rulers(&c); let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0); assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}"); assert_eq!(labels(&ov), ["intersect"]); @@ -428,7 +428,7 @@ fn perpendicular_snapping_rejects_a_curved_target() { ), ) .unwrap(); - let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, None) }; assert!(targets.touching(Point::new(400.0, 500.0), Point::new(450.0, 500.0), 4.0).is_none()); assert!(targets.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), 4.0).is_some()); } @@ -448,7 +448,7 @@ fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { let c = cx(&d, &s, &p); let snap = |c: &ToolContext, from: Point, to: Point| { let leave = Leave::segment(c, from, false); - PointSnap::new(c, || Targets::for_drawing(&d, &[], None, false)).snap_from(c, to, Some(&leave)) + PointSnap::new(c, || Targets::for_drawing(&d, &[], None, None)).snap_from(c, to, Some(&leave)) }; // From (300, 160) the rect's right side (x = 200) is square at (200, 160). let (q, ov) = snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); @@ -459,7 +459,7 @@ fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)).0, q, "deterministic"); // Off, the point only lands on the path, where the pointer is. let curved = Leave::segment(&c, Point::new(300.0, 160.0), false).without_contact(); - let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, false)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved)); + let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, None)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved)); assert!(!labels(&ov).contains(&"perpendicular")); let off = ToolContext { snap_perpendicular: false, ..cx(&d, &s, &p) }; let (q, ov) = snap(&off, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); @@ -474,7 +474,7 @@ fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { assert!(!labels(&snap(&none, Point::new(400.0, 500.0), Point::new(477.0, 459.0)).1).contains(&"tangent")); // Out of reach, or with nothing finite to go on: left alone, no panic. assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(250.0, 175.0)).0, Point::new(250.0, 175.0)); - let t = Targets::for_drawing(&d, &[], None, false); + let t = Targets::for_drawing(&d, &[], None, None); let t = Targets { tangent: true, perpendicular: true, ..t }; for bad in [f64::NAN, f64::INFINITY] { assert!(t.touching(Point::new(bad, 1.0), Point::new(200.0, 160.0), 4.0).is_none()); @@ -492,7 +492,7 @@ fn contact_snaps_only_for_segments_and_in_busy_spots() { let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let snap = |t: &Targets, leave: &Leave| t.snap_from(Point::new(202.0, 163.0), Some(leave), 4.0); - let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, None) }; let (_, ov) = snap(&t, &Leave::segment(&c, Point::new(300.0, 160.0), false)); assert_eq!(labels(&ov), ["perpendicular"]); let (_, ov) = snap(&t, &Leave::diagonal(Point::new(300.0, 160.0), false)); @@ -540,7 +540,7 @@ fn bisection_finds_what_a_scan_finds() { let mut s = Selection::default(); s.set([id]); s.anchors.insert(id, [(0, 1)].into_iter().collect()); - for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { + for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, None), Targets::for_anchor_drag(&d, &s)] { for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); @@ -608,3 +608,253 @@ fn draw_snap_gathers_once_per_document_state() { let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); } + +/// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about +/// where it starts, and the type. +fn glyph_doc(text: &str, angle: f64) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + glyph_doc_with(text, angle, vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }) +} + +/// [`glyph_doc`] with the type in `style`. +fn glyph_doc_with(text: &str, angle: f64, style: vectorcraft_doc::CharStyle) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let at = Point::new(100.0, 300.0); + let mut t = vectorcraft_doc::TextObject::point(at, text, style); + t.xf = Affine::rotate_about(angle, at) * t.xf; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + (d, t) +} + +/// Snap to Glyph: a Pen click near an anchor point of a glyph's outline lands on it, and not with +/// Snap to Glyph off. +#[test] +fn pen_click_near_a_glyph_anchor_lands_on_it() { + use crate::{Action, PointerEvent, PointerKind, Tool}; + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + // An anchor inside the glyph's bounds (a corner where the crossbar meets a stem), which no + // glyph bounds line passes through. + let bb = g.outline.bounding_box(); + let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0; + let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph"); + let (s, p) = (Selection::default(), paint()); + let click = anchor + Vec2::new(1.5, -1.0); + let first_anchor = |glyphs: bool, anchors: bool| { + let c = ToolContext { snap_to_glyph: glyphs, glyph_snap: GlyphSnap { anchors, ..GlyphSnap::default() }, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()) + }; + assert!(first_anchor(true, true).distance(anchor) < 1e-9, "{:?} vs {anchor:?}", first_anchor(true, true)); + assert!(first_anchor(false, true).distance(anchor) > 1e-3); + // The Character panel's Snap to Glyph › Anchor Point off: no glyph anchors (#4232). + assert!(first_anchor(true, false).distance(anchor) > 1e-3); + // Snap to Glyph works with Smart Guides on: Snap to Point alone does not land on glyph anchors. + let c = ToolContext { snap_to_glyph: true, smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + let q = Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()); + assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off"); +} + +/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only. +#[test] +fn drawn_points_line_up_with_the_x_height_of_a_single_line() { + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = &lay.glyphs[0]; + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y; + let p = Point::new(400.0, x_height + 2.0); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!((q.y - x_height).abs() < 1e-9, "{q:?}, x-height {x_height}"); + let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0); + assert_eq!(q.y, p.y); + let (d, _) = glyph_doc("H\nH", 0.0); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert_eq!(q.y, p.y, "a paragraph has no x-height guide"); + // Baseline Shift raises the x-height with the glyphs. + let (d, _) = glyph_doc_with("H", 0.0, vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, ..Default::default() }); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Character Rotation turns the glyph, not its baseline shift. + let style = vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, rotation: 30.0, ..Default::default() }; + let (d, _) = glyph_doc_with("H", 0.0, style); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Glyph Scaling widens a rotated glyph without raising its x-height. + let mut t = + vectorcraft_doc::TextObject::point(Point::ZERO, "HH HH", vectorcraft_doc::CharStyle { size: 60.0, rotation: 90.0, ..Default::default() }); + let frame = kurbo::Shape::to_path(&kurbo::Rect::new(50.0, 100.0, 450.0, 400.0), 0.1); + t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&frame) }; + t.xf = Affine::IDENTITY; + t.para.justify = vectorcraft_doc::Justify::JustifyAll; + t.para.justification.glyph_scaling = [100.0, 100.0, 200.0]; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + assert_eq!(lay.lines.len(), 1); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + assert!(g.scale > 1.2, "Glyph Scaling widens the glyph: {}", g.scale); + let expected = (t.xf * g.origin).y - (300.0 - x_height); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(30.0, expected + 2.0), 4.0); + assert!((q.y - expected).abs() < 1e-9, "{q:?}, x-height {expected}"); +} + +/// Snap to Glyph: the x-height guide of type skewed along a slanted path runs through the glyph's +/// x-height. +#[test] +fn the_x_height_guide_of_skewed_path_type_runs_through_the_glyphs_x_height() { + let mut path = kurbo::BezPath::new(); + path.move_to((100.0, 400.0)); + path.line_to((300.0, 54.0)); + let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "H", vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.path_effect = vectorcraft_doc::PathEffect::Skew; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let top = t.xf * g.xf * Point::new(0.0, -face.x_height()); + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let normal = Vec2::new(ahead.y, -ahead.x); + // Beside the guide's line, past the glyph's end along it. + let p = top + ahead * 80.0 + normal * 1.5; + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!((q - top).cross(ahead).abs() < 1e-6, "{q:?} is off the x-height line through {top:?}"); +} + +/// Snap to Glyph › Proximity Guides: the edges of each character's advance width are guides. +#[test] +fn drawn_points_line_up_with_the_edge_of_a_characters_width() { + let (d, t) = glyph_doc("HH", 0.0); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); + let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0); + assert_eq!(q.x, p.x); + // Character Rotation turns the glyphs, not their widths along the baseline. + let (d, t) = glyph_doc_with("HH", 0.0, vectorcraft_doc::CharStyle { size: 60.0, rotation: 30.0, ..Default::default() }); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); +} + +/// Snap to Glyph › Angular Guides: in a rotated glyph a straight upright stroke is a guide at its +/// angle, which a drawn point lands on beyond the glyph. +#[test] +fn a_rotated_glyph_has_angular_guides_along_its_strokes() { + let angle = 20f64.to_radians(); + let (d, t) = glyph_doc("H", angle); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let stem = g + .outline + .segments() + .find_map(|s| match s { + PathSeg::Line(l) if (l.p1.x - l.p0.x).abs() < 1e-6 && (l.p1.y - l.p0.y).abs() > 10.0 => Some(l), + _ => None, + }) + .expect("an upright stroke"); + let (a, b) = (t.xf * stem.p0, t.xf * stem.p1); + let dir = (b - a) / (b - a).hypot(); + // Past the far end of the stroke, a little beside its line. + let far = if (a - t.xf * Point::new(0.0, 0.0)).hypot() > (b - t.xf * Point::new(0.0, 0.0)).hypot() { a } else { b }; + let out = if far == a { -dir } else { dir }; + let p = far + out * 30.0 + Vec2::new(-dir.y, dir.x) * 1.5; + let on_line = |q: Point| (q - a).cross(dir).abs(); + let (q, ov) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!(on_line(q) < 1e-9, "{q:?} is {} off the stroke's line", on_line(q)); + assert!(!lines(&ov).is_empty()); + let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0); + assert!(on_line(q) > 1e-3, "no angular guide with Snap to Glyph off"); +} + +/// The Character panel's Snap to Glyph options (#4232): each one off takes away its own guides and +/// leaves the others. +#[test] +fn each_snap_to_glyph_option_takes_away_only_its_own_guides() { + // The glyph guides: lines across the page (ys), down it (xs), at an angle, and anchor points. + let count = |d: &vectorcraft_doc::Document, o: GlyphSnap| { + let t = Targets::for_drawing(d, &[], None, Some(o)); + let glyph = |v: &[(f64, Point, Kind)]| v.iter().filter(|e| e.2 == Kind::Glyph).count(); + [glyph(&t.ys), glyph(&t.xs), t.angled.len(), t.points.iter().filter(|e| e.1 == Kind::Anchor).count()] + }; + let all = GlyphSnap::default(); + let (d, t) = glyph_doc("H", 0.0); + let on = count(&d, all); + let lay = layout_text(FontDb::global(), &t); + let g = &lay.glyphs[0]; + let anchors = g.outline.segments().count(); + assert!(anchors > 0); + // One line, one glyph: a baseline, an x-height, two width edges, four corners and its anchors. + for (off, less) in [ + (GlyphSnap { baseline: false, ..all }, [1, 0, 0, 0]), + (GlyphSnap { x_height: false, ..all }, [1, 0, 0, 0]), + (GlyphSnap { bounds: false, ..all }, [4, 4, 0, 0]), + (GlyphSnap { proximity: false, ..all }, [0, 2, 0, 0]), + (GlyphSnap { anchors: false, ..all }, [0, 0, 0, anchors]), + (GlyphSnap { angular: false, ..all }, [0, 0, 0, 0]), + ] { + let got = count(&d, off); + assert_eq!(std::array::from_fn::(|i| on[i] - got[i]), less, "{off:?}: {got:?} of {on:?}"); + } + // A point near the x-height lands on it only with x-height on. + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y; + let p = Point::new(400.0, x_height + 2.0); + assert!((Targets::for_drawing(&d, &[], None, Some(all)).snap_point(p, 4.0).0.y - x_height).abs() < 1e-9); + assert_eq!(Targets::for_drawing(&d, &[], None, Some(GlyphSnap { x_height: false, ..all })).snap_point(p, 4.0).0.y, p.y); + // Rotated type: its baseline, x-height and width edges and its strokes run at an angle, all + // Angular Guides; its glyph bounds still run across and down the page. + let (d, _) = glyph_doc("H", 20f64.to_radians()); + let (on, off) = (count(&d, all), count(&d, GlyphSnap { angular: false, ..all })); + assert!(on[2] > 0, "{on:?}"); + assert_eq!(off, [on[0], on[1], 0, on[3]]); +} + +/// Snap to Glyph: the baseline of type turned slightly runs at its angle, and a point near it +/// lands on it even close to where it starts, far nearer the horizontal through that point. +#[test] +fn a_slightly_rotated_baseline_pulls_points_near_its_start() { + let (d, t) = glyph_doc("H", 2f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let p = start - dir * 10.0 + Vec2::new(-dir.y, dir.x) * 1.0; + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0); + assert!((q - start).cross(dir).abs() < 1e-9, "{q:?} is off the baseline"); +} + +/// Snap to Glyph: a segment Shift keeps level slides along to where it crosses the baseline of +/// rotated type. +#[test] +fn a_shift_constrained_point_slides_onto_a_rotated_baseline() { + let (d, t) = glyph_doc("H", 20f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let crossing = start - dir * 40.0; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let from = Leave::segment(&c, crossing - Vec2::new(60.0, 0.0), true); + let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_from(crossing + Vec2::new(1.5, 0.3), Some(&from), 4.0); + assert!(q.distance(crossing) < 1e-9, "{q:?} vs {crossing:?}"); +} diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index 804939840..f60ac96b5 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Value}; /// Modifier keys. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e55bb4069..dc2b7696b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -55,7 +55,7 @@ mod context; mod cursor; mod input; -pub use context::{PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, PaintDefaults, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PointerEvent, PointerKind, ToolKey}; @@ -219,6 +219,11 @@ pub trait Tool: Send { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// The tool, already active, is chosen again (its toolbar button, its shortcut, `tool.select`). + /// Most tools keep their state; the Blend tool finishes the blend it is adding to. + fn chosen_again(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } /// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the /// Type tool stops editing text the command took out of the selection). fn after_command(&mut self, _cx: &ToolContext) -> Vec { @@ -458,6 +463,7 @@ pub(crate) mod testutil { snap_tangent: true, snap_perpendicular: true, snap_to_glyph: false, + glyph_snap: GlyphSnap::default(), corner_widgets: true, fill_active: true, gradient_stop: None, diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 230c92ea2..0ac424267 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -3,13 +3,14 @@ //! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor //! point of a path blends from that point (`starts`). Each further click adds the object as one //! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens -//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over. +//! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over. //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill color in its own model, unless Shift); drag a mesh point, //! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); //! Alt-click a mesh point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects -//! the mesh, and the next solid fill color recolors just that point. +//! the mesh, and the next solid fill color recolors just that point. Escape during a drag +//! cancels it. use serde_json::json; use vectorcraft_color::Paint; @@ -17,7 +18,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; use crate::meshedit::{MeshEdit, grid_of}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; @@ -87,6 +88,11 @@ impl Tool for BlendTool { (t.first, t.chained) = (None, false); vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))] }; + // A first pick whose object is no longer in the document (deleted, or undone away) is + // dropped, so the click picks the object it hits as the new first pick. + if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) { + (self.first, self.chained) = (None, false); + } match ev.kind { PointerKind::DoubleClick => options(self), PointerKind::Down if ev.mods.alt => options(self), @@ -135,10 +141,24 @@ impl Tool for BlendTool { fn busy(&self) -> bool { self.first.is_some() } + /// Escape drops the object picked first and stops adding to the blend just made, so the next + /// click picks a new first object. The document and the selection stay as they are. + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + if key == crate::ToolKey::Escape { + (self.first, self.chained) = (None, false); + } + vec![] + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained) = (None, false); vec![] } + /// Choosing the Blend tool again finishes the blend: it drops the object picked first and + /// stops adding to the blend just made, so the next click picks a new first object. The + /// document and the selection stay as they are. + fn chosen_again(&mut self, cx: &ToolContext) -> Vec { + self.deactivate(cx) + } } #[derive(Default)] @@ -252,6 +272,14 @@ impl Tool for MeshTool { fn busy(&self) -> bool { self.edit.dragging() } + /// Escape during a drag of a mesh point or handle cancels it: the point goes back and the + /// release does nothing. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape { + return vec![]; + } + self.edit.cancel().unwrap_or_default() + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.edit.unfocus(); self.edit.release().unwrap_or_default() @@ -279,6 +307,72 @@ mod tests { assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]); } + /// Choosing the Blend tool again drops the object picked first and stops adding to the blend + /// just made: the next click is a new first pick. The Mesh tool keeps its state. + #[test] + fn choosing_the_blend_tool_again_finishes_the_blend() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, g) = (d.alloc_id(), d.alloc_id()); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 2, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y); + let mut t = BlendTool::default(); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(t.busy()); + assert!(t.chosen_again(&c).is_empty(), "choosing the tool again changes nothing in the document"); + assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "and drops the pick"); + assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b), "b is picked first"); + // After a blend is made, the next click no longer adds to it. + let mut s = Selection::default(); + s.set([g]); + let c = cx(&d, &s, &p); + let mut t = BlendTool { first: None, chained: true }; + assert!(t.first(&c).is_some()); + assert!(t.chosen_again(&c).is_empty()); + assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made"); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + // Other tools keep their state: the default does nothing. + assert!(create("mesh").unwrap().chosen_again(&c).is_empty()); + } + + #[test] + fn blend_tool_picks_again_when_its_first_pick_is_gone() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, e) = (d.alloc_id(), d.alloc_id()); + for (id, y) in [(b, 100.0), (e, 300.0)] { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, y, 350.0, y + 50.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + } + let (s, p) = (Selection::default(), paint()); + let mut t = BlendTool::default(); + t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + // The first pick is deleted (or undone away) while the tool waits for the second click. + d.remove(a).unwrap(); + let c = cx(&d, &s, &p); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 120.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))], "the click picks b first"); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 320.0)); + assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, e.0]}))]); + // After a blend is made, a click while the selected blend is gone picks the object it hits. + let mut s = Selection::default(); + s.set([NodeId(9999)]); + let mut t = BlendTool { first: None, chained: true }; + let acts = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 320.0, 120.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))]); + } + #[test] fn blend_tool_anchor_clicks_and_options() { let (mut d, a) = doc_with_rect(); @@ -331,6 +425,49 @@ mod tests { assert!(t.first(&c).is_none()); } + /// Escape drops the object picked first, and stops adding to the blend just made: the next + /// click is a new first pick, and the click after it blends with that one. + #[test] + fn escape_drops_the_blend_tools_first_pick() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, g) = (d.alloc_id(), d.alloc_id()); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 2, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y); + let esc = |t: &mut BlendTool| t.key(&c, crate::ToolKey::Escape, Mods::default()); + let mut t = BlendTool::default(); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(t.busy() && !t.overlays(&c).is_empty()); + assert!(t.key(&c, crate::ToolKey::Enter, Mods::default()).is_empty()); + assert!(t.busy(), "Enter keeps the pick"); + assert!(esc(&mut t).is_empty(), "Escape changes nothing in the document"); + assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "Escape drops the pick"); + // The next click picks b first instead of blending a with it. + assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b)); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, a.0]}))]); + // After a blend is made, Escape stops the next click adding to it. + let mut s = Selection::default(); + s.set([g]); + let c = cx(&d, &s, &p); + let mut t = BlendTool { first: None, chained: true }; + assert!(t.first(&c).is_some()); + assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); + assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); + } + #[test] fn mesh_tool_click_path_creates_mesh() { let (d, a) = doc_with_rect(); diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 9037968ae..39f8adcdb 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -102,6 +102,11 @@ impl MeshEdit { self.drag.take().map(|_| vec![Action::Commit]) } + /// Cancel a drag: the point or handle goes back (`None` when nothing was being dragged). + pub fn cancel(&mut self) -> Option> { + self.drag.take().map(|_| vec![Action::Cancel]) + } + pub fn dragging(&self) -> bool { self.drag.is_some() } @@ -194,6 +199,34 @@ mod tests { ); } + /// Escape during a drag of a mesh point or handle, with the Mesh tool or Direct Selection, + /// cancels it, and the release after it does nothing; the point stays the focused one. + #[test] + fn escape_cancels_a_mesh_point_drag() { + let (d, e) = doc_with_envelope(); + let mut s = Selection::default(); + s.set([e]); + let p = paint(); + let c = cx(&d, &s, &p); + let tools: [(&str, Box); 2] = + [("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))]; + for (what, mut t) in tools { + // A point, then (once the point is focused) its handle. + for (x, y) in [(200.0, 100.0), (200.0, 133.33333)] { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)), vec![Action::Begin("Move Mesh Point".into())], "{what}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y - 10.0)); + assert!(t.key(&c, crate::ToolKey::Enter, crate::Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&c, crate::ToolKey::Escape, crate::Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x + 20.0, y - 20.0)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, x + 20.0, y - 20.0)).is_empty(), "{what}: the release does nothing"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. })), "{what}: the point stays focused"); + } + assert!(t.key(&c, crate::ToolKey::Escape, crate::Mods::default()).is_empty(), "{what}: nothing to cancel"); + } + } + #[test] fn direct_selection_drags_a_selected_envelopes_points() { let (d, e) = doc_with_envelope(); diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index b194db0b3..9acefa895 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -30,7 +30,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{BezPath, Point, Vec2}; use crate::direct::hit_handle; -use crate::draw2::{AnchorTool, editable_paths}; +use crate::draw2::{AnchorTool, editable_paths, shown_paths}; use crate::guides::{DrawSnap, Leave}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -40,6 +40,8 @@ pub struct PenTool { drawing: bool, /// ...this one, once the engine made it: another path selected meanwhile isn't drawn on. path: Option, + /// The subpath of `path` being drawn or continued. + subpath: Option, /// The anchor a press placed, while the drag shapes its handles. drag: Option, hover: Option, @@ -72,48 +74,110 @@ const DRAG_THRESHOLD: f64 = 3.0; struct Place { at: Point, path: Option, + subpath: Option, closing: bool, } -/// The open path the pen is extending: the single selected open path. -fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { +/// The open path the pen is extending: the single selected open path, or the single open path of a +/// single selected compound path. +fn active_path(cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { if cx.selection.objects.len() != 1 { return None; } - let id = cx.selection.objects[0]; + let id = cx.selection.objects.first().copied()?; let n = cx.doc.node(id)?; - let NodeKind::Path { path, .. } = &n.kind else { return None }; - let sp = path.subpaths.last()?; - if sp.closed { + let path_node = match &n.kind { + NodeKind::Path { .. } => Some(n), + NodeKind::Compound { children, .. } => { + let open: Vec<&vectorcraft_doc::Node> = + children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.as_ref()).collect(); + if open.len() == 1 { open.first().copied() } else { None } + } + _ => None, + }?; + let path = path_node.path_data()?; + let open_subs: Vec<(usize, &vectorcraft_geom::SubPath)> = path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).collect(); + if open_subs.len() != 1 { return None; } + let (si, sp) = open_subs[0]; let first = sp.anchors.first()?.p; let last = sp.anchors.last()?; - Some((id, first, last.p, last.h_out)) + Some((path_node.id, si, first, last.p, last.h_out)) } -/// An end of an open path (one subpath, not `except`) under `p`, within `tol`: the path the Pen -/// continues or joins to, and whether it's the path's first anchor. Only paths that can be edited, -/// not guides. -fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option) -> Option<(NodeId, bool)> { - let id = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?.leaf; - if Some(id) == except || !cx.doc.is_editable(id) { +/// Checks if an editable endpoint on path `id` lies within `tol` of point `p`, returning the endpoint info and distance. +fn endpoint_on(cx: &ToolContext, id: NodeId, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<((NodeId, usize, bool), f64)> { + if !cx.doc.is_editable(id) || cx.doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Path { guide: true, .. })) { return None; } - let NodeKind::Path { path, guide: false, .. } = &cx.doc.node(id)?.kind else { return None }; - let [sp] = path.subpaths.as_slice() else { return None }; - if sp.closed { - return None; + let pd = cx.doc.node(id).and_then(|n| n.path_data())?; + let mut best: Option<((NodeId, usize, bool), f64)> = None; + for (si, sp) in pd.subpaths.iter().enumerate() { + if Some((id, si)) == except || sp.closed || sp.anchors.is_empty() { + continue; + } + let first = sp.anchors.first().map(|a| a.p); + let Some(last) = sp.anchors.last().map(|a| a.p) else { continue }; + if let Some(end) = pick_endpoint(first, last, p, tol, Endpoint::Last) { + let pt = match end { + Endpoint::First => first.unwrap_or(last), + Endpoint::Last => last, + }; + let dist = p.distance(pt); + if best.as_ref().is_none_or(|(_, d)| dist < *d) { + best = Some(((id, si, end == Endpoint::First), dist)); + } + } } - let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p); - let end = pick_endpoint(Some(first), last, p, tol, Endpoint::Last)?; - Some((id, end == Endpoint::First)) + best } -/// The last anchor of `id`'s last subpath: (subpath, anchor). -fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { +/// An end of an open path (any editable path, including inside groups and compound paths, not `except`) +/// under `p`, within `tol`: the path the Pen continues or joins to, and whether it's the path's first anchor. +fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<(NodeId, usize, bool)> { + let mut best: Option<((NodeId, usize, bool), f64)> = None; + // 1. Shown paths (anchors visible because object, group, or compound path is selected). + for id in shown_paths(cx) { + if let Some(cand) = endpoint_on(cx, id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + if best.is_some() { + return best.map(|(res, _)| res); + } + // 2. Hit-tested object (clicked directly on an unselected path, group, or compound path). + if let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { + let leaf = hit.leaf; + if cx.doc.is_editable(leaf) { + if let Some(cand) = endpoint_on(cx, leaf, p, tol, except) { + return Some(cand.0); + } + if let Some(n) = cx.doc.node(leaf) + && let NodeKind::Compound { children, .. } = &n.kind + { + for c in children { + if let Some(cand) = endpoint_on(cx, c.id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + } + } + } + best.map(|(res, _)| res) +} + +/// The last anchor of `id`'s subpath: (subpath, anchor). +fn last_anchor(cx: &ToolContext, id: NodeId, subpath: Option) -> Option<(usize, usize)> { let pd = cx.doc.node(id)?.path_data()?; - let si = pd.subpaths.len().checked_sub(1)?; + let si = match subpath { + Some(si) if si < pd.subpaths.len() => si, + _ => pd.subpaths.len().checked_sub(1)?, + }; let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?; Some((si, ai)) } @@ -181,8 +245,12 @@ impl Tool for PenTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. - if self.drawing && self.path.is_none() { - self.path = active_path(cx).map(|a| a.0); + if self.drawing + && self.path.is_none() + && let Some(a) = active_path(cx) + { + self.path = Some(a.0); + self.subpath = Some(a.1); } let active = self.active(cx); if self.drawing && active.is_none() && ev.kind == PointerKind::Down { @@ -190,10 +258,12 @@ impl Tool for PenTool { } // The path being drawn moves its bounds as it grows: only its anchors and segments pull. let drawn = active.map(|a| a.0); - let from = active.map(|(_, _, last, handle)| { + + let from = active.map(|(_, _, _, last, handle)| { let leave = Leave::segment(cx, last, ev.mods.shift); if handle == last { leave } else { leave.without_contact() } }); + let p = match ev.kind { PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()), PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()), @@ -208,15 +278,16 @@ impl Tool for PenTool { self.last = ev.pos; self.pressed = ev.pos; self.dragged = false; - if let Some((id, first, last, _)) = active { - let end = last_anchor(cx, id); + if let Some((id, si, first, last, _)) = active { + let end = last_anchor(cx, id, Some(si)); let closing_end = end.filter(|(_, ai)| *ai > 0).map(|_| first); let picked = pick_endpoint(closing_end, last, ev.pos, tol, Endpoint::First); // Pick at the raw pointer before snapping or constraining a new point. // A one-anchor path doesn't close on itself: its anchor is the last one too. if picked == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } // Alt uses Anchor Point behavior at the terminal anchor too. if let Some(acts) = self.alt_convert(cx, ev) { @@ -228,12 +299,13 @@ impl Tool for PenTool { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } - let raw_other = open_end_at(cx, ev.pos, tol, Some(id)); + let raw_other = open_end_at(cx, ev.pos, tol, Some((id, si))); if raw_other.is_none() && p != ev.pos { let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First); if snapped == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } if snapped == Some(Endpoint::Last) && let Some((si, ai)) = end @@ -243,14 +315,17 @@ impl Tool for PenTool { } } // An end of another open path: the two become one, and the path is finished. - if let Some((other, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some(id))) { + if let Some((other, other_si, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some((id, si)))) { self.stop(); let end = if at_first { "first" } else { "last" }; - return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))]; + let mut params = json!({"ids": [id.0, other.0], "ends": ["last", end]}); + params["subpaths"] = json!([si, other_si]); + return vec![Action::Exec("path.join".into(), params)]; } - self.drag = Some(Place { at: p, path: Some(id), closing: false }); + self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false }); self.in_h = p; - return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + let params = json!({"id": id.0, "x": p.x, "y": p.y, "subpath": si}); + return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), params)]; } if let Some(acts) = self.alt_convert(cx, ev) { return acts; @@ -261,22 +336,36 @@ impl Tool for PenTool { // A raw endpoint anywhere wins before either snapped continuation route. for at in std::iter::once(ev.pos).chain((p != ev.pos).then_some(p)) { // Continue a selected open path when clicking on one of its ends. - if let Some((id, first, last, _)) = active_path(cx) + if let Some((id, si, first, last, _)) = active_path(cx) && let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last) { self.drawing = true; self.path = Some(id); + self.subpath = Some(si); if picked == Endpoint::First { - return vec![Action::Exec("path.reverse".into(), json!({}))]; + let p = + if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; + return vec![Action::Exec("path.reverse".into(), p)]; } return vec![]; } // Continue any other open path from the end clicked, selecting it. - if let Some((id, at_first)) = open_end_at(cx, at, tol, None) { - (self.drawing, self.path) = (true, Some(id)); - let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + if let Some((id, si, at_first)) = open_end_at(cx, at, tol, None) { + (self.drawing, self.path, self.subpath) = (true, Some(id), Some(si)); + let top = cx + .doc + .parent_of(id) + .filter(|p| matches!(cx.doc.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .unwrap_or(id); + let mut out = vec![]; + if cx.selection.objects.as_slice() != [top] { + out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); + } if at_first { - out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + // Name the path: the `select.set` above may have just put its compound + // parent in the selection, and a reverse addressed through the + // selection would flip this subpath of every member of it. + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))); } return out; } @@ -284,8 +373,8 @@ impl Tool for PenTool { if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { return vec![act]; } - (self.drawing, self.path) = (true, None); - self.drag = Some(Place { at: p, path: None, closing: false }); + (self.drawing, self.path, self.subpath) = (true, None, None); + self.drag = Some(Place { at: p, path: None, subpath: None, closing: false }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { @@ -309,13 +398,13 @@ impl Tool for PenTool { } return vec![set_out_handle(id, si, ai, h)]; } - let Some(Place { at: mut a, path, closing }) = self.drag else { return vec![] }; + let Some(Place { at: mut a, path, subpath, closing }) = self.drag else { return vec![] }; // Space held moves the anchor being placed, handles and all. if ev.mods.space && !closing { let d = ev.pos - self.last; a += d; self.in_h += d; - self.drag = Some(Place { at: a, path, closing }); + self.drag = Some(Place { at: a, path, subpath, closing }); } self.last = ev.pos; let mut out_h = ev.pos; @@ -332,19 +421,21 @@ impl Tool for PenTool { )]; }; if closing { - return vec![Action::Preview( - "path.close".into(), - json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}), - )]; + let mut params = json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + return vec![Action::Preview("path.close".into(), params)]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. let in_h = if alt { self.in_h } else { a - (out_h - a) }; self.in_h = in_h; - vec![Action::Preview( - "path.appendAnchor".into(), - json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}), - )] + let mut params = json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + vec![Action::Preview("path.appendAnchor".into(), params)] } PointerKind::Up => { self.snap.clear(); @@ -369,7 +460,9 @@ impl Tool for PenTool { _ => vec![], } } + /// Deactivate the pen tool, committing in-flight operations and restoring compound path selection. fn deactivate(&mut self, cx: &ToolContext) -> Vec { + let path = self.path; self.stop(); self.snap.clear(); let placing = self.drag.take().is_some(); @@ -379,6 +472,13 @@ impl Tool for PenTool { if placing || handling { acts.push(Action::Commit); } + if let Some(id) = path + && cx.selection.objects.as_slice() == [id] + && let Some(parent) = cx.doc.parent_of(id) + && cx.doc.node(parent).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) + { + acts.push(Action::Exec("select.set".into(), json!({"ids": [parent.0]}))); + } acts } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -389,7 +489,7 @@ impl Tool for PenTool { if cx.pen_rubber_band && self.drag.is_none() && self.handle.is_none() - && let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) + && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { let mut bp = BezPath::new(); bp.move_to(last); @@ -413,14 +513,14 @@ impl Tool for PenTool { } let tol = cx.tol(5.0).max(cx.point_tol()); let active = self.active(cx); - if let Some((id, first, last, _)) = active { - let closing_end = last_anchor(cx, id).filter(|(_, ai)| *ai > 0).map(|_| first); + if let Some((id, si, first, last, _)) = active { + let closing_end = last_anchor(cx, id, Some(si)).filter(|(_, ai)| *ai > 0).map(|_| first); match pick_endpoint(closing_end, last, p, tol, Endpoint::First) { Some(Endpoint::First) => return Cursor::PenClose, Some(Endpoint::Last) => return Cursor::PenConvert, None => {} } - if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some(id)).is_some() { + if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some((id, si))).is_some() { return Cursor::PenJoin; } } @@ -435,9 +535,7 @@ impl Tool for PenTool { Some([_]) => return Cursor::PenAdd, _ => {} } - if active_path(cx).is_some_and(|(_, first, last, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some()) - || open_end_at(cx, p, tol, None).is_some() - { + if open_end_at(cx, p, tol, None).is_some() { return Cursor::PenContinue; } match auto_add_delete(cx, p, m, tol) { @@ -454,13 +552,31 @@ impl Tool for PenTool { impl PenTool { /// The open path being drawn: the one the pen made or went on with, while it is the single /// selected open path. - fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { - active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id)) + fn active(&self, cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { + if self.drawing + && let Some(pid) = self.path + && (cx.selection.objects.as_slice() == [pid] + || cx.doc.parent_of(pid).is_some_and(|parent| { + cx.selection.objects.as_slice() == [parent] && cx.doc.node(parent).is_some_and(|pn| matches!(pn.kind, NodeKind::Compound { .. })) + })) + && cx.doc.is_editable(pid) + && let Some(n) = cx.doc.node(pid) + && let Some(pd) = n.path_data() + { + let (si, sp) = match self.subpath { + Some(si) => pd.subpaths.get(si).filter(|s| !s.closed).map(|s| (si, s))?, + None => pd.subpaths.iter().enumerate().rev().find(|(_, s)| !s.closed)?, + }; + let first = sp.anchors.first()?.p; + let last = sp.anchors.last()?; + return Some((pid, si, first, last.p, last.h_out)); + } + active_path(cx).filter(|(id, si, ..)| self.drawing && self.path.is_none_or(|p| p == *id) && self.subpath.is_none_or(|s| s == *si)) } /// The path is finished. fn stop(&mut self) { - (self.drawing, self.path) = (false, None); + (self.drawing, self.path, self.subpath) = (false, None, None); } /// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool. diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 408dde110..340a2ea6e 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -37,7 +37,7 @@ fn snapped_last_endpoint_converts_active_path_after_raw_miss() { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(237.0, 403.0); @@ -45,8 +45,8 @@ fn snapped_last_endpoint_converts_active_path_after_raw_miss() { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, last, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), @@ -56,7 +56,7 @@ fn snapped_last_endpoint_converts_active_path_after_raw_miss() { assert!(pen.drag.is_none() && pen.busy()); assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); assert!(pen.handle.is_none() && !pen.busy()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); } #[test] @@ -82,17 +82,17 @@ fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -120,7 +120,7 @@ fn snapped_first_endpoint_closes_active_path_after_raw_miss() { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(63.0, 303.0); @@ -128,12 +128,12 @@ fn snapped_first_endpoint_closes_active_path_after_raw_miss() { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, first, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let place = pen.drag.unwrap(); assert!(place.closing); @@ -165,17 +165,17 @@ fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -280,7 +280,10 @@ fn space_moves_the_anchor_being_placed() { let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt })); assert_eq!( acts, - vec![Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}))] + vec![Action::Preview( + "path.appendAnchor".into(), + json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0], "subpath": 0}) + )] ); } @@ -348,7 +351,7 @@ fn terminal_anchor_conversion_precedes_grid_snapping() { assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); } @@ -398,7 +401,7 @@ fn overlapping_endpoint_tolerances_pick_the_nearest_end() { let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; let actual_cursor = pen.cursor(&cx, at, mods); let expected = if first_end { - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] } else { vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] }; @@ -435,7 +438,7 @@ fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() { let at = Point::new(first.x + offset / zoom, first.y); let mut pen = PenTool::default(); assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; + let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({"subpath": 0}))] } else { vec![] }; let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); if actual != expected { failures.push((zoom, grid, offset, actual)); @@ -458,7 +461,7 @@ fn coincident_endpoints_keep_the_prior_tie_choice() { assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut drawing, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut resuming = PenTool::default(); assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); @@ -515,7 +518,7 @@ fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() { assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut closing, &cx, first.x, first.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; @@ -582,12 +585,13 @@ fn a_drag_from_the_first_anchor_always_closes() { let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]); + assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))]); // What the tool sees once the preview applied: the path closed. d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; let c = cx(&d, &s, &p); - let close = - |x: f64, y: f64, alt: bool| vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))]; + let close = |x: f64, y: f64, alt: bool| { + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt, "subpath": 0}))] + }; for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); } @@ -695,10 +699,10 @@ fn anchors_snap_to_smart_guides() { let to = Point::new(150.0, 318.0); assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); let a = click(&mut t, &cx, 152.0, 318.0, Mods::default()); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); let shift = Mods { shift: true, ..Mods::default() }; let a = click(&mut t, &cx, 152.0, 310.0, shift); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0, "subpath": 0}))); // The first anchor of a new path lands on the rect's corner. let mut t = PenTool::default(); let a = click(&mut t, &cx, 102.0, 99.0, Mods::default()); @@ -822,3 +826,41 @@ fn the_new_path_asterisk_shows_only_before_a_path_is_started() { assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished"); assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair); } + +/// Continuing an open path inside a compound path at its first anchor names the path it +/// reverses. The same press selects the compound, and `path.reverse` without `ids` would then +/// walk the compound and flip that subpath of every member. +#[test] +fn continuing_a_compound_member_at_its_first_anchor_reverses_that_path_by_id() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (a, b, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let two_subpaths = |x: f64| { + vectorcraft_geom::PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 300.0), Point::new(x + 100.0, 300.0)], false), + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 350.0), Point::new(x + 100.0, 350.0)], false), + ]) + }; + let path = |id, x| std::sync::Arc::new(vectorcraft_doc::Node::path(id, two_subpaths(x), vectorcraft_doc::Appearance::default_art())); + let mut c = vectorcraft_doc::Node::new( + compound, + NodeKind::Compound { children: vec![path(a, 10.0), path(b, 300.0)], rule: vectorcraft_geom::FillRule::NonZero }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 1, c).unwrap(); + let p = paint(); + // The member path alone is the selection, as a direct selection inside the compound leaves it. + let mut s = Selection::default(); + s.set([a]); + let cx = ToolContext { smart_guides: false, snap_to_point: false, snap_to_grid: false, snap_to_pixel: false, ..cx(&d, &s, &p) }; + let mut pen = PenTool::default(); + assert_eq!( + click(&mut pen, &cx, 10.0, 300.0, Mods::default()), + vec![ + Action::Exec("select.set".into(), json!({"ids": [compound.0]})), + Action::Exec("path.reverse".into(), json!({"ids": [a.0], "subpath": 0})), + ], + "the reverse names the member path, not the compound the select.set just selected" + ); + assert_eq!((pen.path, pen.subpath), (Some(a), Some(0))); +} diff --git a/crates/tools/src/pie.rs b/crates/tools/src/pie.rs index 57ef47734..af79c1ce4 100644 --- a/crates/tools/src/pie.rs +++ b/crates/tools/src/pie.rs @@ -287,6 +287,24 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(312.0, 150.0))).is_empty()); } + /// Escape during a pie widget drag with the Selection tool cancels it: the angle readout is removed, + /// and the release after it commits nothing. + #[test] + fn escape_cancels_a_pie_widget_drag() { + let (d, _, s) = doc_with_ellipse((0.0, 360.0)); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0))).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(200.0, 20.0))); + assert_eq!(a[0], Action::Begin("Live Shape".into())); + assert_eq!(t.key(&c, crate::ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "the readout is removed"); + assert!(t.pointer(&c, &ev(PointerKind::Drag, Point::new(100.0, 200.0))).is_empty(), "the drag is over"); + assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(100.0, 200.0))).is_empty(), "the release commits nothing"); + } + /// On a whole ellipse the two widgets sit at the very same point, whatever its size, place or /// angle, so a press there always takes the End widget. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1ea9723b9..8d839602a 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -17,6 +17,8 @@ //! scaling its type: the text reflows at its size. //! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and //! E pressed again makes it take the objects it touches. +//! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or +//! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port //! loads the pointer with the text ([`Cursor::LoadedText`]): the next click threads it into the //! closed path, area type or type on a path under the pointer, or into a new frame of the same @@ -35,7 +37,7 @@ use std::cell::RefCell; use std::collections::HashMap; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at}; +use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at, selected_hidden_at}; use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -118,6 +120,9 @@ pub struct SelectionTool { /// What a bounding-box handle being dragged snaps to. targets: Option, moving: Option, + /// The params of the move's last preview: in Shaper's Construction mode the move ends with + /// them and `releaseOutside` ([`construction`]). + last_move: Option, /// The move snapped to a point or a guide at its last step: the arrow turns hollow. snapped: bool, guide: GuideEdit, @@ -195,6 +200,12 @@ impl MoveSnap { } } + /// Snap to Grid and Snap to Pixel alone, for a move whose box is `bounds`: the grid lands an + /// edge of the box on a gridline, and pixels put its top-left on whole pixels. + pub(crate) fn grid_only(bounds: Option) -> Self { + Self { bounds, targets: None, points: None, copy_targets: None, copy_points: None, rulers: None } + } + /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its /// guides. pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { @@ -460,6 +471,33 @@ pub(crate) fn hold_point(cx: &ToolContext, h: &Hit, p: Point) -> Point { cx.doc.node(h.leaf).and_then(|n| n.path_data()).and_then(|pd| hold_on_path(pd, p, reach)).unwrap_or(p) } +/// While an opacity mask or a pattern is edited, a Shaper double-click selects a Shaper Group's +/// original shape without isolating the group's original shapes. A press at `p` on a selected +/// original shape holds it for a move: the hit inside its Shaper Group's original shapes, and that +/// container. Each selected original shape's Shaper Group is checked, front to back, and an object +/// in front of that group at `p` keeps the press. None otherwise. +fn selected_source_hit(cx: &ToolContext, p: Point) -> Option<(Hit, NodeId)> { + if cx.doc.mask_edit.is_none() && cx.doc.pattern_edit.is_none() { + return None; + } + let is_sources = |g: &NodeId| cx.doc.node(*g).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)); + let mut containers = cx.doc.paint_order(cx.selection.objects.iter().filter_map(|id| cx.doc.parent_of(*id)).filter(is_sources)); + containers.dedup(); + let front = hit_test(cx.doc, p, cx.selection_hit_options()); + containers.into_iter().rev().find_map(|sources| { + let group = cx.doc.parent_of(sources)?; + if let Some(f) = &front + && f.leaf != group + && !f.ancestry.contains(&group) + && cx.doc.paint_order([group, f.leaf]).last() == Some(&f.leaf) + { + return None; + } + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { scope: Some(sources), ..cx.selection_hit_options() })?; + cx.selection.contains(h.top_object(Some(sources))).then_some((h, sources)) + }) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -559,7 +597,11 @@ impl Tool for SelectionTool { } /// E during a marquee drag switches the marquee between the objects it touches and only the - /// objects inside it. + /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or + /// rotation, a marquee, a corner widget, a pie widget, the brackets of type on a path, a mirror + /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy + /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape + /// drops the text. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Letter('e') && let State::Marquee { enclosed, .. } = &mut self.state @@ -583,6 +625,13 @@ impl Tool for SelectionTool { self.state = State::Idle; return vec![]; } + if key == ToolKey::Escape && !matches!(self.state, State::Idle) { + self.state = State::Idle; + self.measure = None; + self.guides.clear(); + (self.targets, self.moving, self.snapped) = (None, None, false); + return vec![Action::Cancel]; + } if key == ToolKey::Escape { self.guide.cancel() } else { vec![] } } @@ -628,51 +677,8 @@ impl Tool for SelectionTool { } // Mirror and grid repeat controls take precedence over art and ruler guides; the mirror // axis line and the grid's controls yield to the bounding-box handles they overlap. - if let State::Mirror(mut d) = self.state.clone() { - match ev.kind { - PointerKind::Drag | PointerKind::Up => { - let mut out = d.drag(cx, ev.pos, ev.mods); - if ev.kind == PointerKind::Up { - out.extend(d.finish()); - self.state = State::Idle; - } else { - self.state = State::Mirror(d); - } - return out; - } - _ => return vec![], - } - } - if let State::GridRepeat(mut d) = self.state.clone() { - match ev.kind { - PointerKind::Drag | PointerKind::Up => { - let mut out = d.drag(ev.pos); - if ev.kind == PointerKind::Up { - out.extend(d.finish()); - self.state = State::Idle; - } else { - self.state = State::GridRepeat(d); - } - return out; - } - _ => return vec![], - } - } - if matches!(self.state, State::Idle) - && ev.kind == PointerKind::Down - && let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods) - { - self.state = State::Mirror(d); - self.hover = None; - return vec![]; - } - if matches!(self.state, State::Idle) - && ev.kind == PointerKind::Down - && let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods) - { - self.state = State::GridRepeat(d); - self.hover = None; - return vec![]; + if let Some(out) = self.repeat_controls(cx, ev) { + return out; } if let Some(out) = self.guide.pointer(cx, ev) { return out; @@ -687,99 +693,10 @@ impl Tool for SelectionTool { self.hover = Self::hover_point(cx, p); } match (ev.kind, self.state.clone()) { - (PointerKind::DoubleClick, _) => { - self.state = State::Idle; - // A threaded port: the thread breaks there. Any other port takes the double-click - // too, so it neither edits the type nor isolates. - if let Some((id, port)) = crate::text::port_at(cx, p) { - let (input, output) = crate::text::port_states(cx.doc, id, false); - let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") }; - if state == crate::text::PortState::Threaded { - return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))]; - } - return vec![]; - } - if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p)) - { - return vec![a]; - } - // The Shaper Group's mode widget answers each click of a double-click on it. - if shaperwidget::over_widget(cx, p) { - return vec![]; - } - if let Some(a) = release_guide_at(cx, p, m) { - return vec![a]; - } - if crate::rulerguide::guide_at(cx, p).is_some() { - return vec![]; - } - // A double-click edits what is drawn: not a clipping path's outline. - if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { - let top = h.top_object(cx.isolation); - if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { - return vec![Action::SwitchTool("type".into())]; - } - // An object that [`isolates`], not the one already isolated. - if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { - return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; - } - } else if cx.isolation.is_some() { - return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; - } - vec![] - } + (PointerKind::DoubleClick, _) => self.double_click(cx, p, m), (PointerKind::Down, _) => { - // 0. A port of selected area type or type on a path: the pointer takes its text, - // to thread on from the out port or back from the in port. - if let Some((from, port)) = crate::text::port_at(cx, p) { - self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; - return vec![]; - } - // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the - // bounding-box handles of the current selection. - if let Some(c) = CornerDrag::hit(cx, ev, false) { - self.state = State::Corner(c); - return vec![]; - } - if let Some(d) = PieDrag::hit(cx, ev) { - self.state = State::Pie(d); - return vec![]; - } - if let Some(b) = BracketDrag::hit(cx, ev) { - self.state = State::Bracket(b); - return vec![]; - } - if let Some(a) = shaperwidget::click(cx, p) { - self.state = State::Idle; - return vec![a]; - } - // The type widget answers a double-click only: its clicks neither drag nor deselect. - if typewidget::over_widget(cx, p) { - self.state = State::Idle; - return vec![]; - } - if cx.show_bbox - && let Some(bx) = selection_box(cx) - { - match box_hit(cx, &bx, p) { - Some(BoxHit::Handle(handle)) => { - let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type); - let areas = cx.selection.objects.iter().any(is_area); - // Only area type: the step resizes type areas; anything else scales. - let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; - // What the handle moves snaps as a dragged point does: to pixels or the - // grid, else with Smart Guides to the other objects, else with Snap to - // Point to their anchors; the ruler guides with Snap to Point. - self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); - self.state = State::Scaling { handle, bx, areas }; - return vec![Action::Begin(label.into())]; - } - Some(BoxHit::Rotate) => { - self.state = State::Rotating { center: bx.center(), start: p }; - return vec![Action::Begin("Rotate".into())]; - } - None => {} - } + if let Some(out) = self.press_widgets(cx, ev, p) { + return out; } // 2. Ruler guides (over the art, as they are drawn). if let Some(out) = self.guide.press(cx, ev) { @@ -794,9 +711,13 @@ impl Tool for SelectionTool { self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } - match hit_test(cx.doc, p, cx.selection_hit_options()) { + let (hit, scope) = match selected_source_hit(cx, p) { + Some((h, sources)) => (Some(h), Some(sources)), + None => (hit_test(cx.doc, p, cx.selection_hit_options()), cx.isolation), + }; + match hit { Some(h) => { - let top = h.top_object(cx.isolation); + let top = h.top_object(scope); let mut out = vec![]; let (mut deselect, mut key) = (None, None); if m.shift { @@ -819,32 +740,12 @@ impl Tool for SelectionTool { out } None => { - self.state = State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false }; + self.press_nothing(cx, p, grab, m); vec![] } } } - (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => { - let mut out = vec![]; - if !began { - if p.distance(press) < Self::drag_threshold(cx) { - return out; - } - out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); - self.moving = Some(MoveSnap::new(cx)); - } - let mut d = move_delta(start, p, m.shift, cx.constrain_angle); - let unsnapped = d; - if let Some(snap) = &self.moving { - (d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) }; - } - // Shift has already constrained `d`: any change is a snap. - self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; - self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); - out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); - out - } + (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => self.drag_moving(cx, p, m, (start, press, hold, began)), (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { // Scale in the box's own frame: along the objects' axes when it is rotated. let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); @@ -911,8 +812,17 @@ impl Tool for SelectionTool { self.measure = None; self.guides.clear(); self.moving = None; + let last = self.last_move.take(); match (began, deselect, key) { - (true, ..) => vec![Action::Commit], + // In Construction mode, an original shape dropped wholly outside the group's + // bounds leaves the group, in the move's undo step (`releaseOutside`). + (true, ..) => match last.filter(|p| construction(cx) && p["copy"] != json!(true)) { + Some(mut params) => { + params["releaseOutside"] = json!(true); + vec![Action::Preview("object.transform".into(), params), Action::Commit] + } + None => vec![Action::Commit], + }, (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], // A click on the key object again: no key. (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], @@ -1035,6 +945,9 @@ impl Tool for SelectionTool { if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { return Cursor::ArrowHollow; } + if selected_source_hit(cx, p).is_some() { + return Cursor::Move; + } if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { if cx.selection.contains(h.top_object(cx.isolation)) || m.alt { return Cursor::Move; @@ -1064,6 +977,11 @@ fn can_be_key(cx: &ToolContext, id: NodeId) -> bool { cx.doc.node(id).is_some_and(|n| !n.is_layer()) && !inside_selected && !member } +/// Shaper's Construction mode: a Shaper Group's original shapes are isolated. +fn construction(cx: &ToolContext) -> bool { + cx.isolation.and_then(|id| cx.doc.node(id)).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) +} + /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { @@ -1122,3 +1040,189 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { #[cfg(test)] mod tests; + +impl SelectionTool { + fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + if let State::Mirror(mut d) = self.state.clone() { + match ev.kind { + PointerKind::Drag | PointerKind::Up => { + let mut out = d.drag(cx, ev.pos, ev.mods); + if ev.kind == PointerKind::Up { + out.extend(d.finish()); + self.state = State::Idle; + } else { + self.state = State::Mirror(d); + } + return Some(out); + } + _ => return Some(vec![]), + } + } + if let State::GridRepeat(mut d) = self.state.clone() { + match ev.kind { + PointerKind::Drag | PointerKind::Up => { + let mut out = d.drag(ev.pos); + if ev.kind == PointerKind::Up { + out.extend(d.finish()); + self.state = State::Idle; + } else { + self.state = State::GridRepeat(d); + } + return Some(out); + } + _ => return Some(vec![]), + } + } + if matches!(self.state, State::Idle) + && ev.kind == PointerKind::Down + && let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods) + { + self.state = State::Mirror(d); + self.hover = None; + return Some(vec![]); + } + if matches!(self.state, State::Idle) + && ev.kind == PointerKind::Down + && let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods) + { + self.state = State::GridRepeat(d); + self.hover = None; + return Some(vec![]); + } + None + } + + fn double_click(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Vec { + self.state = State::Idle; + // A threaded port: the thread breaks there. Any other port takes the double-click + // too, so it neither edits the type nor isolates. + if let Some((id, port)) = crate::text::port_at(cx, p) { + let (input, output) = crate::text::port_states(cx.doc, id, false); + let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") }; + if state == crate::text::PortState::Threaded { + return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))]; + } + return vec![]; + } + if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p)) { + return vec![a]; + } + // The Shaper Group's mode widget answers each click of a double-click on it. + if shaperwidget::over_widget(cx, p) { + return vec![]; + } + if let Some(a) = release_guide_at(cx, p, m) { + return vec![a]; + } + if crate::rulerguide::guide_at(cx, p).is_some() { + return vec![]; + } + // A double-click edits what is drawn: not a clipping path's outline. + if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + let top = h.top_object(cx.isolation); + if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { + return vec![Action::SwitchTool("type".into())]; + } + // An object that [`isolates`], not the one already isolated. + if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } + } else if cx.isolation.is_some() { + return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; + } + vec![] + } + + fn press_widgets(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option> { + // 0. A port of selected area type or type on a path: the pointer takes its text, + // to thread on from the out port or back from the in port. + if let Some((from, port)) = crate::text::port_at(cx, p) { + self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; + return Some(vec![]); + } + // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the + // bounding-box handles of the current selection. + if let Some(c) = CornerDrag::hit(cx, ev, false) { + self.state = State::Corner(c); + return Some(vec![]); + } + if let Some(d) = PieDrag::hit(cx, ev) { + self.state = State::Pie(d); + return Some(vec![]); + } + if let Some(b) = BracketDrag::hit(cx, ev) { + self.state = State::Bracket(b); + return Some(vec![]); + } + if let Some(a) = shaperwidget::click(cx, p) { + self.state = State::Idle; + return Some(vec![a]); + } + // The type widget answers a double-click only: its clicks neither drag nor deselect. + if typewidget::over_widget(cx, p) { + self.state = State::Idle; + return Some(vec![]); + } + if cx.show_bbox + && let Some(bx) = selection_box(cx) + { + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(handle)) => { + let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type); + let areas = cx.selection.objects.iter().any(is_area); + // Only area type: the step resizes type areas; anything else scales. + let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; + // What the handle moves snaps as a dragged point does: to pixels or the + // grid, else with Smart Guides to the other objects, else with Snap to + // Point to their anchors; the ruler guides with Snap to Point. + self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); + self.state = State::Scaling { handle, bx, areas }; + return Some(vec![Action::Begin(label.into())]); + } + Some(BoxHit::Rotate) => { + self.state = State::Rotating { center: bx.center(), start: p }; + return Some(vec![Action::Begin("Rotate".into())]); + } + None => {} + } + } + None + } +} + +impl SelectionTool { + /// A press at `p` that hits no object starts a marquee, except where a clipping mask hides + /// the art of a selected object (picked in the Layers panel, say): there, without Shift, it + /// grabs the selection by that art (at `grab`), as a press on its visible part does. + fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) { + self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { + State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None } + } else { + State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false } + }; + } + + fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(press) < Self::drag_threshold(cx) { + return out; + } + out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); + self.moving = Some(MoveSnap::new(cx)); + } + let mut d = move_delta(start, p, m.shift, cx.constrain_angle); + let unsnapped = d; + if let Some(snap) = &self.moving { + (d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) }; + } + // Shift has already constrained `d`: any change is a snap. + self.snapped = (d - unsnapped).hypot() > 1e-9; + self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; + self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); + let params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); + self.last_move = Some(params.clone()); + out.push(Action::Preview("object.transform".into(), params)); + out + } +} diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index f777d59cc..6aace4acd 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2,6 +2,64 @@ use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; +/// While an opacity mask is edited, a press on a selected Shaper original shape moves it, over the +/// art the group shows and over a part of the shape the group does not show. A press on an original +/// shape that is not selected selects the Shaper Group or starts a marquee, as before. +#[test] +fn a_press_on_a_selected_shaper_source_moves_it_while_a_mask_is_edited() { + let (mut d, g, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + let ids: Vec = d.node(sources).unwrap().children().unwrap().iter().map(|n| n.id).collect(); + let (mut s, p) = (Selection::default(), paint()); + s.set([ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + for x in [75.0, 125.0] { + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(x, 350.0), Mods::default()), Cursor::Move, "at {x}"); + let down = t.pointer(&c, &ev(PointerKind::Down, x, 350.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "at {x}: {down:?}"); + let drag = t.pointer(&c, &ev(PointerKind::Drag, x + 20.0, 350.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "at {x}: {drag:?}"); + } + s.set([ids[0]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(down.is_empty(), "{down:?}"); + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 75.0, 350.0)); + assert!(down.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "{down:?}"); +} + +/// While an opacity mask is edited, a selected Shaper original shape that is not the first selected +/// object still moves on a press, and an object in front of the Shaper Group keeps the press. +#[test] +fn a_selected_shaper_source_moves_after_other_objects_and_behind_front_art() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + let ids: Vec = d.node(sources).unwrap().children().unwrap().iter().map(|n| n.id).collect(); + let (mut s, p) = (Selection::default(), paint()); + s.set([rect, ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(125.0, 350.0), Mods::default()), Cursor::Move); + let down = t.pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{down:?}"); + let drag = t.pointer(&c, &ev(PointerKind::Drag, 145.0, 350.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "{drag:?}"); + let front = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(110.0, 340.0, 140.0, 360.0)); + d.insert(Some(layer), 2, Node::path(front, square, Appearance::default_art())).unwrap(); + s.set([ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + assert_ne!(SelectionTool::default().cursor(&c, Point::new(125.0, 350.0), Mods::default()), Cursor::Move); + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(down.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([front.0]))), "{down:?}"); +} + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::new(kind, x, y) } @@ -21,6 +79,53 @@ fn a_press_on_a_masks_edge_selects_its_clip_group() { assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); } +/// The art of a selected clipped object (picked in the Layers panel), pressed where its mask +/// hides it, is grabbed: a drag moves the selection (the art, not its clip group) and a click +/// keeps it selected. With nothing of it selected, or with Shift, a press there starts a marquee. +#[test] +fn a_press_where_a_mask_hides_a_selected_object_drags_it() { + let (d, _, _, art) = doc_with_masked_square(); + let p = paint(); + let mut picked = Selection::default(); + picked.add(art); + let shift = |kind, x, y| { + let mut e = ev(kind, x, y); + e.mods.shift = true; + e + }; + let changes_selection = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select."))); + // (370, 450): on the art, outside the mask (400..500). + let mut t = SelectionTool::default(); + let c = cx(&d, &picked, &p); + assert!(t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0)); + assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?}"); + assert!(a.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 470.0)), vec![Action::Commit]); + // A click there keeps the selection. + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0)); + assert!(!changes_selection(&t.pointer(&c, &ev(PointerKind::Up, 370.0, 450.0)))); + // Nothing selected: a marquee, and a click deselects. + let none = Selection::default(); + let c = cx(&d, &none, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0)); + assert!(!t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_)))); + // Shift: a marquee that adds to the selection. + let c = cx(&d, &picked, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &shift(PointerKind::Down, 370.0, 450.0)); + assert!(!t.pointer(&c, &shift(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_)))); + // An object a click hits there wins: a press on a square over that point selects it. + let mut d = d; + let (front, l) = (d.alloc_id(), d.layers[0].id); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 430.0, 390.0, 470.0)); + d.insert(Some(l), 2, vectorcraft_doc::Node::path(front, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let a = SelectionTool::default().pointer(&cx(&d, &picked, &p), &ev(PointerKind::Down, 370.0, 450.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([front.0]))), "{a:?}"); +} + /// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands /// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it. #[test] @@ -102,6 +207,44 @@ fn a_corner_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a corner widget drag cancels it, and the release after it commits nothing. +/// After an Alt-press on a widget, Escape drops the press, so its release cycles no corner kind. +#[test] +fn escape_cancels_a_corner_widget_drag() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let id = d.alloc_id(); + let mut n = Node::path(id, live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 171.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 171.0)).is_empty(), "the release commits nothing"); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0).with_mods(alt)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 190.0, 191.0).with_mods(alt)).is_empty(), "the Alt-click cycles no corner kind"); +} + /// A type-on-path bracket drag is a transform, so the sharp selection box hides while the span moves. #[test] fn a_bracket_drag_counts_as_transforming_once_it_moves() { @@ -118,6 +261,25 @@ fn a_bracket_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a drag of the brackets of type on a path cancels it, and the release after it +/// commits nothing. +#[test] +fn escape_cancels_a_bracket_drag() { + let (d, id) = crate::testutil::doc_with_path_type(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 280.0, 290.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 320.0, 290.0)); + assert!(matches!(a.first(), Some(Action::Begin(_))), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 330.0, 290.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 330.0, 290.0)).is_empty(), "the release commits nothing"); +} + /// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a /// press there grabs the object by that point. #[test] @@ -460,6 +622,30 @@ fn drag_moves_with_single_undo() { assert_eq!(a, vec![Action::Commit]); } +/// Escape during a move, or an Alt-drag copy, cancels it: the release after it commits nothing, +/// and the next drag is a new one. Escape with nothing to cancel sends nothing. +#[test] +fn escape_cancels_a_move_or_an_alt_drag_copy() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for (what, m) in [("move", Mods::default()), ("copy", Mods { alt: true, ..Default::default() })] { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0).with_mods(m)); + assert!(matches!(&a[1], Action::Preview(_, v) if v["copy"] == m.alt), "{what}: {a:?}"); + assert!(t.transforming(), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the release commits nothing"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "{what}: nothing to cancel"); + } +} + /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps /// to a ruler guide within the Snap to Point distance; not with Snap to Point off. #[test] @@ -907,6 +1093,22 @@ fn marquee_selects() { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } +/// Escape during a marquee drag ends it: the marquee is removed, and its release selects nothing. +#[test] +fn escape_cancels_a_marquee() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); + assert!(!t.busy()); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); +} + /// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be /// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. #[test] @@ -1099,6 +1301,28 @@ fn handle_drag_scales() { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } +/// Escape during a bounding-box scale or rotation cancels it, and the release after it commits +/// nothing. +#[test] +fn escape_cancels_a_scale_or_a_rotation() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // (what, the press, its Begin, the drag) + for (what, from, begin, to) in [("scale", (200.0, 200.0), "Scale", (300.0, 300.0)), ("rotate", (210.0, 210.0), "Rotate", (150.0, 230.0))] { + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1)), vec![Action::Begin(begin.into())], "{what}"); + assert!(matches!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)).as_slice(), [Action::Preview(..)]), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0 + 5.0, to.1)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, to.0 + 5.0, to.1)).is_empty(), "{what}: the release commits nothing"); + } +} + #[test] fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 878fc607e..9e2cb90a9 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -39,8 +39,8 @@ const ROWS: &[Row] = &[ // Paintbrush has none of them). (&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]), (&["smooth"], None, &["fidelity"]), - (&["blobBrush", "eraser"], None, &["size"]), - (&["blobBrush"], None, &["keepSelected", "mergeOnlySelection"]), + (&["blobBrush", "eraser"], None, &["size", "sizeMode", "sizeVariation"]), + (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 62679d99e..f8594226c 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -75,12 +75,15 @@ impl ShapeTool { } } - /// Ctrl/Cmd in a star drag from `start` to `p`: the inner radius stays as it was when the key - /// went down, so the ratio follows the outer radius (and stays so once the key is let go). - fn hold_inner(&mut self, start: Point, p: Point, m: Mods) { + /// Ctrl/Cmd in a star drag from `start` to `p`, where the last drag showed a star with the inner + /// radius `shown`: once the star has begun, the inner radius stays as the star showed it when + /// the key went down, so the ratio follows the outer radius (and stays so once the key is let + /// go). When the key goes down before the star has begun, the inner radius stays as the first + /// star the drag shows has it. + fn hold_inner(&mut self, shown: f64, start: Point, p: Point, m: Mods) { let outer = (p - start).hypot(); match (m.cmd, self.inner) { - (true, None) => self.inner = Some(outer * self.star_ratio), + (true, None) if self.began => self.inner = Some(shown), (false, Some(_)) => self.inner = None, _ => {} } @@ -224,17 +227,23 @@ impl Tool for ShapeTool { self.start = Some(p); self.last = ev.pos; self.began = false; + self.inner = None; vec![] } PointerKind::Drag => { let Some(mut s) = self.start else { return vec![] }; let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref()); let ev = &PointerEvent { pos, ..*ev }; + // The inner radius of the star the last drag showed, with the ratio it drew with: + // the canvas sends a drag only once the pointer moves, so a key pressed in between + // first arrives with the next position. + let ratio = if self.mods.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio }; + let shown = (self.last - s).hypot() * ratio; space_moves(&mut s, &mut self.last, ev, self.began); self.start = Some(s); self.mods = ev.mods; if self.id == "star" { - self.hold_inner(s, ev.pos, ev.mods); + self.hold_inner(shown, s, ev.pos, ev.mods); } let mut out = vec![]; if !self.began { @@ -299,6 +308,7 @@ impl Tool for ShapeTool { } else if key == ToolKey::Escape { self.start = None; self.began = false; + self.inner = None; self.snap.clear(); vec![Action::Cancel] } else { diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 66a4abad9..da4916576 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -106,8 +106,10 @@ fn star_measure_label_matches_the_preview_with_modifiers_and_shrink() { assert_eq!(label(&t), cx.star_radii_label(r1, r2)); } -/// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer -/// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812). +/// During a star drag, Ctrl/Cmd keeps the inner radius the star showed when the key went down +/// while the outer one follows the pointer (the new ratio stays once the key is let go), and Alt +/// keeps the shoulders straight (#812). The canvas sends a drag only once the pointer moves, so +/// the key first arrives with a new position. #[test] fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { let (d, _) = doc_with_rect(); @@ -123,8 +125,7 @@ fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0))); assert_eq!((r1, r2), (40.0, 20.0)); - // Ctrl: the inner radius stays 20 as the outer one grows to 80. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); + // Ctrl, pressed with the pointer still: the inner radius stays 20 as the outer one grows to 80. let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))); assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}"); // Let go: the ratio (a quarter) stays. @@ -138,6 +139,54 @@ fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]); } +/// Ctrl/Cmd pressed while Alt straightens the shoulders keeps the straightened inner radius, a drag +/// canceled with Escape leaves no held radius for the next star, and Ctrl/Cmd pressed inside the +/// drag threshold keeps the inner radius of the first star the drag shows. The canvas lends a +/// selection tool to a press with Ctrl/Cmd held, so every press here is without it. +#[test] +fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let radii = |a: &[Action]| match a.last() { + Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), + other => panic!("{other:?}"), + }; + let close = |(r1, r2): (f64, f64), (w1, w2): (f64, f64)| assert!((r1 - w1).abs() < 1e-9 && (r2 - w2).abs() < 1e-9, "{r1} {r2}, not {w1} {w2}"); + let ctrl = Mods { cmd: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let ctrl_alt = Mods { cmd: true, alt: true, ..Default::default() }; + // Alt shows straight shoulders; Ctrl/Cmd pressed then keeps that inner radius. + let mut t = ShapeTool::new("star"); + let straight = straight_ratio(5).unwrap_or(0.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(alt))), (40.0, 40.0 * straight)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl_alt)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight)); + + // Escape with the key held, then a new press and the key before the first move: the next star + // holds what it shows, not the canceled radius. + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); + assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5)); + + // Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet. + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 51.0, 50.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); + assert_eq!(a[0], Action::Begin("Star".into())); + close(radii(&a), (40.0, 20.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))), (80.0, 20.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 50.0).with_mods(ctrl)), vec![Action::Commit]); +} + #[test] fn shift_square_alt_center() { let t = ShapeTool::new("ellipse"); diff --git a/crates/tools/src/shaperwidget.rs b/crates/tools/src/shaperwidget.rs index 0eb1bcb19..f44a622a1 100644 --- a/crates/tools/src/shaperwidget.rs +++ b/crates/tools/src/shaperwidget.rs @@ -1,6 +1,7 @@ //! The Shaper Group's mode widget: a small arrow beside the right side of the bounds of a Shaper //! Group's source shapes. It shows while the group or one of its faces is selected (Face Selection -//! mode) and while the group's source shapes are isolated (Construction mode). A click on it +//! mode) and while the group's source shapes are isolated (Construction mode), but not while an +//! opacity mask or a pattern is edited. A click on it //! switches between the two modes: it isolates the source shapes (`object.isolate`), or it leaves //! isolation with the group selected (`object.exitIsolation`). The Shaper and Selection tools hit //! it and the canvas draws it from the same geometry. @@ -33,9 +34,10 @@ impl ShaperWidget { /// The widget at `zoom` (screen px per document point), beside the bounds of the group's source /// shapes in both modes. Construction mode: `isolation` is a Shaper Group's source shapes. Face /// Selection mode: the selection is one Shaper Group, or one face or stroke of it. It returns - /// None otherwise, and when the group, or a layer or group that holds it, is hidden or locked. + /// None otherwise, when the group, or a layer or group that holds it, is hidden or locked, and + /// while an opacity mask or a pattern is edited. pub fn of(doc: &Document, selection: &Selection, isolation: Option, zoom: f64) -> Option { - if !zoom.is_finite() || zoom <= 0.0 { + if !zoom.is_finite() || zoom <= 0.0 || doc.mask_edit.is_some() || doc.pattern_edit.is_some() { return None; } let shaper = |id: NodeId| doc.node(id).is_some_and(|n| n.extras().shaper.is_some()); @@ -153,4 +155,21 @@ mod tests { assert!(widget(&d, &[g]).is_none()); assert!(widget(&d, &[face]).is_none()); } + + #[test] + fn no_widget_shows_while_an_opacity_mask_or_a_pattern_is_edited() { + let (mut d, g, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + let mut s = Selection::default(); + s.add(g); + assert!(ShaperWidget::of(&d, &s, None, 1.0).is_some()); + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "mask editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "mask editing, sources isolated"); + d.mask_edit = None; + d.pattern_edit = Some(vectorcraft_doc::pattern::PatternEdit { pattern: "P".into(), layer, original: None }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "pattern editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "pattern editing, sources isolated"); + } } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index af7370e96..9729985c8 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -6,6 +6,9 @@ //! ends the editing, and the next click makes new type. Point type placed by a click and left //! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new //! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too. +//! While no type is being edited, a press with Shift held makes the new type with the tool's +//! horizontal or vertical counterpart, and a press with Alt (Option) held makes it with Type on a +//! Path in place of Area Type or the reverse ([`TypeTool::acting`]); the pointer shows that tool. //! //! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends //! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a @@ -84,6 +87,43 @@ struct TypedList { list: vectorcraft_doc::text::ParaList, } +/// An undo step that changed the text of the type object the tool edits, in the order the history +/// holds them. The plain text before and after it is kept as a hash. For a step the tool made (a +/// typing session or a typed list), `ends` holds the byte where the changed span ends before and +/// after it, so that Undo and Redo of the step put the caret there even where the change repeats +/// the characters next to it; a step a command from outside the tool made has none. +#[derive(Clone)] +struct OwnEdit { + id: NodeId, + before: u64, + after: u64, + ends: Option<(usize, usize)>, + /// False once Undo took the step back, until Redo makes it again. + done: bool, +} + +/// What Undo and Redo change in the tool, kept when a `command.batch` begins so that a batch that +/// fails and is rolled back puts it back ([`TypeTool::batch_notice`]). +struct HistoryState { + editing: Option, + caret: usize, + anchor: usize, + goal_x: Option, + known: Option<(NodeId, String)>, + edits: Vec, + stepped: bool, +} + +/// How many undo steps [`TypeTool::edits`] keeps. +const OWN_EDITS_MAX: usize = 256; + +fn text_hash(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + /// The edited text's layout, keyed by the font database's generation and the text. type CachedLayout = ((u64, TextObject), Arc); @@ -136,6 +176,8 @@ pub struct TypeTool { press: Option<(Point, Point)>, /// The other corner of an area dragged out (snapped). drag: Option, + /// The Type tool the press acts as, with the keys held at the press ([`Self::acting`]). + held: Option<(Mode, bool)>, /// Smart Guides for where new type goes. snap: DrawSnap, /// The press landed in the edited text: dragging selects. @@ -147,10 +189,30 @@ pub struct TypeTool { /// The list typed characters last started (`text.startList`), so that after Undo puts them back /// the caret goes on after them, and after Redo it goes back. listed: Option, + /// The edited object's plain text as the tool last left or saw it, so that after Undo or Redo + /// the caret goes to the end of the span the step changed. + known: Option<(NodeId, String)>, + /// The tool's own last undo steps, oldest first. + edits: Vec, + /// The command that ran was Undo or Redo ([`TypeTool::after_history_step`]). + stepped: bool, + /// The state at the start of each `command.batch` still running, innermost last. + batches: Vec, cache: Mutex>, } impl TypeTool { + pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize) -> Option> { + if !mods.alt { + return None; + } + let id = self.editing?; + let (attribute, by) = plain::step_key(key, mods.shift, a == b)?; + let mut out = self.commit(); + let by = if mods.cmd { by * 5.0 } else { by }; + out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); + Some(out) + } pub fn new(id: &str) -> Self { let mode = match id { "areaType" | "verticalAreaType" => Mode::Area, @@ -160,6 +222,22 @@ impl TypeTool { Self { mode, vertical: id.starts_with("vertical"), ..Default::default() } } + /// The Type tool (which tool, and whether vertical) that a press with `mods` held acts as. + /// While no type is being edited, Shift switches between the horizontal and the vertical tool, + /// and Alt switches between Area Type and Type on a Path (the Type tool has no counterpart for + /// Alt); both together do both. While type is being edited, it is the tool chosen. + fn acting(&self, mods: Mods) -> (Mode, bool) { + if self.editing.is_some() { + return (self.mode, self.vertical); + } + let mode = match self.mode { + Mode::Area if mods.alt => Mode::OnPath, + Mode::OnPath if mods.alt => Mode::Area, + mode => mode, + }; + (mode, self.vertical != mods.shift) + } + fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> { match &cx.doc.node(id)?.kind { NodeKind::Text(t) => Some(t), @@ -300,7 +378,8 @@ impl TypeTool { self.typing = None; self.preedit = None; } - if self.typing.is_none() { + let fresh = self.typing.is_none(); + if fresh { out.push(Action::Begin("Typing".into())); self.typing_ended = false; } @@ -322,6 +401,7 @@ impl TypeTool { let runs = edit::slice_runs(&ty.cur, p, cur.len() - s); let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) }; out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs}))); + self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s)); out } @@ -343,6 +423,7 @@ impl TypeTool { self.editing = Some(id); self.fresh = None; self.listed = None; + self.known = None; self.caret = caret; self.anchor = caret; self.goal_x = None; @@ -366,10 +447,129 @@ impl TypeTool { out.push(Action::Exec("text.startList".into(), json!({"id": id.0, "at": at}))); self.caret = start; self.anchor = start; - self.listed = Some(TypedList { id, start, end: at, text: t.plain_text(), list }); + let text = t.plain_text(); + if let Some(listed) = text.get(..start).zip(text.get(at..)).map(|(a, b)| format!("{a}{b}")) { + self.note_edit(true, id, (&text, at), (&listed, start)); + } + self.listed = Some(TypedList { id, start, end: at, text, list }); out } + /// Note the edited object's text as it is in the document now. + fn remember(&mut self, cx: &ToolContext) { + self.known = self.editing.and_then(|id| Some((id, Self::text(cx, id)?.plain_text()))); + } + + /// After a command from outside the tool: unless it was Undo or Redo, a command that changed + /// the edited text recorded a step, which drops the steps Undo took back from Redo, so the tool + /// drops them too and notes the step. A command that leaves the text as it was (a query) keeps + /// them. + fn after_outside_command(&mut self, cx: &ToolContext) { + let stepped = std::mem::take(&mut self.stepped); + let known = self.known.take(); + self.remember(cx); + if stepped || known == self.known { + return; + } + self.edits.retain(|e| e.done); + if let (Some((was, old)), Some((id, new))) = (&known, &self.known) + && was == id + { + let e = OwnEdit { id: *id, before: text_hash(old), after: text_hash(new), ends: None, done: true }; + self.push_edit(e); + } + } + + /// Note an undo step of the tool's own in `id`: the text before it and after it, each with the + /// byte where the changed span ends. `fresh` starts a new step; otherwise the step the tool + /// noted last grows (the typing session goes on). A new step drops the steps Undo took back, + /// as the history drops them from Redo. + fn note_edit(&mut self, fresh: bool, id: NodeId, before: (&str, usize), after: (&str, usize)) { + let e = OwnEdit { id, before: text_hash(before.0), after: text_hash(after.0), ends: Some((before.1, after.1)), done: true }; + self.edits.retain(|e| e.done); + match self.edits.last_mut() { + Some(last) if !fresh && last.id == id && last.ends.is_some() => *last = e, + _ => self.push_edit(e), + } + self.known = Some((id, after.0.to_string())); + } + + fn push_edit(&mut self, e: OwnEdit) { + if self.edits.len() >= OWN_EDITS_MAX { + self.edits.remove(0); + } + self.edits.push(e); + } + + /// After Undo or Redo, the caret goes to the end of the span the step changed in the edited + /// text, with nothing selected. A step that leaves the text as it was (a formatting change) + /// leaves the caret and the selection where they are, within the text. + fn after_history_step(&mut self, cx: &ToolContext, undo: bool) { + self.stepped = true; + let Some(id) = self.editing else { return }; + let Some(text) = Self::text(cx, id).map(TextObject::plain_text) else { return }; + match self.known.take() { + Some((known, old)) if known == id && old != text => { + let (old_hash, new_hash) = (text_hash(&old), text_hash(&text)); + // Undo takes back the object's last step still made that changed its text, and Redo + // makes again the one Undo took back last, when its texts match (a step that left + // the text as it was does not come here). A step the tool made knows where its + // span ends. Otherwise the text after the changed span is the common suffix (a + // removal's span is empty). + let changed = |e: &&mut OwnEdit| e.id == id && e.before != e.after; + let step = if undo { + self.edits.iter_mut().rev().filter(|e| e.done).find(changed).filter(|e| (e.after, e.before) == (old_hash, new_hash)) + } else { + self.edits.iter_mut().filter(|e| !e.done).find(changed).filter(|e| (e.before, e.after) == (old_hash, new_hash)) + }; + let own = step.and_then(|e| { + e.done = !undo; + e.ends.map(|(before, after)| if undo { before } else { after }) + }); + self.caret = match own { + Some(end) => edit::floor_char(&text, end), + None => text.len() - common_affixes(&old, &text, usize::MAX).1, + }; + self.anchor = self.caret; + self.goal_x = None; + } + _ => { + self.caret = edit::floor_char(&text, self.caret); + self.anchor = edit::floor_char(&text, self.anchor); + } + } + self.known = Some((id, text)); + } + + /// A `command.batch` began (`batch.begin`), ended (`batch.end`), or failed and was rolled back + /// (`batch.rollBack`). After a rollback, what the batch's Undo and Redo steps changed in the tool + /// is as it was at the batch's start, when the tool still edits the same object. + fn batch_notice(&mut self, what: &str) { + match what { + "batch.begin" => self.batches.push(HistoryState { + editing: self.editing, + caret: self.caret, + anchor: self.anchor, + goal_x: self.goal_x, + known: self.known.clone(), + edits: self.edits.clone(), + stepped: self.stepped, + }), + "batch.end" => { + self.batches.pop(); + } + "batch.rollBack" => { + if let Some(s) = self.batches.pop() + && s.editing == self.editing + { + (self.caret, self.anchor, self.goal_x) = (s.caret, s.anchor, s.goal_x); + (self.known, self.edits, self.stepped) = (s.known, s.edits, s.stepped); + } + } + _ => {} + } + } + /// After Undo of a list that typed characters started (and of what was typed after it), the /// text is as it was typed: the caret goes on after the characters. After Redo the text is as /// the list left it: the caret goes back to the paragraph's start. @@ -428,6 +628,7 @@ impl TypeTool { let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p); let mut out = self.finish(cx); self.start_editing(h.leaf, byte); + self.remember(cx); self.clicks = (Some(p), 1); out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))); Some(out) @@ -437,6 +638,7 @@ impl TypeTool { /// edited, a click that lands on no type only ends the editing; the next click makes new type /// (#2871). A drag that draws an area-type frame makes it either way. fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec { + let (mode, vertical) = self.held.take().unwrap_or((self.mode, self.vertical)); // Click into existing text: place the caret. if let Some(out) = self.edit_at(cx, start) { return out; @@ -459,13 +661,13 @@ impl TypeTool { return out; } // Area Type / Type on a Path: click a path. - if self.mode != Mode::Type && drag.is_none() { - let on_path = self.mode == Mode::OnPath; + if mode != Mode::Type && drag.is_none() { + let on_path = mode == Mode::OnPath; if let Some(pid) = Self::path_at(cx, start, !on_path) { let mode = if on_path { "onPath" } else { "area" }; out.push(Action::Exec( "text.createInPath".into(), - json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical, "placeholder": cx.placeholder_text}), + json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": vertical, "placeholder": cx.placeholder_text}), )); out.push(Action::Notify("text.editNew".into())); return out; @@ -481,7 +683,7 @@ impl TypeTool { if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) { params["rotation"] = json!(rotation); } - params["vertical"] = json!(self.vertical); + params["vertical"] = json!(vertical); params["placeholder"] = json!(cx.placeholder_text); out.push(Action::Exec("text.create".into(), params)); out.push(Action::Notify("text.editNew".into())); @@ -505,7 +707,8 @@ impl TypeTool { // new document text by a stale range. self.preedit = None; } - if self.typing.is_none() { + let fresh = self.typing.is_none(); + if fresh { out.push(Action::Begin("Typing".into())); self.typing_ended = false; } @@ -547,6 +750,7 @@ impl TypeTool { let runs = edit::slice_runs(&ty.cur, p, cur.len() - s); let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) }; out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs}))); + self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s)); out } } diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs index 1b058a4b6..32db456c3 100644 --- a/crates/tools/src/text/plain.rs +++ b/crates/tools/src/text/plain.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ImeSurrounding, Range, ToolKey}; /// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). /// `None` when `r` runs past the end of `s` or backwards. diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs index ace85694c..d9c027fbc 100644 --- a/crates/tools/src/text/ports.rs +++ b/crates/tools/src/text/ports.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FontDb, NodeId, NodeKind, Overlay, Point, Rect, Shape, TextKind, TextObject, ToolContext}; /// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the /// out port above the bottom-right corner on the right side, the in port below the top-left corner diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index f5a454d09..1502533d5 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -30,6 +30,8 @@ fn key(tool: &mut TypeTool, d: &Document, k: ToolKey, mods: Mods) -> Vec const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; const CMD: Mods = Mods { shift: false, alt: false, cmd: true, ctrl: false, space: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false }; +const SHIFT_ALT: Mods = Mods { shift: true, alt: true, cmd: false, ctrl: false, space: false }; /// Apply the tool's previews like the engine would (on top of the session snapshot). fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) { @@ -107,6 +109,37 @@ fn type_arrows_keep_devanagari_clusters_with_an_rtl_suffix() { } } +fn assert_unwrapped_hindi_glyphs( + db: &FontDb, + face: &vectorcraft_text::FontFace, + hindi_glyphs: &[&vectorcraft_text::PositionedGlyph], + hindi: &str, + text: &str, +) { + for glyph in hindi_glyphs { + let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face"); + assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes"); + assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index"); + assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present"); + assert!(!glyph.rtl, "setup: Hindi prefix is LTR"); + assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range"); + assert!( + text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len), + "setup: glyph source ranges are UTF-8 boundaries" + ); + } +} + +fn assert_wrapped_hindi_glyphs(db: &FontDb, face: &vectorcraft_text::FontFace, lay: &vectorcraft_text::TextLayout, hindi: &str) { + for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) { + let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face"); + assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes"); + assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index"); + assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present"); + assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR"); + } +} + #[test] fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { use vectorcraft_doc::{Composer, Justify}; @@ -146,18 +179,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { assert!(!natural.vertical, "setup: horizontal text"); let hindi_glyphs: Vec<_> = natural.glyphs.iter().filter(|g| g.byte < hindi.len()).collect(); assert!(!hindi_glyphs.is_empty(), "setup: actual Hindi glyphs exist"); - for glyph in &hindi_glyphs { - let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face"); - assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes"); - assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index"); - assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present"); - assert!(!glyph.rtl, "setup: Hindi prefix is LTR"); - assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range"); - assert!( - text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len), - "setup: glyph source ranges are UTF-8 boundaries" - ); - } + assert_unwrapped_hindi_glyphs(db, &face, &hindi_glyphs, hindi, text); let prefix_width: f64 = hindi_glyphs.iter().filter(|g| g.byte < split).map(|g| g.advance).sum(); let split_width: f64 = hindi_glyphs.iter().filter(|g| g.byte == split).map(|g| g.advance).sum(); assert!(prefix_width.is_finite() && prefix_width > 0.0, "setup: prefix has positive finite advance"); @@ -179,13 +201,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { assert!(!lay.overflow, "setup: the full RTL suffix fits the tall frame"); assert!(!lay.vertical, "setup: horizontal area text"); assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "setup: actual RTL suffix selects Type's visual-arrow route"); - for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) { - let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face"); - assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes"); - assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index"); - assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present"); - assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR"); - } + assert_wrapped_hindi_glyphs(db, &face, &lay, hindi); assert_eq!(edit::next_cluster(text, second), hindi.len(), "setup: logical cluster end is 18"); assert_eq!(edit::prev_cluster(text, hindi.len()), second, "setup: logical cluster start is 6"); assert_eq!(edit::prev_cluster(text, split), second, "setup: raw line endpoint 12 is inside the grapheme"); @@ -246,6 +262,219 @@ fn typing_is_one_coalesced_preview() { assert!(a4.contains(&Action::Begin("Typing".into()))); } +/// What the engine does around Edit › Undo or Redo (`what`) that puts the document back to `to`: +/// it ends the typing session, runs the step, tells the tool, then lets it finish the command. +fn history_step(tool: &mut TypeTool, to: &Document, id: NodeId, what: &str) { + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + tool.set_option("endTyping", &json!(true)); + tool.notify(&cx(to, &sel, &p), what); + assert_eq!(tool.after_command(&cx(to, &sel, &p)), vec![]); +} + +fn type_in(tool: &mut TypeTool, d: &mut Document, s: &str) { + let (sel, p) = (Selection::default(), paint()); + let snap = d.clone(); + let acts = tool.text_input(&cx(d, &sel, &p), s); + apply(d, &snap, &acts); +} + +fn plain_of(d: &Document, id: NodeId) -> String { + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") }; + t.plain_text() +} + +/// Undo of typing puts the caret where the typed text began, and Redo puts it after the text. +#[test] +fn undo_and_redo_of_typing_put_the_caret_where_the_text_changed() { + let (mut d, id, mut tool) = editing("XYZ"); + let before = d.clone(); + type_in(&mut tool, &mut d, "abc"); + assert_eq!((plain_of(&d, id).as_str(), tool.caret), ("abcXYZ", 3)); + let after = d.clone(); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!((tool.caret, tool.anchor), (0, 0)); + history_step(&mut tool, &after, id, "edit.redo"); + assert_eq!((tool.caret, tool.anchor), (3, 3)); + // Typing in the middle of the text: Undo goes back to where the typing began. + tool.caret = 4; + tool.anchor = 4; + type_in(&mut tool, &mut d, "-"); + assert_eq!(plain_of(&d, id), "abcX-YZ"); + history_step(&mut tool, &after, id, "edit.undo"); + assert_eq!((tool.caret, tool.anchor), (4, 4)); +} + +/// Undo of a deletion puts the caret after the text it puts back, and Redo where it was taken out. +#[test] +fn undo_of_a_deletion_puts_the_caret_after_the_restored_text() { + let (mut d, id, mut tool) = editing("Hello big world"); + let before = d.clone(); + let snap = d.clone(); + tool.caret = 10; + tool.anchor = 10; + let acts = key(&mut tool, &d, ToolKey::Backspace, Mods { alt: true, ..Default::default() }); + apply(&mut d, &snap, &acts); + assert_eq!((plain_of(&d, id).as_str(), tool.caret), ("Hello world", 6)); + let after = d.clone(); + // The caret moved away before the Undo. + key(&mut tool, &d, ToolKey::End, Mods::default()); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!((tool.caret, tool.anchor), (10, 10)); + history_step(&mut tool, &after, id, "edit.redo"); + assert_eq!((tool.caret, tool.anchor), (6, 6)); +} + +/// A step that changes no characters (a formatting change) leaves the caret and the selection, and +/// the Undo of the typing before it still puts the caret where the typing began. +#[test] +fn undo_of_formatting_leaves_the_caret_and_selection() { + let (mut d, id, mut tool) = editing("Hello world"); + let before = d.clone(); + tool.caret = 5; + tool.anchor = 5; + type_in(&mut tool, &mut d, ","); + let typed = d.clone(); + // A panel makes the typed text larger (a command from outside the tool). + let mut large = d.clone(); + let NodeKind::Text(t) = &mut large.node_mut(id).unwrap().kind else { panic!("text") }; + edit::style_range(&mut t.runs, 0, 6, |s| s.size = 30.0); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + tool.set_option("endTyping", &json!(true)); + tool.after_command(&cx(&large, &sel, &p)); + tool.anchor = 2; + tool.caret = 8; + history_step(&mut tool, &typed, id, "edit.undo"); + assert_eq!((tool.anchor, tool.caret), (2, 8)); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!((tool.anchor, tool.caret), (5, 5)); +} + +/// The caret lands on character boundaries in multi-byte text. +#[test] +fn undo_and_redo_keep_the_caret_on_character_boundaries() { + let (mut d, id, mut tool) = editing("café 🙂 ok"); + let before = d.clone(); + tool.caret = "café 🙂".len(); + tool.anchor = tool.caret; + type_in(&mut tool, &mut d, "é🙂"); + assert_eq!(plain_of(&d, id), "café 🙂é🙂 ok"); + let after = d.clone(); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!(tool.caret, "café 🙂".len()); + history_step(&mut tool, &after, id, "edit.redo"); + assert_eq!(tool.caret, "café 🙂é🙂".len()); +} + +/// Typing or deleting the same characters as the text next to it: Undo and Redo put the caret where +/// the tool made the change, not at the other end of the repeated characters. +#[test] +fn undo_and_redo_of_repeated_characters_find_the_tools_own_edit() { + for (text, at, typed, undone, redone) in [("aaa", 0, "a", 0, 1), ("éé", 2, "é", 2, 4), ("ab ab", 3, "ab ", 3, 6)] { + let (mut d, id, mut tool) = editing(text); + let before = d.clone(); + tool.caret = at; + tool.anchor = at; + type_in(&mut tool, &mut d, typed); + let after = d.clone(); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!((tool.caret, tool.anchor), (undone, undone), "{text}: undo"); + history_step(&mut tool, &after, id, "edit.redo"); + assert_eq!((tool.caret, tool.anchor), (redone, redone), "{text}: redo"); + } + // A deleted character next to the same character comes back with the caret after it. + let (mut d, id, mut tool) = editing("aaa"); + let before = d.clone(); + tool.caret = 1; + tool.anchor = 1; + let snap = d.clone(); + let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default()); + apply(&mut d, &snap, &acts); + assert_eq!(plain_of(&d, id), "aa"); + history_step(&mut tool, &before, id, "edit.undo"); + assert_eq!(tool.caret, 1); + // Two steps whose texts are each other's: each Undo and Redo finds its own step. + let (mut d, id, mut tool) = editing("aaa"); + let first = d.clone(); + type_in(&mut tool, &mut d, "a"); + let second = d.clone(); + key(&mut tool, &d, ToolKey::End, Mods::default()); + let snap = d.clone(); + let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default()); + apply(&mut d, &snap, &acts); + let third = d.clone(); + assert_eq!(plain_of(&third, id), "aaa"); + for (to, what, at) in [(&second, "edit.undo", 4), (&first, "edit.undo", 0), (&second, "edit.redo", 1), (&third, "edit.redo", 3)] { + history_step(&mut tool, to, id, what); + assert_eq!(tool.caret, at, "{what} to {}", plain_of(to, id)); + } + // A step from outside the tool after an Undo drops the undone step from Redo: Redo of the + // outside step does not take the dropped step's place. + let (mut d, id, mut tool) = editing("aaa"); + let first = d.clone(); + type_in(&mut tool, &mut d, "a"); + history_step(&mut tool, &first, id, "edit.undo"); + assert_eq!(tool.caret, 0); + let mut appended = first.clone(); + let NodeKind::Text(t) = &mut appended.node_mut(id).unwrap().kind else { panic!("text") }; + edit::replace_range(&mut t.runs, 3, 3, "a"); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + assert_eq!(tool.after_command(&cx(&appended, &sel, &p)), vec![]); + history_step(&mut tool, &first, id, "edit.undo"); + history_step(&mut tool, &appended, id, "edit.redo"); + assert_eq!(tool.caret, 4); + // A command from outside the tool that leaves the text as it is (a query) keeps the undone + // step for Redo. + let (mut d, id, mut tool) = editing("aaa"); + let first = d.clone(); + type_in(&mut tool, &mut d, "a"); + let second = d.clone(); + history_step(&mut tool, &first, id, "edit.undo"); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + assert_eq!(tool.after_command(&cx(&first, &sel, &p)), vec![]); + history_step(&mut tool, &second, id, "edit.redo"); + assert_eq!(tool.caret, 1); + // Commands from outside the tool that repeat the change of a step the tool made are steps of + // their own: Undo of the last one does not take back the tool's step. + let (mut d, id, mut tool) = editing("aaa"); + let first = d.clone(); + type_in(&mut tool, &mut d, "a"); + let typed = d.clone(); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + tool.set_option("endTyping", &json!(true)); + let mut removed = typed.clone(); + let NodeKind::Text(t) = &mut removed.node_mut(id).unwrap().kind else { panic!("text") }; + edit::replace_range(&mut t.runs, 3, 4, ""); + assert_eq!(tool.after_command(&cx(&removed, &sel, &p)), vec![]); + let mut appended = removed.clone(); + let NodeKind::Text(t) = &mut appended.node_mut(id).unwrap().kind else { panic!("text") }; + edit::replace_range(&mut t.runs, 3, 3, "a"); + assert_eq!(tool.after_command(&cx(&appended, &sel, &p)), vec![]); + for (to, what, at) in [(&removed, "edit.undo", 3), (&typed, "edit.undo", 4), (&first, "edit.undo", 0), (&typed, "edit.redo", 1)] { + history_step(&mut tool, to, id, what); + assert_eq!(tool.caret, at, "{what} to {}", plain_of(to, id)); + } + // A typing session that leaves the text as it was (a character typed and deleted again) does + // not stop Undo of the step before it from finding where that step typed. + let (mut d, id, mut tool) = editing("aaa"); + let first = d.clone(); + type_in(&mut tool, &mut d, "a"); + key(&mut tool, &d, ToolKey::End, Mods::default()); + // The session's previews each replace the text it started from. + let session = d.clone(); + type_in(&mut tool, &mut d, "b"); + let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default()); + apply(&mut d, &session, &acts); + assert_eq!(plain_of(&d, id), "aaaa"); + history_step(&mut tool, &d, id, "edit.undo"); + history_step(&mut tool, &first, id, "edit.undo"); + assert_eq!(tool.caret, 0); +} + #[test] fn shift_arrows_select_and_typing_replaces() { let (d, _, mut tool) = editing("Hello world"); @@ -459,6 +688,147 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() { } } +/// A press from `tool` at `from` with `down` held, dragged to `to` (none for a click) and released +/// with `up` held → the actions of the release. +fn gesture(tool: &mut TypeTool, c: &ToolContext, from: (f64, f64), to: Option<(f64, f64)>, down: Mods, up: Mods) -> Vec { + tool.pointer(c, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(down)); + let end = to.unwrap_or(from); + if to.is_some() { + tool.pointer(c, &PointerEvent::new(PointerKind::Drag, end.0, end.1).with_mods(down)); + } + tool.pointer(c, &PointerEvent::new(PointerKind::Up, end.0, end.1).with_mods(up)) +} + +/// The command that makes new type among `acts`, with its params. +fn made(acts: &[Action]) -> Option<(&str, &Value)> { + acts.iter().find_map(|a| match a { + Action::Exec(cmd, p) if cmd == "text.create" || cmd == "text.createInPath" => Some((cmd.as_str(), p)), + _ => None, + }) +} + +/// [`crate::testutil::doc_with_rect`] plus an open path from (100, 400) to (400, 400). +fn doc_with_rect_and_line() -> (Document, NodeId, NodeId) { + let (mut d, rect) = crate::testutil::doc_with_rect(); + let l = d.layers[0].id; + let line = d.alloc_id(); + let path = vectorcraft_geom::PathData::from_bezpath(&BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 400.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 400.0)), + ])); + d.insert(Some(l), 1, Node::path(line, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, rect, line) +} + +/// Shift held at the press makes the new type as the horizontal or vertical counterpart of each +/// Type tool would, for a click and for a drag; the keys at the release do not matter, and the +/// next press without Shift acts as the tool chosen. +#[test] +fn shift_switches_between_the_horizontal_and_the_vertical_type_tool() { + let (d, rect) = crate::testutil::doc_with_rect(); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + for (id, at, cmd) in [ + ("type", (400.0, 400.0), "text.create"), + ("verticalType", (400.0, 400.0), "text.create"), + ("areaType", (100.0, 150.0), "text.createInPath"), + ("verticalAreaType", (100.0, 150.0), "text.createInPath"), + ("typeOnPath", (150.0, 100.0), "text.createInPath"), + ("verticalTypeOnPath", (150.0, 100.0), "text.createInPath"), + ] { + let vertical = id.starts_with("vertical"); + let mut tool = TypeTool::new(id); + let acts = gesture(&mut tool, &c, at, None, SHIFT, Mods::default()); + let (made_by, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}")); + assert_eq!(made_by, cmd, "{id}"); + assert_eq!(params["vertical"], json!(!vertical), "{id} with Shift"); + if cmd == "text.createInPath" { + assert_eq!(params["path"], json!(rect.0), "{id}"); + assert_eq!(params["mode"], if id.contains("OnPath") { "onPath" } else { "area" }, "{id}"); + } + assert_eq!(tool.id(), id, "the tool chosen stays"); + let acts = gesture(&mut tool, &c, at, None, Mods::default(), SHIFT); + assert_eq!(made(&acts).map(|(_, p)| p["vertical"].clone()), Some(json!(vertical)), "{id} without Shift at the press"); + } + // A frame dragged out with Shift held. + let mut tool = TypeTool::new("type"); + let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT, Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!((cmd, ¶ms["area"], ¶ms["vertical"]), ("text.create", &json!({"width": 100.0, "height": 100.0}), &json!(true))); +} + +/// Alt held at the press makes the new type as Type on a Path would with the Area Type tools and +/// as Area Type would with the Type on a Path tools, keeping their writing direction; with Shift +/// too, the direction switches as well. The Type tools have no counterpart for Alt. +#[test] +fn alt_switches_between_area_type_and_type_on_a_path() { + let (d, rect, line) = doc_with_rect_and_line(); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let (on_line, on_rect) = ((250.0, 400.0), (100.0, 150.0)); + for (id, at, mods, path, mode, vertical) in [ + ("areaType", on_line, ALT, line, "onPath", false), + ("verticalAreaType", on_line, ALT, line, "onPath", true), + ("typeOnPath", on_rect, ALT, rect, "area", false), + ("verticalTypeOnPath", on_rect, ALT, rect, "area", true), + ("areaType", on_line, SHIFT_ALT, line, "onPath", true), + ("verticalTypeOnPath", on_rect, SHIFT_ALT, rect, "area", false), + ] { + let mut tool = TypeTool::new(id); + let acts = gesture(&mut tool, &c, at, None, mods, Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id} {mods:?}: {acts:?}")); + assert_eq!(cmd, "text.createInPath", "{id} {mods:?}"); + assert_eq!((¶ms["path"], ¶ms["mode"], ¶ms["vertical"]), (&json!(path.0), &json!(mode), &json!(vertical)), "{id} {mods:?}"); + assert_eq!(tool.id(), id); + } + // An open path is no frame for the Area Type tool without Alt, and Type on a Path keeps the + // rectangle as a baseline. + let acts = gesture(&mut TypeTool::new("areaType"), &c, on_line, None, Mods::default(), Mods::default()); + assert_eq!(made(&acts).map(|(cmd, _)| cmd), Some("text.create"), "{acts:?}"); + let acts = gesture(&mut TypeTool::new("typeOnPath"), &c, on_rect, None, Mods::default(), Mods::default()); + assert_eq!(made(&acts).map(|(_, p)| p["mode"].clone()), Some(json!("onPath")), "{acts:?}"); + for id in ["type", "verticalType"] { + let acts = gesture(&mut TypeTool::new(id), &c, (450.0, 450.0), None, ALT, Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}")); + assert_eq!((cmd, ¶ms["vertical"]), ("text.create", &json!(id == "verticalType")), "{id} with Alt"); + } +} + +/// The pointer shows the Type tool that a press would act as with the keys held, and while a press +/// is held, the one it acts as. +#[test] +fn the_pointer_shows_the_type_tool_shift_or_alt_switches_to() { + let (d, _, _) = doc_with_rect_and_line(); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let (away, on_line) = (Point::new(450.0, 450.0), Point::new(250.0, 400.0)); + let cursor = |id: &str, at: Point, mods: Mods| TypeTool::new(id).cursor(&c, at, mods); + assert_eq!(cursor("type", away, SHIFT), Cursor::TypeVertical); + assert_eq!(cursor("verticalType", away, SHIFT), Cursor::Text); + assert_eq!(cursor("verticalAreaType", away, SHIFT), Cursor::Text); + assert_eq!(cursor("areaType", on_line, Mods::default()), Cursor::Text, "no frame for area type"); + assert_eq!(cursor("areaType", on_line, ALT), Cursor::Crosshair, "a baseline for type on a path"); + assert_eq!(cursor("typeOnPath", on_line, ALT), Cursor::Text); + let mut tool = TypeTool::new("type"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y).with_mods(SHIFT)); + assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::TypeVertical, "the press keeps the tool it acts as"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Up, away.x, away.y)); + assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::Text); +} + +/// While type is being edited, Shift and Alt switch no tool: a frame dragged out is the tool +/// chosen's, and the pointer stays its own. +#[test] +fn shift_and_alt_switch_no_tool_while_type_is_edited() { + let (d, _, mut tool) = editing("Hello"); + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + assert_eq!(tool.cursor(&c, Point::new(400.0, 400.0), SHIFT), Cursor::Text); + let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT_ALT, SHIFT_ALT); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!((cmd, ¶ms["vertical"]), ("text.create", &json!(false))); +} + #[test] fn ime_ranges_convert_characters_to_bytes() { assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9)); @@ -1099,3 +1469,22 @@ fn ime_surrounding_reports_the_document_when_it_changed_under_the_session() { assert_eq!(stale, ImeSurrounding::Available { text: "XYZ".into(), cursor: 3, anchor: 3 }); assert!(!tool.ime_session_matches_document(&cx(&d, &sel, &p)), "the commit starts a fresh session"); } + +#[test] +fn shift_enter_inserts_soft_return() { + let (mut d, id, mut tool) = editing("Hello World"); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 5; + tool.anchor = 5; + let snap = d.clone(); + let shift = Mods { shift: true, ..Default::default() }; + let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, shift); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "Hello\u{2028} World"); + // Plain Enter inserts standard newline + let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, Mods::default()); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "Hello\u{2028}\n World"); +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 55f114a3d..5307d6175 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -1,9 +1,12 @@ use super::plain::{ char_range_to_bytes, ime_surrounding_window, plain_ceil_boundary, plain_chars_after, plain_chars_before, plain_floor_boundary, plain_is_boundary, - plain_len, step_key, + plain_len, }; use super::ports::{ported, ports, ports_shown}; -use super::*; +use super::{ + Action, BezPath, Cursor, ImeSurrounding, Mode, Mods, NodeId, Overlay, Point, PointerEvent, PointerKind, Preedit, Range, Rect, Shape, + SurroundingUnit, TextKind, TextRun, Tool, ToolContext, ToolKey, TypeTool, Value, edit, json, +}; impl Tool for TypeTool { fn id(&self) -> &'static str { @@ -51,6 +54,7 @@ impl Tool for TypeTool { return out; } self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); + self.held = Some(self.acting(ev.mods)); self.drag = None; vec![] } @@ -108,7 +112,7 @@ impl Tool for TypeTool { self.clicks = (None, 0); // Pasting what we copied keeps its formatting (unless pasted text is kept plain). let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text { + if !committing && !s.is_empty() && s == clip && !cx.paste_plain_text { let runs = self.clipboard.clone(); return self.replace(cx, "", Some(runs)); } @@ -141,7 +145,7 @@ impl Tool for TypeTool { return false; } let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) + !(self.preedit.is_none() && !normalized.is_empty() && normalized == clip && !cx.paste_plain_text) } /// Whether the typing session still describes the edited document; a session ended by an outside /// command reports false until a replacement commit starts a new one. @@ -171,13 +175,7 @@ impl Tool for TypeTool { self.anchor = self.anchor.min(len); let (a, b) = self.sel(); // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). - if mods.alt - && let Some(id) = self.editing - && let Some((attribute, by)) = step_key(key, mods.shift, a == b) - { - let mut out = self.commit(); - let by = if mods.cmd { by * 5.0 } else { by }; - out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); + if let Some(out) = self.step_attribute(key, mods, a, b) { return out; } let word = mods.cmd || mods.alt; @@ -207,7 +205,7 @@ impl Tool for TypeTool { out.push(Action::SwitchTool(target)); out } - ToolKey::Enter => self.replace(cx, "\n", None), + ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None), ToolKey::Tab => self.replace(cx, "\t", None), ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { @@ -452,6 +450,7 @@ impl Tool for TypeTool { /// goes before the paragraph's text. fn after_command(&mut self, cx: &ToolContext) -> Vec { self.after_typed_list(cx); + self.after_outside_command(cx); let Some(id) = self.editing else { return vec![] }; let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some()); while let Some(c) = cur { @@ -462,12 +461,19 @@ impl Tool for TypeTool { } self.finish(cx) } - /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. + /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. After + /// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text. The + /// `batch.*` notices keep what a failed `command.batch` changed from staying ([`TypeTool::batch_notice`]). fn notify(&mut self, cx: &ToolContext, what: &str) { - if what == "text.editNew" + if matches!(what, "edit.undo" | "edit.redo") { + self.after_history_step(cx, what == "edit.undo"); + } else if what.starts_with("batch.") { + self.batch_notice(what); + } else if what == "text.editNew" && let Some(id) = cx.selection.objects.first().copied() { self.start_editing(id, 0); + self.remember(cx); let t = Self::text(cx, id); self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); // Placeholder text comes selected: typing replaces it. @@ -502,7 +508,7 @@ impl Tool for TypeTool { // Transient selection transferred between Type and Eyedropper; never a saved setting. "textSelection" => { if let (Some(id), Some(a), Some(b)) = ( - v.get("editing").and_then(Value::as_u64), + v.get("editing").or_else(|| v.get("id")).and_then(Value::as_u64), v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) @@ -623,10 +629,16 @@ impl Tool for TypeTool { } o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { - if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() { + /// The pointer of the Type tool that a press would act as with the keys held, or, while a + /// press is held, of the one it acts as ([`TypeTool::acting`]). + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + let (mode, vertical) = match (self.press, self.held) { + (Some(_), Some(held)) => held, + _ => self.acting(m), + }; + if mode != Mode::Type && Self::path_at(cx, p, mode == Mode::Area).is_some() { return Cursor::Crosshair; } - if self.vertical { Cursor::TypeVertical } else { Cursor::Text } + if vertical { Cursor::TypeVertical } else { Cursor::Text } } } diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index ecdb79d43..09933d2a4 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -426,7 +426,7 @@ impl Tool for ArtboardTool { vec![Action::Commit] } Drag::Resize { began: true, .. } => vec![Action::Commit], - Drag::Create { toggle: Some((i, at)), .. } if p.distance(at) < cx.tol(3.0) => { + Drag::Create { toggle: Some((i, at)), .. } if p.distance(at) < cx.tol(5.0) => { self.toggle(cx, i); vec![] } @@ -562,9 +562,14 @@ impl Tool for ArtboardTool { if let Some(Drag::Marquee { start, cur }) = self.drag { o.push(Overlay::Marquee(Rect::from_points(start, cur))); } - // The other selected artboards: dashed bounds (in a drag the document shows them moved). - for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) { - o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true }); + // The other selected artboards: dashed bounds and labels. + for &i in &self.others { + if let Some(ab) = cx.doc.artboards.get(i) { + let r = ab.rect; + o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true }); + let at = Point::new(r.x0 - cx.tol(8.0), r.y0 - cx.tol(4.0)); + o.push(Overlay::Label { p: at, text: format!("{:02} - {}", i + 1, ab.name), color: BLUE }); + } } let Some(ab) = cx.doc.artboards.get(self.active) else { return o }; let r = self.preview.unwrap_or(ab.rect); diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 15b956bae..3f2b350e0 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -1,17 +1,20 @@ //! Measure tool: drag between two points to read distance, angle, dX and dY (Shift constrains to //! 45°). The result stays on screen (and in the tool options, for agents) until the next drag; the -//! document is never changed. +//! document is never changed. Escape during a drag cancels it: the result of the drag before +//! comes back. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; use super::CYAN; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Default)] pub struct MeasureTool { line: Option<(Point, Point)>, dragging: bool, + /// The line measured before the drag under way, which Escape puts back. + before: Option<(Point, Point)>, } /// (distance, angle in degrees counter-clockwise, dx, dy) @@ -33,6 +36,7 @@ impl Tool for MeasureTool { match ev.kind { PointerKind::Down => { let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.before = self.line; self.line = Some((p, p)); self.dragging = true; } @@ -52,6 +56,14 @@ impl Tool for MeasureTool { vec![] } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.dragging { + self.dragging = false; + self.line = self.before; + } + vec![] + } + fn overlays(&self, cx: &ToolContext) -> Vec { let Some((a, b)) = self.line else { return vec![] }; if a == b { @@ -128,4 +140,39 @@ mod tests { assert!(!ov.iter().any(|o| matches!(o, Overlay::Measure { .. })), "Measurement Labels off hides the readout"); assert!(ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "the measured line stays"); } + + /// Escape during a drag ends it and brings back the result of the drag before (none at + /// first); the release after it measures nothing. + #[test] + fn escape_cancels_a_measure_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = MeasureTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -30.0)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert_eq!(t.options(), Value::Null, "no earlier result"); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -30.0)); + assert_eq!(t.options(), Value::Null, "the release measures nothing"); + // A measurement, then a second drag canceled: the first one's result is back. + for (kind, x, y) in [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 40.0, -30.0), (PointerKind::Up, 40.0, -30.0)] { + t.pointer(&cx, &PointerEvent::new(kind, x, y)); + } + let first = t.options(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 100.0)); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty()); + assert!(t.busy(), "Enter leaves the drag alone"); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert!(!t.busy()); + assert_eq!(t.options(), first); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 100.0)); + assert_eq!(t.options(), first); + assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); + } } diff --git a/crates/trace/src/centerline/graph.rs b/crates/trace/src/centerline/graph.rs index 81f37d9d5..a358918cd 100644 --- a/crates/trace/src/centerline/graph.rs +++ b/crates/trace/src/centerline/graph.rs @@ -68,18 +68,26 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi { return None; } + let degree: Vec = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect(); + let (node_of, mut graph) = build_nodes(g, dist, °ree, max_nodes)?; + let mut visited = vec![false; n]; + add_node_branches(g, dist, &node_of, &mut graph, &mut visited, max_branches)?; + add_ring_branches(g, dist, °ree, &node_of, &mut graph, &mut visited, max_branches)?; + Some(graph) +} + +fn build_nodes(g: &Grid, dist: &[f32], degree: &[u8], max_nodes: usize) -> Option<(Vec, Graph)> { let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); - let deg: Vec = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect(); - let mut node_of = vec![usize::MAX; n]; + let mut node_of = vec![usize::MAX; g.on.len()]; let mut graph = Graph::default(); // Ends on their own; touching junction pixels together, deepest pixel first on ties. for i in g.interior() { - let degree = *deg.get(i)?; - if g.on.get(i) != Some(&true) || degree == 2 || *node_of.get(i)? != usize::MAX { + let pixel_degree = *degree.get(i)?; + if g.on.get(i) != Some(&true) || pixel_degree == 2 || *node_of.get(i)? != usize::MAX { continue; } // Isolated pixels cannot form a stroke; leave them to the existing fill path. - if degree == 0 { + if pixel_degree == 0 { continue; } if graph.nodes.len() >= max_nodes.min(MAX_NODES) { @@ -88,40 +96,45 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi let id = graph.nodes.len(); *node_of.get_mut(i)? = id; let mut members = vec![i]; - if degree > 2 { - let mut k = 0; - while let Some(&p) = members.get(k) { - k += 1; - for q in g.neighbours(p) { - if *node_of.get(q)? == usize::MAX && *deg.get(q)? > 2 { - *node_of.get_mut(q)? = id; - members.push(q); + if pixel_degree > 2 { + let mut cursor = 0; + while let Some(&pixel) = members.get(cursor) { + cursor += 1; + for neighbour in g.neighbours(pixel) { + if *node_of.get(neighbour)? == usize::MAX && *degree.get(neighbour)? > 2 { + *node_of.get_mut(neighbour)? = id; + members.push(neighbour); } } } } let best = members.into_iter().max_by(|&a, &b| dval(a).total_cmp(&dval(b)).then(b.cmp(&a)))?; - graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: degree > 2 }); + graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: pixel_degree > 2 }); } + Some((node_of, graph)) +} + +fn add_node_branches(g: &Grid, dist: &[f32], node_of: &[usize], graph: &mut Graph, visited: &mut [bool], max_branches: usize) -> Option<()> { + let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); let mut seen_edge = BTreeSet::new(); - let mut visited = vec![false; n]; - for p in g.interior() { - let a = *node_of.get(p)?; - if a == usize::MAX { + let n = visited.len(); + for pixel in g.interior() { + let node = *node_of.get(pixel)?; + if node == usize::MAX { continue; } - for q in g.neighbours(p) { - let b = *node_of.get(q)?; - if b == a { + for neighbour in g.neighbours(pixel) { + let next_node = *node_of.get(neighbour)?; + if next_node == node { continue; } - if b != usize::MAX { - if seen_edge.insert((p.min(q), p.max(q))) { + if next_node != usize::MAX { + if seen_edge.insert((pixel.min(neighbour), pixel.max(neighbour))) { graph.push_branch( Branch { - pts: vec![graph.nodes.get(a)?.pos, graph.nodes.get(b)?.pos], - d: vec![dval(p), dval(q)], - ends: [Some(a), Some(b)], + pts: vec![graph.nodes.get(node)?.pos, graph.nodes.get(next_node)?.pos], + d: vec![dval(pixel), dval(neighbour)], + ends: [Some(node), Some(next_node)], closed: false, }, max_branches, @@ -129,66 +142,80 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi } continue; } - if *visited.get(q)? { + if *visited.get(neighbour)? { continue; } - let mut px = vec![p]; - let (mut prev, mut cur) = (p, q); + let mut pixels = vec![pixel]; + let (mut previous, mut current) = (pixel, neighbour); let mut guard = n; - while *node_of.get(cur)? == usize::MAX && guard > 0 { + while *node_of.get(current)? == usize::MAX && guard > 0 { guard -= 1; - *visited.get_mut(cur)? = true; - px.push(cur); - let Some(next) = g.neighbours(cur).find(|&m| m != prev && (visited.get(m) == Some(&false) || node_of.get(m) != Some(&usize::MAX))) - else { + *visited.get_mut(current)? = true; + pixels.push(current); + let Some(next) = g.neighbours(current).find(|&candidate| { + candidate != previous && (visited.get(candidate) == Some(&false) || node_of.get(candidate) != Some(&usize::MAX)) + }) else { break; }; - (prev, cur) = (cur, next); + (previous, current) = (current, next); } if guard == 0 { return None; } - let end = (*node_of.get(cur)? != usize::MAX).then_some(*node_of.get(cur)?); + let end = (*node_of.get(current)? != usize::MAX).then_some(*node_of.get(current)?); if end.is_some() { - px.push(cur); + pixels.push(current); } - let mut pts: Vec = px.iter().map(|&i| g.centre(i)).collect(); - let d = px.into_iter().map(dval).collect(); - *pts.first_mut()? = graph.nodes.get(a)?.pos; - if let Some(e) = end { - *pts.last_mut()? = graph.nodes.get(e)?.pos; + let mut points: Vec = pixels.iter().map(|&i| g.centre(i)).collect(); + let distances = pixels.into_iter().map(dval).collect(); + *points.first_mut()? = graph.nodes.get(node)?.pos; + if let Some(end) = end { + *points.last_mut()? = graph.nodes.get(end)?.pos; } - graph.push_branch(Branch { pts, d, ends: [Some(a), end], closed: false }, max_branches)?; + graph.push_branch(Branch { pts: points, d: distances, ends: [Some(node), end], closed: false }, max_branches)?; } } - // Rings never reached from a node. - for s in g.interior() { - if g.on.get(s) != Some(&true) || deg.get(s) != Some(&2) || *visited.get(s)? || *node_of.get(s)? != usize::MAX { + Some(()) +} + +fn add_ring_branches( + g: &Grid, + dist: &[f32], + degree: &[u8], + node_of: &[usize], + graph: &mut Graph, + visited: &mut [bool], + max_branches: usize, +) -> Option<()> { + let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); + let n = visited.len(); + for start in g.interior() { + if g.on.get(start) != Some(&true) || degree.get(start) != Some(&2) || *visited.get(start)? || *node_of.get(start)? != usize::MAX { continue; } - let mut px = vec![]; - let (mut prev, mut cur) = (usize::MAX, s); + let mut pixels = vec![]; + let (mut previous, mut current) = (usize::MAX, start); let mut closed = false; let mut guard = n; while guard > 0 { guard -= 1; - *visited.get_mut(cur)? = true; - px.push(cur); - let Some(next) = g.neighbours(cur).find(|&m| m != prev && visited.get(m) == Some(&false)) else { - closed = g.neighbours(cur).any(|m| m == s) && px.len() > 2; + *visited.get_mut(current)? = true; + pixels.push(current); + let Some(next) = g.neighbours(current).find(|&candidate| candidate != previous && visited.get(candidate) == Some(&false)) else { + closed = g.neighbours(current).any(|neighbour| neighbour == start) && pixels.len() > 2; break; }; - (prev, cur) = (cur, next); + (previous, current) = (current, next); } if guard == 0 { return None; } graph.push_branch( - Branch { pts: px.iter().map(|&i| g.centre(i)).collect(), d: px.into_iter().map(dval).collect(), ends: [None, None], closed }, + Branch { pts: pixels.iter().map(|&i| g.centre(i)).collect(), d: pixels.into_iter().map(dval).collect(), ends: [None, None], closed }, max_branches, )?; } - Some(graph) + Some(()) } impl Graph { diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index bf5bfc402..3c9467d76 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -3,7 +3,12 @@ use super::params::{Method, Mode, TraceParams}; use super::pipeline::bw_tones; use super::pipeline::{TracedPath, is_ignored}; use super::raster::Raster; -use super::*; +use super::{ + AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, + trace_mask, +}; +#[cfg(test)] +use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 85e6373c6..e68118d63 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -79,7 +79,7 @@ use layers::parallel_map_at_most; #[cfg(test)] pub(crate) use layers::shift_probe; pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; -pub use pipeline::{TraceResult, TracedPath, trace, trace_within}; +pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within}; #[cfg(test)] use raster::fit_size; pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 5503a84ff..6ce6ce241 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize, logo}; /// Tracing mode (Illustrator's Mode popup). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 6d3ea27f5..486eb9c0a 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -1,7 +1,9 @@ +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use super::layers::{Layers, parallel_map_at_most}; use super::params::{Method, Mode, TraceParams}; use super::raster::{Raster, TraceError}; -use super::*; +use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, gradient, group, logo, quantize, smooth}; /// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) /// lines stroked with it. @@ -68,11 +70,24 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { /// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The /// colour layers are traced in parallel where threads are available. pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { + trace_watched(img, params, max_anchors, &|_, _| true) +} + +/// [`trace_within`], telling `steps` how far it has got: `steps(done, total)` after each stage +/// that prepares the colour layers (`total` 0 while they aren't known yet) and after each colour +/// layer, from whichever thread traced it. Once `steps` answers `false` the trace stops at the +/// next stage or layer with [`TraceError::Cancelled`]. +pub fn trace_watched( + img: &Raster, + params: &TraceParams, + max_anchors: usize, + steps: &(dyn Fn(u64, u64) -> bool + Sync), +) -> Result { let (w, h) = (img.width as usize, img.height as usize); if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { return Ok(TraceResult::default()); } - let mut out = trace_paths(img, params, max_anchors)?; + let mut out = trace_paths(img, params, max_anchors, steps)?; if params.auto_grouping { // Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can. let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes; @@ -81,9 +96,12 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R Ok(out) } -fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { +fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { let (w, h) = (img.width as usize, img.height as usize); + // After each stage before the colour layers, whose number isn't known yet. + let go_on = || if steps(0, 0) { Ok(()) } else { Err(TraceError::Cancelled) }; if params.mode == Mode::Logo { + go_on()?; return logo::trace_logo(img, params, max_anchors); } // The colour Ignore Color leaves out, if not white. @@ -94,6 +112,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // A photo is traced from a copy with its noise smoothed away (see `smooth`). let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); let img = smoothed.as_ref().unwrap_or(img); + go_on()?; // Gradients are found first and kept out of the palette: no colour is spent on bands of them. // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { @@ -106,6 +125,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result }); let traced_as = grays.as_ref().unwrap_or(img); let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); + go_on()?; let without = found.as_ref().map(|(of, _)| { let mut rest = img.clone(); for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { @@ -116,6 +136,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result rest }); let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); + go_on()?; // The semi-transparent edge of a cut-out image traces as the color beside it. if matches!(params.mode, Mode::Color | Mode::Grayscale) { quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); @@ -138,6 +159,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); + go_on()?; // Pixel count per palette entry, and the box its pixels lie in. let mut counts = vec![0usize; q.palette.len()]; let mut boxes: Vec> = vec![None; q.palette.len()]; @@ -194,7 +216,26 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // freed before the trace starts.) let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1); let at_a_time = ((512 << 20) / (largest * 10)).max(1); - for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { + let total = layers.len() as u64; + if !steps(0, total) { + return Err(TraceError::Cancelled); + } + // Once stopped, the layers not started yet are skipped. + let (done, stopped) = (AtomicU64::new(0), AtomicBool::new(false)); + let traced = parallel_map_at_most(at_a_time, &layers, |&(r, ci)| { + if stopped.load(Ordering::Relaxed) { + return None; + } + let paths = job.layer(r, ci); + if !steps(done.fetch_add(1, Ordering::Relaxed) + 1, total) { + stopped.store(true, Ordering::Relaxed); + } + paths + }); + if stopped.load(Ordering::Relaxed) { + return Err(TraceError::Cancelled); + } + for (paths, &(_, ci)) in traced.into_iter().zip(&layers) { let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?; // A layer with no paths adds no color to the palette. if paths.is_empty() { diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 2e47e64ca..5f0a30568 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::*; +use super::{Quantized, Raster, TRANSPARENT, luma, oklab}; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// diff --git a/crates/trace/src/quantize/denoise.rs b/crates/trace/src/quantize/denoise.rs index 4ca5c29e9..cb40ffa4f 100644 --- a/crates/trace/src/quantize/denoise.rs +++ b/crates/trace/src/quantize/denoise.rs @@ -1,5 +1,5 @@ +use super::TRANSPARENT; use super::rare::RARE_ISLAND; -use super::*; /// Merge whole 4-connected regions, smallest first, into their most common neighbouring label. /// The merge order does not depend on `min_area`: a higher threshold continues the same sequence diff --git a/crates/trace/src/quantize/palette.rs b/crates/trace/src/quantize/palette.rs index 43363b286..8f89a7c96 100644 --- a/crates/trace/src/quantize/palette.rs +++ b/crates/trace/src/quantize/palette.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Palette, Raster, TraceParams, oklab}; #[derive(Clone, Copy, Default)] pub(super) struct Bin { diff --git a/crates/trace/src/quantize/rare.rs b/crates/trace/src/quantize/rare.rs index 69069e48b..d835c5759 100644 --- a/crates/trace/src/quantize/rare.rs +++ b/crates/trace/src/quantize/rare.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, TracedAs, Used, labs, nearest_lab}; -use super::*; +use super::{Raster, oklab}; /// Automatic: the most rare colours added to a flat picture's count. const RARE_BUDGET: usize = 6; @@ -50,287 +50,390 @@ const CUT_EDGE_MIX_FROM: f64 = 0.45; /// salient colours that are rare by area follows the idea of salient-colour preservation in /// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are /// VectorCraft's own. +type ChosenRare = (usize, f64, [f64; 3], usize); + +struct RareGroups { + used: Vec<(usize, [f64; 3], f64)>, + groups: Vec>, + group_of: Vec, +} + +struct RasterGroups<'a> { + width: usize, + height: usize, + pixels: &'a [[u8; 4]], + own: &'a [bool], + choose: &'a [bool], + hk: &'a HistKey, + group_of: &'a [u16], +} + +struct RegionMetrics { + largest: Vec, + islands: Vec, + inner: Vec, + beside_left_out: Vec, +} + +struct BlendContext { + from: Vec, + with: Vec<[f64; 3]>, + pairs: Vec<(usize, usize)>, + near_labs: Vec<[f64; 3]>, + counts_as: Vec>, +} + pub(super) fn rare_colours(img: &Raster, own: &[bool], choose: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> (Vec<[f64; 3]>, Vec) { - let px = img.rgba.as_chunks::<4>().0; - if centers.is_empty() || own.len() != px.len() { + let pixels = img.rgba.as_chunks::<4>().0; + if centers.is_empty() || own.len() != pixels.len() { return (vec![], vec![]); } + let center_labs = labs(centers); + let groups = rare_groups(pixels, own, hk, ¢er_labs); + if groups.groups.is_empty() { + return (vec![], vec![]); + } + let (width, height) = (img.width as usize, img.height as usize); + let raster = RasterGroups { width, height, pixels, own, choose, hk, group_of: &groups.group_of }; + let metrics = region_metrics(&raster, groups.groups.len()); + let chosen = choose_rare_groups(&groups.groups, &groups.used, centers, &metrics); + if chosen.is_empty() { + return (vec![], vec![]); + } + let (onto, other_of) = trace_edge_blends(&raster, &chosen, centers, &metrics); + reindex_traced(chosen, onto, other_of, centers) +} + +fn rare_groups(pixels: &[[u8; 4]], own: &[bool], hk: &HistKey, center_labs: &[[f64; 3]]) -> RareGroups { // The picture's own (not blended) pixels by histogram bin. let mut bins = vec![Bin::default(); 1 << 15]; - for (p, _) in px.iter().zip(own).filter(|(p, f)| **f && p[3] >= 128) { - bins[hk.key(p[0], p[1], p[2])].add(p); + for (pixel, _) in pixels.iter().zip(own).filter(|(pixel, own)| **own && pixel[3] >= 128) { + bins[hk.key(pixel[0], pixel[1], pixel[2])].add(pixel); } - let used: Vec<(usize, [f64; 3], f64)> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); - let used_labs = labs(&used.iter().map(|u| u.1).collect::>()); - let center_labs = labs(centers); + let used: Vec<(usize, [f64; 3], f64)> = + bins.iter().enumerate().filter(|(_, bin)| bin.n > 0).map(|(i, bin)| (i, bin.mean(), bin.n as f64)).collect(); + let used_labs = labs(&used.iter().map(|entry| entry.1).collect::>()); // Each palette colour's main colour: its heaviest bin. - let mut peak: Vec> = vec![None; centers.len()]; - for ((_, _, n), lab) in used.iter().zip(&used_labs) { - let j = nearest_lab(¢er_labs, *lab); - if let Some(slot) = peak.get_mut(j) - && slot.is_none_or(|(m, _)| *n > m) + let mut peak: Vec> = vec![None; center_labs.len()]; + for ((_, _, count), lab) in used.iter().zip(&used_labs) { + let index = nearest_lab(center_labs, *lab); + if let Some(slot) = peak.get_mut(index) + && slot.is_none_or(|(largest, _)| *count > largest) { - *slot = Some((*n, *lab)); + *slot = Some((*count, *lab)); } } - let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt(); - let apart = |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, l)| l)).all(|c| dist(lab, *c) >= RARE_APART); - // The bins far from every colour, heaviest first, grouped: a bin joins the first group whose - // heaviest bin is nearer than RARE_APART. - let mut far: Vec = (0..used.len()).filter(|&u| used_labs.get(u).is_some_and(|l| apart(*l))).collect(); + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let far_from_palette = + |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, lab)| lab)).all(|center| distance(lab, *center) >= RARE_APART); + // Group far bins by their heaviest member, in descending weight order. + let mut far: Vec = (0..used.len()).filter(|&index| used_labs.get(index).is_some_and(|lab| far_from_palette(*lab))).collect(); far.sort_by(|&a, &b| used[b].2.total_cmp(&used[a].2).then(a.cmp(&b))); let mut group_of = vec![u16::MAX; 1 << 15]; let mut groups: Vec> = vec![]; - for u in far { - let (Some(lab), Some(&(bin, ..))) = (used_labs.get(u), used.get(u)) else { continue }; - let at = groups.iter().position(|g| g.first().and_then(|&f| used_labs.get(f)).is_some_and(|l| dist(*l, *lab) < RARE_APART)); - let g = match at { - Some(g) => g, + for index in far { + let (Some(lab), Some(&(bin, ..))) = (used_labs.get(index), used.get(index)) else { continue }; + let group = groups + .iter() + .position(|members| members.first().and_then(|&first| used_labs.get(first)).is_some_and(|first| distance(*first, *lab) < RARE_APART)); + let group = match group { + Some(group) => group, None if groups.len() < 64 => { groups.push(vec![]); groups.len() - 1 } None => continue, }; - if let Some(members) = groups.get_mut(g) { - members.push(u); + if let Some(members) = groups.get_mut(group) { + members.push(index); } - group_of[bin] = g as u16; + group_of[bin] = group as u16; } - if groups.is_empty() { - return (vec![], vec![]); - } - // The largest 4-connected region of each group's own pixels. - let (w, h) = (img.width as usize, img.height as usize); - let group_at = |i: usize| -> Option { - let p = px.get(i)?; - (own.get(i).copied().unwrap_or(false) && p[3] >= 128).then(|| group_of[hk.key(p[0], p[1], p[2])]).filter(|&g| g != u16::MAX) + RareGroups { used, groups, group_of } +} + +fn group_at(pixels: &[[u8; 4]], own: &[bool], hk: &HistKey, group_of: &[u16], index: usize) -> Option { + let pixel = pixels.get(index)?; + (own.get(index).copied().unwrap_or(false) && pixel[3] >= 128) + .then(|| group_of.get(hk.key(pixel[0], pixel[1], pixel[2])).copied().unwrap_or(u16::MAX)) + .filter(|&group| group != u16::MAX) +} + +fn region_metrics(raster: &RasterGroups<'_>, group_count: usize) -> RegionMetrics { + let RasterGroups { width, height, pixels, own, choose, hk, group_of } = *raster; + let mut metrics = RegionMetrics { + largest: vec![0; group_count], + islands: vec![0; group_count], + inner: vec![0; group_count], + beside_left_out: vec![false; group_count], }; - let mut largest = vec![0usize; groups.len()]; - let mut islands = vec![0usize; groups.len()]; - // Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel - // wide and has next to none. - let mut inner = vec![0usize; groups.len()]; - // Groups with a pixel beside a pixel that `choose` leaves out (see `choosing_pixels`): the blend - // test of `own` saw no neighbor on that side. - let mut beside_left_out = vec![false; groups.len()]; - let left_out = |j: usize| px.get(j).is_some_and(|p| p[3] >= 128) && !choose.get(j).copied().unwrap_or(true); - let mut seen = vec![false; w * h]; + let left_out = |index: usize| pixels.get(index).is_some_and(|pixel| pixel[3] >= 128) && !choose.get(index).copied().unwrap_or(true); + let mut seen = vec![false; width * height]; let mut stack = vec![]; - for start in 0..w * h { - let Some(g) = group_at(start).filter(|_| !seen[start]) else { continue }; + for start in 0..width * height { + let Some(group) = group_at(pixels, own, hk, group_of, start).filter(|_| !seen[start]) else { continue }; seen[start] = true; stack.push(start); let mut size = 0; - while let Some(i) = stack.pop() { + while let Some(index) = stack.pop() { size += 1; - let (x, y) = (i % w, i / w); + let (x, y) = (index % width, index / width); let mut alike = 0; - for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() { - if group_at(j) == Some(g) { + for neighbour in [ + (x > 0).then(|| index - 1), + (x + 1 < width).then(|| index + 1), + (y > 0).then(|| index - width), + (y + 1 < height).then(|| index + width), + ] + .into_iter() + .flatten() + { + if group_at(pixels, own, hk, group_of, neighbour) == Some(group) { alike += 1; - if !seen[j] { - seen[j] = true; - stack.push(j); + if !seen[neighbour] { + seen[neighbour] = true; + stack.push(neighbour); } } } if alike == 4 - && let Some(n) = inner.get_mut(g as usize) + && let Some(count) = metrics.inner.get_mut(group as usize) { - *n += 1; + *count += 1; } - if let Some(b) = beside_left_out.get_mut(g as usize).filter(|b| !**b) { - let (x0, y0, x1, y1) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(w - 1), (y + 1).min(h - 1)); - *b = (y0..=y1).any(|ny| (x0..=x1).any(|nx| left_out(ny * w + nx))); + if let Some(beside) = metrics.beside_left_out.get_mut(group as usize).filter(|beside| !**beside) { + let (left, top, right, bottom) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(width - 1), (y + 1).min(height - 1)); + *beside = (top..=bottom).any(|near_y| (left..=right).any(|near_x| left_out(near_y * width + near_x))); } } - if let Some(l) = largest.get_mut(g as usize) { - *l = (*l).max(size); + if let Some(largest) = metrics.largest.get_mut(group as usize) { + *largest = (*largest).max(size); } - if let Some(n) = islands.get_mut(g as usize).filter(|_| size >= RARE_ISLAND) { - *n += 1; + if let Some(islands) = metrics.islands.get_mut(group as usize).filter(|_| size >= RARE_ISLAND) { + *islands += 1; } } - // Coherent and flat groups, largest region first. - let mut chosen: Vec<(usize, f64, [f64; 3], usize)> = vec![]; - for (g, members) in groups.iter().enumerate() { - let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum(); - let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2); - let region = largest.get(g).copied().unwrap_or(0); - let coherent = region >= RARE_REGION || islands.get(g).is_some_and(|&n| n >= RARE_ISLANDS); + metrics +} + +fn choose_rare_groups(groups: &[Vec], used: &[(usize, [f64; 3], f64)], centers: &[[f64; 3]], metrics: &RegionMetrics) -> Vec { + let mut chosen = Vec::new(); + for (group, members) in groups.iter().enumerate() { + let weight: f64 = members.iter().filter_map(|&index| used.get(index)).map(|entry| entry.2).sum(); + let heaviest = members.first().and_then(|&index| used.get(index)).map_or(0.0, |entry| entry.2); + let region = metrics.largest.get(group).copied().unwrap_or(0); + let coherent = region >= RARE_REGION || metrics.islands.get(group).is_some_and(|&count| count >= RARE_ISLANDS); if !coherent || heaviest < 0.5 * weight || weight <= 0.0 { continue; } let mut mean = [0.0; 3]; - for u in members.iter().filter_map(|&u| used.get(u)) { - for (m, c) in mean.iter_mut().zip(u.1) { - *m += c * u.2 / weight; + for entry in members.iter().filter_map(|&index| used.get(index)) { + for (mean_channel, channel) in mean.iter_mut().zip(entry.1) { + *mean_channel += channel * entry.2 / weight; } } - // Not an edge's blend of two colours that the blend test let through (a thin ring of - // anti-aliasing between two bands is coherent too): a colour that lies between two others - // is kept only with an inside, pixels whose neighbours are all of it. - if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) { + // A color lying between two others needs a solid inside, not just a thin blended rim. + if is_mix(mean, centers) && metrics.inner.get(group).is_none_or(|&count| count < RARE_ISLAND) { continue; } - chosen.push((region, weight, mean, g)); + chosen.push((region, weight, mean, group)); } chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))); - // Beside the pixels that choose no color, the blend test of `own` does not see the - // anti-aliasing between a palette color and a rare color drawn along the cut edge. There, a - // color without an inside that lies between two colors, one of them another kept rare color, - // and at least CUT_EDGE_MIX_FROM of the way from the other to it, is that anti-aliasing when at - // least one in CUT_EDGE_MIX_TOUCH of its pixels has pixels of both colors around it: it is not - // kept, and it traces as that rare color (the nearer one when there are several). Art that - // reaches a rim drawn along the edge touches it in a few places only, and the rim keeps its - // color. The smallest regions are tested first, so that of two such colors, one stays, and a - // color found to trace as another counts as that color. - let tested = |g: usize| beside_left_out.get(g).copied().unwrap_or(false) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND); - // The pixels of each color tested, gathered in one pass over the image. - let mut pixels_of: Vec> = vec![vec![]; groups.len()]; - let wanted: Vec = (0..groups.len()).map(|g| tested(g) && chosen.iter().any(|c| c.3 == g)).collect(); + chosen +} + +fn trace_edge_blends( + raster: &RasterGroups<'_>, + chosen: &[ChosenRare], + centers: &[[f64; 3]], + metrics: &RegionMetrics, +) -> (Vec>, Vec>) { + let RasterGroups { width, height, pixels, own, choose, hk, group_of } = *raster; + let group_count = metrics.largest.len(); + let tested = |group: usize| { + metrics.beside_left_out.get(group).copied().unwrap_or(false) && metrics.inner.get(group).is_none_or(|&count| count < RARE_ISLAND) + }; + let wanted: Vec = (0..group_count).map(|group| tested(group) && chosen.iter().any(|candidate| candidate.3 == group)).collect(); + let mut pixels_of = vec![vec![]; group_count]; if wanted.contains(&true) { - for i in 0..w * h { - if let Some(g) = group_at(i).map(usize::from).filter(|&g| wanted.get(g).copied().unwrap_or(false)) - && let Some(p) = pixels_of.get_mut(g) + for index in 0..width * height { + if let Some(group) = + group_at(pixels, own, hk, group_of, index).map(usize::from).filter(|&group| wanted.get(group).copied().unwrap_or(false)) + && let Some(pixels) = pixels_of.get_mut(group) { - p.push(i); + pixels.push(index); } } } - let mut onto: Vec> = vec![None; chosen.len()]; - // For each color in `onto`, where the color on the other side of it comes from (see `from` - // below), or `None` when the color traces as the rare color alone. - let mut other_of: Vec> = vec![None; chosen.len()]; - for k in (0..chosen.len()).rev() { - let Some(&(_, _, c, g)) = chosen.get(k) else { continue }; - if !tested(g) { + let mut onto = vec![None; chosen.len()]; + let mut other_of = vec![None; chosen.len()]; + for index in (0..chosen.len()).rev() { + let Some(&(_, _, color, group)) = chosen.get(index) else { continue }; + if !tested(group) { continue; } - // The palette colors, then the other rare colors still kept, and where each comes from: a - // palette color's index, or the number of palette colors plus the index in `chosen`. - let from: Vec = (0..centers.len()) - .chain(chosen.iter().zip(&onto).enumerate().filter(|(o, (_, t))| *o != k && t.is_none()).map(|(o, _)| centers.len() + o)) - .collect(); - let with: Vec<[f64; 3]> = centers - .iter() - .copied() - .chain(from.iter().skip(centers.len()).filter_map(|&f| chosen.get(f.checked_sub(centers.len())?)).map(|x| x.2)) - .collect(); - let pairs: Vec<(usize, usize)> = (0..with.len()) - .flat_map(|a| (a + 1..with.len()).map(move |b| (a, b))) - .filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&x), Some(&y)) if is_mix(c, &[x, y]))) - .collect(); - if pairs.is_empty() { - continue; + let Some(context) = blend_context(index, color, chosen, &onto, centers) else { continue }; + let Some(group_pixels) = pixels_of.get(group) else { continue }; + let (both, pixel_count) = blend_neighbour_counts(group_pixels, width, height, pixels, choose, &context); + let best = best_blend_target(color, chosen, centers, metrics, &context, &both, pixel_count); + if let Some(target) = onto.get_mut(index) { + *target = best.map(|candidate| candidate.1); } - // Which of `with` the pixels in the 3 × 3 around the color's own pixels are: the nearest - // among `with`, the color itself and the colors that trace as one of `with`. A pixel that - // chooses no color counts only within RARE_APART of the one it is nearest to (beyond the - // cut edge, an outline over the edge shows its own color). - let mut near = with.clone(); - near.push(c); - let mut counts_as: Vec> = (0..with.len()).map(Some).chain([None]).collect(); - for (x, t) in chosen.iter().zip(&onto) { - if let Some(t) = t { - near.push(x.2); - counts_as.push(with.iter().position(|w| w == t)); - } + if let Some(other) = other_of.get_mut(index) { + *other = best.and_then(|candidate| candidate.2); } - let near_labs = labs(&near); - // For each pair, how many of the color's own pixels have pixels of both around them. - let mut both = vec![0usize; pairs.len()]; - let mut pixels = 0usize; - let mut around = vec![false; with.len()]; - for &i in pixels_of.get(g).map_or(&[][..], Vec::as_slice) { - let (x, y) = (i % w, i / w); - pixels += 1; - around.fill(false); - for ny in y.saturating_sub(1)..=(y + 1).min(h - 1) { - for nx in x.saturating_sub(1)..=(x + 1).min(w - 1) { - let j = ny * w + nx; - let Some(p) = px.get(j).filter(|p| p[3] >= 128) else { continue }; - let o = oklab([p[0], p[1], p[2]].map(f64::from)); - let n = nearest_lab(&near_labs, o); - if !choose.get(j).copied().unwrap_or(false) && near_labs.get(n).is_none_or(|l| dist(*l, o) >= RARE_APART) { - continue; - } - if let Some(&Some(a)) = counts_as.get(n) - && let Some(t) = around.get_mut(a) - { - *t = true; - } - } - } - for (n, &(a, b)) in pairs.iter().enumerate() { - if around.get(a).copied().unwrap_or(false) - && around.get(b).copied().unwrap_or(false) - && let Some(v) = both.get_mut(n) + } + (onto, other_of) +} + +fn blend_context(index: usize, color: [f64; 3], chosen: &[ChosenRare], onto: &[Option<[f64; 3]>], centers: &[[f64; 3]]) -> Option { + let from: Vec = (0..centers.len()) + .chain( + chosen + .iter() + .zip(onto) + .enumerate() + .filter(|(other, (_, target))| *other != index && target.is_none()) + .map(|(other, _)| centers.len() + other), + ) + .collect(); + let with: Vec<[f64; 3]> = centers + .iter() + .copied() + .chain(from.iter().skip(centers.len()).filter_map(|&source| chosen.get(source.checked_sub(centers.len())?)).map(|entry| entry.2)) + .collect(); + let pairs: Vec<(usize, usize)> = (0..with.len()) + .flat_map(|a| (a + 1..with.len()).map(move |b| (a, b))) + .filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&left), Some(&right)) if is_mix(color, &[left, right]))) + .collect(); + if pairs.is_empty() { + return None; + } + let mut near = with.clone(); + near.push(color); + let mut counts_as: Vec> = (0..with.len()).map(Some).chain([None]).collect(); + for (candidate, target) in chosen.iter().zip(onto) { + if let Some(target) = target { + near.push(candidate.2); + counts_as.push(with.iter().position(|other| other == target)); + } + } + Some(BlendContext { from, with, pairs, near_labs: labs(&near), counts_as }) +} + +fn blend_neighbour_counts( + group_pixels: &[usize], + width: usize, + height: usize, + pixels: &[[u8; 4]], + choose: &[bool], + context: &BlendContext, +) -> (Vec, usize) { + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let mut both = vec![0; context.pairs.len()]; + let mut pixel_count = 0; + let mut around = vec![false; context.with.len()]; + for &index in group_pixels { + let (x, y) = (index % width, index / width); + pixel_count += 1; + around.fill(false); + for near_y in y.saturating_sub(1)..=(y + 1).min(height - 1) { + for near_x in x.saturating_sub(1)..=(x + 1).min(width - 1) { + let neighbour = near_y * width + near_x; + let Some(pixel) = pixels.get(neighbour).filter(|pixel| pixel[3] >= 128) else { continue }; + let lab = oklab([pixel[0], pixel[1], pixel[2]].map(f64::from)); + let nearest = nearest_lab(&context.near_labs, lab); + if !choose.get(neighbour).copied().unwrap_or(false) + && context.near_labs.get(nearest).is_none_or(|candidate| distance(*candidate, lab) >= RARE_APART) { - *v += 1; - } - } - } - let lab = oklab(c); - // The nearest rare color it traces as, with where the other color of its pair comes from - // when its pixels split between the two (see below). - let mut best: Option<(f64, [f64; 3], Option)> = None; - for (n, &(a, b)) in pairs.iter().enumerate() { - if both.get(n).is_none_or(|&v| v == 0 || v * CUT_EDGE_MIX_TOUCH < pixels) { - continue; - } - for (e, f) in [(a, b), (b, a)].into_iter().filter(|&(e, _)| e >= centers.len()) { - let (Some(&x), Some(&y)) = (with.get(e), with.get(f)) else { continue }; - // How far `c` lies from the other color toward this one. - let d2: f64 = (0..3).map(|q| (x[q] - y[q]).powi(2)).sum(); - let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - y[q]) * (x[q] - y[q])).sum::() / d2 } else { 0.0 }; - if t < CUT_EDGE_MIX_FROM { continue; } - let d = dist(oklab(x), lab); - if best.is_none_or(|b| d < b.0) { - // A rare color along the cut edge with a region of RARE_REGION pixels is a line - // of its own there: the anti-aliasing beside it splits between it and the other - // color. Otherwise the anti-aliasing is most of a thin line, or the part of a - // line over the edge that lies inside it, and traces as the rare color alone. - let body = from - .get(e) - .and_then(|&m| chosen.get(m.checked_sub(centers.len())?)) - .is_some_and(|r| r.0 >= RARE_REGION && beside_left_out.get(r.3).copied().unwrap_or(false)); - best = Some((d, x, from.get(f).copied().filter(|_| body))); + if let Some(&Some(group)) = context.counts_as.get(nearest) + && let Some(is_near) = around.get_mut(group) + { + *is_near = true; } } } - if let Some(o) = onto.get_mut(k) { - *o = best.map(|b| b.1); - } - if let Some(o) = other_of.get_mut(k) { - *o = best.and_then(|b| b.2); + for (pair_index, &(a, b)) in context.pairs.iter().enumerate() { + if around.get(a).copied().unwrap_or(false) + && around.get(b).copied().unwrap_or(false) + && let Some(count) = both.get_mut(pair_index) + { + *count += 1; + } } } - let (out, traced_as) = kept_rare(chosen.iter().zip(&onto).zip(&other_of).map(|(((_, _, c, _), o), &from)| (*c, *o, from))); - // The index in `centers` followed by `out` of a palette color or a rare color, or of the kept - // rare color one that is not kept traces as. - let index = |from: usize| -> Option { - let Some(m) = from.checked_sub(centers.len()) else { return Some(from) }; - let (_, _, x, _) = chosen.get(m)?; - let x = onto.get(m).copied().flatten().unwrap_or(*x); - Some(centers.len() + out.iter().position(|o| *o == x)?) + (both, pixel_count) +} + +fn best_blend_target( + color: [f64; 3], + chosen: &[ChosenRare], + centers: &[[f64; 3]], + metrics: &RegionMetrics, + context: &BlendContext, + both: &[usize], + pixel_count: usize, +) -> Option<(f64, [f64; 3], Option)> { + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let lab = oklab(color); + let mut best = None; + for (pair_index, &(a, b)) in context.pairs.iter().enumerate() { + if both.get(pair_index).is_none_or(|&count| count == 0 || count * CUT_EDGE_MIX_TOUCH < pixel_count) { + continue; + } + for (edge, other) in [(a, b), (b, a)].into_iter().filter(|&(edge, _)| edge >= centers.len()) { + let (Some(&edge_color), Some(&other_color)) = (context.with.get(edge), context.with.get(other)) else { continue }; + let squared = (0..3).map(|channel| (edge_color[channel] - other_color[channel]).powi(2)).sum::(); + let along = if squared > 0.0 { + (0..3).map(|channel| (color[channel] - other_color[channel]) * (edge_color[channel] - other_color[channel])).sum::() / squared + } else { + 0.0 + }; + if along < CUT_EDGE_MIX_FROM { + continue; + } + let separation = distance(oklab(edge_color), lab); + if best.is_none_or(|candidate: (f64, [f64; 3], Option)| separation < candidate.0) { + // A substantial rare region along the edge keeps the identity of its other side. + let has_body = context + .from + .get(edge) + .and_then(|&source| chosen.get(source.checked_sub(centers.len())?)) + .is_some_and(|rare| rare.0 >= RARE_REGION && metrics.beside_left_out.get(rare.3).copied().unwrap_or(false)); + best = Some((separation, edge_color, context.from.get(other).copied().filter(|_| has_body))); + } + } + } + best +} + +fn reindex_traced( + chosen: Vec, + onto: Vec>, + other_of: Vec>, + centers: &[[f64; 3]], +) -> (Vec<[f64; 3]>, Vec) { + let (output, traced) = kept_rare(chosen.iter().zip(&onto).zip(&other_of).map(|(((_, _, color, _), target), &other)| (*color, *target, other))); + let output_index = |source: usize| -> Option { + let Some(index) = source.checked_sub(centers.len()) else { return Some(source) }; + let (_, _, color, _) = chosen.get(index)?; + let color = onto.get(index).copied().flatten().unwrap_or(*color); + Some(centers.len() + output.iter().position(|candidate| *candidate == color)?) }; - // A color whose rare color is not kept (past the budget) is left out. - let traced_as = traced_as + let traced = traced .into_iter() - .filter_map(|(c, o, from)| { - let rare = centers.len() + out.iter().position(|x| *x == o)?; - let other = match from { - Some(f) => index(f)?, + .filter_map(|(color, target, other)| { + let rare = centers.len() + output.iter().position(|candidate| *candidate == target)?; + let other = match other { + Some(source) => output_index(source)?, None => rare, }; - Some((c, rare, other)) + Some((color, rare, other)) }) .collect(); - (out, traced_as) + (output, traced) } /// Of `colors` (a rare color, the rare color it traces as, and where the other color of its pair diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index f096a97d0..fc7cd10f6 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -15,6 +15,9 @@ pub enum TraceError { "this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset" )] TooComplex { max: usize }, + /// The caller of [`crate::trace_watched`] stopped the trace. + #[error("the trace was cancelled")] + Cancelled, } /// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 6ad909495..f5a10d494 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -586,6 +586,28 @@ fn a_trace_past_its_anchor_budget_is_refused() { assert_eq!(trace_within(&img, &p, anchors).unwrap(), full); } +/// #3098: a watched trace says how far it has got, layer by layer, and stops when told to. +#[test] +fn a_watched_trace_counts_its_layers_and_stops_when_told() { + let img = stripes(120, 8); + let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() }; + let seen = std::sync::Mutex::new(vec![]); + let watched = trace_watched(&img, &p, usize::MAX, &|done, total| { + seen.lock().unwrap().push((done, total)); + true + }); + assert_eq!(watched.unwrap(), trace(&img, &p), "watching changes nothing"); + let seen = seen.into_inner().unwrap(); + let total = seen.last().unwrap().1; + assert!(total > 1, "{seen:?}"); + let mut layers: Vec = seen.iter().filter(|s| s.1 == total && s.0 > 0).map(|s| s.0).collect(); + layers.sort_unstable(); + assert_eq!(layers, (1..=total).collect::>(), "each layer once: {seen:?}"); + // Stopped before the layers, and after the first. + assert!(matches!(trace_watched(&img, &p, usize::MAX, &|_, _| false), Err(TraceError::Cancelled))); + assert!(matches!(trace_watched(&img, &p, usize::MAX, &|done, _| done == 0), Err(TraceError::Cancelled))); +} + /// Layers traced in parallel come out in the same order every time: bottom (largest) first. #[test] fn parallel_layers_keep_their_order() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 86b75c03d..6e2527fb6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -8,7 +8,12 @@ use super::state::{ CaptureEpoch, GeometryStamp, InputGeneration, MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, SCREENSHOT_TIMEOUT_MS, SYSTEM_CLIPBOARD_POLL, take_frame_once, }; -use super::*; +use super::{ + Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome, + clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette, + panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme, + titlebar, toolbar, tools_float, trace_runs, unsaved, updates_ui, widgets, +}; mod clipboard; mod commands; @@ -54,6 +59,8 @@ impl VectorcraftApp { from_worker: false, waits_for_still: cfg!(target_arch = "wasm32"), still: None, + fill: None, + fill_worker: None, worker: None, worker_started: false, slices: None, @@ -72,6 +79,7 @@ impl VectorcraftApp { titlebar_inset: 0.0, last_effect: None, last_warp: None, + character_focus: None, run_count: 0, clipboard_out: None, clipboard_published: None, @@ -113,6 +121,7 @@ impl VectorcraftApp { opening: Default::default(), plugin_runs: Default::default(), placing: Default::default(), + trace_runs: Default::default(), recovery: Default::default(), host_modifiers: Default::default(), forwarded: Vec::new(), @@ -281,6 +290,7 @@ impl VectorcraftApp { screen_color::poll(self, ctx); opening::poll(self); plugin_runs::poll(self); + trace_runs::poll(self); placing::poll(self); #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); @@ -288,6 +298,7 @@ impl VectorcraftApp { || !self.opening.pending.is_empty() || !self.plugin_runs.running.is_empty() || !self.placing.pending.is_empty() + || !self.trace_runs.running.is_empty() { // Keep the status bar's progress moving and pick the result up when it arrives. ctx.request_repaint_after(std::time::Duration::from_millis(100)); @@ -479,6 +490,7 @@ impl VectorcraftApp { updates_ui::show(self, &ctx); export_progress::show(self, &ctx); plugin_runs::show(self, &ctx); + trace_runs::show(self, &ctx); palette::show(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index b6260b661..5efe2db30 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; impl VectorcraftApp { /// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b31f42d85..0f136e65e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,5 +1,5 @@ use super::fonts::MAX_RECENT_FONTS; -use super::*; +use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar}; impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 13eca92db..bbdf60bef 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS, + VectorcraftApp, file_access, json, now_ms, +}; use crate::control; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 03bcc240b..867494253 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, io, place}; impl VectorcraftApp { /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index 54fd9f86d..fd34eafc6 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, json}; /// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). pub const MAX_RECENT_FONTS: usize = 15; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 19374770f..5fd9dd8d0 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index 05222c39f..4a020d407 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; use crate::ime; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 8b9f96760..986ec60ae 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once}; +#[cfg(not(target_arch = "wasm32"))] +use super::{fileio, place}; impl VectorcraftApp { /// The first UI pass of a host iteration takes the host's input, once per context, viewport and @@ -122,6 +124,48 @@ impl VectorcraftApp { self.synthetic_frame = false; } + /// Release the pointer buttons egui still holds down when the system reports that no mouse + /// button is down (`system_held` is false), at the pointer's last position. egui ends a drag + /// only on a button release or Escape, so a release the window never received leaves a drag, + /// such as a dock column's resize, following the pointer (#4326). Frames that carry a button + /// event, and control-channel input that holds a button between steps, are left alone. + pub fn release_lost_buttons(&self, ctx: &egui::Context, raw: &mut egui::RawInput, system_held: bool) { + if system_held + || self.synthetic_injected + || !self.synthetic.is_empty() + || raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { .. })) + { + return; + } + let (down, pos, modifiers) = ctx.input_for(raw.viewport_id, |i| { + let p = &i.pointer; + let down: Vec = [ + egui::PointerButton::Primary, + egui::PointerButton::Secondary, + egui::PointerButton::Middle, + egui::PointerButton::Extra1, + egui::PointerButton::Extra2, + ] + .into_iter() + .filter(|b| p.button_down(*b)) + .collect(); + (down, p.latest_pos().or_else(|| p.interact_pos()).or_else(|| p.press_origin()), i.modifiers) + }); + // The releases go after this frame's last pointer movement, at its position, so a pointer + // that moved away from the press does not end it with a click at the press position. + let moved = raw.events.iter().enumerate().rev().find_map(|(i, e)| match e { + egui::Event::PointerMoved(p) => Some((i + 1, *p)), + _ => None, + }); + let (at, pos) = match moved { + Some((at, p)) => (at, Some(p)), + None => (0, pos), + }; + let Some(pos) = pos else { return }; + let releases = down.into_iter().map(|button| egui::Event::PointerButton { pos, button, pressed: false, modifiers }); + raw.events.splice(at..at, releases); + } + /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host /// iteration), carrying control provenance until a settled UI pass has taken the batch. The /// batch belongs to the one root viewport the shipped hosts draw (see diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index e4c6fc925..7c8c4abd6 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::dialogs; +use super::{VectorcraftApp, browsable_url}; impl VectorcraftApp { /// Open a link in the browser (Help → Discord, website, GitHub…). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 39b202330..24288594c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; impl VectorcraftApp { /// Was Preferences confirmed since the last call? The host then writes the preferences file, diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index a680f11b2..ce97fc747 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, CaptureEpoch, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, + VectorcraftApp, file_access, json, now_ms, +}; use crate::control; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 2306d1f45..6aac1150e 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Value, VectorcraftApp, View, ViewInfo, panels}; impl VectorcraftApp { /// Keep `views` aligned with the session's documents (a new one starts at its saved view). diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d5b2e6e1d..9c9e41e7d 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{canvas, cursors, render_worker}; /// Cached canvas raster. pub struct CanvasCache { @@ -24,6 +24,12 @@ pub struct CanvasCache { /// The cache key the canvas last asked for and when it first asked for it (seconds, egui's /// input time), kept while [`Self::waits_for_still`] is set. pub still: Option<(CacheKey, f64)>, + /// The low-resolution frame that covers the parts of the canvas the frame shown leaves + /// uncovered while it is drawn for a view it was not rendered for ([`canvas::Fill`]). + pub fill: Option, + /// The thread that renders [`Self::fill`], beside [`Self::worker`]. None on wasm, which has no + /// fill. + pub fill_worker: Option, pub worker: Option, pub worker_started: bool, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d590c01f6..4482a1283 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,6 +1,7 @@ //! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas //! selection visuals (bounding box, anchors, handles, smart-guide style labels). +use egui::emath::GuiRounding as _; use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use serde_json::json; use vectorcraft_doc::text::TextKind; @@ -18,6 +19,7 @@ mod artboards; mod context; mod cursor; mod drag; +mod fill; mod grid; mod home; mod input; @@ -39,6 +41,10 @@ pub use drag::{apply_requests, dispatch, mods}; pub(crate) use drag::{end_document_drag, release_art_in_window, release_over_panel}; #[cfg(test)] use drag::{frame_moves, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_press_doc_id}; +pub use fill::Fill; +use fill::fill_uncovered; +#[cfg(test)] +use fill::{fill_view, polygon_area}; #[cfg(test)] use grid::ruler_rects; use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; @@ -196,6 +202,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full }; let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; + // Dock layout can differ by fractions of a device pixel between fresh windows. + // Use one pixel-aligned rectangle for drawing, inspection and pointer transforms. + let rect = rect.round_to_pixels(ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); let fitted = app.view().is_some_and(|v| v.fitted); if !fitted { @@ -206,21 +215,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { scroll_bars(app, ui, rect, outer, &t); } let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); - // A press on the canvas while the context menu is open only closes it (a drag too, which egui - // alone would leave open); the tool doesn't get it. - if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { - // The artboard's 3D gizmo owns these gestures while its options are open. - // Do not let a ring drag also move or deselect the editable source path. - } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { - egui::Popup::close_all(ui.ctx()); - } else { - handle_input(app, ui, &resp, rect); - // `hover_doc` is the document point under the pointer in the view `handle_input` left. The - // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view - // there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); - app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p)); - } + canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. @@ -241,35 +236,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let doc = st.doc.clone(); let mask_view = st.shown_mask(); - // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the - // simulated paper colour; a white Background Contents hides the grid). - // An artboard with a background color draws its paper in that color. With Simulate Colored Paper - // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, - // rounded. The transparency grid, while it shows, covers it as it covers the others. - painter.rect_filled(rect, 0.0, t.pasteboard); - if app.ui.view.artboards && !app.ui.view.outline { - let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); - let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; - for ab in &doc.artboards { - let q = xf.quad(ab.rect); - // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). - let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); - painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); - painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); - match &grid { - Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell), - None => { - let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); - } - } - } - } else if app.ui.view.outline { - for ab in &doc.artboards { - painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); - } - } + paint_paper(app, ui, &painter, &xf, rect, &t); // The grid behind the art (Guides & Grid › Grids In Back), else over it below. let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs)); let grids_in_back = app.session.prefs.grids_in_back; @@ -290,9 +257,213 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; + let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images)); + // Placed documents' bitmaps are being made: draw again when they are ready. + if vectorcraft_render::placed_document::busy() { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + poll_worker(app, ui, key.doc); + // With `waits_for_still` set (the web build, which has no render worker), a heavy document is + // not rendered while only the view changes: the frame shown is drawn reprojected below, and the + // first frame after the view has been still for STILL_SECS renders. + let wait = if app.canvas.waits_for_still { + let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); + still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time)) + } else { + None + }; + if let Some(secs) = wait { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); + } + if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { + let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let opts = frame_options(app, &key, mask_view, smooth_images); + draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel }); + } + // The status bar reports each image that a renderer (this thread's or the worker's) could not + // decode, once per image in the process. + let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); + if undecoded > 0 { + app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes")); + } + fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf); + reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation)); + if let Some(look) = grid_look.filter(|_| !grids_in_back) { + grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); + } + // The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from + // 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show. + if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid { + grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false }); + } + artboard_outlines(app, ui, &painter, &xf, &t); + if app.ui.view.guides { + guide_lines(app, &painter, &xf, rect, &t); + } + + if !app.session.slices_hidden() { + slice_overlay(app, &painter, &xf); + } + if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" { + print_tiling_overlay(app, &painter, &xf); + } + original_path_overlay(app, &painter, &xf); + + let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); + let view_info = app.view_info(); + let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, hover_still), revolve_edit, view_info); + draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); + if !revolve_edit { + shaper_widget_overlay(app, &painter, &xf); + } + ime_geometry(app, ui.ctx(), &xf); + crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); + // A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial + // at the canvas center, the point the view turns about. + if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::RotateView { .. })) { + rotate_view_dial(&painter, rect.center(), v.rotation, &t); + } + + if app.ui.view.rulers && app.ui.screen_mode < 3 { + ruler_bands(app, ui, &xf, (full, rect), origin_preview, &t); + } + // The task bar is not drawn while the Simplify or Smooth slider shows. + let strip = crate::dialogs::simplify_slider::showing(app) || crate::dialogs::smooth_slider::showing(app); + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !strip { + task_bar(app, ui, &xf); + } + if app.ui.screen_mode < 3 { + crate::free_transform::show(app, ui, xf.rect); + } + artboard_name_editor(app, ui, &xf); + if resp.hovered() { + canvas_cursor(app, ui, view_info, hover_still); + } +} + +/// The pointer on the canvas: the tool's input, unless the context menu or the 3D gizmo has the press. +fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { + // A press on the canvas while the context menu is open only closes it (a drag too, which egui + // alone would leave open); the tool doesn't get it. + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { + // The artboard's 3D gizmo owns these gestures while its options are open. + // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + egui::Popup::close_all(ui.ctx()); + } else { + handle_input(app, ui, resp, rect); + // `hover_doc` is the document point under the pointer in the view `handle_input` left. The + // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view + // there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting + // showed through them: #4518), unless a drag that began on the canvas goes on there. + let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + let dragging = ui.data(|d| d.get_temp::(drag_id()).is_some()); + app.hover_doc = + ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p)); + } +} + +/// The pointer over the canvas: the view tools' cursors, else the tool's own. +fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engine::ViewInfo, hover_still: bool) { + if opening::long_operation_busy(app) { + ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy)); + return; + } + let m = ui.input(|i| i.modifiers); + let space = ui.input(|i| i.key_down(egui::Key::Space)); + let drag = ui.data(|d| d.get_temp::(drag_id())); + let panning = matches!(drag, Some(Drag::Pan { .. })); + // A zoom drag keeps its cursor when Space is pressed (with or without Shift) or Cmd is let go. + let zoom_drag = matches!(drag, Some(Drag::ZoomBox { .. } | Drag::ZoomScrub { .. })); + let zooming = zoom_drag || if space { m.command } else { app.session.tool_id() == "zoom" }; + // A rotating drag keeps its cursor when Shift is let go. + let rotating = matches!(drag, Some(Drag::RotateView { .. })) + || (!zoom_drag && if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }); + // The hand and the magnifier are drawn like the tool cursors below: Windows has no + // system cursor for them (winit shows the four-headed move cursor for Grab). + let view_cursor = if panning { + Some(Cursor::HandGrab) + } else if rotating { + Some(Cursor::Rotate) + } else if zooming { + Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) + } else if space || app.session.tool_id() == "hand" { + Some(Cursor::Hand) + } else { + None + }; + // In Presentation Mode the pointer is the default one, with no tool cursor. + let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); + let cur = if let Some(c) = tool_cursor.flatten() { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor"))); + // The loaded place cursor carries the file's thumbnail. + if app.session.tool_id() == "place" + && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) + { + crate::place::paint_cursor(app, ui.ctx(), &painter, hp); + } + // The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers + // into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs. + let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); + // User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling; + // off, they keep their size. + let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() }; + match ui.input(|i| i.pointer.hover_pos()) { + // An OS cursor: the system moves it at once, where a painted one trails the pointer + // (#444). The system cursor stands in for cursors without a glyph. Over a window + // without the focus, macOS shows its own pointer whichever cursor the window sets, + // and the glyph is not painted there. + Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => { + let ppp = ui.ctx().pixels_per_point() / ui_scale; + ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp)); + cursor_icon(c) + } + Some(hp) + if custom + && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) + && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => + { + // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). + if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } + } + _ => cursor_icon(c), + } + } else { + egui::CursorIcon::Default + }; + ui.ctx().set_cursor_icon(cur); +} + +/// The rulers, and the origin being dragged out of their corner. +fn ruler_bands(app: &VectorcraftApp, ui: &Ui, xf: &Xf, (full, rect): (egui::Rect, egui::Rect), origin_preview: Option, t: &Tokens) { + let painter = ui.painter_at(rect); + if let Some(origin) = origin_preview { + let at = xf.to_screen(origin); + painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); + painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); + } + // View › Rulers › Show Video Rulers counts the active artboard in video pixels. + let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); + let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) { + Some(ab) => RulerScale::video(ab), + None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())), + }; + rulers(ui, full, xf, app.hover_doc, scale, t); +} + +/// What the cached frame depends on. +fn cache_key( + app: &VectorcraftApp, + ui: &Ui, + st: &vectorcraft_engine::DocState, + t: &Tokens, + v: &View, + (w, h, ppp): (u32, u32, f32), + (pixel, smooth_images): (Option<[i64; 4]>, bool), +) -> CacheKey { // Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid). - let guide_look = LineLook::guides(&app.session.prefs, &t); - let key = CacheKey { + let guide_look = LineLook::guides(&app.session.prefs, t); + CacheKey { guide: ([guide_look.color.r(), guide_look.color.g(), guide_look.color.b()], guide_look.dots), doc: st.uid as usize, revision: st.revision, @@ -322,20 +493,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool), // The Image Trace panel's eye button, while it is held. trace_source: app.ui.trace_hold_source.clone(), - }; - // Placed documents' bitmaps are being made: draw again when they are ready. - if vectorcraft_render::placed_document::busy() { - ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); } +} + +/// Start the render worker, and upload the frame it finished. +fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) { if !app.canvas.worker_started { app.canvas.worker_started = true; if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() { app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone()); + app.canvas.fill_worker = crate::render_worker::Worker::spawn(ui.ctx().clone()); } } // Upload finished background renders. if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) - && done.key.doc == key.doc + && done.key.doc == key_doc { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); @@ -345,121 +517,164 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.moved = done.moved > 0; app.canvas.from_worker = true; } - // With `waits_for_still` set (the web build, which has no render worker), a heavy document is - // not rendered while only the view changes: the frame shown is drawn reprojected below, and the - // first frame after the view has been still for STILL_SECS renders. - let wait = if app.canvas.waits_for_still { - let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); - still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time)) - } else { - None - }; - if let Some(secs) = wait { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); +} + +/// The size and view matrix of the frame to render: the pixel region in Pixel Preview, else the whole canvas. +fn frame_view(pixel: Option<[i64; 4]>, (w, h): (u32, u32), v: &View, ppp: f32) -> (u32, u32, Affine) { + match pixel { + Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), + None => ( + w, + h, + Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) + * Affine::rotate(v.rotation.to_radians()) + * Affine::scale(v.zoom * ppp as f64) + * Affine::translate(-v.center.to_vec2()), + ), } - if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { - let (w, h, view) = match pixel { - Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), - None => ( - w, - h, - Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) - * Affine::rotate(v.rotation.to_radians()) - * Affine::scale(v.zoom * ppp as f64) - * Affine::translate(-v.center.to_vec2()), - ), - }; - let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; - let opts = vectorcraft_render::RenderOptions { - proof: vectorcraft_render::proof::active_proof(), - overprint_preview: vectorcraft_render::proof::overprint_preview_on(), - trim: app.ui.view.trim_view, - hide_templates: !app.ui.view.templates, - hide_guides: !app.ui.view.guides, - tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { - let [r, g, b, _] = c.to_rgba8(1.0); - [r, g, b] - }), - guide: key.guide, - mask_view, - highlight_substitutions: true, - // Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions. - highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, - // Type › Highlight Substituted Fonts: pink behind text in a substitute font. - highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, - // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and - // pattern tiles stay smooth), as in Illustrator. - anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, - progressive_placed: true, - trace_views: true, - // Image Trace › the eye button held: the source image of the selected objects. - trace_source: key.trace_source.clone(), - smooth_images, - // Isolation mode: the art around the isolated group or layer is dimmed (#833). - isolated: key.isolated.map(vectorcraft_doc::NodeId), - // Images far smaller on screen than they are sample their mip levels (#1244). - image_mips: true, - // An image whose pixels can't be decoded shows as a crossed box, and the status message - // below reports it. - mark_undecoded_images: true, - // During a drag or a dialog's preview, objects that only moved draw their raster - // effects from the cached rasters of an earlier frame (#1257). - interacting: key.interacting, - // During a tool's drag, raster effects without a cached raster to draw render at a - // lower resolution (#2558); the frame after the drag renders them as a still frame does. - dragging: key.dragging, - rich_black: key.rich_black, - ..opts - }; - // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. - // A document switch renders synchronously so another document's frame is never shown. - // The first frame after an interaction, when the frame shown drew moved rasters, also goes - // to the worker. An interaction's frames stay on the worker once it drew the frame shown: - // its renderer keeps the rasters of the art's effects that make those frames fast, and this - // thread's renderer has none, so a frame fast on the worker would be slow here. - let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); - let settling = app.canvas.moved && !key.interacting; - let staying = key.interacting && app.canvas.from_worker; - let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); - match (&mut app.canvas.worker, heavy, changed) { - // Only the document changed since the frame shown, in a part of it: that part is - // rendered again, here, into the texture. `last_ms` stays the whole frame's. - (_, _, Some(region)) => { - let t0 = now_ms(); - if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { - let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); - let color = color_image(&part); - let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; - tex.set_partial([x0 as usize, y0 as usize], color, options); - app.canvas.moved |= app.canvas.renderer.stats.moved > 0; - } - app.canvas.key = Some(key.clone()); - app.canvas.shown = Some((doc.clone(), opts.clone())); - app.perf.render_ms = now_ms() - t0; - } - (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), - _ => { - let t0 = now_ms(); - let img = app.canvas.renderer.render(&doc, w, h, view, &opts); - upload(app, ui.ctx(), &img, pixel.is_some()); - app.canvas.key = Some(key.clone()); - app.canvas.shown = Some((doc.clone(), opts.clone())); - app.perf.render_ms = now_ms() - t0; - app.canvas.last_ms = app.perf.render_ms; - app.canvas.moved = app.canvas.renderer.stats.moved > 0; - app.canvas.from_worker = false; +} + +/// The options the canvas renders the document with. +fn frame_options( + app: &VectorcraftApp, + key: &CacheKey, + mask_view: Option, + smooth_images: bool, +) -> vectorcraft_render::RenderOptions { + let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; + vectorcraft_render::RenderOptions { + proof: vectorcraft_render::proof::active_proof(), + overprint_preview: vectorcraft_render::proof::overprint_preview_on(), + trim: app.ui.view.trim_view, + hide_templates: !app.ui.view.templates, + hide_guides: !app.ui.view.guides, + tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + guide: key.guide, + mask_view, + highlight_substitutions: true, + // Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions. + highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, + // Type › Highlight Substituted Fonts: pink behind text in a substitute font. + highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, + // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and + // pattern tiles stay smooth), as in Illustrator. + anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, + progressive_placed: true, + trace_views: true, + // Image Trace › the eye button held: the source image of the selected objects. + trace_source: key.trace_source.clone(), + smooth_images, + // Isolation mode: the art around the isolated group or layer is dimmed (#833). + isolated: key.isolated.map(vectorcraft_doc::NodeId), + // Images far smaller on screen than they are sample their mip levels (#1244). + image_mips: true, + // An image whose pixels can't be decoded shows as a crossed box, and the status message + // below reports it. + mark_undecoded_images: true, + // During a drag or a dialog's preview, objects that only moved draw their raster + // effects from the cached rasters of an earlier frame (#1257). + interacting: key.interacting, + // During a tool's drag, raster effects without a cached raster to draw render at a + // lower resolution (#2558); the frame after the drag renders them as a still frame does. + dragging: key.dragging, + rich_black: key.rich_black, + ..opts + } +} + +/// A frame to render: here, or on the worker. +struct Frame<'a> { + key: &'a CacheKey, + doc: &'a std::sync::Arc, + size: (u32, u32), + view: Affine, + opts: vectorcraft_render::RenderOptions, + pixel: Option<[i64; 4]>, +} + +fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { + let Frame { key, doc, size: (w, h), view, opts, pixel } = frame; + // to the worker. An interaction's frames stay on the worker once it drew the frame shown: + // its renderer keeps the rasters of the art's effects that make those frames fast, and this + // thread's renderer has none, so a frame fast on the worker would be slow here. + let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); + let settling = app.canvas.moved && !key.interacting; + let staying = key.interacting && app.canvas.from_worker; + let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; + let changed = changed_pixels(app, key, doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + _ => { + let t0 = now_ms(); + let img = app.canvas.renderer.render(doc, w, h, view, &opts); + upload(app, ui.ctx(), &img, pixel.is_some()); + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + app.canvas.last_ms = app.perf.render_ms; + app.canvas.moved = app.canvas.renderer.stats.moved > 0; + app.canvas.from_worker = false; } } - // The status bar reports each image that a renderer (this thread's or the worker's) could not - // decode, once per image in the process. - let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); - if undecoded > 0 { - app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes")); +} + +/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards. +fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; + // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the + // simulated paper colour; a white Background Contents hides the grid). + // An artboard with a background color draws its paper in that color. With Simulate Colored Paper + // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, + // rounded. The transparency grid, while it shows, covers it as it covers the others. + painter.rect_filled(rect, 0.0, t.pasteboard); + if app.ui.view.artboards && !app.ui.view.outline { + let setup = &doc.setup; + let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); + let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; + for ab in &doc.artboards { + let q = xf.quad(ab.rect); + // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). + let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); + painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); + painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); + match &grid { + Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), + None => { + let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); + painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); + } + } + } + } else if app.ui.view.outline { + for ab in &doc.artboards { + painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); + } } +} + +/// The last rendered frame, placed through the current view while a new one is on its way. +fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) { if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) - && k.doc == key.doc + && k.doc == key_doc { // The frame being shown covers the canvas rect it was rendered for. While a panel is // dragged that rect is a different size, so reprojecting the frame through the *current* @@ -472,9 +687,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { match k.pixel { // Document pixels: placed over the document rect they cover, turning with the view. Some([x0, y0, x1, y1]) => { - textured_quad(&painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0)); + textured_quad(painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0)); } - None if (k.rot - v.rotation).abs() < 1e-9 => { + None if (k.rot - rotation).abs() < 1e-9 => { // Reproject the last frame if it was rendered for a different view. let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot }; let a = xf.to_screen(old.to_doc(old_rect.min)); @@ -488,23 +703,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() }; let q = [old_rect.left_top(), old_rect.right_top(), old_rect.right_bottom(), old_rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c))); - textured_quad(&painter, tex.id(), &q, (1.0, 1.0)); + textured_quad(painter, tex.id(), &q, (1.0, 1.0)); } } } - if let Some(look) = grid_look.filter(|_| !grids_in_back) { - grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); - } - // The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from - // 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show. - if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid { - grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false }); - } - // Artboard edges and names. The Artboard tool labels its active artboard itself (following - // a move or resize): that one isn't named twice. +} + +/// The artboards' edges, bleeds, marks and names. +fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; + // Artboard edges and names. The Artboard tool labels its selected artboards itself (following + // a move or resize): those aren't named twice. let active_ab = crate::panels::artboards::selected(app, doc.artboards.len()); let artboard_tool = app.session.tool_id() == "artboard"; - let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let tool_selected = artboards::tool_labelled(app); let editing_name = ui.data(|d| d.get_temp::(name_edit_id()).map(|e| e.index)); for (i, ab) in doc.artboards.iter().enumerate() { let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; @@ -515,53 +728,57 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the // canvas draws them, as it does the edges: exports and print leave them out. - artboard_marks(&painter, &xf, ab, t.artboard_mark); - if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() { + artboard_marks(painter, xf, ab, t.artboard_mark); + let labelled_by_tool = u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx)); + if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && !labelled_by_tool { // At the artboard's own top-left corner, which a rotated view turns away from its // bounding box's (#1095). let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); - artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim); + artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim); } } - if app.ui.view.guides { - let look = LineLook::guides(&app.session.prefs, &t); - // Selected guides in the selection colour. - let picked = LineLook { color: t.selection, ..look }; - let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); - let key = app.session.active().and_then(|st| st.selection.key_guide); - let view = view_bounds(&xf); - // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { - let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); - // An artboard guide runs across its artboard, a canvas guide across the window (the part - // of its document line that the canvas shows, turned with the view). - let (a, b) = match doc.guide_span(g) { - Some((from, to)) => (at(from), at(to)), - None => { - let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) }; - let Some(ends) = clip_segment(at(from), at(to), rect) else { continue }; - ends - } - }; - if selected.contains(&i) { picked } else { look }.guide(&painter, a, b); - // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. - if key == Some(i) { - painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); +} + +/// The ruler guides. +fn guide_lines(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; + let look = LineLook::guides(&app.session.prefs, t); + // Selected guides in the selection colour. + let picked = LineLook { color: t.selection, ..look }; + let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); + let key = app.session.active().and_then(|st| st.selection.key_guide); + let view = view_bounds(xf); + // Guides on a hidden layer hide with it. + for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); + // An artboard guide runs across its artboard, a canvas guide across the window (the part + // of its document line that the canvas shows, turned with the view). + let (a, b) = match doc.guide_span(g) { + Some((from, to)) => (at(from), at(to)), + None => { + let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) }; + let Some(ends) = clip_segment(at(from), at(to), rect) else { continue }; + ends } + }; + if selected.contains(&i) { picked } else { look }.guide(painter, a, b); + // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. + if key == Some(i) { + painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); } } +} - if !app.session.slices_hidden() { - slice_overlay(app, &painter, &xf); - } - if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" { - print_tiling_overlay(app, &painter, &xf); - } - original_path_overlay(app, &painter, &xf); - - // Selection visuals and tool overlays. - let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); - let view_info = app.view_info(); +/// The selection's visuals and the tools' overlays, to be drawn. +fn selection_visuals( + app: &mut VectorcraftApp, + painter: &egui::Painter, + xf: &Xf, + (hidden_chars, hover_still): (HiddenChars, bool), + revolve_edit: bool, + view_info: vectorcraft_engine::ViewInfo, +) -> Vec { // View → Hide Gradient Annotator hides the Gradient tool's annotator. let mut overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { app.session.overlays(view_info) @@ -572,11 +789,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { overlays.extend(crate::dialogs::live_paint_gaps::overlays(app)); if app.ui.view.edges && !revolve_edit { if hover_still { - hover_highlight(app, &painter, &xf); + hover_highlight(app, painter, xf); } - selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); - mirror_axis_overlay(app, &painter, &xf); - grid_repeat_overlay(app, &painter, &xf); + selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); + mirror_axis_overlay(app, painter, xf); + grid_repeat_overlay(app, painter, xf); if app.ui.view.text_threads { // Under the tools' overlays: a selected frame's own ports are drawn over its thread's. let mut threads = thread_overlays(app, xf.zoom); @@ -588,111 +805,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if app.ui.view.hidden_chars { app.canvas.hidden_chars = hidden_chars; - hidden_chars_overlay(app, &painter, &xf); + hidden_chars_overlay(app, painter, xf); } } - draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); - if !revolve_edit { - shaper_widget_overlay(app, &painter, &xf); - } - ime_geometry(app, ui.ctx(), &xf); - crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); - // A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial - // at the canvas center, the point the view turns about. - if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::RotateView { .. })) { - rotate_view_dial(&painter, rect.center(), v.rotation, &t); - } - - if app.ui.view.rulers && app.ui.screen_mode < 3 { - if let Some(origin) = origin_preview { - let at = xf.to_screen(origin); - painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); - painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); - } - // View › Rulers › Show Video Rulers counts the active artboard in video pixels. - let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); - let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) { - Some(ab) => RulerScale::video(ab), - None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())), - }; - rulers(ui, full, &xf, app.hover_doc, scale, &t); - } - // The task bar is not drawn while the Simplify slider shows. - if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { - task_bar(app, ui, &xf); - } - if app.ui.screen_mode < 3 { - crate::free_transform::show(app, ui, xf.rect); - } - artboard_name_editor(app, ui, &xf); - // Cursor. - if resp.hovered() { - if opening::long_operation_busy(app) { - ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy)); - return; - } - let m = ui.input(|i| i.modifiers); - let space = ui.input(|i| i.key_down(egui::Key::Space)); - let drag = ui.data(|d| d.get_temp::(drag_id())); - let panning = matches!(drag, Some(Drag::Pan { .. })); - let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; - // A rotating drag keeps its cursor when Shift is let go. - let rotating = - matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; - // The hand and the magnifier are drawn like the tool cursors below: Windows has no - // system cursor for them (winit shows the four-headed move cursor for Grab). - let view_cursor = if panning { - Some(Cursor::HandGrab) - } else if rotating { - Some(Cursor::Rotate) - } else if zooming { - Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) - } else if space || app.session.tool_id() == "hand" { - Some(Cursor::Hand) - } else { - None - }; - // In Presentation Mode the pointer is the default one, with no tool cursor. - let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); - let cur = if let Some(c) = tool_cursor.flatten() { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor"))); - // The loaded place cursor carries the file's thumbnail. - if app.session.tool_id() == "place" - && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) - { - crate::place::paint_cursor(app, ui.ctx(), &painter, hp); - } - // The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers - // into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs. - let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); - // User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling; - // off, they keep their size. - let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() }; - match ui.input(|i| i.pointer.hover_pos()) { - // An OS cursor: the system moves it at once, where a painted one trails the pointer - // (#444). The system cursor stands in for cursors without a glyph. Over a window - // without the focus, macOS shows its own pointer whichever cursor the window sets, - // and the glyph is not painted there. - Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => { - let ppp = ui.ctx().pixels_per_point() / ui_scale; - ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp)); - cursor_icon(c) - } - Some(hp) - if custom - && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) - && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => - { - // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). - if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } - } - _ => cursor_icon(c), - } - } else { - egui::CursorIcon::Default - }; - ui.ctx().set_cursor_icon(cur); - } + overlays } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/artboards.rs b/crates/ui-egui/src/canvas/artboards.rs index 986be51aa..2e62fff4a 100644 --- a/crates/ui-egui/src/canvas/artboards.rs +++ b/crates/ui-egui/src/canvas/artboards.rs @@ -1,4 +1,6 @@ -use super::*; +use serde_json::{Value, json}; + +use super::{Color32, Point, Pos2, Rect, Shape, Stroke, Ui, VectorcraftApp, Xf, vec2}; /// The width an artboard needs on screen for its name to show (px): narrower, the name is left /// out rather than spilling over its neighbours. @@ -89,6 +91,15 @@ pub(super) fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index egui::Rect::from_min_size(sp, galley.size()).expand(2.0) } +/// The artboards the Artboard tool labels itself, following a move or resize (its selected +/// ones): the canvas doesn't name them again. Empty with another tool. +pub(super) fn tool_labelled(app: &VectorcraftApp) -> Vec { + if app.session.tool_id() != "artboard" { + return vec![]; + } + app.session.tool_options()["selected"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() +} + /// The artboard whose on-canvas name contains `screen`, if that name is being drawn. pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option { let st = app.session.active()?; @@ -96,11 +107,12 @@ pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: P if !artboard_tool && st.doc.artboards.len() <= 1 { return None; } - let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let tool_selected = tool_labelled(app); let painter = ui.painter(); let mut hit = None; for (i, ab) in st.doc.artboards.iter().enumerate() { - if tool_labelled == u64::try_from(i).ok() { + let is_tool_labelled = u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx)); + if is_tool_labelled { continue; } let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); @@ -110,16 +122,18 @@ pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: P hit = Some(i); } } - if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok()) - && let Some(ab) = st.doc.artboards.get(i) - && tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen) - { - return Some(i); + for &idx in &tool_selected { + if let Some(i) = usize::try_from(idx).ok() + && let Some(ab) = st.doc.artboards.get(i) + && tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen) + { + return Some(i); + } } hit } -pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { +pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize, shift: bool) { let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0); if index >= n { return; @@ -128,7 +142,11 @@ pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { v.artboard = index; } if app.session.tool_id() == "artboard" { - app.session.set_tool_option("active", &json!(index)); + if shift { + crate::panels::artboards::toggle(app, index, n); + } else { + app.session.set_tool_option("active", &json!(index)); + } } } @@ -194,10 +212,10 @@ pub(super) fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &X pub(super) fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option { let st = app.session.active()?; let ab = st.doc.artboards.get(index)?; - let artboard_tool = app.session.tool_id() == "artboard"; - let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let tool_selected = tool_labelled(app); let painter = ui.painter(); - let rect = if tool_labelled == u64::try_from(index).ok() { + let is_tool_labelled = u64::try_from(index).ok().is_some_and(|idx| tool_selected.contains(&idx)); + let rect = if is_tool_labelled { tool_name_rect(painter, xf, ab.rect, index, &ab.name) } else { let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 534321242..8db01d422 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -1,5 +1,5 @@ use super::outline::{hit_at, hit_with, selection_hit_at}; -use super::*; +use super::{Ui, VectorcraftApp, Xf, json, widgets}; /// Right-click: the object under the pointer is selected first unless it already is, then the /// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index b67276df4..6d39883be 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CacheKey, Cursor, Mods, Point, VectorcraftApp}; /// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still` /// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again @@ -66,7 +66,7 @@ pub(super) fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&Cac /// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel /// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both. -fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { +pub(super) fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { a.pixel.is_some() == b.pixel.is_some() && *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() } } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index e17690dd3..bfc3a070f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Drag, Mods, Point, PointerEvent, PointerKind, Pos2, StylusFeed, Ui, VectorcraftApp, Xf, widgets}; /// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the /// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it. diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs new file mode 100644 index 000000000..f68c17558 --- /dev/null +++ b/crates/ui-egui/src/canvas/fill.rs @@ -0,0 +1,198 @@ +use super::cursor::only_view_differs; +use super::{Affine, CacheKey, Color32, Point, Pos2, Shape, VectorcraftApp, Xf, color_image, pos2, vec2}; + +/// The fraction of the canvas's resolution, each way, that [`Fill`] is rendered at. +const FILL_SCALE: f64 = 0.25; +/// How many times the canvas's width and height [`Fill`] covers, around the view it is rendered +/// for. A pan or zoom-out that stays within it draws it again without rendering it. +const FILL_REACH: f64 = 2.0; + +/// [`crate::CanvasCache::fill`]: a frame of the art from [`crate::CanvasCache::fill_worker`], +/// rendered for the canvas key `key` as [`fill_view`] gives, and its texture. +pub struct Fill { + /// The document, revision and view it was rendered for. + pub(super) key: CacheKey, + /// Document → its pixels. + pub(super) view: Affine, + /// Its size in pixels. + pub(super) size: (u32, u32), + pub(super) tex: egui::TextureHandle, +} + +/// The size in pixels and the view (document → its pixels) of the fill for the canvas key `key`: +/// [`FILL_SCALE`] of the canvas's resolution, covering [`FILL_REACH`] times the canvas's width and +/// height around the view of `key`. +pub(super) fn fill_view(key: &CacheKey) -> (u32, u32, Affine) { + let fw = (f64::from(key.w) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32; + let fh = (f64::from(key.h) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32; + let ppp = if key.ppp > 0.0 { f64::from(key.ppp) } else { 1.0 }; + let view = Affine::translate((f64::from(fw) / 2.0, f64::from(fh) / 2.0)) + * Affine::rotate(key.rot.to_radians()) + * Affine::scale(key.zoom * ppp * FILL_SCALE) + * Affine::translate((-key.cx, -key.cy)); + (fw, fh, view) +} + +/// While the frame shown is drawn for a view it was not rendered for, and its key differs from +/// `key` in the view alone (the frame for `key` is on its way from the render worker), the parts +/// of the canvas `rect` it leaves uncovered show `doc` from [`crate::CanvasCache::fill`]. The fill +/// worker ([`crate::CanvasCache::fill_worker`]) renders it with the frame shown's options and these +/// changes: images draw only from pixels already decoded +/// ([`vectorcraft_render::RenderOptions::kept_images_only`]) and draw mips +/// ([`vectorcraft_render::RenderOptions::image_mips`]), raster effects render within a drag's +/// budget ([`vectorcraft_render::RenderOptions::dragging`]), and rasters cached for moved objects +/// are not reused ([`vectorcraft_render::RenderOptions::interacting`] off). This thread only copies a +/// finished fill into its texture and draws it. A fill is asked for again when the one shown or on +/// its way does not cover the canvas, when the view's zoom is more than twice or less than half +/// its own, and when the document, its revision or anything but the view changed; until it +/// arrives, the fill shown is drawn where it reaches. It is dropped once the canvas shows the frame +/// for `key`, and while the frame shown differs from `key` in more than the view. +pub(super) fn fill_uncovered( + app: &mut VectorcraftApp, + ctx: &egui::Context, + painter: &egui::Painter, + key: &CacheKey, + doc: &std::sync::Arc, + rect: egui::Rect, + xf: &Xf, +) { + // Taken every frame, so a fill that is no longer needed does not stay queued. + let done = app.canvas.fill_worker.as_mut().and_then(|w| w.poll()); + let (Some(k), Some((_, opts))) = (&app.canvas.key, &app.canvas.shown) else { + app.canvas.fill = None; + return; + }; + // The fill takes the frame shown's options, which match `key`'s only when the two keys differ + // in the view alone. + if k == key || !only_view_differs(k, key) || app.canvas.texture.is_none() { + app.canvas.fill = None; + return; + } + let screen_to_doc = xf.affine().inverse(); + let corners = + [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| screen_to_doc * Point::new(p.x as f64, p.y as f64)); + let usable = |f: &CacheKey| only_view_differs(f, key) && (0.5..=2.0).contains(&(key.zoom / f.zoom)); + let covers = |f: &CacheKey| { + let (w, h, view) = fill_view(f); + usable(f) + && corners.iter().all(|c| { + let p = view * *c; + (-0.5..=f64::from(w) + 0.5).contains(&p.x) && (-0.5..=f64::from(h) + 0.5).contains(&p.y) + }) + }; + let opts = vectorcraft_render::RenderOptions { kept_images_only: true, image_mips: true, interacting: false, dragging: true, ..opts.clone() }; + let gaps = shown_frame(k, rect, xf).map_or_else(Vec::new, |shown| uncovered(shown, rect)); + if let Some(done) = done.filter(|d| usable(&d.key)) { + let color = color_image(&done.img); + let tex = match app.canvas.fill.take() { + Some(mut f) => { + f.tex.set(color, egui::TextureOptions::LINEAR); + f.tex + } + None => ctx.load_texture("canvas-fill", color, egui::TextureOptions::LINEAR), + }; + let (_, _, view) = fill_view(&done.key); + app.canvas.fill = Some(Fill { key: done.key, view, size: (done.img.width, done.img.height), tex }); + } + let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; + if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { + let (w, h, view) = fill_view(key); + worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }); + } + let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return }; + let to_uv = Affine::scale_non_uniform(1.0 / f64::from(f.size.0.max(1)), 1.0 / f64::from(f.size.1.max(1))) * f.view * screen_to_doc; + let from_uv = to_uv.inverse(); + let mut mesh = egui::Mesh::with_texture(f.tex.id()); + for gap in gaps { + // Only where the fill reaches: its pixels are 0..1 each way in uv. + let uv: Vec = gap.iter().map(|p| to_uv * Point::new(p.x as f64, p.y as f64)).collect(); + let uv = clip_polygon(&clip_polygon(&uv, |p| p.x, 0.0, false), |p| p.x, 1.0, true); + let uv = clip_polygon(&clip_polygon(&uv, |p| p.y, 0.0, false), |p| p.y, 1.0, true); + if uv.len() < 3 { + continue; + } + let first = mesh.vertices.len() as u32; + for p in &uv { + let pos = from_uv * *p; + mesh.vertices.push(egui::epaint::Vertex { + pos: pos2(pos.x as f32, pos.y as f32), + uv: pos2(p.x as f32, p.y as f32), + color: Color32::WHITE, + }); + } + for i in 1..uv.len() as u32 - 1 { + mesh.indices.extend([first, first + i, first + i + 1]); + } + } + painter.add(Shape::mesh(mesh)); +} + +/// Where the canvas draws the frame shown (its key `k`) in the view `xf` of the canvas `rect`: +/// the unit square (the frame's texture coordinates) → screen points. None when the mapping is +/// degenerate. +fn shown_frame(k: &CacheKey, rect: egui::Rect, xf: &Xf) -> Option { + let to_screen = match k.pixel { + Some([x0, y0, x1, y1]) => { + xf.affine() * Affine::translate((x0 as f64, y0 as f64)) * Affine::scale_non_uniform((x1 - x0) as f64, (y1 - y0) as f64) + } + None => { + let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 }; + let old_rect = egui::Rect::from_center_size(rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp)); + let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() }; + xf.affine() + * old.affine().inverse() + * Affine::translate((old_rect.min.x as f64, old_rect.min.y as f64)) + * Affine::scale_non_uniform(old_rect.width() as f64, old_rect.height() as f64) + } + }; + let det = to_screen.determinant(); + (det.is_finite() && det.abs() > 1e-12).then_some(to_screen) +} + +/// The parts of `rect` outside the parallelogram `shown` maps the unit square to, as convex +/// polygons of screen points, leaving out those thinner than half a point (a frame whose pixel +/// size was rounded from the canvas's leaves such slivers along its edges). +fn uncovered(shown: Affine, rect: egui::Rect) -> Vec> { + // In the unit square's coordinates the canvas is a convex polygon and the frame shown is + // 0..1 each way: the parts left of it, right of it, and above and below it in between. + let inv = shown.inverse(); + let canvas: Vec = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()] + .iter() + .map(|p| inv * Point::new(p.x as f64, p.y as f64)) + .collect(); + let left = clip_polygon(&canvas, |p| p.x, 0.0, true); + let right = clip_polygon(&canvas, |p| p.x, 1.0, false); + let between = clip_polygon(&clip_polygon(&canvas, |p| p.x, 0.0, false), |p| p.x, 1.0, true); + let above = clip_polygon(&between, |p| p.y, 0.0, true); + let below = clip_polygon(&between, |p| p.y, 1.0, false); + [left, right, above, below] + .into_iter() + .map(|part| part.into_iter().map(|p| shown * p).map(|p| pos2(p.x as f32, p.y as f32)).collect::>()) + .filter(|part| { + // Twice the area over the perimeter: about the width of a thin strip. + let perimeter: f32 = part.iter().zip(part.iter().cycle().skip(1)).map(|(a, b)| a.distance(*b)).sum(); + perimeter > 0.0 && 2.0 * polygon_area(part) / perimeter > 0.5 + }) + .collect() +} + +/// The part of convex polygon `poly` where `along(p)` is below `at` (`below`) or above it. +fn clip_polygon(poly: &[Point], along: impl Fn(&Point) -> f64, at: f64, below: bool) -> Vec { + let inside = |p: &Point| if below { along(p) <= at } else { along(p) >= at }; + let mut out = Vec::with_capacity(poly.len() + 1); + for (a, b) in poly.iter().zip(poly.iter().cycle().skip(1)) { + if inside(a) { + out.push(*a); + } + if inside(a) != inside(b) { + let t = (at - along(a)) / (along(b) - along(a)); + out.push(a.lerp(*b, t)); + } + } + out +} + +/// The area of polygon `poly` (shoelace formula). +pub(super) fn polygon_area(poly: &[Pos2]) -> f32 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() / 2.0 +} diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index a36380df0..15576b9ae 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -1,5 +1,7 @@ use super::drag::mods; -use super::*; +use super::{ + Color32, Point, PointerEvent, PointerKind, Pos2, RULER, Rect, Sense, Shape, Stroke, Tokens, Ui, Unit, VectorcraftApp, Xf, json, pos2, vec2, +}; /// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per /// look, and its cell size in screen points. diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs index 1d23d9882..2987a03c5 100644 --- a/crates/ui-egui/src/canvas/home.rs +++ b/crates/ui-egui/src/canvas/home.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CornerRadius, Tokens, Ui, VectorcraftApp, json, theme, vec2, widgets}; /// The Home screen (`app.home`; with no document open, unless its preference is off). pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 319e9c84b..6141707e9 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -7,43 +7,11 @@ use super::view::{ HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, wheel, whole_pixels, zoom_about, }; -use super::*; +use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json}; /// Dispatch canvas pointer, keyboard and drop events through the active document view. pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let turn_id = egui::Id::unique("canvas-wheel-turn"); - let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); - let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn))); - if turn != turn_before { - ui.data_mut(|d| d.insert_temp(turn_id, turn)); - } - if turn.glide != 0.0 { - ui.ctx().request_repaint(); - } - let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); - // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also - // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The - // tool's events below take their document points from the view this moves to. - if resp.hovered() { - if (factor - 1.0).abs() > 1e-6 - && let (Some(p), Some(vm)) = (hover, app.view_mut()) - { - zoom_about(vm, rect, p, vm.zoom * factor); - } - if scroll != egui::Vec2::ZERO { - scroll_view(app, ui, scroll); - } - if let Some(p) = hover - && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) - && let Some(vm) = app.view_mut() - { - rotate_about(vm, rect, p, deg); - } - } + let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), @@ -51,6 +19,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // double-taps) where it was seen last (#491). let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); let view = app.view_info(); + let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view }; touch_gestures(app, ui, rect, view); // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the // tool's press is dropped, as a right-click presses nothing. @@ -97,24 +66,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) }); - // In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, - // drag or hover, and a drag does not pan the view. A drag that began before the mode was turned - // on ends as usual. if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) { - let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); - // `press` is this frame's new press, or else the press held so far. A quick tap can be - // pressed and released in one frame. - let press = if pressed { Some(Drag::Present) } else { drag }; - if press.is_some() && !pointer.primary_down() { - ui.data_mut(|dd| dd.remove::(drag_id())); - // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release - // does not move the view to the next artboard. - if press == Some(Drag::Present) && resp.clicked() { - crate::shortcuts::presentation_step(app, true); - } - } else if pressed { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); - } + present_step(app, ui, &f, drag, at); return; } @@ -125,12 +78,6 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { crate::stylus::sync_eraser_tool(app); } - let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and - // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. - let zoom_mode = if space { m.command } else { tool == "zoom" }; - let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; - let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a // press on the canvas itself reaches the tools, else it would land at the canvas's centre. @@ -144,37 +91,17 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // The artboard's name sits above its corner. A press there renames on a double-click // and does not start a tool gesture (a fitted artboard, a new one, Artboard Options). if let Some(i) = artboard_name_at(app, ui, &xf, p) { - ui.data_mut(|dd| dd.insert_temp(name_press_id(), i)); + let press_shift = ui + .input(|inp| { + inp.events.iter().rev().find_map(|event| match event { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(modifiers.shift), + _ => None, + }) + }) + .unwrap_or(m.shift); + ui.data_mut(|dd| dd.insert_temp(name_press_id(), (i, press_shift))); } else { - let d = if crate::touch::finger_navigates_only() { - // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. - Drag::Gesture - } else if zoom_mode { - if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } - } else if pan_mode { - Drag::Pan { start: p, center: v.center, middle: false } - } else if rotate_mode { - let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } - } else { - // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; - dispatch(app, &ev, view); - crate::document_drag::begin(app, ui.ctx(), ev.pos); - Drag::Tool - }; - // Panning, zooming or turning the view takes the pointer from the tool's hover. - if !tool_holds(d) { - app.session.tool_hover_ended(view); - } - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), d); - dd.insert_temp(drag_pos_id(), p); - dd.insert_temp(drag_layer_id(), resp.layer_id); - // A new press has not dragged yet, whatever an interrupted one left behind. - dd.remove::(tool_drag_doc_id()); - dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); - }); + begin_drag(app, ui, &f, p); } } else if drag.is_none() && resp.hovered() @@ -189,157 +116,9 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center()); let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() }; if held { - ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); - match d { - Drag::Pan { start, center, .. } => { - let ppp = ui.ctx().pixels_per_point(); - if let Some(vm) = app.view_mut() { - vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); - } - } - Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } - if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); - } - } - Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), - Drag::ZoomScrub { start, held, moved } => { - let dt = ui.input(|i| i.unstable_dt).min(0.25); - let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); - // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). - let exponent = if moved { - f64::from(pointer.delta().x) * SCRUB_ZOOM - } else if held > HOLD_DELAY { - f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } - } else { - 0.0 - }; - if exponent != 0.0 - && let Some(vm) = app.view_mut() - { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); - } - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); - } - // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). - Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { - // Back on the canvas: the move goes on from where it left, with the pointer. - egui::DragAndDrop::clear_payload(ui.ctx()); - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), Drag::Tool); - dd.insert_temp(drag_pos_id(), p); - }); - } - Drag::Art | Drag::Gesture | Drag::Present => {} - Drag::Tool if drag_art_out(app, ui, resp, p) => { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); - } - Drag::Tool => { - // Every position the pen or mouse passed this frame, not just the last, for a - // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a - // mouse reports far more often than frames come). Touch events (a pen on Windows - // or the web, a finger) and a macOS pen's moves carry their own pressure; a - // mouse's, a pen's on X11 among them, take the host's latest. Other tools get - // the last position only, so a costly drag preview runs once a frame. - let moves = tool_frame_moves(ui, app); - // A frame whose pointer ends where the last one did still moved in between. - if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() { - for (at, force) in moves { - let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); - } - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts - // a quarter second at most. - if app.session.tool_wants_ticks() { - let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); - let r = app.session.tool_tick(dt, view); - apply_requests(app, r); - } - } - } + drag_held(app, ui, &f, d, p); } else { - // The last position the tool's drag got: none if the press never dragged. - let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); - ui.data_mut(|dd| { - dd.remove::(drag_id()); - dd.remove::(drag_pos_id()); - dd.remove::(tool_drag_doc_id()); - dd.remove::(tool_press_doc_id()); - }); - match d { - Drag::ZoomBox { start } => { - let r = egui::Rect::from_two_pos(start, p); - if let Some(vm) = app.view_mut() { - if r.width() > 8.0 && r.height() > 8.0 { - // The marquee's center and its size on screen set the view, at any rotation. - vm.center = xf.to_doc(r.center()); - let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); - } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - } - // A click (not held, not dragged) steps the zoom as with the marquee. - Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { - if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - Drag::ZoomScrub { .. } => {} - Drag::Tool => { - // The second click of a double-click: don't fit another artboard on this release. - // A double-click on an object does not open Artboard Options. Empty artboard - // space still does. - if resp.double_clicked() && app.session.tool_id() == "artboard" { - app.session.set_tool_option("suppressFitClick", &json!(true)); - } - // A move that came in the release's own frame shapes the preview first: the - // tools commit the last preview, which should be where the pointer let go - // (#834). Only once the press has dragged, so a click's jitter stays a click - // (the Pen's corner anchor, no nudge with Direct Selection). - if let Some(last) = last_drag - && last != xf.to_doc(p) - { - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - Drag::Art if on_canvas(ui, resp, p) => { - // Came back to the canvas and released in one frame: finish the move here. - egui::DragAndDrop::clear_payload(ui.ctx()); - let pressure = pen_pressure(ui, app, false); - let pos = xf.to_doc(p); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); - } - Drag::Art => { - // Released over a panel: the move goes back. (The app ends it before the panels - // see the release, so a panel can take the art; this is the headless path.) - // With no document open there is nothing to roll back; the tool still gets its up. - let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - } - Drag::Gesture | Drag::Present => {} - Drag::Pan { .. } | Drag::RotateView { .. } => {} - } + drag_released(app, ui, &f, d, p); } } else if let Some(p) = hover_tool && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) @@ -363,34 +142,391 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); } if pointer.primary_released() - && let Some(i) = ui.data_mut(|d| d.remove_temp::(name_press_id())) + && let Some((i, shift)) = ui.data_mut(|d| d.remove_temp::<(usize, bool)>(name_press_id())) { - select_named_artboard(app, i); + let release_shift = ui + .input(|inp| { + inp.events.iter().rev().find_map(|event| match event { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } => Some(modifiers.shift), + _ => None, + }) + }) + .unwrap_or(m.shift); + select_named_artboard(app, i, shift || release_shift); } if resp.double_clicked() && let Some(p) = at { - if let Some(i) = artboard_name_at(app, ui, &xf, p) { - begin_artboard_name_edit(ui, app, i); - } else { - let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - let before = app.session.tool_id().to_string(); - dispatch(app, &ev, view); - // A double-click on type switched a selection tool to the Type tool: the caret goes where - // the type was clicked, so typing edits it at once. - if before != "type" && app.session.tool_id() == "type" { - app.session.set_tool_option("prevTool", &json!(before)); - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(app, &PointerEvent { kind, ..ev }, view); - } - } - } + double_click(app, ui, &f, p); } if drag.is_some() || pointer.is_moving() || mods_changed { ui.ctx().request_repaint(); } } +/// What the pointer handlers share for one frame. +struct Pointing<'a> { + resp: &'a egui::Response, + rect: egui::Rect, + pointer: &'a egui::PointerState, + m: egui::Modifiers, + space: bool, + xf: Xf, + v: View, + view: vectorcraft_engine::ViewInfo, +} + +/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the +/// modifiers, whether Space is held and where the pointer hovers over the canvas. +fn wheel_view( + app: &mut VectorcraftApp, + ui: &Ui, + resp: &egui::Response, + rect: egui::Rect, +) -> (egui::PointerState, egui::Modifiers, bool, Option) { + let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; + let animate = animated_zoom(&app.session.prefs); + let turn_id = egui::Id::unique("canvas-wheel-turn"); + let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); + let mut turn = turn_before; + let (pointer, m, space, (factor, scroll)) = + ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn))); + if turn != turn_before { + ui.data_mut(|d| d.insert_temp(turn_id, turn)); + } + if turn.glide != 0.0 { + ui.ctx().request_repaint(); + } + let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); + // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also + // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The + // tool's events below take their document points from the view this moves to. + if resp.hovered() { + if (factor - 1.0).abs() > 1e-6 + && let (Some(p), Some(vm)) = (hover, app.view_mut()) + { + zoom_about(vm, rect, p, vm.zoom * factor); + } + if scroll != egui::Vec2::ZERO { + scroll_view(app, ui, scroll); + } + if let Some(p) = hover + && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) + && let Some(vm) = app.view_mut() + { + rotate_about(vm, rect, p, deg); + } + } + (pointer, m, space, hover) +} + +/// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, drag or hover, and a +/// drag does not pan the view. A drag that began before the mode was turned on ends as usual. +fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option, at: Option) { + let (resp, pointer) = (f.resp, f.pointer); + let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); + // `press` is this frame's new press, or else the press held so far. A quick tap can be + // pressed and released in one frame. + let press = if pressed { Some(Drag::Present) } else { drag }; + if press.is_some() && !pointer.primary_down() { + ui.data_mut(|dd| dd.remove::(drag_id())); + // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release + // does not move the view to the next artboard. + if press == Some(Drag::Present) && resp.clicked() { + crate::shortcuts::presentation_step(app, true); + } + } else if pressed { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); + } +} + +/// A press on the canvas at `p` starts a gesture: a zoom, pan or turn of the view, or the tool's drag. +fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { resp, rect, m, space, xf, v, view, .. } = *f; + let tool = app.session.tool_id(); + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. + let zoom_mode = if space { m.command } else { tool == "zoom" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { + if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } + } else if pan_mode { + Drag::Pan { start: p, center: v.center, middle: false } + } else if rotate_mode { + let c = rect.center(); + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } + } else { + // Cmd with another tool drags with the selection tool used last (the session lends it). + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; + dispatch(app, &ev, view); + crate::document_drag::begin(app, ui.ctx(), ev.pos); + Drag::Tool + }; + // Panning, zooming or turning the view takes the pointer from the tool's hover. + if !tool_holds(d) { + app.session.tool_hover_ended(view); + } + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), d); + dd.insert_temp(drag_pos_id(), p); + dd.insert_temp(drag_layer_id(), resp.layer_id); + // A new press has not dragged yet, whatever an interrupted one left behind. + dd.remove::(tool_drag_doc_id()); + dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); + // Space held at the press (Cmd+Space lends the Zoom tool) sizes the marquee until + // it is let go ([`zoom_marquee_start`]). + if matches!(d, Drag::ZoomBox { .. }) { + dd.insert_temp(zoom_space_free_id(), !space); + } + }); +} + +/// A gesture held at `p`: the view follows, or the tool gets drag events. +fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) { + let Pointing { resp, rect, pointer, m, space, xf, view, .. } = *f; + // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. + let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); + ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + match d { + Drag::Pan { start, center, .. } => { + let ppp = ui.ctx().pixels_per_point(); + if let Some(vm) = app.view_mut() { + vm.center = center; + pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + } + } + Drag::RotateView { start_angle, start_rot, snaps } => { + let c = rect.center(); + let a = (p.y - c.y).atan2(p.x - c.x) as f64; + let mut deg = start_rot + (a - start_angle).to_degrees(); + if snaps && m.shift { + deg = (deg / 15.0).round() * 15.0; + } + if let Some(vm) = app.view_mut() { + vm.rotation = vectorcraft_geom::normalize_deg(deg); + } + } + Drag::ZoomBox { start } => { + let start = zoom_marquee_start(ui, start, before, p, space); + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomBox { start })); + marquee(ui.painter(), egui::Rect::from_two_pos(start, p)); + } + Drag::ZoomScrub { start, held, moved } => { + let dt = ui.input(|i| i.unstable_dt).min(0.25); + let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); + // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). + let exponent = if moved { + f64::from(pointer.delta().x) * SCRUB_ZOOM + } else if held > HOLD_DELAY { + f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } + } else { + 0.0 + }; + if exponent != 0.0 + && let Some(vm) = app.view_mut() + { + zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + } + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); + } + // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). + Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { + // Back on the canvas: the move goes on from where it left, with the pointer. + egui::DragAndDrop::clear_payload(ui.ctx()); + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), Drag::Tool); + dd.insert_temp(drag_pos_id(), p); + }); + } + Drag::Art | Drag::Gesture | Drag::Present => {} + Drag::Tool if drag_art_out(app, ui, resp, p) => { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); + } + Drag::Tool => { + // Every position the pen or mouse passed this frame, not just the last, for a + // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a + // mouse reports far more often than frames come). Touch events (a pen on Windows + // or the web, a finger) and a macOS pen's moves carry their own pressure; a + // mouse's, a pen's on X11 among them, take the host's latest. Other tools get + // the last position only, so a costly drag preview runs once a frame. + let moves = tool_frame_moves(ui, app); + // A frame whose pointer ends where the last one did still moved in between. + if tool_drag_moved(ui, &xf, p, pointer) || !moves.is_empty() { + for (at, force) in moves { + let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); + } + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts + // a quarter second at most. + if app.session.tool_wants_ticks() { + let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); + let r = app.session.tool_tick(dt, view); + apply_requests(app, r); + } + } + } +} + +/// A gesture released at `p`: the view settles, or the tool gets its up. +fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) { + let Pointing { resp, rect, m, space, xf, view, .. } = *f; + // The last position the tool's drag got: none if the press never dragged. + let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); + // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. + let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); + ui.data_mut(|dd| { + dd.remove::(drag_id()); + dd.remove::(drag_pos_id()); + dd.remove::(tool_drag_doc_id()); + dd.remove::(tool_press_doc_id()); + }); + match d { + Drag::ZoomBox { start } => { + let start = zoom_marquee_start(ui, start, before, p, space); + ui.data_mut(|dd| dd.remove::(zoom_space_free_id())); + let r = egui::Rect::from_two_pos(start, p); + if let Some(vm) = app.view_mut() { + if r.width() > 8.0 && r.height() > 8.0 { + // The marquee's center and its size on screen set the view, at any rotation. + vm.center = xf.to_doc(r.center()); + let fill = (rect.width() / r.width()).min(rect.height() / r.height()); + vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + } else { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + } + // A click (not held, not dragged) steps the zoom as with the marquee. + Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { + if let Some(vm) = app.view_mut() { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + Drag::ZoomScrub { .. } => {} + Drag::Tool => { + // The second click of a double-click: don't fit another artboard on this release. + // A double-click on an object does not open Artboard Options. Empty artboard + // space still does. + if resp.double_clicked() && app.session.tool_id() == "artboard" { + app.session.set_tool_option("suppressFitClick", &json!(true)); + } + // A move that came in the release's own frame shapes the preview first: the + // tools commit the last preview, which should be where the pointer let go + // (#834). Only once the press has dragged, so a click's jitter stays a click + // (the Pen's corner anchor, no nudge with Direct Selection). + if let Some(last) = last_drag + && last != xf.to_doc(p) + { + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + Drag::Art if on_canvas(ui, resp, p) => { + // Came back to the canvas and released in one frame: finish the move here. + egui::DragAndDrop::clear_payload(ui.ctx()); + let pressure = pen_pressure(ui, app, false); + let pos = xf.to_doc(p); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); + } + Drag::Art => { + // Released over a panel: the move goes back. (The app ends it before the panels + // see the release, so a panel can take the art; this is the headless path.) + // With no document open there is nothing to roll back; the tool still gets its up. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + Drag::Gesture | Drag::Present => {} + Drag::Pan { .. } | Drag::RotateView { .. } => {} + } +} + +/// A double-click at `p`: an artboard's name edits, else the tool gets it. +fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { m, space, xf, view, .. } = *f; + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + begin_artboard_name_edit(ui, app, i); + } else { + let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let before = app.session.tool_id().to_string(); + dispatch(app, &ev, view); + // A double-click on type switched a selection tool to the Type tool: the caret goes where + // the type was clicked, so typing edits it at once. + if before != "type" && app.session.tool_id() == "type" { + app.session.set_tool_option("prevTool", &json!(before)); + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(app, &PointerEvent { kind, ..ev }, view); + } + } + } +} + +/// Whether Space moves the Zoom tool's marquee in the drag under way ([`zoom_marquee_start`]). +fn zoom_space_free_id() -> egui::Id { + egui::Id::unique("canvas-zoom-space-free") +} + +/// The start corner of the Zoom tool's marquee in this frame. With Space held, the marquee moves +/// as far as the pointer did since `before` and keeps its size; with Space let go, the drag sizes +/// it again from where it moved to. Space held since the press (Cmd+Space lends the Zoom tool) +/// sizes the marquee until it is let go. A marquee with no size yet is sized by the movement, so +/// Space pressed before the pointer moves does not keep it at the press point. The frame's pointer +/// moves and Space presses and releases are taken in the order they came, so each move follows the +/// Space state it came with. +fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2, space: bool) -> Pos2 { + enum Step { + Space(bool), + Move(Pos2), + } + let id = zoom_space_free_id(); + let steps: Vec = ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key: egui::Key::Space, pressed, repeat: false, .. } => Some(Step::Space(*pressed)), + egui::Event::PointerMoved(q) => Some(Step::Move(*q)), + _ => None, + }) + .collect() + }); + // Space as it was when the frame began: the opposite of its first press or release, else as it is now. + let mut held = steps.iter().find_map(|s| if let Step::Space(down) = s { Some(!down) } else { None }).unwrap_or(space); + let mut free = !held || ui.data(|d| d.get_temp::(id)).unwrap_or(true); + let (mut start, mut at) = (start, before); + for s in steps.into_iter().chain(std::iter::once(Step::Move(p))) { + match s { + Step::Space(down) => { + held = down; + free |= !down; + } + Step::Move(q) => { + if let Some(b) = at + && held + && free + && b != start + { + start += q - b; + } + at = Some(q); + } + } + } + ui.data_mut(|d| d.insert_temp(id, free)); + start +} + /// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white /// artboard and on the grey pasteboard alike (#725). fn marquee(p: &egui::Painter, r: egui::Rect) { diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 907f33612..b5d14e151 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BezPath, Color32, Node, NodeKind, PathEl, Point, Pos2, Shape, Stroke, SymbolBoxes, VectorcraftApp, Xf, pos2}; pub(super) fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec> { // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6722c4761..704745f8a 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,7 +1,7 @@ use super::artboards::names_an_artboard; use super::input::marquee_through; use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn}; -use super::*; +use super::{Color32, CornerRadius, NodeKind, Overlay, Point, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index e2fc0f165..a799bbe29 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -1,16 +1,17 @@ -use super::*; +use super::{Affine, CacheKey, VectorcraftApp, Xf}; /// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied /// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen. /// A raster whose bytes don't match its size gives an empty image of that size. pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage { let size = [img.width as usize, img.height as usize]; - let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]]; - let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels); - if bytes.len() == img.pixels.len() { - bytes.copy_from_slice(&img.pixels); + let expected = size[0].saturating_mul(size[1]).saturating_mul(4); + if expected == img.pixels.len() { + let pixels: Vec = bytemuck::cast_slice(&img.pixels).to_vec(); + egui::ColorImage::new(size, pixels) + } else { + egui::ColorImage::new(size, vec![egui::Color32::TRANSPARENT; size[0].saturating_mul(size[1])]) } - egui::ColorImage::new(size, pixels) } /// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 273fce5e6..461730c6e 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Color32, CornerRadius, NodeKind, Pos2, Rect, Sense, Stroke, StrokeKind, TextKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, json, pos2, vec2, widgets, +}; /// The Contextual Task Bar's area. fn task_bar_id() -> egui::Id { @@ -115,7 +117,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none())); } else if is_group { items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none())); - items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + // Isolate is left out while Enter Isolation Mode is off, as it is while an opacity mask is edited. + if crate::menus::enabled(app, "object.isolate") { + items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + } } else if is_compound { items.push(edit_path()); items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); @@ -139,14 +144,16 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); + // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); @@ -173,6 +180,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let pinned = place.pinned; let mut moved = Vec2::ZERO; let mut run: Option<(&str, serde_json::Value)> = None; + let mut show_swatches = false; egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.panel) @@ -205,25 +213,32 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { run = Some((*cmd, params.clone())); } } - // The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool. + // The selected objects' Fill, Stroke, Recolor Artwork and Lock, left out with the Artboard tool. if !on_artboards { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill); - ui.painter().rect_stroke( - egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), - 0.0, - Stroke::new(1.0, t.button_border), - StrokeKind::Outside, - ); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); if resp.on_hover_text(tl!("Fill")).clicked() { app.session.fill_active = true; - app.ui.open_panel = Some("swatches".into()); + show_swatches = true; + } + // The Stroke chip, drawn as a ring as the Control bar's is. + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + if resp.on_hover_text(tl!("Stroke")).clicked() { + app.session.fill_active = false; + show_swatches = true; } // Recolor Artwork, when the selection has colours to recolour. if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() { run = Some(("ui.recolorDialog", json!({}))); } - if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() { + // The Lock button is off in isolation mode, as Object › Lock › Selection is. + let lockable = crate::menus::enabled(app, "object.lock"); + if widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked() { run = Some(("object.lock", json!({}))); } } @@ -258,6 +273,14 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } ui.ctx().request_repaint(); } + // The Swatches panel shows where it is docked, floating or closed. A panel in an icon column + // toggles when activated, so an open flyout stays as it is. + if show_swatches + && app.ui.open_panel.as_deref() != Some("swatches") + && let Err(e) = app.run("window.panel.activate", json!({ "panel": "swatches" })) + { + app.ui.status = e; + } match run { // Image Trace traces the image at once with the Default preset. As a quick trace from // Properties or the Control bar, it leaves the Image Trace panel as it was. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 55a1341ab..6a30f248f 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2,6 +2,32 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +#[test] +fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { + let mut observed = Vec::new(); + for width in [1324.0 + 29.0 / 32.0, 1324.875, 1325.875] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + show(&mut app, &mut child); + }); + out.textures_delta.clear(); + let rect = app.canvas_rect.unwrap(); + observed.push((rect, Xf::new(rect, app.view().unwrap()).to_doc(pos2(440.0, 330.0)))); + } + assert_eq!(observed[0], observed[1], "the same physical canvas must map the same pointer identically"); + assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping"); +} + /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent /// edges and all; one whose bytes don't match its size becomes an empty image of that size. #[test] @@ -679,6 +705,57 @@ fn the_artboard_tool_names_its_active_artboard_once() { assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); } +#[test] +fn shift_clicking_canvas_artboard_label_toggles_multi_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + if let Some(v) = app.view_mut() { + v.center = Point::new(320.0, 50.0); + v.zoom = 0.8; + v.fitted = true; + } + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_options()["selected"], json!([0])); + + let drawn = shapes(&mut app, &ctx); + let label_pos = |name: &str| { + drawn + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text() == name => Some(t.pos + vec2(10.0, 5.0)), + _ => None, + }) + .unwrap() + }; + let at1 = label_pos("02 - Artboard 2"); + + // Shift-click artboard 2's name label (Shift held on press, released before mouse release) + let shift = egui::Modifiers::SHIFT; + let button = |pressed| egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed, modifiers: shift }; + frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(at1), button(true)]); + frame( + &mut app, + &ctx, + vec![ + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ], + ); + + assert_eq!(app.session.tool_options()["selected"], json!([1, 0]), "Shift-clicking artboard label adds it to selection"); + + // Click artboard 3's name label without Shift + let at2 = label_pos("03 - Artboard 3"); + let plain_btn = + |pressed| egui::Event::PointerButton { pos: at2, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(at2), plain_btn(true)]); + frame(&mut app, &ctx, vec![plain_btn(false)]); + + assert_eq!(app.session.tool_options()["selected"], json!([2]), "Plain click selects that artboard alone"); +} + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. #[test] @@ -2309,6 +2386,144 @@ fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() { app.canvas.worker = None; } +/// While the worker renders the frame for a panned view, the fill worker renders a +/// low-resolution frame, and the part of the canvas the last frame no longer covers shows the +/// art from it, drawn only there. When the worker's frame arrives, it covers the canvas and the +/// fill is dropped. +#[test] +fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() { + // The canvas key holds the global placed-document bitmap clock, which tests on other threads + // move: a frame whose key changed by more than the view drops the fill. An attempt that fails + // while the clock moved runs again; one that fails while it stood still is a real failure. + for _ in 0..20 { + let clock = vectorcraft_render::placed_document::generation(); + match std::panic::catch_unwind(pan_fills_the_area_the_last_frame_leaves) { + Ok(()) => return, + Err(_) if vectorcraft_render::placed_document::generation() != clock => {} + Err(e) => std::panic::resume_unwind(e), + } + } + pan_fills_the_area_the_last_frame_leaves(); +} + +fn pan_fills_the_area_the_last_frame_leaves() { + let ctx = egui::Context::default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 80, "height": 100})).unwrap(); + app.canvas.worker_started = true; // render on this thread + app.ui.view.rulers = false; + app.ui.screen_mode = 2; // no scroll bars: the canvas is the window + // One frame → the meshes drawn and the fill's pixels, when this frame uploaded them. + let frame = |app: &mut VectorcraftApp| { + let (shapes, mut delta) = frame_output(app, &ctx); + let fill = app.canvas.fill.as_ref().map(|f| f.tex.id()); + let pixels = delta.set.iter().find(|(id, _)| Some(**id) == fill).and_then(|(_, d)| d.last()).map(|d| match &d.image { + egui::ImageData::Color(img) => img.clone(), + }); + delta.clear(); + let meshes: Vec = shapes + .into_iter() + .filter_map(|s| match s { + Shape::Mesh(m) => Some(std::sync::Arc::unwrap_or_clone(m)), + _ => None, + }) + .collect(); + (meshes, pixels) + }; + frame(&mut app); + { + let v = app.view_mut().unwrap(); + v.zoom = 4.0; + v.center = Point::new(100.0, 150.0); + } + frame(&mut app); // the art from about x 0 to 200, without the rectangle + let old = app.canvas.key.clone().unwrap(); + let before = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(app.canvas.fill.is_none()); + let (worker, jobs, completed) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + let (fill_worker, fill_jobs, fill_completed) = crate::render_worker::Worker::test_channel(); + app.canvas.fill_worker = Some(fill_worker); + app.canvas.last_ms = 50.0; + // Pan right by 150 pt: the last frame covers the canvas's left quarter, and the rectangle + // comes into its right quarter. + app.view_mut().unwrap().center = Point::new(250.0, 150.0); + frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown"); + let job = jobs.try_recv().expect("the frame for the new view is on the worker"); + let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker"); + let (fw, fh, _) = fill_view(&job.key); + assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h)); + assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives"); + // The fill arrives; the next frame draws it, and asks for no other fill. + let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts); + let (doc, opts) = (fill_job.doc, fill_job.opts); + fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap(); + let (meshes, pixels) = frame(&mut app); + assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas"); + assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown"); + let fill = app.canvas.fill.as_ref().expect("the fill is kept"); + let mesh = meshes.iter().find(|m| m.texture_id == fill.tex.id()).expect("the fill is drawn"); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let edge = xf.to_screen(before.to_doc(rect.right_top())).x; // the last frame's right edge + assert!(edge > rect.left() + 100.0 && edge < xf.to_screen(Point::new(300.0, 0.0)).x, "{edge} in {rect:?}"); + assert!( + mesh.vertices.iter().all(|v| v.pos.x >= edge - 1e-3 && rect.expand(1e-3).contains(v.pos)), + "only right of the last frame: {:?}", + mesh.vertices + ); + let area: f32 = mesh.indices.as_chunks::<3>().0.iter().map(|t| polygon_area(&t.map(|i| mesh.vertices[i as usize].pos))).sum(); + let uncovered = (rect.right() - edge) * rect.height(); + assert!((area - uncovered).abs() < 1.0, "the whole uncovered area, {uncovered} square points: {area}"); + // The fill's pixel under a point of the canvas, from the uv the mesh gives that point. + let img = pixels.expect("the fill's pixels were uploaded"); + let to_uv = Affine::scale_non_uniform(1.0 / fill.size.0 as f64, 1.0 / fill.size.1 as f64) * fill.view * xf.affine().inverse(); + let alpha = |p: Pos2| { + let uv = to_uv * Point::new(p.x as f64, p.y as f64); + img.pixels[(uv.y * img.size[1] as f64) as usize * img.size[0] + (uv.x * img.size[0] as f64) as usize].a() + }; + assert!(alpha(xf.to_screen(Point::new(340.0, 150.0))) > 0, "the rectangle, in the uncovered area"); + assert_eq!(alpha(xf.to_screen(Point::new(250.0, 150.0))), 0, "no art there"); + for v in &mesh.vertices { + assert!((to_uv * Point::new(v.pos.x as f64, v.pos.y as f64) - Point::new(v.uv.x as f64, v.uv.y as f64)).hypot() < 1e-4); + } + // Panned until the fill reaches only the canvas's left half: it is drawn only there, and the + // fill for the new view is asked for. + let fill_tex = fill.tex.id(); + let reach = fill.view.inverse() * Point::new(fill.size.0 as f64, 0.0); // the fill's right edge + app.view_mut().unwrap().center = Point::new(reach.x, 150.0); + let (meshes, _) = frame(&mut app); + let right = Xf::new(rect, app.view().unwrap()).to_screen(reach).x; + assert!((right - rect.center().x).abs() < 1.0, "{right} in {rect:?}"); + let mesh = meshes.iter().find(|m| m.texture_id == fill_tex).expect("the fill is drawn where it reaches"); + assert!(mesh.vertices.iter().all(|v| v.pos.x <= right + 1e-3), "only where the fill reaches: {:?}", mesh.vertices); + let next = fill_jobs.try_recv().expect("the next fill"); + assert_eq!(Some(&next.key), app.canvas.worker.as_ref().unwrap().pending.as_ref()); + app.view_mut().unwrap().center = Point::new(250.0, 150.0); + // That fill finishes after the worker's frame: it is taken and not kept. + let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts); + fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap(); + // The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept. + let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); + let expected = job.key.clone(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap(); + let (meshes, _) = frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown"); + assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped"); + assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken"); + // A pan with Outline turned on: the frame shown's options are not the ones asked for, and + // no fill is asked for. + app.ui.view.outline = true; + app.view_mut().unwrap().center = Point::new(400.0, 150.0); + frame(&mut app); + assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker"); + assert!(fill_jobs.try_recv().is_err() && app.canvas.fill.is_none(), "no fill"); + app.canvas.worker = None; + app.canvas.fill_worker = None; +} + /// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas /// is not rendered while the view moves: each frame draws the frame shown moved and scaled with /// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been @@ -3765,6 +3980,60 @@ fn wheel_notches_zoom_in_a_glide() { assert_eq!(app.view().unwrap().zoom / before, done, "and it stops"); } +/// #4283: with Performance › Animated Zoom off, or on without GPU Performance (which it needs), a +/// wheel notch zooms the whole step on the frame it arrives, both the plain wheel with Zoom with +/// Mouse Wheel and Alt-wheel without it. +#[test] +fn wheel_notches_zoom_at_once_without_animated_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let at = app.canvas_rect.unwrap().center(); + for (animated, gpu, wheel_zooms, alt) in + [(false, true, true, false), (false, true, false, true), (true, false, true, false), (true, false, false, true)] + { + app.session.execute("prefs.set", &json!({"key": "animatedZoom", "value": animated})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "gpuPerformance", "value": gpu})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap(); + // Any glide or spread scroll a case before left behind dies down first. + for _ in 0..30 { + run(&mut app); + } + let before = app.view().unwrap().zoom; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": alt})); + // One line notch's whole zoom. + let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed) * 0.01).exp(); + // The first frame the zoom moves (the event may land a frame later). + let mut first = 1.0; + for _ in 0..3 { + run(&mut app); + first = app.view().unwrap().zoom / before; + if first != 1.0 { + break; + } + } + assert!( + (first - expected).abs() < 1e-9, + "the whole notch arrives at once (animated {animated}, gpu {gpu}, wheel zooms {wheel_zooms}, alt {alt}: expected {expected}, got {first})" + ); + for _ in 0..30 { + run(&mut app); + } + let done = app.view().unwrap().zoom / before; + assert!( + (done - first).abs() < 1e-9, + "all of it on the first frame (animated {animated}, gpu {gpu}, wheel zooms {wheel_zooms}, alt {alt}: {first} then {done})" + ); + } +} + /// In each frame where a wheel scroll moves the view, the hover point (read by the rulers' /// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point /// under the pointer in the view the canvas draws in that frame. @@ -4095,6 +4364,168 @@ fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() { } } +/// Space held while the Zoom tool drags a marquee moves the marquee and keeps its size. When Space +/// is let go, the drag sizes the marquee again from where it moved to, and the release zooms to the +/// moved marquee. +/// Space held since a Cmd+Space press sizes the marquee. +#[test] +fn space_moves_the_zoom_marquee_while_dragging() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let drawn = |app: &mut VectorcraftApp| -> Vec { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + line.clone() + }; + let z0 = app.view().unwrap().zoom; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true, Modifiers::NONE)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::PointerMoved(at + vec2(140.0, 70.0))]); + let moved = drawn(&mut app); + assert_eq!(moved[0], at + vec2(40.0, 20.0), "Space moves the marquee: {moved:?}"); + assert_eq!(moved[2], at + vec2(140.0, 70.0), "and keeps its 100 × 50 size: {moved:?}"); + frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::PointerMoved(at + vec2(180.0, 95.0))]); + let sized = drawn(&mut app); + assert_eq!((sized[0], sized[2]), (at + vec2(40.0, 20.0), at + vec2(180.0, 95.0)), "let go, the drag sizes it again: {sized:?}"); + let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(110.0, 57.5)); + frame(&mut app, &ctx, vec![button(at + vec2(180.0, 95.0), false, Modifiers::NONE)]); + let v = *app.view().unwrap(); + let want = z0 * f64::from((rect.width() / 140.0).min(rect.height() / 75.0)); + assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the moved marquee: {z0} → {}, not {want}", v.zoom); + assert!(v.center.distance(middle) < 1e-6, "centered on it: {:?}, not {middle:?}", v.center); + // A Cmd+Space press: Space held from the press on sizes the marquee, also once Cmd is let go. + app.select_tool("selection"); + app.view_mut().unwrap().zoom = z0; + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(50.0, 25.0))]); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at + vec2(100.0, 50.0))]); + let held = drawn(&mut app); + assert_eq!((held[0], held[2]), (at, at + vec2(100.0, 50.0)), "Space held since the press sizes it: {held:?}"); + frame(&mut app, &ctx, vec![button(at + vec2(100.0, 50.0), false, Modifiers::NONE)]); + frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + assert!((app.view().unwrap().zoom - want).abs() < 1e-6, "{z0} → {}, not {want}", app.view().unwrap().zoom); +} + +/// Space pressed after the Zoom tool's press and before the drag moves: the first movement sizes +/// the marquee, Space then moves it, and the release zooms to it instead of stepping the zoom. +#[test] +fn space_before_the_zoom_drag_moves_sizes_the_marquee_first() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let z0 = app.view().unwrap().zoom; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true)]); + frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(120.0, 60.0))]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(140.0, 70.0))]); + let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(80.0, 40.0)); + frame(&mut app, &ctx, vec![button(at + vec2(140.0, 70.0), false)]); + frame(&mut app, &ctx, vec![space(false)]); + let v = *app.view().unwrap(); + let want = z0 * f64::from((rect.width() / 120.0).min(rect.height() / 60.0)); + assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the 120 × 60 marquee: {z0} → {}, not {want}", v.zoom); + assert!(v.center.distance(middle) < 1e-6, "moved by Space and centered on it: {:?}, not {middle:?}", v.center); +} + +/// Space pressed or let go between two pointer moves in one frame: the move before the change and +/// the move after it each follow the Space state they came with. +#[test] +fn the_zoom_marquee_follows_space_and_the_pointer_in_the_order_they_came() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let corners = |app: &mut VectorcraftApp| -> (Pos2, Pos2) { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + (line[0], line[2]) + }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(140.0, 70.0))]); + // Moved with Space held, then sized after it was let go. + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(160.0, 80.0)), space(false), Event::PointerMoved(at + vec2(200.0, 100.0))]); + assert_eq!(corners(&mut app), (at + vec2(60.0, 30.0), at + vec2(200.0, 100.0)), "moved, then sized"); + // Sized before Space was pressed, then moved with it held. + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(210.0, 105.0)), space(true), Event::PointerMoved(at + vec2(230.0, 115.0))]); + assert_eq!(corners(&mut app), (at + vec2(80.0, 40.0), at + vec2(230.0, 115.0)), "sized, then moved"); + frame(&mut app, &ctx, vec![button(at + vec2(230.0, 115.0), false)]); + frame(&mut app, &ctx, vec![space(false)]); +} + +/// Space pressed while the Zoom tool drags keeps the zoom cursor: the drag does not turn into a +/// Hand tool pan, and with Shift also held it does not show the rotate cursor. +#[test] +fn space_during_a_zoom_drag_keeps_the_zoom_cursor() { + use egui::{CursorIcon, Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + // `shown` returns the system cursor the canvas sets over a window without the focus, as in + // `the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool`. + let shown = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.cursor_icon + }; + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::ZoomIn); + shown(&mut app, vec![button(c, true)]); + shown(&mut app, vec![Event::PointerMoved(c + vec2(40.0, 30.0))]); + assert_eq!(shown(&mut app, vec![space(true), Event::PointerMoved(c + vec2(60.0, 40.0))]), CursorIcon::ZoomIn, "Space during the drag"); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT)]), CursorIcon::ZoomIn, "Space+Shift during the drag"); + shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]); + shown(&mut app, vec![button(c + vec2(60.0, 40.0), false)]); + assert_eq!(shown(&mut app, vec![]), CursorIcon::Grab, "after the drag, Space shows the hand"); + shown(&mut app, vec![space(false)]); +} + /// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the /// screen corners of the document rectangle between the press and the pointer. #[test] @@ -6067,6 +6498,121 @@ fn a_pen_tap_within_one_frame_clicks_where_it_tapped() { assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing"); } +/// The Contextual Task Bar shows a Stroke chip beside the Fill chip, drawn with the selection's +/// stroke paint. A click on it brings the Stroke proxy forward and opens Swatches, as a click on +/// the Fill chip does for the Fill proxy. +#[test] +fn the_task_bar_shows_a_stroke_chip_beside_fill() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [id], "color": "#2244cc"})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + // Past the bar's fade-in, so its chips draw at full opacity. + for _ in 0..30 { + frame(&mut app, &ctx, vec![]); + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill. + let stroke_x = bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0; + let fill_x = stroke_x - 26.0 - 6.0; + let blue = Color32::from_rgb(0x22, 0x44, 0xcc); + let chip_with = |x: f32| { + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Rect(r) => r.fill == blue && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0, + _ => false, + }) + }; + assert!(chip_with(stroke_x), "the Stroke chip is drawn with the stroke color"); + assert!(!chip_with(fill_x), "the Fill chip is not"); + + let click = |app: &mut VectorcraftApp, x: f32| { + let at = pos2(x, bar.center().y); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + app.session.fill_active = true; + click(&mut app, stroke_x); + assert!(!app.session.fill_active, "the Stroke proxy is in front"); + assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); + app.ui.open_panel = None; + click(&mut app, fill_x); + assert!(app.session.fill_active, "the Fill proxy is in front"); + assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); +} + +/// The Contextual Task Bar's Fill and Stroke chips show the paints the proxies show: a group's +/// painted art, and "?" when the selected objects' paints differ. A click on a chip shows the +/// Swatches panel wherever it is, closed included. +#[test] +fn the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut square = |x: f64, stroke: &str| { + let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [id], "color": stroke})).unwrap(); + id + }; + square(160.0, "#2244cc"); + let second = square(220.0, "#22cc44"); + app.select_tool("selection"); + app.session.execute("select.all", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let shapes = |app: &mut VectorcraftApp| { + for _ in 0..30 { + frame(app, &ctx, vec![]); + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill. + let stroke_x = |bar: egui::Rect| bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0; + let chip_with = |shapes: &[egui::epaint::ClippedShape], bar: egui::Rect, x: f32, color: Color32| { + shapes.iter().any(|c| match &c.shape { + egui::Shape::Rect(r) => r.fill == color && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0, + _ => false, + }) + }; + let (blue, green) = (Color32::from_rgb(0x22, 0x44, 0xcc), Color32::from_rgb(0x22, 0xcc, 0x44)); + + // Two strokes that differ: "?" on white, as the proxy shows them. + let out = shapes(&mut app); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + assert!(chip_with(&out, bar, stroke_x(bar), Color32::WHITE), "the Stroke chip shows the strokes differ"); + assert!(!chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip does not show the first object's stroke"); + + // A group shows its painted art's stroke, as the proxy does. + app.session.execute("paint.setStroke", &json!({"ids": [second], "color": "#2244cc"})).unwrap(); + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("object.group", &json!({})).unwrap(); + let out = shapes(&mut app); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + assert!(chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip shows the group's art's stroke"); + assert!(!chip_with(&out, bar, stroke_x(bar), green)); + + // Swatches closed: a click on the Stroke chip shows it. + app.run("window.panel.close", json!({"panel": "swatches"})).unwrap(); + assert!(!crate::panel_docking::showing(&app.ui, "swatches")); + let at = pos2(stroke_x(bar), bar.center().y); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + frame(&mut app, &ctx, vec![]); + assert!(!app.session.fill_active, "the Stroke proxy is in front"); + assert!(crate::panel_docking::showing(&app.ui, "swatches"), "the Swatches panel shows"); +} + /// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours, /// and it opens the Recolor Artwork dialog; art painted with None has no button. #[test] @@ -6102,6 +6648,43 @@ fn the_task_bar_recolors_coloured_art() { assert!(app.ui.dialog.is_none(), "no Recolor button without colours"); } +/// The Contextual Task Bar's Lock button is off in isolation mode, as Object › Lock › Selection +/// is: a click locks nothing and shows no error. Outside isolation mode it locks the selection. +#[test] +fn the_task_bar_lock_button_is_off_in_isolation_mode() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + let b = app.session.execute("shape.rectangle", &json!({"x": 210, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a.clone(), b]})).unwrap(); + let g = app.session.execute("object.group", &json!({})).unwrap()["id"].clone(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let click_lock = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![]); + frame(app, &ctx, vec![]); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, then Lock. + let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 15.0, bar.center().y); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + let locked = |app: &VectorcraftApp, id: &serde_json::Value| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().locked + }; + app.session.execute("object.isolate", &json!({"id": g.clone()})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a.clone()]})).unwrap(); + click_lock(&mut app); + assert!(!locked(&app, &a), "nothing is locked in isolation mode"); + assert_eq!(app.ui.status, "", "no error"); + app.session.execute("object.exitIsolation", &json!({})).unwrap(); + app.session.execute("select.set", &json!({"ids": [g.clone()]})).unwrap(); + click_lock(&mut app); + assert!(locked(&app, &g), "the group is locked outside isolation mode"); +} + /// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image /// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace /// object it made, the first button is Expand, which keeps only the traced paths. @@ -6200,6 +6783,32 @@ fn the_task_bar_offers_the_artboard_tools_actions() { assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); } +/// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is +/// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate. +#[test] +fn the_task_bar_leaves_isolate_out_while_an_opacity_mask_is_edited() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone() + }; + let group = |app: &mut VectorcraftApp, x: f64| { + let (a, b) = (rect(app, x), rect(app, x + 50.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + app.session.execute("object.group", &json!({})).unwrap(); + }; + group(&mut app, 20.0); + assert_eq!(names(&mut app), ["Ungroup", "Isolate", "Duplicate"]); + let (obj, m) = (rect(&mut app, 200.0), rect(&mut app, 200.0)); + app.session.execute("transparency.makeOpacityMask", &json!({"ids": [obj, m]})).unwrap(); + app.session.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap(); + group(&mut app, 200.0); + assert_eq!(names(&mut app), ["Ungroup", "Duplicate"]); +} + /// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens /// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens /// Export for Screens with the selected artboards checked, and Duplicate copies each selected @@ -6291,7 +6900,7 @@ fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() { app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let mut square = |x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone(); - let (a, b) = (square(120.0, 40.0), square(160.0, 150.0)); + let (a, b) = (square(140.0, 40.0), square(160.0, 150.0)); app.select_tool("selection"); let ctx = egui::Context::default(); let bar = || task_bar_rect(&ctx).expect("the task bar shows"); @@ -6770,6 +7379,28 @@ fn distance_guides_follow_what_lies_under_a_still_pointer() { assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document"); } +/// #4518: a floating panel or a modal dialog over the canvas hides the pointer from it, so Object +/// Highlighting, the rulers and the Info panel don't follow a pointer that is over the dialog. +#[test] +fn a_panel_or_dialog_over_the_canvas_hides_the_pointer_from_it() { + let (mut app, ctx, _, over, _) = distance_scene(true); + let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover); + run(&mut app, vec![egui::Event::PointerMoved(over)], None); + run(&mut app, vec![], None); + assert!(app.hover_doc.is_some(), "over the canvas"); + for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] { + // A new window takes a sizing pass before it covers anything, as in the test above. + for _ in 0..3 { + run(&mut app, vec![], Some(cover)); + } + assert_eq!(app.hover_doc, None, "under it"); + for _ in 0..3 { + run(&mut app, vec![], None); + } + assert!(app.hover_doc.is_some(), "back once it closes"); + } +} + /// The 800 × 600 window [`frame`] draws the canvas in. const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } }; diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index c3f1b8007..bbb8bf5f0 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -1,6 +1,6 @@ use super::grid::{clip_segment, pref_color}; use super::outline::{OVERLAY_MAX_ANCHORS, c32, stroke_path}; -use super::*; +use super::{Color32, CornerRadius, Node, NodeKind, Overlay, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, pos2, vec2}; /// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story. /// The marks of each text object are kept from one frame to the next ([`HiddenChars`]). diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index f4ca00008..add171611 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,5 +1,5 @@ use super::drag::{drag_id, drop_press}; -use super::*; +use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; /// Fit the view (View → Fit Artboard / Fit All / Actual Size). pub fn fit(app: &mut VectorcraftApp, how: &str) { @@ -64,7 +64,8 @@ pub(super) const HOLD_DELAY: f32 = 0.3; pub(super) const HOLD_ZOOM: f64 = std::f64::consts::LN_2; /// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms -/// as it is dragged sideways or held, instead of zooming to the area dragged across. +/// as it is dragged sideways or held, instead of zooming to the area dragged across, and a wheel +/// notch's zoom glides in ([`wheel`]). pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool { p.animated_zoom && p.gpu_performance } @@ -336,11 +337,19 @@ struct ThumbDrag { /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option /// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel /// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points -/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom -/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once. -pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { +/// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated +/// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does +/// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at +/// once either way. +pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); + // A wheel event's motion in points. + let points = |unit: &egui::MouseWheelUnit, delta: egui::Vec2| match unit { + egui::MouseWheelUnit::Point => delta, + egui::MouseWheelUnit::Line => delta * line, + egui::MouseWheelUnit::Page => delta * page, + }; if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. @@ -348,6 +357,17 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f3 turn.alt = m.alt && !m.command; } let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + if turn.alt && !animate { + // Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame + // they arrive; the rest of egui's spread scroll is dropped. + let d: f32 = i + .events + .iter() + .filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None }) + .map(|d| d.x + d.y) + .sum(); + return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan); + } return if turn.alt && scroll != egui::Vec2::ZERO { (zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan) } else { @@ -360,16 +380,12 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f3 for e in &i.events { match e { egui::Event::MouseWheel { unit, delta, modifiers, .. } => { - let d = match unit { - egui::MouseWheelUnit::Point => *delta, - egui::MouseWheelUnit::Line => *delta * line, - egui::MouseWheelUnit::Page => *delta * page, - }; + let d = points(unit, *delta); if modifiers.command { scroll.x += d.x + d.y; } else if modifiers.shift { scroll.y += d.x + d.y; - } else if *unit == egui::MouseWheelUnit::Point { + } else if *unit == egui::MouseWheelUnit::Point || !animate { zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp(); scroll.x += d.x; } else { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2000aaacb..b453cc685 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -29,7 +29,7 @@ pub use status_bar::status_bar; use status_bar::{StatusReadoutWidths, status_readouts}; #[cfg(test)] use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title}; -pub use tabs::{doc_tabs, isolation_bar}; +pub use tabs::{doc_tabs, isolation_bar, step_document}; /// The application bar's height, in points (compact, as in PhotoCraft). pub const APP_BAR_HEIGHT: f32 = 32.0; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index a0d063b07..2f4f8c7b9 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{NodeKind, Sense, Stroke, Tokens, Ui, VectorcraftApp, json, stroke_panel, theme, vec2, widgets}; /// An anchor button: icon, tooltip, command and the convert command's `to`. type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>); diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 947f0dcf2..7fc2e749b 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,5 +1,5 @@ use super::status_bar::ANIMATED_ZOOM_HINT; -use super::*; +use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { Some(match tool { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 92d718fc9..9a1ffbfbb 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -1,5 +1,5 @@ use super::tabs::artboard_list; -use super::*; +use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, ZOOM_STOPS, json, theme, vec2, widgets, zoom_label}; /// Draw the status message, bounded readouts and artboard navigation controls. pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 649030172..06d2964a7 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label}; /// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited /// "Name* @ 66.67 % (/Opacity Mask)". @@ -114,21 +114,47 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } } else if let Some(i) = activate { - app.ui.home = None; - let changed = app.session.active_index() != Some(i); - if app.session.set_active(i) - && changed - && app.session.tool_id() == "artboard" - && let Some(n) = app.session.active().map(|d| d.doc.artboards.len()) - { - // A document switch recreates the tool; restore the displayed board as its target. - // Clicking the current tab keeps its existing multiselection and tool options. - let selected = crate::panels::artboards::selected(app, n); - crate::panels::artboards::select(app, selected); - } + show_document(app, i); } } +/// Makes document `i` the current one, as a click on its tab does: the Home screen closes, and +/// with the Artboard tool the artboard the document shows becomes the tool's target. +fn show_document(app: &mut VectorcraftApp, i: usize) { + app.ui.home = None; + let changed = app.session.active_index() != Some(i); + if app.session.set_active(i) + && changed + && app.session.tool_id() == "artboard" + && let Some(n) = app.session.active().map(|d| d.doc.artboards.len()) + { + // A document switch recreates the tool; restore the displayed board as its target. + // Clicking the current tab keeps its existing multiselection and tool options. + let selected = crate::panels::artboards::selected(app, n); + crate::panels::artboards::select(app, selected); + } +} + +/// `window.nextDocument` (`forward`) and `window.previousDocument`: makes the open document after +/// the current one in tab order (before it) the current one, as a click on its tab does. After the +/// last document comes the first, and before the first document comes the last. On the Home +/// screen, where no tab is the current one, the next document is the first and the previous +/// document is the last. Returns `{index}`. +pub fn step_document(app: &mut VectorcraftApp, forward: bool) -> Result { + let n = app.session.documents().len(); + if n == 0 { + return Err("no document open".into()); + } + let i = match (app.session.active_index().filter(|_| app.ui.home.is_none()), forward) { + (Some(a), true) => (a + 1) % n, + (Some(a), false) => (a + n - 1) % n, + (None, true) => 0, + (None, false) => n - 1, + }; + show_document(app, i); + Ok(json!({ "index": i })) +} + /// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the /// canvas, so entering isolation mode does not change the canvas's size (#3126). pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { @@ -136,12 +162,20 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { if let Some(st) = app.session.active() && let Some(iso) = st.isolation { - let mut crumbs: Vec = - st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + let ancestry = st.doc.ancestry(iso).unwrap_or_default(); + struct Crumb { + id: Option, + is_layer: bool, + name: String, + } + let mut crumbs: Vec = ancestry + .iter() + .filter_map(|id| st.doc.node(*id).map(|n| Crumb { id: Some(*id), is_layer: n.is_layer(), name: n.display_name() })) + .collect(); // Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right. let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone()); if let Some(pn) = &pattern_edit { - crumbs.push(pn.clone()); + crumbs.push(Crumb { id: None, is_layer: false, name: pn.clone() }); } let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0)); ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag()); @@ -149,13 +183,41 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click()); icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); - ui.painter().text( - bar.left_center() + vec2(30.0, 0.0), - egui::Align2::LEFT_CENTER, - crumbs.join(" › "), - egui::FontId::proportional(12.0), - t.text, - ); + + let mut x = bar.left() + 30.0; + let y_center = bar.center().y; + let font_id = egui::FontId::proportional(12.0); + let mut nav_cmd = None; + let total = crumbs.len(); + for (i, crumb) in crumbs.iter().enumerate() { + let is_leaf = i + 1 == total; + let galley = ui.painter().layout_no_wrap(crumb.name.clone(), font_id.clone(), t.text); + let w = galley.size().x; + let text_pos = egui::pos2(x, y_center - galley.size().y * 0.5); + if !is_leaf { + let crumb_rect = egui::Rect::from_min_max(egui::pos2(x - 3.0, bar.top() + 3.0), egui::pos2(x + w + 3.0, bar.bottom() - 3.0)); + let resp = ui.interact(crumb_rect, ui.scope_id().with(("iso-crumb", i)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(crumb_rect, 3.0, t.hover); + } + if resp.clicked() { + if crumb.is_layer { + nav_cmd = Some(("object.exitIsolation", json!({}))); + } else if let Some(target) = crumb.id { + nav_cmd = Some(("object.isolate", json!({ "id": target.0 }))); + } + } + ui.painter().galley(text_pos, galley, if resp.hovered() { t.text } else { t.text_dim }); + x += w + 6.0; + let sep = ui.painter().layout_no_wrap("›".to_string(), font_id.clone(), t.text_dim); + let sw = sep.size().x; + ui.painter().galley(egui::pos2(x, y_center - sep.size().y * 0.5), sep, t.text_dim); + x += sw + 6.0; + } else { + ui.painter().galley(text_pos, galley, t.text); + x += w; + } + } let mut pattern_cmd = None; if pattern_edit.is_some() { let mut x = bar.right() - 6.0; @@ -176,9 +238,16 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { if bresp.clicked() { pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done")); if pattern_cmd.is_none() { - app.run("object.exitIsolation", json!({})).ok(); + // Up one level: the isolated object's parent, or out of isolation mode from a layer. + nav_cmd = Some(match ancestry.iter().rev().nth(1).filter(|p| st.doc.node(**p).is_some_and(|n| !n.is_layer())) { + Some(parent) => ("object.isolate", json!({ "id": parent.0 })), + None => ("object.exitIsolation", json!({})), + }); } } + if let Some((cmd, params)) = nav_cmd { + app.run(cmd, params).ok(); + } if let Some(cmd) = pattern_cmd { app.run(cmd, json!({})).ok(); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 8a340d9f5..929034a72 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -707,81 +707,103 @@ fn artboard_navigation_properties_frame(app: &mut crate::VectorcraftApp, ctx: &e .collect() } +/// Delete the shown artboard with `key`, or with Properties' Delete Artboard button. +fn trigger_artboard_delete(app: &mut crate::VectorcraftApp, ctx: &egui::Context, key: Option, before: &vectorcraft_doc::Document) { + if let Some(key) = key { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::status_bar(app, ui); + }); + out.textures_delta.clear(); + } else { + artboard_navigation_properties_frame(app, ctx, vec![]); + let texts = artboard_navigation_properties_frame(app, ctx, vec![]); + assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_navigation_properties_frame(app, ctx, vec![event]); + } + } +} + /// None clicks Properties' Delete Artboard; Some routes the real key through shortcuts. fn artboard_navigation_delete(key: Option) { for multiple in [false, true] { - let (mut app, id) = artboard_navigation_app("artboard"); - let selected = if multiple { json!([0, 2]) } else { json!([0]) }; - app.session.set_tool_option("selected", &selected); - assert_eq!(app.view().unwrap().artboard, 0); - assert_eq!(app.session.tool_options()["active"], json!(0)); - assert_eq!(app.session.tool_options()["selected"], selected); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let undo = st.history.undo.len(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - - artboard_navigation_next(&mut app, &ctx); - - assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); - assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); - assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "navigation only changes view and tool state"); - assert_eq!(st.selection, selection, "ordinary artwork stays selected"); - assert_eq!(st.history.undo.len(), undo); - assert!(st.history.redo.is_empty()); - assert!(!st.is_dirty()); - - if let Some(key) = key { - let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { - crate::shortcuts::handle(&mut app, ui.ctx()); - super::status_bar(&mut app, ui); - }); - out.textures_delta.clear(); - } else { - artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); - let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - } - } - - let st = app.session.active().unwrap(); - assert_eq!( - st.doc.artboards, - vec![before.artboards[0].clone(), before.artboards[2].clone()], - "first and third original board records survive" - ); - assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); - assert_eq!(st.selection, selection); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - assert!(st.history.redo.is_empty()); - assert!(st.is_dirty()); - let deleted = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "one Undo restores every board and the artwork"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, deleted); + artboard_navigation_delete_case(key, multiple); } } +fn artboard_navigation_delete_case(key: Option, multiple: bool) { + let (mut app, id) = artboard_navigation_app("artboard"); + let selected = if multiple { json!([0, 2]) } else { json!([0]) }; + app.session.set_tool_option("selected", &selected); + assert_eq!(app.view().unwrap().artboard, 0); + assert_eq!(app.session.tool_options()["active"], json!(0)); + assert_eq!(app.session.tool_options()["selected"], selected); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let undo = st.history.undo.len(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + navigate_to_next_board(&mut app, &ctx, &before, &selection, undo); + delete_navigated_board(&mut app, &ctx, key, id, &before, &selection, undo); +} + +fn navigate_to_next_board( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + before: &vectorcraft_doc::Document, + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + artboard_navigation_next(app, ctx); + assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); + assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); + assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.as_ref(), before, "navigation only changes view and tool state"); + assert_eq!(&st.selection, selection, "ordinary artwork stays selected"); + assert_eq!(st.history.undo.len(), undo); + assert!(st.history.redo.is_empty()); + assert!(!st.is_dirty()); +} + +fn delete_navigated_board( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + key: Option, + id: vectorcraft_doc::NodeId, + before: &vectorcraft_doc::Document, + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + trigger_artboard_delete(app, ctx, key, before); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.artboards, vec![before.artboards[0].clone(), before.artboards[2].clone()], "first and third original board records survive"); + assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); + assert_eq!(&st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert!(st.history.redo.is_empty()); + assert!(st.is_dirty()); + let deleted = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.as_ref(), before, "one Undo restores every board and the artwork"); + assert_eq!(&st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, deleted); +} + #[test] fn artboard_navigation_next_then_delete_uses_shown_board() { artboard_navigation_delete(Some(egui::Key::Delete)); @@ -880,6 +902,77 @@ fn the_isolation_bar_does_not_take_layout_height_from_the_canvas() { assert_eq!(top(&mut app), before, "isolation mode leaves the canvas where it was"); } +/// #4468: In nested isolation, clicking the back button steps up one level in the hierarchy, +/// and clicking ancestor breadcrumbs navigates directly to that isolation level. +#[test] +fn isolation_bar_navigation_and_back_button_steps_up() { + use vectorcraft_doc::NodeId; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + // Group 1 contains a and b + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let g1 = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut app, 90.0); + // Group 2 contains g1 and c + app.run("select.set", json!({"ids": [g1, c]})).unwrap(); + let g2 = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + + // Isolate path a (deepest level: Layer 1 > > > ) + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + egui_extras::install_image_loaders(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0)); + + let click_at = |app: &mut crate::VectorcraftApp, pos: egui::Pos2| { + let button = egui::PointerButton::Primary; + let mut run_frame = |events| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| { + super::isolation_bar(app, ui, screen); + }); + out.textures_delta.clear(); + }; + run_frame(vec![egui::Event::PointerMoved(pos)]); + run_frame(vec![egui::Event::PointerButton { pos, button, pressed: true, modifiers: Default::default() }]); + run_frame(vec![egui::Event::PointerButton { pos, button, pressed: false, modifiers: Default::default() }]); + }; + + // Clicking back from `a` steps up to parent group `g1` + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g1))); + + // Clicking back from `g1` steps up to grandparent group `g2` + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g2))); + + // Clicking back from `g2` (parent is layer) exits isolation mode completely + click_at(&mut app, egui::pos2(15.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, None); + + // Deep isolate again to test clicking ancestor breadcrumb directly + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + // First crumb is Layer 1 (at x ~ 30..80). Clicking it exits isolation directly. + click_at(&mut app, egui::pos2(45.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, None); + + // Deep isolate again to test clicking an intermediate ancestor group breadcrumb directly + app.run("object.isolate", json!({"id": a})).unwrap(); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a))); + + // Second crumb is g2 ("") at x ~ 95..140. + // Clicking g2 isolates g2 directly. + click_at(&mut app, egui::pos2(115.0, 12.0)); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g2))); +} + /// The Control bar's Isolate Selected Object isolates a path inside a group that Direct /// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol /// instances, blends, two objects and an envelope's contents get no isolate button, and pattern @@ -1201,59 +1294,175 @@ fn artboard_document_tab_roundtrip_preserves_other_tools() { } } -#[test] -fn artboard_close_active_tab_properties_delete_uses_shown_board() { - let (mut app, ctx) = artboard_document_app(); - let history = |st: &vectorcraft_engine::DocState| { - let entries = |items: &[vectorcraft_engine::HistoryEntry]| { - items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>() - }; - (entries(&st.history.undo), entries(&st.history.redo)) - }; - let outside_canvas = |app: &crate::VectorcraftApp| { - let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); - assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); - }; - app.select_tool("artboard"); - app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); - app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); - artboard_navigation_next(&mut app, &ctx); - outside_canvas(&app); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let (uid, revision) = (st.uid, st.revision); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let history_before = history(st); - let history_limits = (st.history.limit, st.history.byte_budget); - assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); - assert!(!selection.objects.is_empty()); - assert!(history_before.1.is_empty()); - assert!(!st.is_dirty()); - let mut expected = before.as_ref().clone(); - expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; +/// Press F6 with `modifiers` through the shortcut handler. +fn press_f6(app: &mut crate::VectorcraftApp, modifiers: egui::Modifiers) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); +} +/// Cmd+F6 is Next Document and Cmd+Shift+F6 is Previous Document by default: Ctrl+F6 and +/// Ctrl+Shift+F6 on Windows and Linux, Command+F6 and Shift+Command+F6 on macOS. Keyboard +/// Shortcuts lists both under Window. F6 and Shift+F6 are still the Color and Appearance panels' +/// keys, and no other command, tool or panel has either key by default. +#[test] +fn cmd_f6_and_cmd_shift_f6_are_next_and_previous_document() { + use crate::shortcut_editor::{command_for_key, default_command_shortcut, panel_shortcut}; + assert_eq!(default_command_shortcut("window.nextDocument"), Some("Cmd+F6")); + assert_eq!(default_command_shortcut("window.previousDocument"), Some("Cmd+Shift+F6")); + assert_eq!(command_for_key("Cmd+F6"), Some("window.nextDocument")); + assert_eq!(command_for_key("Cmd+Shift+F6"), Some("window.previousDocument")); + assert_eq!((panel_shortcut("color"), panel_shortcut("appearance")), (Some("F6"), Some("Shift+F6"))); + let chord = crate::shortcuts::parse("Cmd+Shift+F6").expect("parses"); + assert_eq!((chord.modifiers, chord.logical_key), (egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::F6)); + let shown = (crate::menus::pretty_shortcut("Cmd+F6"), crate::menus::pretty_shortcut("Cmd+Shift+F6")); + let want = if cfg!(target_os = "macos") { ("⌘F6", "⇧⌘F6") } else { ("Ctrl+F6", "Ctrl+Shift+F6") }; + assert_eq!((shown.0.as_str(), shown.1.as_str()), want); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let listed = app.run("shortcuts.list", json!({"query": "Document"})).unwrap(); + let rows: Vec<_> = listed + .as_array() + .expect("rows") + .iter() + .filter(|r| r["id"].as_str().is_some_and(|id| id.ends_with("Document") && id.starts_with("window."))) + .map(|r| (r["label"].clone(), r["group"].clone(), r["default"].clone())) + .collect(); + assert_eq!( + rows, + [(json!("Next Document"), json!("Window"), json!("Cmd+F6")), (json!("Previous Document"), json!("Window"), json!("Cmd+Shift+F6"))], + "Keyboard Shortcuts lists both" + ); + assert_eq!(app.run("shortcuts.conflicts", json!({})).unwrap(), json!([]), "no other default has the keys"); +} + +/// Cmd+F6 makes the next open document in tab order the current one, and Cmd+Shift+F6 makes the +/// previous one the current one. After the last document comes the first, and before the first document comes the +/// last. From the Home screen the keys go to the first and the last document and close the screen. +/// With no document open the commands are disabled and fail with "no document open". +#[test] +fn cmd_f6_goes_to_the_next_document_and_cmd_shift_f6_to_the_previous_one() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert!(!crate::menus::enabled(&app, "window.nextDocument") && !crate::menus::enabled(&app, "window.previousDocument")); + assert_eq!(app.run("window.nextDocument", json!({})).unwrap_err(), "no document open"); + assert_eq!(app.run("window.previousDocument", json!({})).unwrap_err(), "no document open"); + for name in ["A", "B", "C"] { + app.run("file.new", json!({"width": 100, "height": 100, "name": name})).unwrap(); + } + assert!(crate::menus::enabled(&app, "window.nextDocument") && crate::menus::enabled(&app, "window.previousDocument")); + assert_eq!(app.session.active_index(), Some(2)); + let mut seen = vec![]; + for modifiers in [egui::Modifiers::COMMAND; 4] { + press_f6(&mut app, modifiers); + seen.push(app.session.active_index()); + } + assert_eq!(seen, [Some(0), Some(1), Some(2), Some(0)], "Cmd+F6 goes right and wraps from the last to the first"); + seen.clear(); + for modifiers in [egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; 4] { + press_f6(&mut app, modifiers); + seen.push(app.session.active_index()); + } + assert_eq!(seen, [Some(2), Some(1), Some(0), Some(2)], "Cmd+Shift+F6 goes left and wraps from the first to the last"); + assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0})); + assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 2})); + app.session.set_active(1); + app.run("app.home", json!({})).unwrap(); + press_f6(&mut app, egui::Modifiers::COMMAND); + assert_eq!((app.session.active_index(), app.ui.home), (Some(0), None), "from the Home screen Cmd+F6 shows the first"); + app.session.set_active(1); + app.run("app.home", json!({})).unwrap(); + press_f6(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!((app.session.active_index(), app.ui.home), (Some(2), None), "and Cmd+Shift+F6 the last"); + // One document: the keys keep it current. + let mut one = crate::VectorcraftApp::new(Session::new(), Default::default()); + one.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + press_f6(&mut one, egui::Modifiers::COMMAND); + press_f6(&mut one, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(one.session.active_index(), Some(0)); +} + +/// With the Artboard tool, Cmd+F6 and Cmd+Shift+F6 switch documents as a click on a tab does: the +/// document switched back to keeps the artboard it shows as the tool's target, so the Properties +/// panel's Delete Artboard then removes that artboard. Switching is not an edit. +#[test] +fn cmd_f6_switches_documents_as_a_tab_click_does_with_the_artboard_tool() { + let (mut app, ctx) = artboard_document_app(); + app.select_tool("artboard"); + artboard_navigation_next(&mut app, &ctx); + assert_eq!(app.session.tool_options()["selected"], json!([1])); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + press_f6(&mut app, egui::Modifiers::COMMAND); + assert_eq!(app.session.active_index(), Some(1)); + // The app draws frames while the other document is the current one. + artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + artboard_document_tabs_frame(&mut app, &ctx, vec![]); + press_f6(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.session.active_index(), Some(0)); + app.sync_views(); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "the tool targets the displayed artboard"); + assert_eq!(app.view().unwrap().artboard, 1, "the document keeps its displayed artboard"); + assert_eq!(app.session.active().unwrap().doc, before, "switching documents is not a document edit"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, r)| r.center()).expect("Properties Delete button"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); + } + let names: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Keep"], "Delete Artboard after the keys removes the artboard the document shows"); +} + +/// One history entry: its label, document and selection. +type HistoryRow = (String, Arc, Arc); + +/// The undo and redo entries of a document. +type HistoryDump = (Vec, Vec); + +fn history_of(st: &vectorcraft_engine::DocState) -> HistoryDump { + let entries = + |items: &[vectorcraft_engine::HistoryEntry]| items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>(); + (entries(&st.history.undo), entries(&st.history.redo)) +} + +fn assert_outside_canvas(app: &crate::VectorcraftApp, ctx: &egui::Context) { + let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); + assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); +} + +/// Document A as it was saved, before its second tab closed and its shown board was deleted. +struct Baseline { + uid: u64, + revision: u64, + before: Arc, + selection: vectorcraft_doc::Selection, + history_before: HistoryDump, + history_limits: (usize, usize), + expected: vectorcraft_doc::Document, +} + +/// Close document B's tab with its close box, leaving A as it was. +fn close_b_tab(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) { + let Baseline { uid, revision, before, selection, history_before, history_limits, expected: _ } = base; // Route from the status bar along the panel edge to the tab strip, never through canvas. for at in [egui::pos2(1000.0, 280.0), egui::pos2(1000.0, 20.0)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + assert_outside_canvas(app, ctx); } - artboard_document_click_tab(&mut app, &ctx, 1); - outside_canvas(&app); + artboard_document_click_tab(app, ctx, 1); + assert_outside_canvas(app, ctx); app.session.active_mut().unwrap().mark_saved(); let other_uid = app.session.active().unwrap().uid; - assert_ne!(other_uid, uid); + assert_ne!(other_uid, *uid); assert!(app.session.documents().iter().all(|d| !d.is_dirty()), "both ordinary documents are in saved state"); assert!(app.ui.dialog.is_none()); eprintln!("ARTBOARD_CLOSE_TARGET_SETUP_READY"); // The actual B tab x, using the existing close-box test's title-relative hit position. - let titles = artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + let titles = artboard_document_tabs_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); assert_eq!(titles.len(), 2); let title = titles[1]; let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; @@ -1261,35 +1470,39 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { assert!(!app.canvas_rect.unwrap().contains(at)); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![event]); + assert_outside_canvas(app, ctx); } assert_eq!(app.session.documents().len(), 1, "B tab x must really close B"); assert_eq!(app.session.active_index(), Some(0)); - assert_eq!(app.session.active().unwrap().uid, uid, "the survivor is the original A, not a replacement document"); + assert_eq!(app.session.active().unwrap().uid, *uid, "the survivor is the original A, not a replacement document"); assert!(app.session.documents().iter().all(|d| d.uid != other_uid)); assert!(app.ui.dialog.is_none(), "a clean close must not leave a save/discard modal"); for _ in 0..2 { - artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); } let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "closing B must not edit A"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_before, "closing B preserves all A Undo/Redo entries"); - assert_eq!(st.revision, revision); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert_eq!(st.doc.as_ref(), before.as_ref(), "closing B must not edit A"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st), *history_before, "closing B preserves all A Undo/Redo entries"); + assert_eq!(st.revision, *revision); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); assert!(!st.is_dirty()); assert_eq!(app.view().unwrap().artboard, 1); eprintln!("ARTBOARD_CLOSE_TARGET_CLOSED_B"); +} +/// Delete the board Properties shows, then Undo and Redo it. +fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) { + let Baseline { uid, revision, before, selection, history_before, history_limits, expected } = base; // Cross the tab strip to the Properties panel before moving down to its Delete control. app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); - outside_canvas(&app); + artboard_navigation_properties_frame(app, ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); + assert_outside_canvas(app, ctx); app.sync_views(); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + let texts = artboard_navigation_properties_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); assert!(texts.iter().any(|(label, _)| label == "Shown"), "actual Properties must display Shown before deletion"); assert_eq!(app.view().unwrap().artboard, 1); let tool_before_delete = app.session.tool_options(); @@ -1300,14 +1513,15 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let commands = app.run_count; for event in [egui::Event::PointerMoved(at), button(true), button(false)] { app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); + artboard_navigation_properties_frame(app, ctx, vec![event]); + assert_outside_canvas(app, ctx); } let st = app.session.active().unwrap(); assert_eq!(app.run_count, commands.wrapping_add(1), "the actual Properties click executes one command"); assert_eq!(st.doc.artboards.len(), 1, "the real Delete action removed one board"); assert_eq!(st.history.undo.len(), history_before.0.len() + 1); assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); eprintln!("ARTBOARD_CLOSE_TARGET_DELETE_CLICKED tool_before={tool_before_delete}"); // Do not assert tool alignment before this destructive-action oracle: it must expose the wrong deletion. assert_eq!( @@ -1315,40 +1529,77 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { vec!["Keep"], "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); - assert_eq!(st.doc.as_ref(), &expected, "only Shown and its attached guide may be removed from the full document"); + assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); - assert_eq!(st.selection, selection); - assert_eq!(st.uid, uid); + assert_eq!(&st.selection, selection); + assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); - let history_after = history(st); + undo_and_redo_the_deletion(app, base); +} + +/// Undo and Redo of the deletion that [`delete_shown_board`] made. +fn undo_and_redo_the_deletion(app: &mut crate::VectorcraftApp, base: &Baseline) { + let Baseline { uid, before, selection, history_before, history_limits, expected, revision: _ } = base; + let st = app.session.active().unwrap(); + let history_after = history_of(st); assert_eq!(&history_after.0[..history_before.0.len()], history_before.0.as_slice()); let entry = history_after.0.last().unwrap(); - assert_eq!(entry.1, before); - assert_eq!(entry.2.as_ref(), &selection); + assert_eq!(entry.1.as_ref(), before.as_ref()); + assert_eq!(entry.2.as_ref(), selection); assert!(history_after.1.is_empty()); assert!(st.is_dirty()); let deleted = st.doc.clone(); app.run("edit.undo", json!({})).unwrap(); let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc, before, "one Undo restores the full saved A including both boards, guides and artwork"); - assert_eq!(st.selection, selection); - assert_eq!(history(st).0, history_before.0); + assert_eq!(st.uid, *uid); + assert_eq!(st.doc.as_ref(), before.as_ref(), "one Undo restores the full saved A including both boards, guides and artwork"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st).0, history_before.0); assert_eq!(st.history.redo.len(), 1); assert_eq!(st.history.redo[0].label, "Delete Artboard"); - assert_eq!(st.history.redo[0].doc, deleted); - assert_eq!(st.history.redo[0].selection.as_ref(), &selection); + assert_eq!(st.history.redo[0].doc.as_ref(), deleted.as_ref()); + assert_eq!(st.history.redo[0].selection.as_ref(), selection); assert!(!st.is_dirty()); app.run("edit.redo", json!({})).unwrap(); let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc.as_ref(), &expected, "one Redo repeats only the correct deletion"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_after); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert_eq!(st.uid, *uid); + assert_eq!(st.doc.as_ref(), expected, "one Redo repeats only the correct deletion"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st), history_after); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); assert!(st.is_dirty()); assert_eq!(app.session.documents().len(), 1, "Undo/Redo do not reopen B"); eprintln!("ARTBOARD_CLOSE_TARGET_MODEL_AND_HISTORY_PRESERVED"); } + +#[test] +fn artboard_close_active_tab_properties_delete_uses_shown_board() { + let (mut app, ctx) = artboard_document_app(); + app.select_tool("artboard"); + app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); + app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); + artboard_navigation_next(&mut app, &ctx); + assert_outside_canvas(&app, &ctx); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let (uid, revision) = (st.uid, st.revision); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let history_before = history_of(st); + let history_limits = (st.history.limit, st.history.byte_budget); + assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); + assert_eq!(before.guides.len(), 2); + assert!(!selection.objects.is_empty()); + assert!(history_before.1.is_empty()); + assert!(!st.is_dirty()); + let mut expected = before.as_ref().clone(); + expected.artboards = vec![before.artboards[0].clone()]; + expected.guides = vec![before.guides[0].clone()]; + let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; + close_b_tab(&mut app, &ctx, &base); + delete_shown_board(&mut app, &ctx, &base); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cd6efdf7c..68bba9ade 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -259,41 +259,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()), - "ui.pointer" => { - let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; - let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); - let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); - for e in events { - let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { - "down" => PointerKind::Down, - "drag" => PointerKind::Drag, - "up" => PointerKind::Up, - "move" => PointerKind::Move, - "doubleclick" | "dblclick" => PointerKind::DoubleClick, - other => return err(format!("unknown pointer kind `{other}`")), - }; - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); - let pos = if e.get("space").and_then(Value::as_str) == Some("screen") { - match xf { - Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), - None => return err("no canvas yet"), - } - } else { - vectorcraft_geom::Point::new(x, y) - }; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); - let hold = PointerEvent::json_hold(e); - if hold > 0.0 { - let r = app.session.tool_tick(hold, view); - crate::canvas::apply_requests(app, r); - } - } - ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) - } + "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { @@ -318,51 +284,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ctx.request_repaint(); ok(Value::Null) } - "ui.click" | "ui.drag" => { - // Validate the entire expansion before appending even the initial move/press. - let steps = if req.method == "ui.drag" { - match input_count(p, "steps", 8, MAX_DRAG_STEPS) { - Ok(n) => n, - Err(e) => return err(e), - } - } else { - 0 - }; - let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) { - Ok(n) => n, - Err(e) => return err(e), - }; - let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { - return err("synthetic input event count is out of range"); - }; - if let Err(e) = reserve_synthetic(app, events) { - return err(e); - } - // Screen-space (egui points) pointer input through egui itself: reaches every widget. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; - let button = match s("button") { - Some("right") | Some("secondary") => egui::PointerButton::Secondary, - _ => egui::PointerButton::Primary, - }; - let modifiers = modifiers(p); - let a = egui::pos2(f("x"), f("y")); - let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); - if req.method == "ui.drag" { - for i in 1..=steps { - let t = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); - } - } - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - for _ in 1..count { - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - } - ctx.request_repaint(); - ok(Value::Null) - } + "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { if let Err(e) = reserve_synthetic(app, 2) { return err(e); @@ -392,48 +314,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ctx.request_repaint(); ok(Value::Null) } - "ui.set" => { - let mut r = Ok(Value::Null); - if let Some(b) = s("brightness") { - r = app.run("window.brightness", json!({"brightness": b})); - } - if let Some(pn) = s("panel") { - r = app.run("window.panel", json!({"panel": pn})); - } - if let Some(st) = s("status") { - app.ui.status = st.to_string(); - } - for (k, flag) in [ - ("rulers", "view.rulers"), - ("outline", "view.outline"), - ("grid", "view.grid"), - ("smartGuides", "view.smartGuides"), - ("boundingBox", "view.boundingBox"), - ] { - if let Some(want) = p.get(k).and_then(Value::as_bool) { - let cur = match k { - "rulers" => app.ui.view.rulers, - "outline" => app.ui.view.outline, - "grid" => app.ui.view.grid, - "smartGuides" => app.ui.view.smart_guides, - _ => app.ui.view.bounding_box, - }; - if cur != want { - r = app.run(flag, json!({})); - } - } - } - if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { - app.ui.control_bar = v; - } - if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) { - r = app.run("imageTrace.preview", json!({"on": v})); - } - if p.get("closePanel").is_some() { - app.ui.open_panel = None; - } - wrap(r) - } + "ui.set" => set_ui(app, req), "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let fields = match dialog_fields(p) { @@ -446,7 +327,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } for (f, value) in fields { - d.fields.insert(f, value); + crate::dialogs::set_field(d, f, value); } ok(serde_json::to_value(&*d).unwrap_or_default()) } @@ -619,3 +500,136 @@ pub(crate) fn save_screenshot_with_limit( #[cfg(test)] mod tests; + +/// `ui.pointer`: pointer events through the tools. +fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let view = app.view_info(); + let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + for e in events { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return err(format!("unknown pointer kind `{other}`")), + }; + let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let pos = if e.get("space").and_then(Value::as_str) == Some("screen") { + match xf { + Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), + None => return err("no canvas yet"), + } + } else { + vectorcraft_geom::Point::new(x, y) + }; + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let r = app.session.tool_tick(hold, view); + crate::canvas::apply_requests(app, r); + } + } + ctx.request_repaint(); + wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) +} + +/// `ui.click` and `ui.drag`: pointer input through egui itself. +fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + // Validate the entire expansion before appending even the initial move/press. + let steps = if req.method == "ui.drag" { + match input_count(p, "steps", 8, MAX_DRAG_STEPS) { + Ok(n) => n, + Err(e) => return err(e), + } + } else { + 0 + }; + let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) { + Ok(n) => n, + Err(e) => return err(e), + }; + let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { + return err("synthetic input event count is out of range"); + }; + if let Err(e) = reserve_synthetic(app, events) { + return err(e); + } + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let button = match s("button") { + Some("right") | Some("secondary") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let modifiers = modifiers(p); + let a = egui::pos2(f("x"), f("y")); + let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + if req.method == "ui.drag" { + for i in 1..=steps { + let t = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + } + } + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + for _ in 1..count { + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + } + ctx.request_repaint(); + ok(Value::Null) +} + +/// `ui.set`: view flags, panels and status. +fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + let mut r = Ok(Value::Null); + if let Some(b) = s("brightness") { + r = app.run("window.brightness", json!({"brightness": b})); + } + if let Some(pn) = s("panel") { + r = app.run("window.panel", json!({"panel": pn})); + } + if let Some(st) = s("status") { + app.ui.status = st.to_string(); + } + for (k, flag) in [ + ("rulers", "view.rulers"), + ("outline", "view.outline"), + ("grid", "view.grid"), + ("smartGuides", "view.smartGuides"), + ("boundingBox", "view.boundingBox"), + ] { + if let Some(want) = p.get(k).and_then(Value::as_bool) { + let cur = match k { + "rulers" => app.ui.view.rulers, + "outline" => app.ui.view.outline, + "grid" => app.ui.view.grid, + "smartGuides" => app.ui.view.smart_guides, + _ => app.ui.view.bounding_box, + }; + if cur != want { + r = app.run(flag, json!({})); + } + } + } + if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { + app.ui.control_bar = v; + } + if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) { + r = app.run("imageTrace.preview", json!({"on": v})); + } + if p.get("closePanel").is_some() { + app.ui.open_panel = None; + } + wrap(r) +} diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index 8319fe7c5..ece8895b4 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -334,6 +334,9 @@ fn focus_frame(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool, eve fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + // Every frame renders on this thread, however long it takes: a frame finished by the render + // worker during the idle frames asks for a repaint. + app.canvas.worker_started = true; let ctx = egui::Context::default(); for _ in 0..3 { frame(&mut app, &ctx, vec![]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 49dfe37b9..3ca9da699 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -62,6 +62,7 @@ pub mod new_brush; pub mod new_color_group; mod new_document; pub mod new_swatch; +pub mod new_text_style; pub mod office_export; pub mod package; pub(crate) mod path_ops; @@ -99,6 +100,7 @@ pub(crate) mod shapes; pub mod shortcut_import; pub(crate) mod simplify_slider; pub mod slices; +pub(crate) mod smooth_slider; pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; @@ -110,6 +112,7 @@ pub mod text_import; mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; +pub mod trace_presets; pub(crate) mod transform; pub mod transform_each; pub mod variables; @@ -176,6 +179,10 @@ pub(crate) struct DialogSpec { /// A button left of Cancel, as (label, field), that sets `field: true` and confirms, as OK /// does otherwise (Transform Each's Copy). pub alternate: Option<(&'static str, &'static str)>, + /// Puts the fields back to their defaults. While Alt (Option on macOS) is held, the Cancel + /// button of a dialog with an `ok` reads Reset; Reset (or `ui.dialogReset`) runs this and the + /// dialog stays open. + pub reset: Option, pub min_width: f32, pub max_width: Option, /// The body runs a live preview interaction that Cancel rolls back. @@ -211,6 +218,7 @@ impl DialogSpec { ok: Some("OK"), discard: None, alternate: None, + reset: None, min_width: 320.0, max_width: None, preview: false, @@ -291,6 +299,7 @@ registry! { GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, + NewTextStyle: [new_text_style::KIND] => new_text_style::SPEC, EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, ColorBalance: [color_balance::KIND] => color_balance::SPEC, @@ -300,6 +309,7 @@ registry! { EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC, FlattenTransparency: [flatten::KIND] => flatten::SPEC, FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC, + ImageTracePreset: [trace_presets::KIND] => trace_presets::SPEC, SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, SaveSymbolLibrary: [save_symbol_library::KIND] => save_symbol_library::SPEC, SaveBrushLibrary: [save_brush_library::KIND] => save_brush_library::SPEC, @@ -352,6 +362,7 @@ registry! { ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, + SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, @@ -377,14 +388,15 @@ registry! { ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } -/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close), -/// so the catalog tests can insist they are translated. +/// The button labels the shared dialog frame can show (OK, discard, the fixed Cancel/Close and +/// Reset), so the catalog tests can insist they are translated. pub fn button_labels() -> Vec<&'static str> { let mut v = vec!["Cancel", "Close"]; for spec in std::iter::once(&DialogSpec::FORM).chain(DialogKind::ALL.iter().map(|k| k.spec())) { v.extend(spec.ok); v.extend(spec.discard); v.extend(spec.alternate.map(|(label, _)| label)); + v.extend(spec.reset.map(|_| "Reset")); } v.sort_unstable(); v.dedup(); @@ -410,6 +422,15 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { || (spec.passes)(d, field) } +/// Set `field` of `d` to `value` for `ui.dialog.set`, as the dialog's own control for it does: a +/// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is. +pub fn set_field(d: &mut Dialog, field: String, value: Value) { + if d.kind == brush_options::KIND { + brush_art_options::note_set(d, &field); + } + d.fields.insert(field, value); +} + /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, /// `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { @@ -444,7 +465,10 @@ pub(crate) fn parent(d: &Dialog) -> Option { pub fn cancel(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); if let Some(d) = app.ui.dialog.take() { - if spec(&d.kind).preview { + // Toolbar-only Flare options start no preview and must not cancel a gesture + // that was already active before opening them. + let owns_preview = d.kind != flare_options::KIND || d.fields.contains_key(form::PREVIEWED); + if spec(&d.kind).preview && owns_preview { let _ = app.session.cancel_interaction(); } app.ui.dialog = parent(&d); @@ -452,6 +476,23 @@ pub fn cancel(app: &mut VectorcraftApp) { settle(app); } +/// Whether the open dialog has a Reset ([`DialogSpec::reset`]). +pub(crate) fn can_reset(app: &VectorcraftApp) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| spec(&d.kind).reset.is_some()) +} + +/// Reset (`ui.dialogReset`, or Cancel while Alt or Option is held): the open dialog's fields go +/// back to their defaults and it stays open → the dialog; an error when no dialog is open or the +/// open one has no Reset. +pub(crate) fn reset(app: &mut VectorcraftApp) -> DialogResult { + let Some(mut d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; + let Some(reset) = spec(&d.kind).reset else { return Err("the open dialog has no Reset".into()) }; + reset(app, &mut d); + let out = serde_json::to_value(&d).unwrap_or_default(); + app.ui.dialog = Some(d); + Ok(out) +} + /// Apply the open dialog (OK). pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { missing_fonts::requeue_replaced(app); @@ -527,6 +568,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut ok = false; let mut ok_enabled = true; let mut cancel = false; + let mut reset = false; let mut discard = false; let mut alternate = false; // Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go. @@ -587,8 +629,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ok = true; } ui.add_space(8.0); - if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() { - cancel = true; + // While Alt (Option) is held, Cancel reads Reset on a dialog that has one. + let resets = spec.ok.is_some() && spec.reset.is_some() && ui.input(|i| i.modifiers.alt); + let label = if resets { + "Reset" + } else if spec.ok.is_some() { + "Cancel" + } else { + "Close" + }; + if widgets::secondary_button(ui, label).clicked() { + if resets { + reset = true; + } else { + cancel = true; + } } if let Some((label, _)) = spec.alternate { ui.add_space(8.0); @@ -614,6 +669,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.dialog = Some(d); if cancel { self::cancel(app); + } else if reset { + if let Err(e) = self::reset(app) { + app.status(e); + } } else if ok && let Err(e) = confirm(app) { app.status(e); } diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 1898172a8..f9b5bf558 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -14,14 +14,17 @@ //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, -//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has). +//! `flipAcross`, `outerCornerTile` and `innerCornerTile` (`original`: the corner art the brush +//! draws there | `none` | `autoCentered` | `autoBetween` | `autoSliced` | `autoOverlap`: a tile +//! made from the side tile), +//! `__tiles` (slot → preview key, for the tiles the brush has). use std::hash::{Hash, Hasher}; use std::sync::Arc; use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId}; @@ -41,6 +44,43 @@ const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLe const TILES: [(&str, &str); 5] = [("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")]; +/// Pattern brush corner tiles: (dialog field, art field of the definition, source field, art field +/// of the other kind of corner). +const CORNER_TILES: [(&str, &str, &str, &str); 2] = + [("outerCornerTile", "outer_corner", "outer_source", "inner_corner"), ("innerCornerTile", "inner_corner", "inner_source", "outer_corner")]; + +/// The art field that corner tile `field` draws with in definition `def`: its own art, or, with +/// neither art nor a source, the other kind of corner's art (as [`PatternBrush`] draws it). +fn corner_art_field<'a>(def: Option<&Value>, field: &'a str) -> Option<&'a str> { + let set = |f: &str| def.and_then(|d| d.get(f)).is_some_and(|v| !v.is_null()); + let (_, art, source, other) = CORNER_TILES.into_iter().find(|(_, art, _, _)| *art == field)?; + if set(art) { + Some(art) + } else if !set(source) && set(other) { + Some(other) + } else { + None + } +} + +/// The dialog field that records that corner tile `key`'s menu was set. +fn corner_set_key(key: &str) -> String { + format!("__{key}Set") +} + +/// Sets corner tile `key` to `choice` from its menu. +pub(super) fn choose_corner(d: &mut Dialog, key: &str, choice: &str) { + d.fields.insert(key.into(), json!(choice)); + note_set(d, key); +} + +/// Records that field `field` was set, from its menu or `ui.dialog.set`, when it is a corner tile. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if CORNER_TILES.iter().any(|(key, ..)| *key == field) { + d.fields.insert(corner_set_key(field), json!(true)); + } +} + /// The most key colours offered. const MAX_COLORS: usize = 16; @@ -121,6 +161,10 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map) { f.insert("fit".into(), json!(id_of(&p.fit))); f.insert("flipAlong".into(), json!(p.flip_along)); f.insert("flipAcross".into(), json!(p.flip_across)); + for ((key, art, _, _), source) in CORNER_TILES.into_iter().zip([p.outer_source, p.inner_source]) { + let choice = if corner_art_field(Some(&def), art).is_some() { "original" } else { source.map_or("none", CornerSource::id) }; + f.insert(key.into(), json!(choice)); + } // A preview key per tile the brush has: its art's hash, so a changed tile redraws. let tiles: Map = TILES .iter() @@ -200,13 +244,37 @@ pub(super) fn params(d: &Dialog) -> Value { "overlap": d.bool("overlap"), }) } - _ => json!({ - "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), - "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), - "fit": id_of(&parse_field::(d, "fit")), - "flip_along": d.bool("flipAlong"), - "flip_across": d.bool("flipAcross"), - }), + _ => { + let mut p = json!({ + "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), + "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), + "fit": id_of(&parse_field::(d, "fit")), + "flip_along": d.bool("flipAlong"), + "flip_across": d.bool("flipAcross"), + }); + // None or an automatic tile replaces the corner art. Original keeps the art the corner + // draws, the other kind of corner's art included, so changing the other kind leaves it. + // A None the menu was not set to, on a corner without art or a source, leaves the + // definition as it is: that corner still draws the other kind's art when it gets some. + let def = d.fields.get("__def"); + for (key, art, source, other) in CORNER_TILES { + let choice = d.str(key); + let unset = |f: &str| def.and_then(|d| d.get(f)).is_none_or(Value::is_null); + if choice == CornerSource::None.id() && !d.bool(&corner_set_key(key)) && unset(source) && corner_art_field(def, art).is_none() { + continue; + } + if let Some(s) = CornerSource::parse(&choice) { + p[art] = Value::Null; + p[source] = json!(s.id()); + } else if choice == "original" + && let Some(from) = corner_art_field(def, art).filter(|f| *f != art) + && CORNER_TILES.iter().find(|(_, a, _, _)| *a == other).is_some_and(|(k, ..)| d.str(k) != "original") + { + p[art] = def.and_then(|d| d.get(from)).cloned().unwrap_or_default(); + } + } + p + } }; p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default(); p @@ -379,21 +447,44 @@ fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) { radios(ui, d, "fit", &fits); } -/// The pattern's five tiles, each with its art (or None when the brush has no such tile). -fn tiles(ui: &mut egui::Ui, d: &Dialog) { +/// What a corner tile can be: (choice, label). Original (the corner art the brush draws there) is +/// offered only when the brush draws corner art there. +fn corner_choices() -> [(&'static str, &'static str); 6] { + [ + ("original", tl!("Original")), + (CornerSource::None.id(), tl!("None")), + (CornerSource::AutoCentered.id(), tl!("Auto-Centered")), + (CornerSource::AutoBetween.id(), tl!("Auto-Between")), + (CornerSource::AutoSliced.id(), tl!("Auto-Sliced")), + (CornerSource::AutoOverlap.id(), tl!("Auto-Overlap")), + ] +} + +/// The pattern's five tiles, each with its art (or None when the brush has no such tile). Below +/// each corner tile, a menu sets it to the brush's art, None or a tile made from the side tile. +fn tiles(ui: &mut egui::Ui, d: &mut Dialog) { let t = Tokens::get(ui.ctx()); let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")]; - let keys = d.fields.get("__tiles"); + let keys = d.fields.get("__tiles").cloned(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; for ((field, slot), name) in TILES.into_iter().zip(names) { + let corner = CORNER_TILES.iter().find(|(_, art, _, _)| *art == field).map(|(key, ..)| *key); + // The art the tile draws with: its own, or a corner's that falls back on the other's. + let drawn = if corner.is_some() { corner_art_field(d.fields.get("__def"), field) } else { Some(field) }; + let drawn_slot = drawn.and_then(|f| TILES.iter().find(|(tf, _)| *tf == f)).map_or(slot, |(_, s)| *s); + let choices: Vec<(&str, &str)> = corner_choices().into_iter().filter(|(c, _)| drawn.is_some() || *c != "original").collect(); + let choice = corner.map(|key| d.str(key)); + // The label of the corner's choice when it shows no art. + let shown = choice.as_deref().filter(|c| *c != "original"); + let label = shown.and_then(|c| choices.iter().find(|(id, _)| *id == c)).map_or(tl!("None"), |(_, l)| *l); ui.vertical(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, Color32::WHITE); - let key = keys.and_then(|k| k.get(slot)).and_then(Value::as_str); + let key = keys.as_ref().filter(|_| shown.is_none()).and_then(|k| k.get(drawn_slot)).and_then(Value::as_str); let tex = key.and_then(|key| { widgets::doc_preview(ui, key, r.size(), |w, h| { - let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?; + let art: Node = serde_json::from_value(d.fields.get("__def")?.get(drawn?)?.clone()).ok()?; tile_doc(&art, w, h) }) }); @@ -402,18 +493,21 @@ fn tiles(ui: &mut egui::Ui, d: &Dialog) { ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { - ui.painter().text( - r.center(), - egui::Align2::CENTER_CENTER, - tl!("None"), - egui::FontId::proportional(11.5), - Color32::from_gray(140), - ); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.5), Color32::from_gray(140)); } } ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); resp.on_hover_text(name); ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate()); + if let (Some(key), Some(cur)) = (corner, &choice) { + let current = choices.iter().find(|(id, _)| id == cur).map_or(tl!("None"), |(_, l)| *l); + let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect(); + if let Some((id, _)) = + widgets::dropdown_named(ui, ("brush-pattern-corner", key), Some(name), current, &labels, 76.0).and_then(|i| choices.get(i)) + { + choose_corner(d, key, id); + } + } }); } }); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 7ca4b316c..9adeffed1 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -443,6 +443,110 @@ fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); } +/// Each corner tile has a menu below it: Original (only when the brush has corner art), None and +/// the four automatic tiles. OK saves the choice, and an automatic tile or None replaces the art. +#[test] +fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile() { + use crate::widgets::accessibility_tests::tree; + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("none".into(), "none".into()), "Diamonds has no corner art"); + let shown_tree = tree(|ui| super::super::show(&mut app, ui.ctx())); + for name in ["Outer Corner Tile", "Inner Corner Tile"] { + let roles: Vec<_> = shown_tree.iter().filter(|n| n.label() == Some(name)).map(|n| n.role()).collect(); + assert!(roles.contains(&egui::accesskit::Role::ComboBox), "a menu named {name}: {roles:?}"); + } + for name in ["Side Tile", "Start Tile", "End Tile"] { + assert!(!shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "no menu for {name}"); + } + set(&mut app, json!({"outerCornerTile": "autoBetween", "innerCornerTile": "autoOverlap"})); + let text = shown(&mut app); + assert!(text.contains("Auto-Between") && text.contains("Auto-Overlap"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoBetween"), &json!("autoOverlap"))); + // Chain has corner art: Original keeps it, None and an automatic tile replace it, one corner + // at a time. + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def["inner_corner"].is_object() && def.get("outer_source").is_none(), "{def}"); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + set(&mut app, json!({"innerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!(def["inner_source"], json!("none")); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "none".into())); + set(&mut app, json!({"outerCornerTile": "autoSliced"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoSliced"), &json!("none"))); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("outerCornerTile"), "autoSliced"); +} + +/// A pattern brush with art for one kind of corner draws it at both kinds. Its other corner tile +/// shows that art as Original, and OK keeps it there when the first corner tile changes. +#[test] +fn pattern_options_keep_the_corner_art_a_corner_borrows() { + let mut app = app(); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.new", json!({"type": "pattern", "name": "One Corner", "params": {"side": art.clone(), "outer_corner": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "One Corner"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + set(&mut app, json!({"outerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "One Corner"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def["inner_corner"].is_object(), "{def}"); + assert_eq!(def["outer_source"], json!("none")); +} + +/// OK leaves the corner fields of a corner tile the menus were not set to as they were: a corner +/// without art keeps borrowing the other kind's art when it gets some, and a corner that borrows +/// art still borrows it. A None picked from the menu is saved, after another choice too. +#[test] +fn pattern_options_ok_leaves_corner_tiles_the_menus_were_not_set_to() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + set(&mut app, json!({"scale": 150})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def.get("outer_source").is_none() && def.get("inner_source").is_none(), "{def}"); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.options", json!({"name": "Diamonds", "params": {"outer_corner": art.clone()}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("innerCornerTile"), "original", "the inner corners draw the outer art"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + + app.run("brush.new", json!({"type": "pattern", "name": "Plain", "params": {"side": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "autoCentered"); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "none"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["outer_source"], json!("none")); + assert!(def.get("inner_source").is_none(), "{def}"); + // None set through the control channel is saved too. + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let (set, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "innerCornerTile", "value": "none"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &set) else { panic!("not done") }; + assert!(r.get("error").is_none(), "{r}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["inner_source"], json!("none")); +} + /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their /// field, which screen readers name as the row, with the field's number and text over the /// value's whole range. diff --git a/crates/ui-egui/src/dialogs/color_guide_options.rs b/crates/ui-egui/src/dialogs/color_guide_options.rs index faeb11e0d..1fe685e60 100644 --- a/crates/ui-egui/src/dialogs/color_guide_options.rs +++ b/crates/ui-egui/src/dialogs/color_guide_options.rs @@ -45,7 +45,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(10.0); // Preview: the base colour's row of variations. let o = options(app, d); - let base = base_color(app, ui.ctx()); + let base = base_color(app); let n = o.steps as i32; let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); let w = row.width() / (2 * n + 1) as f32; diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index eb72ae7bb..60c037597 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -3,7 +3,8 @@ //! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; an eyedropper //! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color //! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one -//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back. +//! undo step through `paint.setFill` / `paint.setStroke` and makes the color the Color Guide's +//! base color; Cancel puts the art back. //! Opened for an artboard (Properties › Artboards › Fill), it shows the color on the artboard's //! paper live, and OK keeps it as one undo step through `artboard.setProps {index, color}`. //! Opened over Recolor Artwork (double-click a row's new color), its OK sets that row's new color @@ -636,7 +637,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Ok(Value::Null); } let (cmd, params) = paint_command(app, d); - super::form::commit_preview(app, cmd, params) + let c = in_document_model(app, picked(d).color); + let r = super::form::commit_preview(app, cmd, params)?; + // A color picked for the fill or stroke also becomes the Color Guide's base color. + if !d.fields.contains_key("artboard") { + crate::panels::color_guide::choose_base(app, c); + } + Ok(r) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs index cc650978b..4d678b106 100644 --- a/crates/ui-egui/src/dialogs/color_picker/tests.rs +++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs @@ -275,6 +275,30 @@ fn open_set_hex_and_confirm_fills() { assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); } +/// OK makes the picked fill or stroke color the Color Guide's base color (#3137); Cancel and a +/// color picked for an artboard leave the base as it is. +#[test] +fn ok_makes_the_picked_color_the_color_guides_base() { + let mut app = app(); + let base = |app: &VectorcraftApp| crate::panels::color_guide::base_color(app).to_hex(); + app.ui.color_guide_base = Some(Color::WHITE); + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("2050a0")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(base(&app), "#2050a0"); + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("a02050")); + frame(&mut app); + super::super::cancel(&mut app); + assert_eq!(base(&app), "#2050a0", "Cancel keeps the base"); + app.run("select.set", json!({"ids": []})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(base(&app), "#2050a0", "an artboard's color leaves the base"); +} + /// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the /// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on /// its left. diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index 06f89ce28..9e84b39f8 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -131,7 +131,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.horizontal(|ui| { widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| { let mut scale = f.get("scale").and_then(Value::as_str).unwrap_or_default().to_owned(); - let changed = ui.name_inputs(tl!("Scale"), |ui| ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0))).changed(); + let changed = ui + .name_inputs(tl!("Scale"), |ui| { + ui.add(egui::TextEdit::singleline(&mut scale).align(egui::Align2::LEFT_CENTER).desired_width(72.0)) + }) + .changed(); if changed { f.insert("scale".into(), json!(scale)); } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index b668f0791..02346ab1f 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -166,7 +166,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), None if id == SCRIBBLE => scribble_fields(ui, d, app.session.general_unit()), - None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()), + None if id == ZIG_ZAG || id == ROUGHEN => size_fields(ui, d, &id, app.session.general_unit()), + None if id == PUCKER_BLOAT => pucker_bloat_fields(ui, d), + None if id == TWEAK => tweak_fields(ui, d, app.session.general_unit()), None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()), None if id == "artistic.filmGrain" => film_grain_fields(ui, d), None if id == free_distort::FREE_DISTORT => { @@ -559,49 +561,77 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un /// The Zig Zag effect (Effect › Distort & Transform › Zig Zag…). const ZIG_ZAG: &str = "distort.zigZag"; -/// Zig Zag's dialog (#1133): Size and Ridges per segment as sliders with their fields, Relative or -/// Absolute and the Points as radio buttons. Returns whether the values differ from those last -/// previewed. -fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { - const LABEL_W: f32 = 124.0; - const FIELD_W: f32 = 70.0; +/// The Roughen effect (Effect › Distort & Transform › Roughen…). +const ROUGHEN: &str = "distort.roughen"; + +/// The Pucker & Bloat effect (Effect › Distort & Transform › Pucker & Bloat…). +const PUCKER_BLOAT: &str = "distort.puckerBloat"; + +/// The second slider of [`size_fields`]: Zig Zag's Ridges per segment, Roughen's Detail (per inch), +/// as (key, label, default, suffix). +fn second_slider(effect: &str) -> (&'static str, &'static str, f64, &'static str) { + if effect == ROUGHEN { ("detail", "Detail:", 10.0, "/in") } else { ("ridges", "Ridges per segment:", 4.0, "") } +} + +/// Zig Zag's and Roughen's dialog (#1133, #4488): Size and Ridges per segment (Roughen: Detail) as +/// sliders with their fields, Relative or Absolute and the Points as radio buttons. Returns +/// whether the values differ from those last previewed. +/// The label column and field width of [`relative_rows`] and the sliders under them. +const DISTORT_LABEL_W: f32 = 124.0; +const DISTORT_FIELD_W: f32 = 70.0; + +/// A slider with its field for each of `rows` (key, label, default), each a share of the object's +/// size with Relative or a distance with Absolute, then the Relative and Absolute radio buttons. +fn relative_rows(ui: &mut egui::Ui, d: &mut Dialog, rows: &[(&str, &str, f64)], unit: vectorcraft_doc::Unit) { let rail = Tokens::get(ui.ctx()).input_border; let track = move |_| rail; let relative = d.bool("relative"); - widgets::subheader(ui, tl!("Options")); - // Size is a share of the object's size (Relative) or a distance (Absolute). - widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| { - let shown = if relative { - Some(widgets::SliderValue::field(d.f64("size", 10.0).clamp(0.0, 100.0), 0.0..=100.0, "%", 0)) - } else { - form::length_value(d, "size", unit, 0.0..=100.0) - }; - form::slider_rail_valued(ui, d, "size", tl!("Size:"), 0.0..=100.0, shown, &track); - if !relative { - form::length(ui, d, "size", unit, FIELD_W); - } else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) { - d.fields.insert("size".into(), json!(n)); - } - }); + for &(key, label, default) in rows { + widgets::label_row(ui, tl!(label), DISTORT_LABEL_W, |ui| { + let shown = if relative { + Some(widgets::SliderValue::field(d.f64(key, default).clamp(0.0, 100.0), 0.0..=100.0, "%", 0)) + } else { + form::length_value(d, key, unit, 0.0..=100.0) + }; + form::slider_rail_valued(ui, d, key, tl!(label), 0.0..=100.0, shown, &track); + if !relative { + form::length(ui, d, key, unit, DISTORT_FIELD_W); + } else if let Some(n) = widgets::range_field(ui, ("zz", key), d.f64(key, default), 0.0..=100.0, "%", 0, DISTORT_FIELD_W) { + d.fields.insert(key.into(), json!(n)); + } + }); + } ui.horizontal(|ui| { - ui.add_space(LABEL_W + ui.spacing().item_spacing.x); + ui.add_space(DISTORT_LABEL_W + ui.spacing().item_spacing.x); for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { if widgets::radio(ui, label, relative == on, true) { d.fields.insert("relative".into(), json!(on)); } } }); - widgets::label_row(ui, tl!("Ridges per segment:"), LABEL_W, |ui| { - form::slider_rail_named(ui, d, "ridges", tl!("Ridges per segment:"), 0.0..=100.0, &track); - if let Some(n) = widgets::range_field(ui, ("zz", "ridges"), d.f64("ridges", 4.0), 0.0..=100.0, "", 0, FIELD_W) { - d.fields.insert("ridges".into(), json!(n)); +} + +fn size_fields(ui: &mut egui::Ui, d: &mut Dialog, effect: &str, unit: vectorcraft_doc::Unit) -> bool { + const LABEL_W: f32 = DISTORT_LABEL_W; + const FIELD_W: f32 = DISTORT_FIELD_W; + let rail = Tokens::get(ui.ctx()).input_border; + let track = move |_| rail; + let size = if effect == ROUGHEN { 5.0 } else { 10.0 }; + widgets::subheader(ui, tl!("Options")); + // Size is a share of the object's size (Relative) or a distance (Absolute). + relative_rows(ui, d, &[("size", "Size:", size)], unit); + let (key, label, default, suffix) = second_slider(effect); + widgets::label_row(ui, tl!(label), LABEL_W, |ui| { + form::slider_rail_named(ui, d, key, tl!(label), 0.0..=100.0, &track); + if let Some(n) = widgets::range_field(ui, ("zz", key), d.f64(key, default), 0.0..=100.0, suffix, 0, FIELD_W) { + d.fields.insert(key.into(), json!(n)); } }); ui.add_space(4.0); widgets::subheader(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", "Points")); let points = d.str("points"); ui.horizontal(|ui| { - for (label, value) in choices(ZIG_ZAG, "points").unwrap_or_default() { + for (label, value) in choices(effect, "points").unwrap_or_default() { if widgets::radio(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", label), points == *value, true) { d.fields.insert("points".into(), json!(value)); } @@ -610,6 +640,32 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit values_changed(d) } +/// The Tweak effect (Effect › Distort & Transform › Tweak…). +const TWEAK: &str = "distort.tweak"; + +/// Tweak's dialog (#4488): Horizontal and Vertical as sliders with their fields (a share of the +/// object's size with Relative, a distance with Absolute), then which points it moves. Returns +/// whether the values differ from those last previewed. +fn tweak_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { + widgets::subheader(ui, tl!("Amount")); + relative_rows(ui, d, &[("h", "Horizontal:", 10.0), ("v", "Vertical:", 10.0)], unit); + ui.add_space(4.0); + widgets::subheader(ui, tl!("Modify")); + form::check(ui, d, "anchors", tl!("Anchor Points")); + form::check(ui, d, "in", tl!("\"In\" Control Points")); + form::check(ui, d, "out", tl!("\"Out\" Control Points")); + values_changed(d) +} + +/// Pucker & Bloat's dialog (#4488): its amount as a slider from Pucker (−200 %) to Bloat (200 %) +/// with its field. Returns whether the value differs from the one last previewed. +fn pucker_bloat_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let rail = Tokens::get(ui.ctx()).input_border; + form::slider(ui, d, "amount", tl!("Amount:"), -200.0..=200.0, "%", &move |_| rail); + form::slider_ends(ui, form::SLIDER_LABEL, (tl!("Pucker"), tl!("Bloat"))); + values_changed(d) +} + /// Drop Shadow (Effect › Stylize › Drop Shadow…). const DROP_SHADOW: &str = "stylize.dropShadow"; diff --git a/crates/ui-egui/src/dialogs/expand.rs b/crates/ui-egui/src/dialogs/expand.rs index 85be7ab1b..7d0bfd109 100644 --- a/crates/ui-egui/src/dialogs/expand.rs +++ b/crates/ui-egui/src/dialogs/expand.rs @@ -24,9 +24,20 @@ const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("st pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Expand").into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM }; -/// Open Expand for the selection, every option on (as `object.expand {}` does). +/// Why Object › Expand… is dimmed: the selection has appearance attributes, which Expand Appearance +/// turns into plain art first (#3089). +pub(crate) fn appearance_first() -> &'static str { + tl!("Expand Appearance first (Object › Expand Appearance)") +} + +/// Open Expand for the selection, every option on (as `object.expand {}` does). A selection with +/// appearance attributes (effects, several fills or strokes…) refuses with [`appearance_first`]: +/// the menu item is dimmed for it. pub fn open(app: &mut VectorcraftApp) -> Result { let info = app.session.execute("object.expand.info", &json!({})).map_err(|e| e.to_string())?; + if info["appearance"] == json!(true) { + return Err(appearance_first().into()); + } let mut fields = json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": DEFAULT_STEPS}); for (key, _) in OPTIONS { fields[format!("__{key}")] = info[key].clone(); @@ -169,4 +180,49 @@ mod tests { let st = app.session.doc().unwrap(); assert!(matches!(st.doc.node(st.selection.objects[0]).unwrap().kind, NodeKind::Path { live: None, .. })); } + + /// A line with a stroke and a Zig Zag effect is selected (#3089). + fn zig_zag_line(app: &mut VectorcraftApp) { + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("path.create", json!({"d": "M 20 100 L 180 100"})).unwrap(); + app.run("paint.setFill", json!({"none": true})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000"})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 10, "ridges": 6, "relative": false}})).unwrap(); + } + + #[test] + fn expand_waits_for_expand_appearance_on_a_line_with_an_effect() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + zig_zag_line(&mut app); + assert!(!crate::menus::enabled(&app, "ui.expandDialog"), "Expand… is dimmed for the Zig Zag effect"); + assert!(crate::menus::enabled(&app, "effect.expandAppearance")); + // A shortcut or the command palette doesn't open it either; the status bar says why. + let before = app.session.doc().unwrap().doc.clone(); + crate::menus::invoke(&mut app, "object.expand", json!({})); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.status, appearance_first()); + assert_eq!(app.run("ui.expandDialog", json!({})).unwrap_err(), appearance_first()); + assert_eq!(app.session.doc().unwrap().doc, before, "the line is left as it was"); + // Expand Appearance first: the line stays a stroked line, and Expand… is available for it. + crate::menus::invoke(&mut app, "effect.expandAppearance", json!({})); + let st = app.session.doc().unwrap(); + let line = st.doc.node(st.selection.objects[0]).unwrap(); + assert!(line.appearance.effects.is_empty() && !line.appearance.stroke_paint().is_none()); + assert!(crate::menus::enabled(&app, "ui.expandDialog")); + crate::menus::invoke(&mut app, "object.expand", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + } + + #[test] + fn expand_is_dimmed_for_two_fills_but_not_for_a_plain_rectangle() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap(); + assert!(crate::menus::enabled(&app, "ui.expandDialog"), "a basic appearance"); + app.run("appearance.addFill", json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.expandDialog"), "two fills"); + // Nothing selected: dimmed as before. + app.run("select.none", json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.expandDialog")); + } } diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index ee918ecf4..32ee696af 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -103,7 +103,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("all".into(), json!(all)); } let mut range = d.str("range"); - if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(90.0)).changed() { + if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0)).changed() { d.fields.insert("range".into(), json!(range)); } }); diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 6db57091f..b85b67634 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -67,9 +67,14 @@ fn opened_without(app: &mut VectorcraftApp, root: &Path, names: &[&str]) -> Vec< files } -/// Draw the dialog until its search ends (10 seconds at most). +/// How long a test waits for the dialog's search to end. The wait only ends a test whose search +/// never ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a +/// search's default `maxSeconds` of 60, which would end a search that does not end by itself. +const WAIT: Duration = Duration::from_secs(30); + +/// Draw the dialog until its search ends ([`WAIT`] at most). fn searched(app: &mut VectorcraftApp) { - let until = Instant::now() + Duration::from_secs(10); + let until = Instant::now() + WAIT; while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { frame(app); std::thread::sleep(Duration::from_millis(5)); diff --git a/crates/ui-egui/src/dialogs/flare_options.rs b/crates/ui-egui/src/dialogs/flare_options.rs index 44ae5eb58..b94f6a7c2 100644 --- a/crates/ui-egui/src/dialogs/flare_options.rs +++ b/crates/ui-egui/src/dialogs/flare_options.rs @@ -1,13 +1,15 @@ //! Flare Tool Options (double-click the Flare tool, or click with it): the options the Flare tool //! keeps, which draw the next flare dragged out, last across tool switches and are saved with the //! preferences. OK sets them with `tool.setOption {tool: "flare", values}`; opened by a click, it -//! then draws a flare there with them (`shape.flare`, on the active perspective plane while the -//! grid shows). +//! accepts a flare preview there with them (`shape.flare`, on the active perspective plane while the +//! grid shows). While Alt (Option on macOS) is held, Cancel reads Reset, which puts every option +//! back to the tool's default and leaves the dialog open (`ui.dialogReset`). A click-opened +//! dialog previews its settings live; Reset refreshes that preview and Cancel rolls it back. //! //! Fields: the options (`vectorcraft_tools::extra::FLARE_OPTIONS`): `diameter` and `pathLength` //! (points), `opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and //! `largest` (%), `rays` and `rings` (counts), `direction` (degrees); `raysOn` and `ringsOn`; and -//! `x`, `y` where a click opened it. +//! `x`, `y` where a click opened it, with `preview` on initially for that click. use serde_json::{Map, Value, json}; use vectorcraft_geom::Point; @@ -21,7 +23,15 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of the Flare Tool Options. pub const KIND: &str = "flareOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Flare Tool Options").into(), body, confirm, min_width: 440.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Flare Tool Options").into(), + body, + confirm, + reset: Some(reset), + preview: true, + min_width: 440.0, + ..DialogSpec::FORM +}; /// Width of the label column and of the fields. const LABEL_W: f32 = 78.0; @@ -35,6 +45,7 @@ pub fn open(app: &mut VectorcraftApp, at: Option) -> Value { _ => Map::new(), }; if let Some(p) = at { + fields.insert("preview".into(), json!(true)); fields.insert("x".into(), json!(p.x)); fields.insert("y".into(), json!(p.y)); } @@ -102,23 +113,53 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); ui.end_row(); }); + if let Some((cmd, params)) = preview_command(app, d) { + form::preview(app, ui, d, "Flare", &cmd, params); + } false } +/// A click-created flare previews at that original click; toolbar options draw no artwork. +fn preview_command(app: &VectorcraftApp, d: &Dialog) -> Option<(String, Value)> { + let at = Point::new(d.fields.get("x")?.as_f64().filter(|v| v.is_finite())?, d.fields.get("y")?.as_f64().filter(|v| v.is_finite())?); + // Use the same clamping and whole-count conversion as accepted tool settings. + let mut tool = vectorcraft_tools::create("flare"); + for (key, default, ..) in FLARE_OPTIONS { + tool.set_option(key, &json!(d.f64(key, default))); + } + for key in ["raysOn", "ringsOn"] { + tool.set_option(key, &json!(d.bool(key))); + } + let params = flare_params(&tool.options(), at); + Some(vectorcraft_engine::perspective_click(&app.session, "shape.flare", ¶ms, at).unwrap_or_else(|| ("shape.flare".into(), params))) +} + +/// Reset (Cancel with Alt or Option held): every option goes back to the Flare tool's default +/// (`FLARE_OPTIONS`, with Rays and Rings on). The dialog keeps a click's `x` and `y`, so OK still draws there. +fn reset(_: &VectorcraftApp, d: &mut Dialog) { + if let Value::Object(defaults) = vectorcraft_tools::create("flare").options() { + d.fields.extend(defaults); + } +} + /// OK: set the tool's options; opened by a click, draw a flare there with them. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let command = preview_command(app, d); + if command.is_none() && (d.fields.contains_key("x") || d.fields.contains_key("y") || d.fields.contains_key(form::PREVIEWED)) { + return Err("Flare click coordinates must be finite numbers".into()); + } let mut values: Map = FLARE_OPTIONS.iter().filter(|(k, ..)| d.fields.contains_key(*k)).map(|(k, default, ..)| (k.to_string(), json!(d.f64(k, *default)))).collect(); for key in ["raysOn", "ringsOn"] { values.insert(key.into(), json!(d.bool(key))); } - let at = d.fields.get("x").and_then(Value::as_f64).zip(d.fields.get("y").and_then(Value::as_f64)).map(|(x, y)| Point::new(x, y)); - let options = run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values }))?; - let Some(at) = at else { return Ok(options) }; - let params = flare_params(&options, at); - match vectorcraft_engine::perspective_click(&app.session, "shape.flare", ¶ms, at) { - Some((id, p)) => app.run(&id, p), - None => app.run("shape.flare", params), + if let Some((cmd, params)) = command { + // Accept the preview once; option persistence follows successful artwork creation. + let result = form::commit_preview(app, &cmd, params)?; + app.run("tool.setOption", json!({ "tool": "flare", "values": values }))?; + Ok(result) + } else { + run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values })) } } @@ -148,6 +189,81 @@ mod tests { children.iter().filter_map(|c| c.name.clone()).collect() } + /// Invalid control-channel coordinates must not turn a click dialog into toolbar options. + #[test] + fn invalid_click_coordinates_keep_the_dialog_and_preview_cancellable() { + for (key, value) in [("x", Value::Null), ("y", Value::Null), ("x", json!("invalid"))] { + let mut app = app(); + let original = app.session.doc().unwrap().doc.layers.clone(); + let depth = app.session.doc().unwrap().history.undo.len(); + let options = app.session.tool_options_of("flare"); + open(&mut app, Some(Point::new(220.0, 330.0))); + painted(&mut app); + let preview = app.session.doc().unwrap().doc.layers.clone(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert(key.into(), value); + d.fields.insert("rings".into(), json!(4)); + assert_eq!(super::super::confirm(&mut app).unwrap_err(), "Flare click coordinates must be finite numbers"); + assert!(app.ui.dialog.is_some()); + assert!(app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, preview); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + assert_eq!(app.session.tool_options_of("flare"), options); + super::super::cancel(&mut app); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + } + } + + #[test] + fn cancelling_toolbar_options_preserves_an_existing_interaction() { + let mut app = app(); + app.session.begin_interaction("Existing gesture").unwrap(); + app.session.preview("shape.rectangle", &json!({"x":10,"y":10,"width":40,"height":40})).unwrap(); + let previewed = app.session.doc().unwrap().doc.layers.clone(); + open(&mut app, None); + painted(&mut app); + super::super::cancel(&mut app); + assert!(app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, previewed); + app.session.cancel_interaction().unwrap(); + } + + #[test] + fn reset_updates_live_preview_and_cancel_discards_art_and_options() { + let mut app = app(); + app.run("tool.setOption", json!({"tool":"flare", "values":{"rings":3}})).unwrap(); + let original = app.session.doc().unwrap().doc.layers.clone(); + let depth = app.session.doc().unwrap().history.undo.len(); + let options = app.session.tool_options_of("flare"); + open(&mut app, Some(Point::new(220.0, 330.0))); + let text = painted(&mut app); + assert!(text.contains("Preview")); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 3); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + app.ui.dialog.as_mut().unwrap().fields.insert("rings".into(), json!(4.0)); + painted(&mut app); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 4); + app.run("ui.dialogReset", json!({})).unwrap(); + painted(&mut app); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 10); + assert_eq!(app.session.tool_options_of("flare"), options, "preview doesn't persist options"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + assert_eq!(app.session.tool_options_of("flare"), options); + // Accepting the same reset preview produces one flare and one Undo step. + open(&mut app, Some(Point::new(220.0, 330.0))); + painted(&mut app); + app.run("ui.dialogReset", json!({})).unwrap(); + painted(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 10); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + } + #[test] fn the_toolbar_opens_the_options_and_ok_keeps_them_for_the_next_drag() { let mut app = app(); @@ -200,4 +316,24 @@ mod tests { let o = app.session.tool_options_of("flare"); assert_eq!((o["opacity"].as_f64(), o["diameter"].as_f64()), (Some(100.0), Some(0.0))); } + + #[test] + fn ui_dialog_reset_puts_the_defaults_back_and_keeps_where_a_click_opened_it() { + let mut app = app(); + assert_eq!(app.run("ui.dialogReset", json!({})).unwrap_err(), "no dialog open"); + app.run("tool.setOption", json!({"tool": "flare", "values": {"rings": 3, "raysOn": false, "largest": 120}})).unwrap(); + crate::dialogs::open_tool_dialog(&mut app, "flare", json!({"x": 300, "y": 300})); + let d = app.run("ui.dialogReset", json!({})).unwrap(); + assert_eq!((d["kind"].as_str(), d["fields"]["rings"].as_f64(), d["fields"]["raysOn"].as_bool()), (Some(KIND), Some(10.0), Some(true))); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.f64("largest", 0.0), d.f64("x", 0.0), d.f64("y", 0.0)), (50.0, 300.0, 300.0)); + // The tool keeps its options until OK, which draws the flare with the defaults where the click was. + assert_eq!(app.session.tool_options_of("flare")["rings"].as_u64(), Some(3)); + super::super::confirm(&mut app).unwrap(); + let parts = flare_parts(&app); + assert_eq!((parts.iter().filter(|p| *p == "Ring").count(), parts.iter().any(|p| p == "Rays")), (10, true), "{parts:?}"); + // A dialog without a Reset refuses it. + app.ui.dialog = Some(Dialog::new("move", json!({"dx": 1}))); + assert_eq!(app.run("ui.dialogReset", json!({})).unwrap_err(), "the open dialog has no Reset"); + } } diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index b4c54ed2f..3cd0a5111 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -319,16 +319,14 @@ mod tests { #[test] fn ok_says_in_the_status_bar_when_an_image_could_not_be_decoded() { const WARNING: &str = "an image couldn't be decoded and is missing from the picture"; - // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. + // A PNG whose header reads but whose pixel data is cut short. let mut png = Vec::new(); image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); - let idat = png.windows(4).position(|w| w == b"IDAT").unwrap(); - png.truncate(idat + 8); for preview in [true, false] { let mut app = scene(); - let placed = app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let placed = vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({})); // The image is half transparent itself, so Flatten Transparency flattens it wherever it was placed. app.run("transparency.set", json!({"ids": placed["ids"], "opacity": 50})).unwrap(); app.run("select.all", json!({})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index f1c45cf18..b82fe54f8 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -181,7 +181,14 @@ fn text_edit_limited_name(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { crate::theme::strong_selected_text(ui); - ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width).char_limit(max_chars)) + ui.add( + egui::TextEdit::singleline(&mut s) + .id(id) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(width) + .char_limit(max_chars), + ) }); if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); @@ -404,7 +411,7 @@ pub(super) fn param_fields( } } Value::String(mut s) => { - if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(140.0)).changed() { d.fields.insert(k.clone(), json!(s)); changed = true; } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index c1dda9b32..783e79093 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -3,12 +3,14 @@ //! preferences. OK sets them with `tool.setOption {tool, values}`. The Pencil also has `liveFit` //! (Live curve fitting, on: its preview fits the stroke while it is drawn). //! -//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush and Smooth), `fill` (Pencil and +//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush, Smooth and Blob Brush), `fill` (Pencil and //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; Eraser), `livePreview` (Pencil: the stroke shows -//! with its fill and stroke while it is drawn, or as a thin line until the release) and `size` -//! (points; Blob Brush and Eraser). +//! with its fill and stroke while it is drawn, or as a thin line until the release), `size` +//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation` +//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest +//! pen pressure and Size + Variation at the heaviest). use serde_json::{Map, Value, json}; @@ -27,11 +29,13 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"]; /// The options the dialog edits, in the order it shows them (a tool has those it reports). -const KEYS: [&str; 12] = [ +const KEYS: [&str; 14] = [ "fidelity", "angle", "roundness", "size", + "sizeMode", + "sizeVariation", "fill", "altSmooth", "keepSelected", @@ -134,6 +138,28 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } +/// Width of the Blob Brush's and the Eraser's Size mode menu. +const MODE_W: f32 = 96.0; + +/// The Blob Brush's and the Eraser's Size rows: Size with its Fixed or Pressure menu before the +/// field, then the Variation that Pressure adds and takes (greyed while Size is Fixed). +fn blob_size(ui: &mut egui::Ui, d: &mut Dialog) { + const MODES: [&str; 2] = ["fixed", "pressure"]; + let pressure = d.str("sizeMode") == "pressure"; + let labels = ["Fixed", "Pressure"]; + let label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; + let size = d.f64("size", 10.0); + widgets::label_row(ui, tl!("Size:"), label_w, |ui| { + if let Some(m) = widgets::dropdown(ui, "freehand-size-mode", labels[usize::from(pressure)], &labels, MODE_W).and_then(|i| MODES.get(i)) { + d.fields.insert("sizeMode".into(), json!(m)); + } + if let Some(x) = widgets::range_field(ui, ("freehand-field", "size"), size, 0.1..=1000.0, "pt", 1, FIELD_W) { + d.fields.insert("size".into(), json!(x)); + } + }); + ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0))); +} + /// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's /// lowest Fidelity to [`FIDELITY_MAX`] across the row with the field at its end, both to two /// decimals, and Accurate and Smooth under the slider's ends. @@ -182,7 +208,9 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if d.fields.contains_key("roundness") { number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (1.0, 100.0)); } - if d.fields.contains_key("size") { + if d.fields.contains_key("sizeMode") { + blob_size(ui, d); + } else if d.fields.contains_key("size") { number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0)); } if d.fields.contains_key("fill") { diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 40db4a3eb..6d316ef02 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -98,7 +98,8 @@ fn each_tool_shows_its_options_and_ok_sets_them() { let pb_text = painted(&mut app); assert!(pb_text.contains("Paintbrush Tool Options")); assert!(!pb_text.contains("Alt key toggles to Smooth Tool") && !pb_text.contains("Live preview"), "{pb_text}"); - // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Size, Keep selected and Merge only with selection. + // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Fidelity, Size, Keep selected and Merge only + // with selection. app.run("tool.options", json!({"tool": "smooth"})).unwrap(); let text = painted(&mut app); assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}"); @@ -112,9 +113,10 @@ fn each_tool_shows_its_options_and_ok_sets_them() { for tool in ["blobBrush", "eraser"] { app.run("tool.options", json!({"tool": tool})).unwrap(); let text = painted(&mut app); - assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}"); - // The Blob Brush also has Keep selected and Merge only with selection. + // The Blob Brush also has Fidelity, Keep selected and Merge only with selection. let blob = tool == "blobBrush"; + assert!(text.contains("Size:"), "{tool}: {text}"); + assert_eq!(text.contains("Fidelity:"), blob, "{tool}: {text}"); assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); // The Eraser also has Angle and Roundness. assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); @@ -200,6 +202,39 @@ fn a_new_fidelity_changes_what_the_pencil_draws() { assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default"); } +/// The Blob Brush's Fidelity, set in its Tool Options, changes the shape its strokes paint: at +/// 0.01 pt the brush follows a stroke's jitter, and at the default 1.5 pt it smooths it away. +#[test] +fn a_new_fidelity_changes_what_the_blob_brush_draws() { + let mut app = app(); + // The anchors of the blob a jittery wave paints around height `y0`. + let anchors = |app: &mut VectorcraftApp, y0: f64| { + app.select_tool("blobBrush"); + let view = app.view_info(); + let jitter = |i: i32| if i % 2 == 0 { 1.0 } else { -1.0 }; + let pts: Vec<_> = (0..=40).map(|i| (20.0 + f64::from(i) * 8.0, y0 + 30.0 * (f64::from(i) * 0.25).sin() + jitter(i))).collect(); + let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); + for p in &pts[1..] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); + let doc = &app.session.doc().unwrap().doc; + doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::()) + }; + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("fidelity", 0.0), 1.5); + super::super::confirm(&mut app).unwrap(); + let smooth = anchors(&mut app, 100.0); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.01)); + super::super::confirm(&mut app).unwrap(); + app.select_tool("selection"); + assert_eq!(app.session.tool_options_of("blobBrush")["fidelity"].as_f64(), Some(0.01), "it lasts while another tool is chosen"); + let precise = anchors(&mut app, 200.0); + assert!(smooth > 0 && smooth < precise, "{smooth} anchors at the default, {precise} at fidelity 0.01"); +} + #[test] fn the_new_path_is_selected_only_with_keep_selected_on() { let mut app = app(); @@ -278,6 +313,30 @@ fn the_blob_brush_options_set_what_a_stroke_merges_with_and_selects() { /// Eraser Tool Options' Angle and Roundness shape the tip the next Eraser drag erases with, /// and the tool keeps them while another tool is chosen. +/// The Blob Brush's and the Eraser's Size is Fixed or Pressure (#4590, #3961), with the Variation +/// Pressure adds and takes; OK sets both, each tool keeping its own, and the Pencil has neither. +#[test] +fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() { + let mut app = app(); + for (tool, variation) in [("blobBrush", 6.0), ("eraser", 4.0)] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + let text = painted(&mut app); + assert!(text.contains("Fixed") && text.contains("Variation:"), "{tool}: {text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.str("sizeMode"), d.f64("sizeVariation", -1.0)), ("fixed".to_string(), 0.0), "{tool}: Fixed with no variation by default"); + d.fields.insert("sizeMode".into(), json!("pressure")); + d.fields.insert("sizeVariation".into(), json!(variation)); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of(tool); + assert_eq!((o["sizeMode"].clone(), o["sizeVariation"].as_f64()), (json!("pressure"), Some(variation)), "{tool}"); + app.run("tool.options", json!({"tool": tool})).unwrap(); + assert!(painted(&mut app).contains("Pressure"), "{tool}"); + } + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + let text = painted(&mut app); + assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); +} + #[test] fn the_eraser_options_set_the_tips_angle_and_roundness() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index a74735363..c5b7d7e58 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -379,7 +379,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let mut text = d.str("entry"); // The entry box has the keys when the window opens, so Enter moves down instead of applying. widgets::take_dialog_focus(ui, entry_id, &text); - let r = ui.add(egui::TextEdit::singleline(&mut text).id(entry_id).desired_width(260.0)); + let r = ui.add(egui::TextEdit::singleline(&mut text).id(entry_id).align(egui::Align2::LEFT_CENTER).desired_width(260.0)); if r.changed() { d.fields.insert("entry".into(), json!(clean(&text))); } diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index 45ec6850f..eb30028b0 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -297,7 +297,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn password_row(ui: &mut egui::Ui, d: &mut Dialog) { widgets::label_row(ui, tl!("Password"), LABEL_WIDTH, |ui| { let mut s = d.str("password"); - let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0)); + let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).align(egui::Align2::LEFT_CENTER).desired_width(180.0)); if r.changed() { d.fields.insert("password".into(), json!(s)); } diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs index 2dbfdf810..20266d97b 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs @@ -55,6 +55,11 @@ fn open(app: &mut VectorcraftApp, path: &Path) { crate::io::open_path(app, &path.to_string_lossy()).unwrap(); } +/// How long a test waits for the dialog's search to end. The wait only ends a test whose search +/// never ends. A machine busy with other builds can hold a walker for seconds. It is shorter than a +/// search's default `maxSeconds` of 60, which would end a search that does not end by itself. +const WAIT: Duration = Duration::from_secs(30); + fn kind(app: &VectorcraftApp) -> Option<&str> { app.ui.dialog.as_ref().map(|d| d.kind.as_str()) } @@ -101,7 +106,7 @@ fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() { let r = super::super::confirm(&mut app).unwrap(); assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); assert_eq!(kind(&app), Some(KIND), "the dialog stays open while it searches"); - let until = Instant::now() + Duration::from_secs(10); + let until = Instant::now() + WAIT; while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { frame(&mut app); std::thread::sleep(Duration::from_millis(5)); diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs new file mode 100644 index 000000000..1949f0054 --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -0,0 +1,60 @@ +//! New Character Style and New Paragraph Style: the new style's name. OK makes the style from the +//! text that was selected when the dialog opened (`charStyle.new`, `paraStyle.new`). +//! +//! Fields: `name`, `__para` (true for a paragraph style) and `__target` (the `id`, `start` and +//! `end` of the Type tool's selection, empty for the selected objects, and the `attrs` that text had +//! when the dialog opened). + +use serde_json::{Value, json}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// The dialog kind of New Character Style and New Paragraph Style. +pub const KIND: &str = "newTextStyle"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +fn para(d: &Dialog) -> bool { + d.fields.get("__para").and_then(Value::as_bool).unwrap_or(false) +} + +fn heading(d: &Dialog) -> String { + if para(d) { tl!("New Paragraph Style") } else { tl!("New Character Style") }.into() +} + +/// Open New Character Style (`para`: New Paragraph Style) with the name `charStyle.new` would give +/// the style, to make it from the text `target` names. +pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result { + let d = &app.session.active().ok_or("no document open")?.doc; + let name = vectorcraft_engine::cmd::textstyles::new_style_name(d, para); + let mut target = target; + if let Some(attrs) = vectorcraft_engine::cmd::textstyles::target_style_attrs(&app.session, &target, para) { + target["attrs"] = Value::Object(attrs); + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "__para": para, "__target": target }))); + Ok(json!({ "dialog": KIND })) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + grid(ui, |ui| { + label(ui, tl!("Style Name:")); + form::text(ui, d, "name", 190.0); + ui.end_row(); + }); + false +} + +/// OK: make the style with the name. A name another style has, or an empty one, keeps the dialog +/// open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = d.fields.get("__target").filter(|t| t.is_object()).cloned().unwrap_or_else(|| json!({})); + params["name"] = json!(d.str("name")); + let r = app.run(if para(d) { "paraStyle.new" } else { "charStyle.new" }, params); + if r.is_ok() { + app.ui.dialog = None; + } + r +} diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index b767f8dae..5355ef0f6 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -582,7 +582,7 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } } let mut range = get(d, "range").as_str().unwrap_or_default().to_string(); - let edit = egui::TextEdit::singleline(&mut range).desired_width(140.0).hint_text("1-3, 5"); + let edit = egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(140.0).hint_text("1-3, 5"); if ui.add_enabled(artboards == PrintArtboards::Range, edit).changed() { set(d, "range", json!(range)); } diff --git a/crates/ui-egui/src/dialogs/rearrange_artboards.rs b/crates/ui-egui/src/dialogs/rearrange_artboards.rs index 7a619878c..6fa90910a 100644 --- a/crates/ui-egui/src/dialogs/rearrange_artboards.rs +++ b/crates/ui-egui/src/dialogs/rearrange_artboards.rs @@ -30,6 +30,15 @@ pub fn fields() -> Value { json!({"layout": "gridByRow", "order": "leftToRight", "columns": 2, "spacing": 20, "moveArtwork": true}) } +/// The fields the menu item opens with, retaining the spacing from its last confirmation. +pub fn open_fields(app: &VectorcraftApp) -> Value { + let mut fields = fields(); + if let Some(spacing) = app.ui.rearrange_artboards_spacing.as_f64() { + fields["spacing"] = json!(spacing); + } + fields +} + /// A layout's toggle: its tooltip and its artboards' cells (column, row) in their order, left to /// right. fn layout_glyph(l: ArtboardLayout) -> (&'static str, &'static [(u8, u8)]) { @@ -110,7 +119,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - run_and_close(app, CMD, params(d)) + let result = run_and_close(app, CMD, params(d))?; + app.ui.rearrange_artboards_spacing = json!(d.f64("spacing", 20.0)); + Ok(result) } #[cfg(test)] @@ -169,6 +180,19 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); } + /// The spacing confirmed last is the one the dialog opens with next time (#4558). + #[test] + fn keeps_spacing_when_reopening_after_rearranging() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 50, "artboards": 3})).unwrap(); + app.run("ui.menuDialog", json!({"command": CMD})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!(37)); + crate::dialogs::confirm(&mut app).unwrap(); + + app.run("ui.menuDialog", json!({"command": CMD})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("spacing", 20.0), 37.0); + } + /// Clicking the layout and order toggles sets the fields (the toggles' rects from the frame). #[test] fn the_toggles_set_layout_and_order() { diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index b320897de..cbf450a6a 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -87,6 +87,9 @@ fn shown(f: &Format) -> impl Iterator { || (range && o.name == "artboard") || (separate && o.name == "range") || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards") + // EPS Save keeps a page and the complete editing document; cropped and + // selected-only output belongs to Export, never this save dialog. + || (f.id == "eps" && matches!(o.name, "useArtboards" | "selectedOnly")) || (f.id == "ait" && o.name == "aiVersion")) }) } @@ -121,7 +124,7 @@ fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("all".into(), json!(all)); } let mut range = d.str("range"); - let field = egui::TextEdit::singleline(&mut range).desired_width(90.0).hint_text("1-3, 5"); + let field = egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(90.0).hint_text("1-3, 5"); if ui.add_enabled(!all, field).changed() { d.fields.insert("range".into(), json!(range)); } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 9c8ae60bb..fe46331a5 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -72,7 +72,7 @@ fn save_as_svg_opens_svg_options_then_writes_svg() { // The save panel offers every save format, the document's own first. let pick = &picks.borrow()[0]; let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); - assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai", "EPS"]); assert_eq!(pick.name, "Untitled-1.vectorcraft"); let d = dialog(&app); assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); @@ -332,3 +332,18 @@ fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { assert_eq!(dialog(&app).str("compatibility"), "1.5"); assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); } + +#[test] +fn eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches() { + let (mut app, written, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let names: Vec<_> = shown(fileio::format("eps").unwrap()).map(|option| option.name).collect(); + assert!(!names.contains(&"useArtboards") && !names.contains(&"selectedOnly")); + assert!(names.contains(&"previewFormat")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let saved = written.borrow(); + assert_eq!(saved[0].0, "art.eps"); + assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + assert_eq!(app.session.active().unwrap().format, "eps"); +} diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 4b8fc33c6..72a8d64ac 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -321,7 +321,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { let mut s = get(d, path).as_str().unwrap_or_default().to_string(); - if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).desired_width(240.0)).changed() { + if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(240.0)).changed() { set(d, path, json!(s)); } } @@ -493,7 +493,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { d.fields.insert("__allArtboards".into(), json!(all)); } let mut range = d.str("range"); - if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(140.0).hint_text("1-3, 5")).changed() { + if ui + .add_enabled(!all, egui::TextEdit::singleline(&mut range).align(egui::Align2::LEFT_CENTER).desired_width(140.0).hint_text("1-3, 5")) + .changed() + { d.fields.insert("range".into(), json!(range)); } }); @@ -700,7 +703,7 @@ fn password_check(ui: &mut egui::Ui, d: &mut Dialog, path: &str, flag: &str, lab fn password_field(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { let mut s = get(d, path).as_str().unwrap_or_default().to_string(); - if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).password(true).desired_width(200.0)).changed() { + if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).password(true).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() { set(d, path, json!(s)); } } diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index fc766c6d6..6ef85620a 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -131,15 +131,13 @@ fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { #[test] fn an_image_that_couldnt_be_decoded_is_named_in_the_status() { let (mut app, written, _) = app(); - // A PNG whose header reads (so it places at its size) but whose pixel data is cut short. - let mut cut = vec![]; + // A PNG whose header reads but whose pixel data is cut short. + let mut png = vec![]; image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) - .write_to(&mut std::io::Cursor::new(&mut cut), image::ImageFormat::Png) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); - let idat = cut.windows(4).position(|w| w == b"IDAT").unwrap(); - cut.truncate(idat + 8); // The image is placed on the first artboard. - app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut), "at": [60, 50]})).unwrap(); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [60, 50]})); app.run("ui.savePdfDialog", json!({"path": "/tmp/cut.pdf"})).unwrap(); super::super::confirm(&mut app).unwrap(); assert_eq!(app.ui.status, "Saved /tmp/cut.pdf with 1 note(s): an image couldn't be decoded and is missing from the picture"); diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index bc9f2916f..392c043f3 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -181,6 +181,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); } else if d.kind == "arc" { arc_form(ui, d, unit); + } else if LINKED.contains(&d.kind.as_str()) { + linked_size_form(ui, d, unit); } else { form::grid(ui, d, unit); } @@ -227,7 +229,17 @@ fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { if d.f64("lengthY", 100.0) != ly0 { arc_pin_to_anchor(d); } - form::choice(ui, d, "arcType", tl!("Type:"), (form::FIELD_W, form::FIELD_W - 12.0), &[("open", tl!("Open")), ("closed", tl!("Closed"))]); + form::choice( + ui, + d, + "arcType", + tl!("Type:"), + (form::FIELD_W, form::FIELD_W - 12.0), + &[ + ("open", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Open")), + ("closed", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Closed")), + ], + ); form::choice( ui, d, @@ -250,6 +262,102 @@ fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { form::check(ui, d, "fillArc", tl!("Fill Arc")); } +/// The click dialogs whose Width and Height share a Constrain Width and Height Proportions link +/// (, +/// ): the same +/// link as the Transform panel's ([`crate::panels::transform::constrain_link`]), drawn the same way. +const LINKED: [&str; 3] = ["rectangle", "roundedRectangle", "ellipse"]; + +/// The dialog field holding the link's state (UI-only, as every `__` field is). +const LINK: &str = "__constrain"; + +/// The dialog field holding the link state the dialog last applied ([`link_changed`]): a change of +/// [`LINK`], by its button or from outside (`ui.dialog.set`), shows as a difference from it. +const APPLIED: &str = "__constrainApplied"; + +/// The dialog field holding height ÷ width while the link is on: the proportions it keeps. +const RATIO: &str = "__ratio"; + +/// Where the link's state waits for the next of these dialogs (egui memory, this run only). +const LINK_STATE: &str = "shape-size-link"; + +/// Width and Height (then a rounded rectangle's radius) with the Constrain Width and Height +/// Proportions link beside the two size rows. While it is on, typing one side sets the other, so +/// the shape keeps the proportions it had when the link was turned on. The next of these dialogs +/// opens with the link as this one left it. +fn linked_size_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + if !d.fields.contains_key(LINK) { + d.fields.insert(LINK.into(), json!(crate::panels::pstate::(ui.ctx(), LINK_STATE))); + } + sync_link(ui.ctx(), d); + const BUTTON: f32 = 22.0; + let mut rows: Option = None; + egui::Grid::new("shape-size-dlg").num_columns(3).spacing([10.0, 8.0]).show(ui, |ui| { + for (key, label) in [("width", "Width:"), ("height", "Height:")] { + let row = form::row_label(ui, label); + if ui.label_inputs_by(row.id, |ui| form::length(ui, d, key, unit, form::FIELD_W)) { + follow_link(d, key); + } + // The link's column: the button is drawn over both rows' cells once the grid is laid out. + let (_, cell) = ui.allocate_space(egui::vec2(BUTTON, BUTTON)); + rows = Some(rows.map_or(cell, |r| r.union(cell))); + ui.end_row(); + } + if d.kind == "roundedRectangle" { + form::length_field(ui, d, "radius", &form::humanized("radius"), unit); + } + }); + let Some(rows) = rows else { return }; + let on = d.bool(LINK); + let rect = egui::Rect::from_center_size(rows.center(), egui::vec2(BUTTON, BUTTON)); + let resp = ui.interact(rect, ui.scope_id().with("shape-size-link"), egui::Sense::click()); + let icon = if on { "link" } else { "link-2-off" }; + if widgets::paint_icon_button(ui, resp, icon, "Constrain Width and Height Proportions", on).clicked() { + d.fields.insert(LINK.into(), json!(!on)); + sync_link(ui.ctx(), d); + } +} + +/// Apply a change of the link's state, however it was made ([`link_changed`]), and keep the new +/// state for the next of these dialogs. +fn sync_link(ctx: &egui::Context, d: &mut Dialog) { + if let Some(on) = link_changed(d) { + crate::panels::set_pstate(ctx, LINK_STATE, on); + } +} + +/// When the link's state differs from the one the dialog last applied (or none was applied yet), +/// apply it and return it: turned on, the link keeps the proportions of the sizes the dialog shows +/// then; turned off, it forgets them. A side of zero when it turns on leaves nothing to keep, so +/// typing changes only the side typed until the link is turned off and on again. +fn link_changed(d: &mut Dialog) -> Option { + let on = d.bool(LINK); + if d.fields.get(APPLIED).and_then(Value::as_bool) == Some(on) { + return None; + } + d.fields.insert(APPLIED.into(), json!(on)); + let ratio = d.f64("height", 100.0) / d.f64("width", 100.0); + if on && ratio.is_finite() && ratio != 0.0 { + d.fields.insert(RATIO.into(), json!(ratio)); + } else { + d.fields.remove(RATIO); + } + Some(on) +} + +/// After the side `typed` ("width" or "height") was typed with the link on, set the other side +/// from the proportions the link keeps. +fn follow_link(d: &mut Dialog, typed: &str) { + let ratio = d.f64(RATIO, 0.0); + if !d.bool(LINK) || !ratio.is_finite() || ratio == 0.0 { + return; + } + let (other, v) = if typed == "width" { ("height", d.f64("width", 100.0) * ratio) } else { ("width", d.f64("height", 100.0) / ratio) }; + if v.is_finite() { + d.fields.insert(other.into(), json!(v)); + } +} + /// The top-left corner of a rectangle or ellipse: the click itself, or with Alt held (draw from centre) the click minus half the size. fn box_origin(d: &Dialog) -> (f64, f64) { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); @@ -402,6 +510,145 @@ mod tests { assert_eq!(box_origin(&fields(true)), (80.0, 40.0)); } + /// Turn the link on or off as its button or `ui.dialog.set` does, then apply it as the form + /// does on its next frame; what [`link_changed`] returned. + fn turn(d: &mut Dialog, on: bool) -> Option { + d.fields.insert(LINK.into(), json!(on)); + link_changed(d) + } + + fn type_size(d: &mut Dialog, typed: &str, value: f64) -> (f64, f64) { + d.fields.insert(typed.into(), json!(value)); + follow_link(d, typed); + // The form applies the link's state again on every frame: an unchanged state changes nothing. + assert_eq!(link_changed(d), None); + (d.f64("width", -1.0), d.f64("height", -1.0)) + } + + #[test] + fn the_link_keeps_the_proportions_it_had_when_turned_on() { + let mut d = Dialog::new("rectangle", json!({"x": 0.0, "y": 0.0, "width": "40 pt", "height": "20 pt"})); + // Off: a typed side changes only itself. + assert_eq!(turn(&mut d, false), Some(false)); + assert_eq!(type_size(&mut d, "width", 80.0), (80.0, 20.0)); + // On at 80 × 20: each side follows the other at 4 : 1, also through a zero and back. + assert_eq!(turn(&mut d, true), Some(true)); + for (typed, value, expected) in + [("width", 100.0, (100.0, 25.0)), ("height", 50.0, (200.0, 50.0)), ("width", 0.0, (0.0, 0.0)), ("width", 40.0, (40.0, 10.0))] + { + assert_eq!(type_size(&mut d, typed, value), expected, "{typed} = {value}"); + } + } + + /// Turned off and on again from outside the dialog (`ui.dialog.set`), the link forgets the old + /// proportions and keeps the ones the dialog shows when it comes back on. + #[test] + fn the_link_follows_changes_made_from_outside_the_dialog() { + let mut d = Dialog::new("rectangle", json!({"width": 80.0, "height": 20.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(turn(&mut d, false), Some(false)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 30.0), (30.0, 20.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (30.0, 60.0)); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(type_size(&mut d, "width", 10.0), (10.0, 20.0)); + // Set to what it already is: nothing to apply, the proportions stay. + assert_eq!(turn(&mut d, true), None); + assert_eq!(type_size(&mut d, "height", 40.0), (20.0, 40.0)); + } + + /// A side of zero when the link turns on leaves nothing to keep (no NaN): typing changes only + /// the side typed, even once both sides are sizes again, until the link is turned on again. + #[test] + fn a_link_turned_on_at_a_zero_side_stays_unconstrained_until_turned_on_again() { + let mut d = Dialog::new("ellipse", json!({"width": 0.0, "height": 30.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 50.0), (50.0, 30.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (50.0, 60.0)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!((turn(&mut d, false), turn(&mut d, true)), (Some(false), Some(true))); + assert_eq!(type_size(&mut d, "width", 100.0), (100.0, 120.0)); + } + + /// The real dialog: Width comes before Height, the link is a named button that turns on with a + /// click, and the next Rectangle dialog opens with it on. + #[test] + fn the_rectangle_dialog_shows_width_then_height_and_a_link_button() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + const LINK_NAME: &str = "Constrain Width and Height Proportions"; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h.check_accessibility(); + let (w, ht) = (h.get_by_role_and_label(Role::TextInput, "Width:").rect(), h.get_by_role_and_label(Role::TextInput, "Height:").rect()); + assert!(w.center().y < ht.center().y, "Width is above Height: {w:?} {ht:?}"); + assert!(!h.state().ui.dialog.as_ref().unwrap().bool(LINK)); + let link = h.get_by_role_and_label(Role::Button, LINK_NAME).rect(); + assert!(link.left() > w.right() && link.top() < ht.center().y && link.bottom() > w.center().y, "the link sits beside both rows: {link:?}"); + h.get_by_role_and_label(Role::Button, LINK_NAME).click(); + h.run_steps(2); + let d = h.state().ui.dialog.as_ref().unwrap(); + assert!(d.bool(LINK)); + assert_eq!(d.f64(RATIO, 0.0), 1.0); + // Cancel, then click again: the link is still on. + let reopen = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| { + h.state_mut().ui.dialog = None; + h.run_steps(2); + super::super::open_tool_dialog(h.state_mut(), "rectangle", json!({"x": 10.0, "y": 10.0})); + h.run_steps(2); + h.state().ui.dialog.as_ref().unwrap().bool(LINK) + }; + assert!(reopen(&mut h)); + // Turned off from outside the dialog (`ui.dialog.set`): the next dialog opens with it off. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(LINK.into(), json!(false)); + h.run_steps(2); + assert!(!h.state().ui.dialog.as_ref().unwrap().fields.contains_key(RATIO)); + assert!(!reopen(&mut h)); + } + + /// With the link on, typing a width over a 40 × 20 rectangle's and pressing Enter draws it + /// 80 × 40. + #[test] + fn typing_a_width_with_the_link_on_draws_the_shape_in_proportion() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!(40.0)); + d.fields.insert("height".into(), json!(20.0)); + d.fields.insert(LINK.into(), json!(true)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("80 pt".into())]); + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + let b = doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!((b.width() - 80.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + } + #[test] fn a_negative_line_length_draws_and_keeps_the_same_direction() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index ab32ada06..c65b4e602 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -667,7 +667,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim)); let mut name = d.str("name"); - if ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut name).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() { d.fields.insert("name".into(), json!(name)); } }); @@ -867,7 +867,7 @@ fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses } ui.add_space(8.0); let mut q = d.str("__query"); - if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(180.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut q).align(egui::Align2::LEFT_CENTER).hint_text(tl!("Search")).desired_width(180.0)).changed() { d.fields.insert("__query".into(), json!(q)); } }); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index caf898fdd..cf3866018 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -8,6 +8,8 @@ //! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). //! `vectorcraft_pathops::simplify_level_tolerance` maps levels to tolerances. //! +//! The strip itself ([`Strip`]) is shared with Object › Path › Smooth's slider (`smooth_slider`). +//! //! Fields: `level` (0..=100, the command's parameter). use egui::{Color32, CornerRadius, Order, Pos2, Sense, Stroke, vec2}; @@ -25,9 +27,122 @@ pub(crate) const KIND: &str = "simplifySlider"; pub(super) const SPEC: DialogSpec = DialogSpec { window: Some(show), confirm, preview: true, ..DialogSpec::FORM }; -/// The command the slider previews, and its undo label. -const CMD: &str = "object.path.simplify"; -const LABEL: &str = "Simplify"; +/// What the Simplify slider previews, and its buttons. +const STRIP: Strip = Strip { + kind: KIND, + cmd: "object.path.simplify", + label: "Simplify", + default_level: SIMPLIFY_LEVEL, + auto: ("Auto-Simplify", auto), + more_options: Some(more_options), +}; + +/// A UI command a button of a strip runs. +type Button = fn(&mut VectorcraftApp) -> Result; + +/// A slider strip under the selection that previews `cmd {level}` while it is open: its first +/// button picks the level `cmd {auto: true}` does, and an optional last one, More Options, opens a +/// dialog in its place. Enter or a click outside the strip keeps the result as one undo step, and +/// Escape puts the paths back. +pub(super) struct Strip { + /// The dialog kind. + pub kind: &'static str, + /// The command previewed. + pub cmd: &'static str, + /// The undo step's name, and the slider's. + pub label: &'static str, + /// The level when the field holds no number. + pub default_level: f64, + /// The auto button's tip, and what it runs. + pub auto: (&'static str, Button), + /// What More Options runs, when the strip has it. + pub more_options: Option