mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-17 04:36:54 -04:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7018a3370 | ||
|
|
ee61583606 | ||
|
|
92647fa5b6 | ||
|
|
1e3886c053 | ||
|
|
5eea7e4f66 | ||
|
|
2e9870b1d0 | ||
|
|
951f7c2842 | ||
|
|
34ae88292d | ||
|
|
a8fbae1b1f | ||
|
|
3c5d0e7ce4 | ||
|
|
fbcb217789 | ||
|
|
7c80b286c7 | ||
|
|
91fa3af21d | ||
|
|
577239d734 | ||
|
|
39979d4753 | ||
|
|
47b6b3285d | ||
|
|
ecbbba4f2e | ||
|
|
424a49604c | ||
|
|
fdc08e25d6 | ||
|
|
1ff7ac7557 | ||
|
|
2a3efa02f2 | ||
|
|
7e423f91e9 | ||
|
|
8ac4c9d268 | ||
|
|
d39e02b3fe | ||
|
|
5fd7fd4312 | ||
|
|
57c79dc0b4 | ||
|
|
1fbd35560d | ||
|
|
37dfdd5da5 | ||
|
|
743930ac11 | ||
|
|
476d228262 | ||
|
|
6961bed99d | ||
|
|
efdb04db8a | ||
|
|
80685831ac | ||
|
|
3688ad7a46 | ||
|
|
8ca4cd2cbf | ||
|
|
3d2e8bbe82 | ||
|
|
0af1741e24 | ||
|
|
6b0d1ed1c0 | ||
|
|
db2bbcf405 | ||
|
|
9bee299dd5 | ||
|
|
0f987812b0 | ||
|
|
49bdc5cdd4 | ||
|
|
7a8387f467 | ||
|
|
12758df31f | ||
|
|
a76faaa21f | ||
|
|
21a9da7360 | ||
|
|
f31d91bf1a | ||
|
|
52771905e6 | ||
|
|
0ffe0c7c0e | ||
|
|
8689863fbf | ||
|
|
2d0da1472f | ||
|
|
816097c6bf | ||
|
|
1d7ad173aa |
No files matched your search
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -135,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
|
||||
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
|
||||
/// that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
|
||||
}
|
||||
|
||||
/// Removes `dir` and everything in it, creates it again empty and returns it.
|
||||
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
fn mcp_automation_roots() {
|
||||
let base = tmp("roots");
|
||||
automation_roots(&tmp("roots"));
|
||||
}
|
||||
|
||||
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
|
||||
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
|
||||
/// with "File exists".
|
||||
#[test]
|
||||
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
|
||||
let dir = tmp("earlier-run");
|
||||
automation_roots(&dir.join("roots"));
|
||||
automation_roots(&empty_dir(dir).join("roots"));
|
||||
}
|
||||
|
||||
fn automation_roots(base: &std::path::Path) {
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
|
||||
@@ -8,9 +8,16 @@ use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
|
||||
/// removes that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
let dir = DIR.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
});
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Linux Wayland: tell winit where the cursor theme is (#2450).
|
||||
//!
|
||||
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
|
||||
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
|
||||
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
|
||||
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
|
||||
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
|
||||
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
|
||||
//!
|
||||
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
|
||||
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
|
||||
//!
|
||||
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
|
||||
//! any thread starts and before the window opens.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
|
||||
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
|
||||
|
||||
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct DesktopCursor {
|
||||
pub theme: Option<String>,
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// The variables to set, given the environment, the host icon folders that exist and the
|
||||
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
|
||||
/// stay as they are.
|
||||
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
|
||||
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
|
||||
}
|
||||
if !set("XCURSOR_THEME")
|
||||
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
|
||||
{
|
||||
out.push(("XCURSOR_THEME", OsString::from(theme)));
|
||||
}
|
||||
if !set("XCURSOR_SIZE")
|
||||
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
|
||||
{
|
||||
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
|
||||
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
|
||||
|
||||
/// The themes `index.theme` lists after `Inherits=`.
|
||||
fn inherits(index: &str) -> Vec<String> {
|
||||
index
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("Inherits="))
|
||||
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
|
||||
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
|
||||
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
|
||||
/// whether a file is there.
|
||||
pub fn theme_usable(
|
||||
var: impl Fn(&str) -> Option<OsString>,
|
||||
home: Option<&Path>,
|
||||
read: impl Fn(&Path) -> Option<String>,
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> bool {
|
||||
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
|
||||
Some(v) => std::env::split_paths(&v).collect(),
|
||||
None => DEFAULT_ICON_DIRS
|
||||
.iter()
|
||||
.filter_map(|d| match d.strip_prefix("~/") {
|
||||
Some(rest) => home.map(|h| h.join(rest)),
|
||||
None => Some(PathBuf::from(d)),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
|
||||
let mut todo = vec![first];
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
while let Some(theme) = todo.pop() {
|
||||
// Bounded: themes inherit in short chains, and a cycle must end.
|
||||
if seen.contains(&theme) || seen.len() >= 16 {
|
||||
continue;
|
||||
}
|
||||
for dir in &dirs {
|
||||
let base = dir.join(&theme);
|
||||
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
|
||||
return true;
|
||||
}
|
||||
if let Some(index) = read(&base.join("index.theme")) {
|
||||
todo.extend(inherits(&index));
|
||||
}
|
||||
}
|
||||
seen.push(theme);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
|
||||
/// sets a pointer, and the canvas must not hide the system one (#2450).
|
||||
pub fn theme_missing() -> bool {
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
|
||||
}
|
||||
|
||||
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
|
||||
fn gsettings_value(output: &str) -> &str {
|
||||
output.trim().trim_matches('\'')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_cursor() -> DesktopCursor {
|
||||
let get = |key: &str| {
|
||||
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
};
|
||||
DesktopCursor {
|
||||
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
|
||||
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
|
||||
/// unset. Call first thing in `main`, while the process is single-threaded.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn apply() {
|
||||
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
|
||||
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
|
||||
log::debug!("cursor theme: {name}={value:?}");
|
||||
// SAFETY: called at the top of `main`, before the app starts any thread.
|
||||
unsafe { std::env::set_var(name, value) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn adwaita() -> DesktopCursor {
|
||||
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
|
||||
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
|
||||
("XCURSOR_THEME", OsString::from("Adwaita")),
|
||||
("XCURSOR_SIZE", OsString::from("24")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_the_user_made_and_other_sessions_stay_untouched() {
|
||||
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
|
||||
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
// A theme-less desktop and an absurd size set nothing.
|
||||
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
|
||||
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
|
||||
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
|
||||
// Compared as paths, so the joins match on Windows too, where they use `\`.
|
||||
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
|
||||
let read = |p: &Path| {
|
||||
if p == Path::new("/i/Breeze/index.theme") {
|
||||
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
|
||||
} else if p == Path::new("/i/Loop/index.theme") {
|
||||
Some("Inherits=Loop\n".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let with = |theme: &'static str| {
|
||||
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
|
||||
};
|
||||
assert!(theme_usable(with("Bibata"), None, read, exists));
|
||||
assert!(theme_usable(with("Breeze"), None, read, exists));
|
||||
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
|
||||
assert!(!theme_usable(with("Nope"), None, read, exists));
|
||||
assert!(!theme_usable(with("Loop"), None, read, exists));
|
||||
// Unset: Xcursor's default folders and the `default` theme.
|
||||
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gsettings_output_is_unquoted() {
|
||||
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
|
||||
assert_eq!(gsettings_value("24\n"), "24");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
|
||||
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
|
||||
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
|
||||
//! The panel gets a Format menu as its accessory view
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
|
||||
//! `beginSheetModalForWindow:completionHandler:`
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
|
||||
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
|
||||
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
|
||||
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
|
||||
//! one. The save command takes the format from the extension, so an extension typed by hand still
|
||||
//! decides it.
|
||||
//!
|
||||
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
|
||||
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
|
||||
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
|
||||
//! the menu's action have no safe API; this module allows `unsafe_code`.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
/// The accessory's height and the room around and between its label and menu, in points.
|
||||
const HEIGHT: f64 = 38.0;
|
||||
const MARGIN: f64 = 12.0;
|
||||
const GAP: f64 = 8.0;
|
||||
/// The Format menu's size, in points.
|
||||
const MENU: NSSize = NSSize::new(260.0, 26.0);
|
||||
|
||||
struct Ivars {
|
||||
panel: Weak<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
|
||||
#[unsafe(super = NSView)]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = Ivars]
|
||||
#[name = "VectorcraftSaveFormatAccessory"]
|
||||
struct FormatAccessory;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no additional requirements.
|
||||
unsafe impl NSObjectProtocol for FormatAccessory {}
|
||||
|
||||
impl FormatAccessory {
|
||||
// SAFETY: AppKit's target/action sends the popup as the single object argument.
|
||||
#[unsafe(method(chooseFormat:))]
|
||||
fn choose_format(&self, sender: &NSPopUpButton) {
|
||||
let Some(panel) = self.ivars().panel.load() else { return };
|
||||
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
|
||||
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
|
||||
set_allowed_types(&panel, &self.ivars().filters, index);
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
|
||||
/// two remain, and the panel is then left to rfd.
|
||||
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
|
||||
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if named.len() < 2 { Vec::new() } else { named }
|
||||
}
|
||||
|
||||
/// The menu entry of the suggested name's type, or the first (the request's default).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = fileio::extension(name);
|
||||
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
|
||||
/// writes) is replaced, and other dots stay part of the name (a version number).
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let has = fileio::extension(name);
|
||||
let known =
|
||||
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
|
||||
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
|
||||
}
|
||||
|
||||
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
|
||||
/// the first one to a name typed without an extension, and a name typed with another of them is
|
||||
/// saved in that format.
|
||||
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
|
||||
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
|
||||
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
|
||||
first.iter().chain(others).copied().collect()
|
||||
}
|
||||
|
||||
/// Set `panel`'s allowed file types ([`allowed_types`]).
|
||||
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
|
||||
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
|
||||
#[allow(deprecated)]
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
if !pick.name.is_empty() {
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
|
||||
}
|
||||
let accessory = accessory(mtm, &panel, filters, selected);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
let (tx, rx) = channel();
|
||||
let answered = panel.clone();
|
||||
let handler = RcBlock::new(move |response: NSModalResponse| {
|
||||
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
|
||||
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(paths);
|
||||
});
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.sizeToFit();
|
||||
let size = label.frame().size;
|
||||
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
|
||||
let x = MARGIN + size.width + GAP;
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
|
||||
for (name, _) in &filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
|
||||
}
|
||||
// The filter list comes from the built-in format registry, small enough for NSInteger.
|
||||
if let Ok(index) = isize::try_from(selected) {
|
||||
popup.selectItemAtIndex(index);
|
||||
}
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
|
||||
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
|
||||
// sheet is open. The selector has the target/action signature NSControl calls it with.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
}
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
accessory
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
|
||||
/// named type, or one for every file, gets no menu, and rfd shows it.
|
||||
#[test]
|
||||
fn save_panels_with_several_formats_get_the_menu() {
|
||||
let save = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
assert!(save.len() >= 2, "{save:?}");
|
||||
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
|
||||
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
|
||||
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
|
||||
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
|
||||
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
|
||||
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
|
||||
for (name, expected) in [
|
||||
("図面 1.vectorcraft", "図面 1.svg"),
|
||||
("a.PDF", "a.svg"),
|
||||
("art", "art.svg"),
|
||||
("art.v2", "art.v2.svg"),
|
||||
("art.svg", "art.svg"),
|
||||
("old.drawcraft", "old.svg"),
|
||||
("photo.png", "photo.svg"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
|
||||
/// type. The others stay allowed.
|
||||
#[test]
|
||||
fn the_chosen_format_is_the_first_allowed_type() {
|
||||
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
|
||||
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
for (label, extensions) in &filters {
|
||||
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's label is translated in every language with complete menus.
|
||||
#[test]
|
||||
fn the_label_is_translated() {
|
||||
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
mod cursor_theme;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
mod logging;
|
||||
@@ -35,6 +36,8 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_screen_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
// The panel is built and the sheet is begun below, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
@@ -485,12 +488,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(_) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
|
||||
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// A save panel with two or more named file types is built by `mac_save_format`, which sets
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
|
||||
// made recent macOS versions dismiss the sheet at once (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
@@ -517,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
wayland,
|
||||
no_cursor_theme: wayland && cursor_theme::theme_missing(),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
@@ -646,6 +656,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
";
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
|
||||
#[cfg(target_os = "linux")]
|
||||
cursor_theme::apply();
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
|
||||
@@ -171,9 +171,25 @@ impl GenericCmyk {
|
||||
xyz_to_lab(self.to_xyz(cmyk))
|
||||
}
|
||||
|
||||
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
|
||||
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
/// With `bpc` (media-relative only) the press black displays as black, undoing the
|
||||
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
|
||||
/// darkest rich black shows as black again.
|
||||
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
if absolute {
|
||||
return self.to_xyz(cmyk);
|
||||
}
|
||||
let x = self.to_xyz_rel(cmyk);
|
||||
if !bpc {
|
||||
return x;
|
||||
}
|
||||
let bp = self.black_rel;
|
||||
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
|
||||
}
|
||||
|
||||
/// Display sRGB, see [`Self::display_xyz`].
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
@@ -425,11 +441,12 @@ impl GenericCmyk {
|
||||
self.from_lab(srgb_to_lab(rgb), intent, bpc)
|
||||
}
|
||||
|
||||
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
|
||||
pub fn gamut_error(&self, src: Lab) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
|
||||
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
|
||||
/// (with `bpc`, the press black showing as black).
|
||||
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
|
||||
let cmyk = self.separate(target);
|
||||
delta_e2000(self.to_lab_rel(cmyk), target)
|
||||
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,10 +346,10 @@ impl Cms {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
@@ -376,7 +376,7 @@ impl Cms {
|
||||
{
|
||||
return v;
|
||||
}
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
|
||||
}
|
||||
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
|
||||
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
|
||||
@@ -408,6 +408,15 @@ impl Cms {
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
|
||||
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
|
||||
/// as black). Unclipped, for interpolating tables.
|
||||
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
|
||||
_ => self.cmyk_to_lab(cmyk),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
@@ -429,7 +438,7 @@ impl Cms {
|
||||
/// CIE Lab (D50) of any colour.
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
|
||||
Color::Lab { l, a, b } => Lab::new(l, a, b),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
@@ -490,7 +499,7 @@ impl Cms {
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let src = self.lab(c);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
|
||||
@@ -523,7 +532,7 @@ impl Cms {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
@@ -534,7 +543,7 @@ impl Cms {
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
|
||||
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
|
||||
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
|
||||
#[test]
|
||||
fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
let c = cms();
|
||||
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
|
||||
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
|
||||
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
|
||||
// The paper still shows as white, and without compensation the press black stays a dark grey.
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
|
||||
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
|
||||
let p = IccProfile::from_bytes(None, &bytes).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
|
||||
// The encoded profile converts without black-point compensation, so compare without it.
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
|
||||
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
|
||||
let want = cms.cmyk_to_srgb(cmyk, false);
|
||||
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
|
||||
|
||||
@@ -181,6 +181,10 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
// `from_str_radix` accepts a leading `+`, so check the digits first.
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
|
||||
match h.len() {
|
||||
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
|
||||
@@ -412,6 +416,14 @@ mod tests {
|
||||
assert_eq!(Color::from_hex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejects_sign_characters() {
|
||||
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
|
||||
assert_eq!(Color::from_hex(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_roundtrip() {
|
||||
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
|
||||
|
||||
+12
-7
@@ -430,17 +430,22 @@ impl GraphSpec {
|
||||
let cells = self.cells();
|
||||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
|
||||
let mut out = String::from(",");
|
||||
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
|
||||
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
|
||||
let field = |c: &str, always: bool| {
|
||||
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
|
||||
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
|
||||
format!("\"{}\"", c.replace('"', "\"\""))
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
};
|
||||
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !self.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
|
||||
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
|
||||
out.push_str(&format!("\"{c}\""));
|
||||
} else {
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
|
||||
@@ -94,26 +94,27 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
|
||||
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
|
||||
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
|
||||
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
|
||||
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
|
||||
/// that colour, where Multiply would darken it. With `discard_white`
|
||||
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
|
||||
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
if !a.has_overprint() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
let multiplies =
|
||||
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
multiply_overprints(c, discard_white);
|
||||
darken_overprints(c, discard_white);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 }
|
||||
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
|
||||
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) * 4 > max {
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
@@ -143,6 +143,38 @@ mod tests {
|
||||
assert!(rgba_within(b"not an image", u64::MAX).is_none());
|
||||
}
|
||||
|
||||
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
|
||||
/// no pixel data.
|
||||
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
|
||||
/// refused like any other past the allowance, and an image within the allowance still decodes.
|
||||
#[test]
|
||||
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
|
||||
let huge = tiff_header(1 << 31, 1 << 31);
|
||||
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
|
||||
assert!(rgba_within(&huge, u64::MAX).is_none());
|
||||
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_a_pixel_or_the_average_around_it() {
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
|
||||
|
||||
+17
-12
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]).
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
|
||||
/// since a bare `_` would read back as a space.
|
||||
pub fn from_object_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
|
||||
out.push('_');
|
||||
} else if c == '_' {
|
||||
out.push_str("_x5F_");
|
||||
} else if i == 0 && c.is_ascii_digit() {
|
||||
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
|
||||
// A first character that cannot start an XML name is escaped, never given a bare `_`
|
||||
// prefix (that would read back as a space).
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if out.is_empty() || !starts_name(&out) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
|
||||
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn starts_name(id: &str) -> bool {
|
||||
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
|
||||
}
|
||||
|
||||
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
|
||||
fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
@@ -114,4 +108,15 @@ mod tests {
|
||||
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
|
||||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_names_that_cannot_start_an_xml_name() {
|
||||
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
|
||||
let id = from_object_name(name);
|
||||
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
|
||||
}
|
||||
assert_eq!(from_object_name("-abc"), "_x2D_abc");
|
||||
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
|
||||
assert_eq!(from_object_name("²x"), "_xB2_x");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
|
||||
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -108,6 +108,8 @@ struct Expandable {
|
||||
placed: bool,
|
||||
/// Repeats (Object expands them into groups of their instances).
|
||||
repeats: bool,
|
||||
/// Intertwine groups (Object expands them into groups of what they draw).
|
||||
intertwines: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
@@ -123,6 +125,7 @@ impl Expandable {
|
||||
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
@@ -134,7 +137,7 @@ impl Expandable {
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
|
||||
}
|
||||
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
|
||||
// over the others, expand below.
|
||||
if object && e.intertwines {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
|
||||
if object && e.blends {
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::*;
|
||||
|
||||
|
||||
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
|
||||
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
|
||||
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
|
||||
/// contradict it). The image size limits are checked with the scale, as for an artboard.
|
||||
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
|
||||
let v = match p.get("rect") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
if !f.raster {
|
||||
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
|
||||
}
|
||||
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
|
||||
}
|
||||
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
|
||||
let Some(&[x, y, w, h]) = nums.as_deref() else {
|
||||
return Err(bad(C, "rect is [x, y, width, height] in points"));
|
||||
};
|
||||
let (x1, y1) = (x + w, y + h);
|
||||
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
|
||||
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
|
||||
}
|
||||
Ok(Some(Rect::new(x, y, x1, y1)))
|
||||
}
|
||||
|
||||
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
|
||||
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
|
||||
with_single_artboard(doc.clone(), rect, name)
|
||||
@@ -408,6 +434,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
// SVG reads its own `useArtboards` (an SVG option).
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
|
||||
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
|
||||
if let Some(rect) = page_rect(f, p)? {
|
||||
let mut q = super::export::without_artboards(p);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("rect");
|
||||
o.remove("useArtboards");
|
||||
}
|
||||
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
|
||||
}
|
||||
if use_artboards == Some(false) {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
let mut q = super::export::without_artboards(p);
|
||||
@@ -479,6 +514,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
|
||||
let bytes =
|
||||
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
|
||||
enc.files.push((Some(b), bytes));
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
|
||||
@@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
}
|
||||
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
|
||||
// closed, its top-level layers as its groups (#1475). Ours carry their own.
|
||||
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
|
||||
doc.layers_open = Some(vec![]);
|
||||
}
|
||||
// Save writes the file back only in a format it writes, only a whole PDF (see
|
||||
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
|
||||
// writes .ai that way).
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
|
||||
default: "null",
|
||||
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
|
||||
};
|
||||
const RECT: FormatOption = FormatOption {
|
||||
name: "rect",
|
||||
ty: "array",
|
||||
default: "null",
|
||||
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
|
||||
};
|
||||
const PPI: FormatOption = FormatOption {
|
||||
name: "ppi",
|
||||
ty: "number",
|
||||
@@ -348,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
|
||||
name: "embedIcc",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
|
||||
};
|
||||
const IMAGE_MAP: FormatOption = FormatOption {
|
||||
name: "imageMap",
|
||||
@@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
|
||||
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
|
||||
};
|
||||
const TIFF_OPTIONS: &[FormatOption] =
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
const BMP_COLOR_MODEL: FormatOption =
|
||||
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
|
||||
const BMP_DEPTH: FormatOption = FormatOption {
|
||||
@@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
|
||||
default: "24",
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const PSD_LAYERS: FormatOption = FormatOption {
|
||||
name: "layers",
|
||||
ty: "boolean",
|
||||
@@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
},
|
||||
Format {
|
||||
id: "jpg",
|
||||
@@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
},
|
||||
Format {
|
||||
id: "tiff",
|
||||
|
||||
@@ -374,3 +374,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
|
||||
open(&mut s, name, &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
|
||||
}
|
||||
|
||||
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
|
||||
/// open.
|
||||
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
|
||||
let st = s.doc().unwrap();
|
||||
let (mut rows, mut open) = (vec![], vec![]);
|
||||
st.doc.walk(|n| {
|
||||
if n.children().is_some() {
|
||||
rows.push(n.name.clone().unwrap_or_default());
|
||||
if st.layers_open.contains(n.id) {
|
||||
open.push(n.name.clone().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
});
|
||||
(rows, open)
|
||||
}
|
||||
|
||||
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
|
||||
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
|
||||
/// open as before (with their top-level layers open unless the file says otherwise).
|
||||
#[test]
|
||||
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
|
||||
let mut s = Session::new();
|
||||
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
|
||||
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
|
||||
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
|
||||
let (rows, open) = open_rows(&s);
|
||||
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
|
||||
assert_eq!(open, Vec::<String>::new(), "{r}");
|
||||
}
|
||||
// The same art as an EPS: its top-level layers open.
|
||||
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
|
||||
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
|
||||
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
|
||||
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
|
||||
}
|
||||
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
|
||||
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
|
||||
let mut s = session(40.0, 30.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
|
||||
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
|
||||
let on = png(&mut s, json!({"format": "png"}));
|
||||
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
|
||||
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
|
||||
assert!(png_chunk(&off, b"sRGB").is_none());
|
||||
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_the_raster_options() {
|
||||
let mut s = Session::new();
|
||||
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
// While another thread keeps exporting a document whose image can't be decoded (two exports at
|
||||
// once, or the canvas drawing such a document), PNG exports of art with no image report
|
||||
// nothing, and every export on the other thread still reports its own image.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
|
||||
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
|
||||
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im = ImageObject {
|
||||
key: "broken".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: vectorcraft_geom::Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
|
||||
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let mut s = session(400.0, 400.0, 1);
|
||||
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
|
||||
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
|
||||
let stop = AtomicBool::new(false);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// Nothing in the scope panics, so the other thread is always told to stop.
|
||||
let (theirs, ours) = std::thread::scope(|scope| {
|
||||
let other = scope.spawn(|| {
|
||||
let mut warned = vec![];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
|
||||
warned.push(w.is_ok_and(|w| says_so(&w)));
|
||||
let _ = tx.send(());
|
||||
}
|
||||
warned
|
||||
});
|
||||
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
|
||||
let ours: Vec<Option<bool>> = (0..5)
|
||||
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
|
||||
.collect();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
(other.join().ok(), started.then_some(ours))
|
||||
});
|
||||
let theirs = theirs.expect("the other thread's exports");
|
||||
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
|
||||
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let boards = s.doc().unwrap().doc.artboards.len();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
|
||||
let img = rgba(&b64(&r));
|
||||
assert_eq!((img.width(), img.height()), (40, 300));
|
||||
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
|
||||
// Twice the resolution, and a window that holds both art and empty page.
|
||||
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
|
||||
assert_eq!((img.width(), img.height()), (80, 20));
|
||||
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_refuses_what_cannot_be_exported() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let bad = [
|
||||
json!({"format": "png", "rect": [0, 0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, -5, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, "a", 10]}),
|
||||
json!({"format": "png", "rect": "20,40,40,300"}),
|
||||
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
|
||||
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("document.export", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -82,20 +82,42 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
|
||||
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
|
||||
let mut parts = vec![];
|
||||
let mut quoted = false;
|
||||
let mut start = 0;
|
||||
for (i, ch) in s.char_indices() {
|
||||
if ch == '"' {
|
||||
quoted = !quoted;
|
||||
} else if ch == sep && !quoted {
|
||||
parts.push(s.get(start..i).unwrap_or_default());
|
||||
start = i + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
parts.push(s.get(start..).unwrap_or_default());
|
||||
parts
|
||||
}
|
||||
|
||||
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
|
||||
fn cell(c: &str) -> Cell {
|
||||
let c = c.trim();
|
||||
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
|
||||
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
|
||||
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
|
||||
let rows: Vec<Vec<Cell>> = csv
|
||||
.lines()
|
||||
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
|
||||
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
|
||||
.into_iter()
|
||||
.map(|l| l.trim_end_matches('\r'))
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let sep = if l.contains('\t') { '\t' } else { ',' };
|
||||
l.split(sep)
|
||||
.map(|c| {
|
||||
let c = c.trim();
|
||||
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
|
||||
Cell { text: c.trim_matches('"').to_string(), quoted }
|
||||
})
|
||||
.collect()
|
||||
let tabs = split_unquoted(l, '\t');
|
||||
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
|
||||
parts.into_iter().map(cell).collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
|
||||
@@ -192,7 +214,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
|
||||
|
||||
fn fmt_value(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 {
|
||||
format!("{}", v.round() as i64)
|
||||
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
|
||||
// `{:.0}` prints its exact digits.
|
||||
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
|
||||
} else {
|
||||
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -1900,6 +1924,16 @@ mod tests {
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
#[test]
|
||||
fn fmt_value_does_not_saturate_beyond_i64() {
|
||||
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
|
||||
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
|
||||
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
|
||||
assert_eq!(super::fmt_value(42.0), "42");
|
||||
assert_eq!(super::fmt_value(-0.0), "0");
|
||||
assert_eq!(super::fmt_value(2.5), "2.5");
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, ty: &str) -> NodeId {
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
NodeId(
|
||||
@@ -2358,6 +2392,30 @@ mod tests {
|
||||
assert_eq!(cells, g.cells(), "{csv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
|
||||
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
|
||||
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
|
||||
let r = s
|
||||
.execute(
|
||||
"graph.create",
|
||||
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(before["series"], series);
|
||||
assert_eq!(before["categories"], categories);
|
||||
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
|
||||
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
|
||||
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
|
||||
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
|
||||
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
|
||||
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
|
||||
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
|
||||
//! `vectorcraft_doc::intertwine`).
|
||||
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
|
||||
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
|
||||
//!
|
||||
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
|
||||
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
|
||||
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
/// Replace Intertwine group `id` by a plain group of the art it draws
|
||||
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
|
||||
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
|
||||
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
|
||||
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
|
||||
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
|
||||
super::live::fix_ids(d, &mut g);
|
||||
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
|
||||
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
|
||||
/// many. A live Intertwine is expanded before it can be ungrouped:
|
||||
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
|
||||
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
|
||||
let mut groups = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
n.walk(&mut |c| {
|
||||
if is_intertwine(c) {
|
||||
groups.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for g in groups.iter().rev() {
|
||||
expand_group(d, *g)?;
|
||||
}
|
||||
Ok(groups.len())
|
||||
}
|
||||
|
||||
// ---------- Arrange ----------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
@@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let st = s.doc()?;
|
||||
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
|
||||
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
}
|
||||
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
// The last highlighted layer that is not inside another highlighted row: with a layer and its
|
||||
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
|
||||
// instead of becoming the target (#2383).
|
||||
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
|
||||
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
|
||||
let into = ids
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.filter(is_layer)
|
||||
.find(|id| roots.contains(id))
|
||||
.or_else(|| ids.iter().rev().copied().find(is_layer))
|
||||
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
|
||||
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
|
||||
}
|
||||
|
||||
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
|
||||
fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
if n.id == NodeId(0) {
|
||||
n.id = d.alloc_id();
|
||||
}
|
||||
|
||||
@@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
|
||||
@@ -103,6 +103,11 @@ impl PreviewMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
|
||||
fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0))
|
||||
}
|
||||
|
||||
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -125,6 +130,8 @@ pub struct DocSettings {
|
||||
pub bleed: [f64; 4],
|
||||
pub background: Background,
|
||||
pub color_mode: ColorMode,
|
||||
/// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_ppi")]
|
||||
pub raster_effects_ppi: f64,
|
||||
pub preview_mode: PreviewMode,
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.reorder";
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
|
||||
let i = artboard_index(&s.doc()?.doc, p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
s.edit("Reorder Artboards", |d, _| {
|
||||
if i >= d.artboards.len() {
|
||||
@@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
|
||||
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
|
||||
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
|
||||
/// An `index` that names no artboard is an error.
|
||||
fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> {
|
||||
let t = s.tool_options_of("artboard");
|
||||
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
|
||||
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
|
||||
let i = match p.get("index") {
|
||||
Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?,
|
||||
None => t["active"].as_u64().unwrap_or(0) as usize,
|
||||
};
|
||||
Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
|
||||
}
|
||||
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
|
||||
/// [`artboard_indices`] (`missing index`, `no such artboard`).
|
||||
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
|
||||
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
|
||||
}
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
@@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
||||
}
|
||||
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p);
|
||||
let (i, art) = board_and_art(s, p, "artboard.duplicate")?;
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
|
||||
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
|
||||
@@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
|
||||
/// then deletes them.
|
||||
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
|
||||
let (i, art) = board_and_art(s, p);
|
||||
let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
|
||||
let art = artboard_art(s, i, art)?;
|
||||
let st = s.doc()?;
|
||||
if cut && st.doc.artboards.len() <= 1 {
|
||||
|
||||
@@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
|
||||
/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out
|
||||
/// of the image, which then only holds what the effects add around it (a shadow to go under the
|
||||
/// vector object). The document's raster effects settings add room around it and finish its pixels.
|
||||
/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not
|
||||
/// positive, or when its PNG can't be encoded.
|
||||
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
|
||||
// What it paints (its members' effects included), and how far its fills' and strokes' raster
|
||||
// effects reach beyond that.
|
||||
@@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
let look = out.raster_effects.clone();
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let b = b.inflate(reach, reach);
|
||||
// An area too large for f64 gives a zero scale and an image transform that is not finite.
|
||||
let area = b.width() * b.height();
|
||||
if !area.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
// Anti-alias off: hard edges (the effects themselves stay smooth).
|
||||
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
|
||||
@@ -345,7 +345,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
|
||||
|
||||
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
|
||||
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
|
||||
/// its top-level layers open.
|
||||
/// its top-level layers open (a `.ai` another app wrote opens with none open).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OpenRows {
|
||||
ids: std::collections::HashSet<NodeId>,
|
||||
@@ -1007,7 +1007,7 @@ impl Default for Prefs {
|
||||
font_preview_size: s("medium"),
|
||||
recent_fonts_count: 10,
|
||||
missing_glyph_protection: true,
|
||||
highlight_alternate_glyphs: true,
|
||||
highlight_alternate_glyphs: false,
|
||||
placeholder_text: true,
|
||||
units_general: s("points"),
|
||||
units_stroke: s("points"),
|
||||
@@ -1198,6 +1198,8 @@ pub struct Session {
|
||||
pub(crate) plane_widget_press: bool,
|
||||
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
|
||||
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
|
||||
/// Which selected type overflows its frame, for the markers every tool but the Type tools shows.
|
||||
pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks,
|
||||
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
|
||||
/// starts; dropping it stops it.
|
||||
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
|
||||
@@ -1268,6 +1270,7 @@ impl Session {
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
ruler_guide: None,
|
||||
overflow_marks: Default::default(),
|
||||
font_search: None,
|
||||
link_search: None,
|
||||
search_rules: None,
|
||||
|
||||
@@ -151,6 +151,67 @@ fn release_gives_the_objects_back_as_they_were() {
|
||||
assert!(red(&render(&s), 50, 30));
|
||||
}
|
||||
|
||||
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
|
||||
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
|
||||
/// it apart.
|
||||
#[test]
|
||||
fn expand_turns_the_group_into_plain_art_that_ungroups() {
|
||||
let mut s = session();
|
||||
let (g, a, b) = intertwined(&mut s);
|
||||
arrange(&mut s, json!({"at": [50, 30]}));
|
||||
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
|
||||
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
|
||||
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
|
||||
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
|
||||
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
|
||||
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
|
||||
let mut ids = vec![];
|
||||
n.walk(&mut |c| ids.push(c.id));
|
||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
|
||||
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
|
||||
let r = render(&s);
|
||||
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
|
||||
// Plain art: Ungroup takes it apart.
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
|
||||
let r = render(&s);
|
||||
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
|
||||
// Undo brings the Intertwine group back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
|
||||
}
|
||||
|
||||
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
|
||||
/// outlines the strokes of the rings and of the part drawn over the other one.
|
||||
#[test]
|
||||
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
|
||||
let mut s = session();
|
||||
let (g, ..) = intertwined(&mut s);
|
||||
arrange(&mut s, json!({"at": [50, 30]}));
|
||||
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
|
||||
select(&mut s, &[g, c]);
|
||||
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
s.execute("object.expand", &json!({})).unwrap();
|
||||
let (mut intertwines, mut strokes) = (0, 0);
|
||||
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
|
||||
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
|
||||
strokes += usize::from(!n.appearance.stroke_paint().is_none());
|
||||
});
|
||||
assert_eq!((intertwines, strokes), (0, 0));
|
||||
let r = render(&s);
|
||||
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arranged_areas_move_with_the_group() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() {
|
||||
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(col).is_none());
|
||||
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
|
||||
// A layer and its sublayer: the sublayer merges into its layer, never the other way round.
|
||||
run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(sub).is_none());
|
||||
assert_eq!(children(&s, layer), vec![c, a, g]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
|
||||
// Flatten: other visible layers merge in, hidden ones go, templates stay.
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
@@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() {
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
}
|
||||
|
||||
/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel
|
||||
/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383).
|
||||
#[test]
|
||||
fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() {
|
||||
for reversed in [false, true] {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, _, _, c) = tree(&mut s);
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
let d = rect(&mut s, 500.0);
|
||||
// Panel order, top first: Layer 2, Layer 1, its sublayer.
|
||||
let mut rows = vec![l2.0, layer.0, sub.0];
|
||||
if reversed {
|
||||
rows.reverse();
|
||||
}
|
||||
run(&mut s, "layer.highlight", json!({"ids": rows}));
|
||||
let into = id(&run(&mut s, "layer.merge", json!({})));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}");
|
||||
assert_eq!(into, st.doc.layers[0].id);
|
||||
for n in [a, c, d] {
|
||||
assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}");
|
||||
}
|
||||
assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
|
||||
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
|
||||
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
|
||||
|
||||
@@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
|
||||
/// placing and listing read).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
|
||||
@@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
|
||||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
|
||||
}
|
||||
|
||||
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
|
||||
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
|
||||
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
|
||||
/// artboard.
|
||||
#[test]
|
||||
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let (was, n) = (boards(&s), undo_len(&s));
|
||||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
|
||||
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
|
||||
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
|
||||
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
|
||||
s.execute("artboard.fitToSelection", &json!({})).unwrap();
|
||||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
|
||||
}
|
||||
|
||||
// ---------- Edit Colors ----------
|
||||
|
||||
#[test]
|
||||
@@ -2423,6 +2448,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_checks_the_rgba_size_before_decoding() {
|
||||
// 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA
|
||||
// (4 bytes a pixel) they are just over or just within the 2 GB allowance.
|
||||
let trim = |w: u32, h: u32| {
|
||||
let png = super::tests_linkspanel::png_header(w, h);
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string()
|
||||
};
|
||||
let over = trim(23_171, 23_170);
|
||||
assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}");
|
||||
// One column fewer is within the allowance: it is decoded, and its missing rows can't be read.
|
||||
let within = trim(23_170, 23_170);
|
||||
assert!(within.contains("the image's pixels can't be read"), "{within}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||||
|
||||
@@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() {
|
||||
assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() {
|
||||
let mut s = Session::new();
|
||||
let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]});
|
||||
s.apply_prefs(serde_json::from_value(saved).unwrap());
|
||||
let ppi: Vec<Option<f64>> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect();
|
||||
assert_eq!(ppi, [Some(1.0), Some(2400.0)]);
|
||||
s.execute("file.new", &json!({"preset": "Zero"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rearrange_keeps_its_grid() {
|
||||
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
|
||||
|
||||
@@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() {
|
||||
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
|
||||
}
|
||||
|
||||
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
|
||||
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
|
||||
/// artboards, the art and the clipboard stay as they were.
|
||||
#[test]
|
||||
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
|
||||
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
|
||||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
|
||||
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
|
||||
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied");
|
||||
}
|
||||
|
||||
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
|
||||
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
|
||||
/// is one undo step, and the tool takes the pasted artboard.
|
||||
|
||||
@@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
("pen", Cursor::Pen),
|
||||
("addAnchor", Cursor::PenAdd),
|
||||
("deleteAnchor", Cursor::PenDelete),
|
||||
("anchorPoint", Cursor::Pen),
|
||||
("curvature", Cursor::Pen),
|
||||
("mesh", Cursor::PenAdd),
|
||||
("anchorPoint", Cursor::AnchorPoint),
|
||||
("curvature", Cursor::Curvature),
|
||||
("mesh", Cursor::MeshAdd),
|
||||
("eyedropper", Cursor::Eyedropper),
|
||||
("slice", Cursor::Slice),
|
||||
("blend", Cursor::Blend),
|
||||
("shapeBuilder", Cursor::ShapeBuilder),
|
||||
("paintbrush", Cursor::Paintbrush),
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("shaper", Cursor::Shaper),
|
||||
("pencil", Cursor::Pencil),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("eraser", Cursor::Eraser),
|
||||
("knife", Cursor::Knife),
|
||||
];
|
||||
for (tool, c) in own {
|
||||
assert_eq!(cursor(&mut s, tool), c, "{tool} off");
|
||||
@@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
"polygon",
|
||||
"star",
|
||||
"flare",
|
||||
"paintbrush",
|
||||
"blobBrush",
|
||||
"shaper",
|
||||
"pencil",
|
||||
"smooth",
|
||||
"pathEraser",
|
||||
"join",
|
||||
"eraser",
|
||||
"knife",
|
||||
"mirrorCut",
|
||||
"lineCut",
|
||||
"rectCut",
|
||||
@@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on");
|
||||
}
|
||||
assert_eq!(cursor(&mut s, "type"), Cursor::Text);
|
||||
assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed);
|
||||
assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
for (tool, c) in own {
|
||||
assert_eq!(cursor(&mut s, tool), c, "{tool} off again");
|
||||
@@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() {
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::AnchorPoint,
|
||||
Cursor::Curvature,
|
||||
] {
|
||||
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
|
||||
}
|
||||
@@ -1284,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() {
|
||||
assert!(s.prefs.font_names_in_english);
|
||||
}
|
||||
|
||||
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType
|
||||
/// substitutions are not pink on every canvas until the user turns the preference on.
|
||||
#[test]
|
||||
fn highlight_alternate_glyphs_preference_is_off_by_default() {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.prefs.highlight_alternate_glyphs, "off by default");
|
||||
set_pref(&mut s, "highlightAlternateGlyphs", json!(true));
|
||||
assert!(s.prefs.highlight_alternate_glyphs);
|
||||
set_pref(&mut s, "highlightAlternateGlyphs", json!(false));
|
||||
assert!(!s.prefs.highlight_alternate_glyphs);
|
||||
}
|
||||
|
||||
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
|
||||
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use super::*;
|
||||
@@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() {
|
||||
assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72));
|
||||
}
|
||||
|
||||
/// The transforms of the images in `doc` flattened for output.
|
||||
fn image_transforms(doc: &Document) -> Vec<[f64; 6]> {
|
||||
let mut xfs = vec![];
|
||||
crate::flatten_raster_effects(doc).unwrap().walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
xfs.push(im.xf.as_coeffs());
|
||||
}
|
||||
});
|
||||
xfs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_add_around_out_of_range_opens_with_it_in_range() {
|
||||
let mut s = session();
|
||||
shadowed(&mut s);
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
d.raster_effects.add_around = 1e300;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
|
||||
assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND);
|
||||
let xfs = image_transforms(&back);
|
||||
assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}");
|
||||
// 1,000 pt on every side at 72 ppi.
|
||||
assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000);
|
||||
d.raster_effects.add_around = -5.0;
|
||||
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_too_large_for_f64_gets_no_effect_image() {
|
||||
let mut s = session();
|
||||
let r = shadowed(&mut s);
|
||||
// Settings out of range in a document made without opening a file.
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
d.raster_effects.add_around = 1e300;
|
||||
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") };
|
||||
(*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None);
|
||||
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
|
||||
// Expand Appearance of it by id, with nothing selected, puts no image into the document (the
|
||||
// rectangle at its own size gets one).
|
||||
let normal = (*s.doc().unwrap().doc).clone();
|
||||
let mut expanded_images = |d: Document| {
|
||||
let st = s.doc_mut().unwrap();
|
||||
(st.doc, st.selection) = (std::sync::Arc::new(d), Default::default());
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let mut images = 0;
|
||||
s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
|
||||
images
|
||||
};
|
||||
assert_eq!(expanded_images(normal), 1);
|
||||
assert_eq!(expanded_images(d), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_color_model_and_anti_alias_reach_the_pixels() {
|
||||
let mut s = session();
|
||||
@@ -158,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() {
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default()));
|
||||
}
|
||||
|
||||
/// Save the active document with the raster effects resolution `ppi` and open the file.
|
||||
fn reopen_with_ppi(s: &mut Session, ppi: f64) {
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
d.raster_effects_ppi = ppi;
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
|
||||
s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() {
|
||||
for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] {
|
||||
let mut s = session();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
|
||||
reopen_with_ppi(&mut s, saved);
|
||||
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}");
|
||||
assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened);
|
||||
assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened);
|
||||
// Rasterize without a ppi param renders the 10 pt square at it.
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let out = s.execute("object.rasterize", &json!({})).unwrap();
|
||||
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
|
||||
for saved in [0.0, -5.0] {
|
||||
let mut s = session();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap();
|
||||
reopen_with_ppi(&mut s, saved);
|
||||
// EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi.
|
||||
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0);
|
||||
let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap();
|
||||
let mut sizes = vec![];
|
||||
vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
sizes.push((im.width, im.height));
|
||||
}
|
||||
});
|
||||
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
|
||||
}
|
||||
}
|
||||
@@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() {
|
||||
s.tool_text("d", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "abcd");
|
||||
}
|
||||
|
||||
/// The red out port of selected overflowing area type is drawn with the Selection tool too, and
|
||||
/// not twice with the Type tool, which draws it itself (#2323).
|
||||
#[test]
|
||||
fn overflow_marker_shows_with_the_selection_tool() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let long = "Words that fill more than the frame holds. ".repeat(20);
|
||||
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count();
|
||||
assert_eq!(red(&mut s), 1, "the out port");
|
||||
s.select_tool("type", v).unwrap();
|
||||
assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one");
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(red(&mut s), 0, "nothing selected");
|
||||
}
|
||||
@@ -399,6 +399,12 @@ impl Session {
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
|
||||
// The Type tools draw the overflow markers of selected type themselves.
|
||||
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
|
||||
let mut marks = std::mem::take(&mut self.overflow_marks);
|
||||
v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx)));
|
||||
self.overflow_marks = marks;
|
||||
}
|
||||
if let Some(g) = self.ruler_guide.take() {
|
||||
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
|
||||
self.ruler_guide = Some(g);
|
||||
|
||||
@@ -189,6 +189,11 @@ fn every_command_with_junk_params() {
|
||||
fn structured_junk() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})),
|
||||
("document.export", json!({"format": "png", "rect": [1e308, -1e308, 1e308, 1e308]})),
|
||||
("document.export", json!({"format": "png", "rect": [0, 0, 1e300, 1e300], "scale": 64})),
|
||||
("document.export", json!({"format": "tiff", "rect": [null, "x", -1, 0.0000001], "useArtboards": true})),
|
||||
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
|
||||
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
|
||||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||||
|
||||
@@ -102,7 +102,7 @@ fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo
|
||||
assert_eq!(node(&s, b), &before_b);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
|
||||
// Check the captured cursor last: a mismatch must not hide the actual conversion/history controls.
|
||||
assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join");
|
||||
assert_eq!(advertised, Cursor::AnchorPoint, "Alt cursor must describe the conversion actually performed, not Join");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -552,8 +552,11 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
// Untrusted: capped, named and pointing at objects the file has.
|
||||
doc.tidy_saved_selections();
|
||||
doc.tidy_graph_designs();
|
||||
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
|
||||
let mut depth_notes = vec![];
|
||||
doc.limit_depth_noting(&mut depth_notes);
|
||||
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
|
||||
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
|
||||
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() {
|
||||
fn bug_f64_bit_exact_roundtrip() {
|
||||
check_native_roundtrip_exact(&rich_doc()).unwrap();
|
||||
let mut d = Document::new(126.86291501015239, 214.69463130731182);
|
||||
d.raster_effects_ppi = 0.1 + 0.2;
|
||||
d.raster_effects_ppi = (0.1 + 0.2) * 1000.0;
|
||||
check_native_roundtrip_exact(&d).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1072,6 +1072,21 @@ fn show_font_names_in_english_does_not_change_font_list_for_agents() {
|
||||
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
|
||||
}
|
||||
|
||||
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so the canvas stays
|
||||
/// clear until an agent (or the Preferences dialog) turns it on. Headless MCP has no canvas.
|
||||
#[test]
|
||||
fn highlight_alternate_glyphs_pref_round_trips_for_agents() {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
|
||||
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.set", "params": {"key": "highlightAlternateGlyphs", "value": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(true));
|
||||
}
|
||||
|
||||
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
|
||||
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
|
||||
#[test]
|
||||
|
||||
@@ -494,7 +494,7 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s
|
||||
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
|
||||
}
|
||||
own.sort_by_key(|(b, _)| *b);
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round();
|
||||
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
use vectorcraft_color::cms::{self, ProfileKind};
|
||||
use vectorcraft_doc::marks::PrinterMarks;
|
||||
use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints};
|
||||
use vectorcraft_doc::overprint::{clear_overprints, darken_overprints};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
|
||||
pub use layout::{MAX_TILES, TileGrid};
|
||||
@@ -167,7 +167,7 @@ pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, D
|
||||
}
|
||||
|
||||
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
|
||||
/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any
|
||||
/// (drawn with Darken, as Overprint Preview shows them), after which nothing overprints any
|
||||
/// more; or preserved. Separations always honour them.
|
||||
fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
|
||||
let how = set.advanced.overprints;
|
||||
@@ -179,7 +179,7 @@ fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
|
||||
let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
|
||||
for n in nodes {
|
||||
if how == PrintOverprints::Simulate {
|
||||
multiply_overprints(n, discard_white);
|
||||
darken_overprints(n, discard_white);
|
||||
}
|
||||
clear_overprints(n);
|
||||
}
|
||||
@@ -411,7 +411,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfEr
|
||||
ex.intent = opts.settings.color.intent;
|
||||
// The writer's colour output: the printer profile's conversion.
|
||||
ex.out = w.out.clone();
|
||||
// Composite output keeps overprints as the Advanced option says (plates multiply them).
|
||||
// Composite output keeps overprints as the Advanced option says (plates darken them).
|
||||
ex.overprint = !separations && pdf.advanced.overprint == Overprint::Preserve;
|
||||
}
|
||||
for &i in &plan.order {
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
|
||||
use vectorcraft_doc::overprint::multiply_overprints;
|
||||
use vectorcraft_doc::overprint::darken_overprints;
|
||||
use vectorcraft_doc::{ColorMode, Document, Node};
|
||||
|
||||
use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings};
|
||||
@@ -93,14 +93,14 @@ impl<'a> Separator<'a> {
|
||||
}
|
||||
|
||||
/// The document as plate `plate`: art, symbols, pattern tiles and images in the grey of the
|
||||
/// ink's coverage. Overprinting fills and strokes multiply, so where they have none of this
|
||||
/// ink's coverage. Overprinting fills and strokes darken, so where they have none of this
|
||||
/// ink the plate below shows through instead of being knocked out.
|
||||
pub fn plate(&self, plate: &str) -> Document {
|
||||
let mut d = self.doc.clone();
|
||||
let discard_white = d.setup.discard_white_overprint;
|
||||
let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
|
||||
for n in nodes {
|
||||
multiply_overprints(n, discard_white);
|
||||
darken_overprints(n, discard_white);
|
||||
self.node(plate, Arc::make_mut(n));
|
||||
}
|
||||
for blob in d.images.values_mut() {
|
||||
|
||||
@@ -340,7 +340,7 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_
|
||||
assert_ne!(converted, flat(ink, 16));
|
||||
let source = cms::active();
|
||||
let dest = cms::Cms::new(&cms::ColorSettings { cmyk: DEVICE_CMYK.into(), ..source.settings().clone() }).unwrap();
|
||||
let want = dest.lab_to_cmyk(source.cmyk_to_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8);
|
||||
let want = dest.lab_to_cmyk(source.cmyk_display_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8);
|
||||
assert!(close(&converted[..4], &want, 3), "{converted:?} vs {want:?}");
|
||||
// To RGB: the colour shown on screen.
|
||||
let (w, blob, _) = first(json!({"output": {"conversion": "destination", "destination": SRGB}}));
|
||||
|
||||
@@ -191,6 +191,20 @@ fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() {
|
||||
assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_set_ems_apart_keep_their_place() {
|
||||
// Gaps of about 1.75 and 2.6 em (#2384): each space's own tracking is more than an em.
|
||||
let (x1, x2) = (48.0, 84.0);
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "ab"), (x1, 50.0, 10.0, "cd"), (x2, 50.0, 10.0, "ef")]);
|
||||
let t = texts(&import(&bytes).unwrap());
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].plain_text(), "ab cd ef");
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]);
|
||||
let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x;
|
||||
assert!((at(3) - f64::from(x1)).abs() < 0.05, "the second word starts where the file has it: {} vs {x1}", at(3));
|
||||
assert!((at(6) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(6));
|
||||
}
|
||||
|
||||
/// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30).
|
||||
fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document {
|
||||
let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
|
||||
@@ -98,13 +98,13 @@ fn discarded_and_simulated_overprints_write_no_overprint() {
|
||||
assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings);
|
||||
assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file");
|
||||
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
|
||||
for (how, multiplies) in [("discard", false), ("simulate", true)] {
|
||||
for (how, darkens) in [("discard", false), ("simulate", true)] {
|
||||
let set = json!({"advanced": {"overprints": how}});
|
||||
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
|
||||
let r = job(&d, set);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
|
||||
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
|
||||
assert_eq!(text.contains("/Darken"), darkens, "{how}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
|
||||
}
|
||||
// Separations honour overprints whatever the option says.
|
||||
|
||||
@@ -153,7 +153,7 @@ pub(crate) struct InkLut(CmykLut);
|
||||
impl InkLut {
|
||||
fn build(cms: &Cms) -> Self {
|
||||
Self(CmykLut::build(|inks| {
|
||||
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks));
|
||||
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(inks));
|
||||
[x, y, z, 0.0]
|
||||
}))
|
||||
}
|
||||
|
||||
+44
-16
@@ -123,6 +123,10 @@ pub struct RenderOptions {
|
||||
pub mask_view: Option<NodeId>,
|
||||
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
|
||||
pub highlight_substitutions: bool,
|
||||
/// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType
|
||||
/// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph
|
||||
/// highlights.
|
||||
pub highlight_alternate_glyphs: bool,
|
||||
/// Edge smoothing (raster export option).
|
||||
pub anti_alias: AntiAlias,
|
||||
/// Screen view: placed documents draw from cached bitmaps made in the background (see
|
||||
@@ -227,6 +231,7 @@ impl Default for RenderOptions {
|
||||
guide: ([0x4a, 0xd8, 0xff], false),
|
||||
mask_view: None,
|
||||
highlight_substitutions: false,
|
||||
highlight_alternate_glyphs: false,
|
||||
anti_alias: AntiAlias::Art,
|
||||
progressive_placed: false,
|
||||
trace_views: false,
|
||||
@@ -1768,12 +1773,12 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
let tb = t.xf.transform_rect_bbox(g.bounds);
|
||||
// Overprinting characters multiply per draw, like overprinting fills and strokes
|
||||
// Overprinting characters darken per draw, like overprinting fills and strokes
|
||||
// ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer.
|
||||
let overprints = proof::overprints(f.opts);
|
||||
let overprint = |ctx: &mut RenderContext, on: bool| {
|
||||
if overprints {
|
||||
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
|
||||
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Darken) } else { BlendMode::default() });
|
||||
}
|
||||
};
|
||||
// The object's own fills and strokes paint the whole outline: those below the Characters
|
||||
@@ -1789,8 +1794,11 @@ impl Renderer {
|
||||
}
|
||||
if f.opts.highlight_substitutions {
|
||||
let setup = &f.doc.setup;
|
||||
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
|
||||
{
|
||||
for (on, path) in [
|
||||
(setup.highlight_substituted_fonts, &g.substituted_fonts),
|
||||
(setup.highlight_substituted_glyphs, &g.substituted_glyphs),
|
||||
(f.opts.highlight_alternate_glyphs, &g.alternate_glyphs),
|
||||
] {
|
||||
if on && !path.elements().is_empty() {
|
||||
ctx.set_transform(xf);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
@@ -2218,6 +2226,9 @@ struct TextGeom {
|
||||
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
|
||||
substituted_fonts: BezPath,
|
||||
substituted_glyphs: BezPath,
|
||||
/// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences ›
|
||||
/// Highlight Alternate Glyphs.
|
||||
alternate_glyphs: BezPath,
|
||||
/// Inline graphics placed by the layout.
|
||||
inlines: Vec<vectorcraft_text::InlineGlyph>,
|
||||
}
|
||||
@@ -2243,12 +2254,18 @@ impl TextGeom {
|
||||
/// Approximate heap bytes, for the cache's budget.
|
||||
fn bytes(&self) -> usize {
|
||||
size_of::<Self>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ self.spans.len() * size_of::<(usize, usize, usize)>()
|
||||
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose
|
||||
/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate).
|
||||
pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool {
|
||||
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
|
||||
}
|
||||
|
||||
fn text_geom(t: &TextObject) -> TextGeom {
|
||||
text_geom_snapped(t, None)
|
||||
}
|
||||
@@ -2288,30 +2305,39 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new());
|
||||
let plain = t.plain_text();
|
||||
for g in &layout.glyphs {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
let Some(&(missing, face)) = faces.get(g.run) else { continue };
|
||||
let target = if missing {
|
||||
&mut substituted_fonts
|
||||
} else if face.is_some_and(|f| f != g.font_id) {
|
||||
&mut substituted_glyphs
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(line) = layout.lines.get(g.line) else { continue };
|
||||
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
|
||||
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
|
||||
target.extend(cell.iter());
|
||||
if missing {
|
||||
substituted_fonts.extend(cell.iter());
|
||||
continue;
|
||||
}
|
||||
if face.is_some_and(|f| f != g.font_id) {
|
||||
substituted_glyphs.extend(cell.iter());
|
||||
continue;
|
||||
}
|
||||
// Same face: OpenType may have swapped the glyph (liga, calt, …).
|
||||
let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue };
|
||||
let cluster_chars = cluster.chars().count();
|
||||
let Some(first) = cluster.chars().next() else { continue };
|
||||
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
|
||||
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
|
||||
alternate_glyphs.extend(cell.iter());
|
||||
}
|
||||
}
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
push(&mut all, run, &bar);
|
||||
}
|
||||
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
|
||||
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines }
|
||||
}
|
||||
|
||||
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
|
||||
/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only).
|
||||
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
|
||||
/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1).
|
||||
pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) {
|
||||
@@ -2367,6 +2393,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_adjust;
|
||||
#[cfg(test)]
|
||||
mod tests_alternate;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
|
||||
+32
-14
@@ -56,27 +56,45 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
|
||||
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
|
||||
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// How many images have failed to decode so far (process-wide): an export compares the count
|
||||
/// before and after to say it left images out.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
thread_local! {
|
||||
/// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to
|
||||
/// report.
|
||||
static DECODE_FAILURES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
/// How many images have failed to decode on this thread so far: an export compares the count
|
||||
/// before and after it draws to say it left images out. Images that other threads (another export,
|
||||
/// the canvas, thumbnails) fail to decode meanwhile are not counted.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted
|
||||
/// in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let img = decode_within_allowance(bytes).ok();
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its
|
||||
/// header first, and an image of more than that as RGBA gets a limit error before anything is
|
||||
/// decoded (#1717).
|
||||
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
|
||||
let reader = || -> image::ImageResult<_> {
|
||||
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(r)
|
||||
};
|
||||
let (w, h) = reader()?.into_dimensions()?;
|
||||
if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 {
|
||||
return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory)));
|
||||
}
|
||||
img
|
||||
reader()?.decode()
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! Placed raster images are not separated (limitation).
|
||||
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
|
||||
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
|
||||
//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the
|
||||
//! Darken, which approximates their inks printing over the inks below: a zero ink lets the
|
||||
//! inks below show through, where a knockout would replace them.
|
||||
//!
|
||||
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
|
||||
@@ -25,7 +25,7 @@ use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
|
||||
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
|
||||
use vectorcraft_color::{Color, Swatch, SwatchGroup};
|
||||
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
|
||||
use vectorcraft_doc::overprint::multiply_overprints;
|
||||
use vectorcraft_doc::overprint::darken_overprints;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Shared};
|
||||
|
||||
use crate::RenderOptions;
|
||||
@@ -103,8 +103,8 @@ impl PartialEq for Plates {
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
|
||||
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
|
||||
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones darken
|
||||
/// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates
|
||||
/// print it ([`plate_color`]).
|
||||
struct How<'a> {
|
||||
doc: &'a Document,
|
||||
@@ -122,7 +122,7 @@ impl How<'_> {
|
||||
|
||||
/// Change the fills, strokes and other colors of node `a`, which holds no members.
|
||||
fn apply(&self, a: &mut Arc<Node>) {
|
||||
multiply_overprints(a, self.discard_white);
|
||||
darken_overprints(a, self.discard_white);
|
||||
if let Some((c, proof, visible)) = self.plates {
|
||||
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
|
||||
}
|
||||
|
||||
@@ -776,6 +776,23 @@ fn invalid_dash_pattern_strokes_solid() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An image that can't be decoded is counted on the thread that tried to decode it: an export
|
||||
/// compares [`decode_failures`] before and after it draws, so another thread's failure meanwhile is
|
||||
/// not its own.
|
||||
#[test]
|
||||
fn decode_failures_counts_this_threads_images_only() {
|
||||
let undecodable = || paint::decode_pixmap(b"\x89PNG\r\n\x1a\n").is_none();
|
||||
let before = decode_failures();
|
||||
let other = std::thread::spawn(move || {
|
||||
let start = decode_failures();
|
||||
undecodable() && decode_failures() == start + 1
|
||||
});
|
||||
assert!(other.join().unwrap(), "counted on the other thread");
|
||||
assert_eq!(decode_failures(), before, "the other thread's failure");
|
||||
assert!(undecodable());
|
||||
assert_eq!(decode_failures(), before + 1, "this thread's failure");
|
||||
}
|
||||
|
||||
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
|
||||
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
|
||||
/// Anti-aliased Images off, #394).
|
||||
@@ -799,6 +816,73 @@ fn images_take_their_nearest_pixel_without_smoothing() {
|
||||
assert_eq!(greys(false), 0, "nearest: black, then white");
|
||||
}
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
out.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||||
out.extend(chunk);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(chunk);
|
||||
out.extend(crc.sum().to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and
|
||||
/// no pixels).
|
||||
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
// (tag, type: 3 SHORT or 4 LONG, value), one value each.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a
|
||||
/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded
|
||||
/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding.
|
||||
#[test]
|
||||
fn an_image_too_large_as_rgba_is_not_decoded() {
|
||||
let over = paint::decode_within_allowance(&png_header(23_171, 23_170));
|
||||
assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}");
|
||||
let failed = decode_failures();
|
||||
assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none());
|
||||
assert!(decode_failures() > failed);
|
||||
// Within the allowance: decoded, and its missing rows can't be read.
|
||||
let within = paint::decode_within_allowance(&png_header(23_170, 23_170));
|
||||
assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}");
|
||||
// A header whose size as RGBA does not fit in 64 bits gets the same limit error.
|
||||
let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31));
|
||||
assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}");
|
||||
}
|
||||
|
||||
/// A gray image within the allowance draws its gray levels.
|
||||
#[test]
|
||||
fn a_gray_image_draws_its_gray_levels() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.images.insert("gray".into(), ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
|
||||
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() });
|
||||
assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255]));
|
||||
}
|
||||
|
||||
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
|
||||
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
|
||||
/// edges stay smooth.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Preferences › Type › Highlight Alternate Glyphs (#394): OpenType substitutions get a pink
|
||||
//! cell behind them on screen when the preference is on.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_opentype_alternate_marks_ligatures_and_forms() {
|
||||
assert!(is_opentype_alternate(100, 100, 2), "ligature cluster");
|
||||
assert!(is_opentype_alternate(50, 99, 1), "contextual / stylistic form");
|
||||
assert!(!is_opentype_alternate(50, 50, 1), "nominal cmap glyph");
|
||||
assert!(!is_opentype_alternate(50, 0, 2), "missing glyph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
// Source Serif 4 ships with standard ligatures for "fi".
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let style = CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 70.0), "fi", style))));
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
|
||||
let off = RenderOptions {
|
||||
background: Some([255, 255, 255, 255]),
|
||||
highlight_substitutions: true,
|
||||
highlight_alternate_glyphs: false,
|
||||
..Default::default()
|
||||
};
|
||||
let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() };
|
||||
let mut r = Renderer::new();
|
||||
let without = r.render(&d, 200, 100, Affine::IDENTITY, &off);
|
||||
let with = r.render(&d, 200, 100, Affine::IDENTITY, &on);
|
||||
assert_ne!(without.pixels, with.pixels, "the preference changes the frame");
|
||||
// Pink SUBSTITUTED = (255, 120, 190) at alpha 110, blended over white / ink.
|
||||
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
|
||||
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
|
||||
}
|
||||
@@ -261,3 +261,22 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() {
|
||||
add(&mut d, n);
|
||||
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
|
||||
}
|
||||
|
||||
/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint
|
||||
/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour.
|
||||
#[test]
|
||||
fn overprint_of_the_same_colour_shows_no_darker_band() {
|
||||
for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] {
|
||||
let mut d = Document::new_with_mode(40.0, 40.0, mode);
|
||||
let mut n =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0));
|
||||
for it in &mut n.appearance.items {
|
||||
*it.overprint_mut() = true;
|
||||
}
|
||||
add(&mut d, n);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() };
|
||||
let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts);
|
||||
// (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill.
|
||||
assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20));
|
||||
}
|
||||
}
|
||||
@@ -1535,7 +1535,8 @@ fn proof_previews_draw_changes_as_a_new_renderer_does() {
|
||||
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
|
||||
let lab_doc = || {
|
||||
let mut d = proof_doc();
|
||||
let lab = Color::lab(50.0, 60.0, 40.0);
|
||||
// Outside the press gamut, so its CMYK alternate looks different from the Lab colour.
|
||||
let lab = Color::lab(50.0, 90.0, 70.0);
|
||||
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
|
||||
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
|
||||
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
|
||||
|
||||
@@ -705,7 +705,7 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::LivePaintBucket
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
# The cursors VectorCraft needs: one [[cursor]] per `Cursor` value (crates/tools/src/lib.rs).
|
||||
#
|
||||
# This is the source of truth for what each cursor is for, written for whoever draws them:
|
||||
# designers never decide what a cursor is for, this file does. `cargo xtask cursors` warns about
|
||||
# a `Cursor` value without an entry. Adding a cursor: CLAUDE.md › Adding a cursor.
|
||||
#
|
||||
# app the app's name as designers read it
|
||||
# id the `Cursor` variant's name (PenAdd), which names its drawing
|
||||
# group the heading it's listed under for designers
|
||||
# shown_when every situation that shows it, one plain sentence each
|
||||
# hotspot where the click lands
|
||||
# look optional: only what the drawing must mean or match (a badge, a sibling cursor).
|
||||
# Never describe another app's cursors; describe what ours must say.
|
||||
# designer optional, default true: false for a cursor the operating system draws on purpose
|
||||
# (nothing to draw)
|
||||
#
|
||||
# Wording: plain sentences for a designer, behaviour only. `cargo xtask brands` rejects vendor
|
||||
# names here.
|
||||
|
||||
app = "VectorCraft"
|
||||
|
||||
# ── Selection ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Arrow"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool over empty canvas or over an unselected object (no Alt).",
|
||||
"Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).",
|
||||
"Any tool with no cursor of its own (Touch Type, Rotate View).",
|
||||
"The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin."
|
||||
|
||||
[[cursor]]
|
||||
id = "ArrowHollow"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Direct Selection at rest: away from anchors and curved segments, and over handles and fills.",
|
||||
"The pen-family tools with Cmd/Ctrl held (which lends Direct Selection).",
|
||||
"While a Selection or Direct Selection drag snaps to a point or a guide (the solid arrow turns hollow).",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The same pointer as Arrow, hollow: white with a dark outline."
|
||||
|
||||
[[cursor]]
|
||||
id = "DirectAnchor"
|
||||
group = "Selection"
|
||||
shown_when = ["Direct Selection over an anchor of a path that isn't selected."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a small hollow square badge (an unselected anchor)."
|
||||
|
||||
[[cursor]]
|
||||
id = "DirectAnchorSelected"
|
||||
group = "Selection"
|
||||
shown_when = ["Direct Selection over an anchor of a selected path."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a small filled square badge (a selected anchor)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ReshapeSegment"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Direct Selection over a curved segment of a path that isn't selected as a whole.",
|
||||
"The Anchor Point tool over a segment, and while it reshapes one.",
|
||||
"The Pen with Alt held over a segment of a selected path, and while it reshapes one.",
|
||||
]
|
||||
hotspot = "The arrowhead's tip."
|
||||
look = "Says \"a drag bends this segment\": an arrowhead with a small curve between two anchor marks."
|
||||
|
||||
[[cursor]]
|
||||
id = "GroupSelection"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Group Selection tool: each click selects the next enclosing group.",
|
||||
"Direct Selection with Alt held (Alt makes it Group Selection for the next click).",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a plus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "SelectionCopy"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Alt-dragging a copy with the Selection tool (the drag leaves the original behind).",
|
||||
"Alt-dragging a copy with Direct Selection, dragging a path whole (by its fill, or by an anchor of a path selected whole).",
|
||||
]
|
||||
hotspot = "The front arrowhead's tip."
|
||||
look = "Says \"a copy\": a double arrowhead, one in front of the other."
|
||||
|
||||
[[cursor]]
|
||||
id = "Move"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).",
|
||||
"Reshape over a path or while dragging.",
|
||||
"Inside the Free Transform and Crop Image boxes.",
|
||||
"Puppet Warp over a pin.",
|
||||
"The Mesh tool over a mesh point or handle.",
|
||||
"The Artboard tool inside an artboard.",
|
||||
"The Gradient tool over the annotator's handles and during most gradient drags.",
|
||||
"The Perspective Grid and Perspective Selection over plane widgets and handles.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "A four-way move cursor."
|
||||
|
||||
[[cursor]]
|
||||
id = "CornerRadius"
|
||||
group = "Selection"
|
||||
shown_when = ["Selection and Direct Selection (not Group Selection) over a Live Corners widget, and while dragging one (rounding the corners)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a rounded-corner badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PathBracket"
|
||||
group = "Selection"
|
||||
shown_when = ["Selection and Direct Selection over a bracket of selected type on a path, or dragging one (the type slides along its path)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a bracket badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "TypeWidget"
|
||||
group = "Selection"
|
||||
shown_when = ["The Selection tool over the type widget of selected type (a double-click converts point type to area type and back)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a type badge (a letter T)."
|
||||
|
||||
# ── Pen family ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Pen"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen tool where a click starts a new path.",
|
||||
"The Curvature tool away from its points.",
|
||||
"With Use Precise Cursors on, the crosshair shows instead.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "A pen nib. Every Pen-family cursor is this nib with a different badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenDrawing"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen tool while a path is being drawn, where a click adds the path's next point.",
|
||||
"The close, join and convert badges still show over the points they act on.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with no badge."
|
||||
|
||||
[[cursor]]
|
||||
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 Curvature tool over a segment.",
|
||||
"Stands in for MeshAdd and PuppetPin until they're drawn.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a plus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenDelete"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen over an anchor of a selected path (Auto Add/Delete), or over a blend-spine point.",
|
||||
"The Delete Anchor Point tool everywhere (and the Add Anchor Point tool with Alt).",
|
||||
"The Mesh tool with Alt over a mesh point.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a minus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenClose"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen while drawing, over the first anchor of the path (a click closes it).",
|
||||
"The Curvature tool over its first point (three points or more), or near the other end of the current open path.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a ring badge (closing the loop)."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenContinue"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen (not drawing) over an end of an open path (a click continues it).",
|
||||
"The Curvature tool over an open end.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a slash badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenJoin"
|
||||
group = "Pen"
|
||||
shown_when = ["The Pen while drawing, over an end of another open path (a click joins the two)."]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a badge showing a line running into an end point."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenConvert"
|
||||
group = "Pen"
|
||||
shown_when = ["The Pen while drawing, over the last anchor (a click retracts its outgoing handle). With Alt the Pen shows AnchorPoint and ReshapeSegment instead."]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a caret badge (like the AnchorPoint caret)."
|
||||
|
||||
[[cursor]]
|
||||
id = "AnchorPoint"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Anchor Point tool, except over a segment (ReshapeSegment shows there).",
|
||||
"The Pen with Alt held, over an anchor or a handle end of a selected path.",
|
||||
]
|
||||
hotspot = "The anchor mark at the caret's apex."
|
||||
look = "A caret (an upside-down V) with a small anchor square at its apex."
|
||||
|
||||
[[cursor]]
|
||||
id = "Curvature"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.",
|
||||
"Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a curve badge."
|
||||
|
||||
# ── Type ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Text"
|
||||
group = "Type"
|
||||
shown_when = [
|
||||
"Every horizontal type tool: Type, Area Type and Type on a Path (the Area and Path ones show the crosshair over a suitable path).",
|
||||
"Stands in for TypeVertical until it's drawn.",
|
||||
]
|
||||
hotspot = "The I-beam's centre."
|
||||
look = "An I-beam with a short crossbar marking the baseline."
|
||||
|
||||
[[cursor]]
|
||||
id = "TypeVertical"
|
||||
group = "Type"
|
||||
shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path (the Area and Path ones show the crosshair over a suitable path)."]
|
||||
hotspot = "The I-beam's centre."
|
||||
look = "The Text I-beam turned a quarter turn."
|
||||
|
||||
# ── Transform and resize ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Crosshair"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The generic drawing cursor: all shape, line, grid, flare, cut, graph and liquify tools, and Place.",
|
||||
"The Rotate, Reflect, Scale and Shear tools with a selection (Reflect, Scale and Shear even mid-drag).",
|
||||
"The Area Type and Type on a Path tools over a suitable path.",
|
||||
"The Artboard tool outside every artboard; the Gradient tool away from the annotator; Free Transform corners in Distort or Perspective mode.",
|
||||
"With Use Precise Cursors on, it replaces the Pen, Eyedropper, Slice, Blend and Shape Builder cursors.",
|
||||
"Stands in for every drawing tool cursor not drawn yet.",
|
||||
]
|
||||
hotspot = "The centre of the open gap."
|
||||
look = "Four arms around an open centre, no dot, so the art under the hotspot stays visible."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeH"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The left and right handles of the bounding box (or whichever handle reads as left/right once the box is rotated) with Selection, Free Transform, Artboard, Slice Selection and Crop Image.",
|
||||
"Over or dragging a vertical ruler guide.",
|
||||
"Perspective Grid extent handles and near-horizontal Perspective Selection handles.",
|
||||
"The freeform gradient's spread ring.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "A horizontal double arrow. ResizeV, ResizeNwSe and ResizeNeSw are the same arrow turned."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeV"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top and bottom handles of the same boxes as ResizeH.",
|
||||
"Over or dragging a horizontal ruler guide.",
|
||||
"The Perspective Grid's horizon, height and cell-size handles, and near-vertical Perspective Selection handles.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, vertical."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeNwSe"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top-left and bottom-right corner handles of the same boxes as ResizeH (rotation-adjusted).",
|
||||
"Perspective Selection handles at 112.5° to 157.5°.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, diagonal from top-left to bottom-right."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeNeSw"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top-right and bottom-left corner handles of the same boxes as ResizeH (rotation-adjusted).",
|
||||
"Perspective Selection handles at 22.5° to 67.5°.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, diagonal from bottom-left to top-right."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShearH"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"Free Transform with Cmd/Ctrl held over the top or bottom handle of the bounding box, and while that drag shears the object sideways.",
|
||||
"Without Cmd/Ctrl the same handles show ResizeV.",
|
||||
]
|
||||
hotspot = "The centre, between the two arrows."
|
||||
look = "Two single arrows side by side, one above the other, pointing opposite ways (left and right): one side sliding past the other."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShearV"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"Free Transform with Cmd/Ctrl held over the left or right handle of the bounding box, and while that drag shears the object up or down.",
|
||||
"Without Cmd/Ctrl the same handles show ResizeH.",
|
||||
]
|
||||
hotspot = "The centre, between the two arrows."
|
||||
look = "The ShearH arrows turned upright."
|
||||
|
||||
[[cursor]]
|
||||
id = "Rotate"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The Rotate tool while dragging.",
|
||||
"The Gradient tool over its rotate zone, or rotating.",
|
||||
"Puppet Warp with Alt near a selected pin.",
|
||||
"The bounding-box rotate cursor when the pointer is exactly on the box's centre.",
|
||||
]
|
||||
hotspot = "Below the arc, in its empty middle."
|
||||
look = "An arc with an arrowhead at each end. RotateToward is the same arc, turned."
|
||||
|
||||
[[cursor]]
|
||||
id = "RotateToward"
|
||||
group = "Transform and resize"
|
||||
shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."]
|
||||
hotspot = "Below the arc, in its empty middle, as for Rotate."
|
||||
look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at."
|
||||
|
||||
# ── Navigation ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Hand"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Hand tool, or Space held with any tool (not with Cmd/Ctrl): ready to pan."]
|
||||
hotspot = "The middle of the hand."
|
||||
look = "An open hand. HandGrab is the same hand closed."
|
||||
|
||||
[[cursor]]
|
||||
id = "HandGrab"
|
||||
group = "Navigation"
|
||||
shown_when = ["While panning: a Hand tool drag, a Space drag, or a middle-button drag with any tool."]
|
||||
hotspot = "The middle of the hand, as for Hand."
|
||||
look = "The Hand, closed (grabbing)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ZoomIn"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space with any tool: a click zooms in."]
|
||||
hotspot = "The lens centre."
|
||||
look = "A magnifier with a plus in its lens."
|
||||
|
||||
[[cursor]]
|
||||
id = "ZoomOut"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space, with Alt held: a click zooms out."]
|
||||
hotspot = "The lens centre."
|
||||
look = "The ZoomIn magnifier with a minus in its lens."
|
||||
|
||||
[[cursor]]
|
||||
id = "NotAllowed"
|
||||
group = "Navigation"
|
||||
shown_when = [
|
||||
"The Rotate, Reflect, Scale and Shear tools with nothing selected.",
|
||||
"Puppet Warp off the mesh, where no pin can go.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
designer = false
|
||||
|
||||
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Eyedropper"
|
||||
group = "Colour and gradient"
|
||||
shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."]
|
||||
hotspot = "The pipette's tip."
|
||||
look = "A pipette."
|
||||
|
||||
[[cursor]]
|
||||
id = "AddStop"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Gradient tool over the annotator's bar (a click adds a stop).",
|
||||
"The freeform gradient over its line or the art.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a plus badge. RemoveStop is the same with a minus."
|
||||
|
||||
[[cursor]]
|
||||
id = "RemoveStop"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Gradient tool while a stop is dragged off the bar (releasing deletes it).",
|
||||
"The freeform gradient while a point is dragged out.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a minus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "MeshAdd"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Mesh tool over a mesh or a path, off a mesh point: a click adds a mesh point (turning a path into a mesh).",
|
||||
"Elsewhere the Mesh tool shows PenDelete (Alt over a mesh point) and Move (over a point or handle).",
|
||||
]
|
||||
hotspot = "Where the mesh point goes."
|
||||
|
||||
[[cursor]]
|
||||
id = "LivePaintBucket"
|
||||
group = "Colour and gradient"
|
||||
shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."]
|
||||
hotspot = "Where the paint pours out."
|
||||
|
||||
# ── Width, blend, slice and shape builder ─────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Width"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool away from strokes."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a lens badge (a stroke that swells in the middle). WidthAdd and WidthPoint build on it."
|
||||
|
||||
[[cursor]]
|
||||
id = "WidthAdd"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool over a stroke, away from its width points (a drag adds one)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The Width cursor with a plus."
|
||||
|
||||
[[cursor]]
|
||||
id = "WidthPoint"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool over a width point or a handle end, and during any Width drag."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The Width cursor with a mark across the swell (the width being dragged)."
|
||||
|
||||
[[cursor]]
|
||||
id = "Blend"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool away from art. With Use Precise Cursors on, the crosshair shows instead."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a hollow square badge. BlendObject and BlendAnchor change the badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlendObject"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool over an object it can blend (away from its anchors)."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a filled square badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlendAnchor"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool over an anchor point (the blend starts there)."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a target badge (a ring with a centre dot)."
|
||||
|
||||
[[cursor]]
|
||||
id = "Slice"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Slice tool, always. With Use Precise Cursors on, the plain crosshair shows instead."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a blade badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "SliceSelect"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Slice Selection tool away from the selected slice's handles."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a small slice badge (a rectangle cut by a line)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShapeBuilder"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Shape Builder tool (merge mode), and during a drag that started without Alt."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a plus badge. ShapeBuilderErase is the same with a minus."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShapeBuilderErase"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Shape Builder with Alt held (erase mode), and during a drag that started with Alt."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a minus badge."
|
||||
|
||||
# ── Drawing and editing tools ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Pencil"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."]
|
||||
hotspot = "The lead tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Paintbrush"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."]
|
||||
hotspot = "The bristle tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlobBrush"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "Eraser"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."]
|
||||
hotspot = "The eraser's centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "Smooth"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Smooth tool: a drag along a selected path smooths it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "PathEraser"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Path Eraser tool: a drag along a selected path erases that part of it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Join"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Join tool: scrubbing across the gap or overlap between open path ends joins them."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Knife"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Knife tool: a freehand drag cuts objects along the line."]
|
||||
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."]
|
||||
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."]
|
||||
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."
|
||||
|
||||
[[cursor]]
|
||||
id = "Shaper"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Shaper tool: a rough drawn shape becomes a clean live shape (rectangle, ellipse, polygon, line); scribbling over art deletes it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Lasso"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it."]
|
||||
hotspot = "Where the loop is drawn from."
|
||||
|
||||
[[cursor]]
|
||||
id = "MagicWand"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Magic Wand tool: a click selects objects like the one clicked (per the Magic Wand options); Shift adds, Alt subtracts."]
|
||||
hotspot = "The wand's tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "PuppetPin"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = [
|
||||
"Puppet Warp over the selection's mesh where a pin can go: a click adds a pin.",
|
||||
"Elsewhere Puppet Warp shows NotAllowed (off the mesh), Move (over a pin) and Rotate (Alt near a selected pin).",
|
||||
]
|
||||
hotspot = "Where the pin goes."
|
||||
|
||||
[[cursor]]
|
||||
id = "Measure"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Measure tool: drag to measure distance and angle."]
|
||||
hotspot = "Exact, because precision matters here (an open centre suits it)."
|
||||
|
||||
[[cursor]]
|
||||
id = "PrintTiling"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."]
|
||||
hotspot = "The page corner being placed."
|
||||
|
||||
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
|
||||
# The brush size is drawn by the app as a circle around the cursor.
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSprayer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Sprayer: a drag sprays instances of the current symbol; Alt reverses it."]
|
||||
hotspot = "The spray centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolShifter"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Shifter: a drag pushes symbol instances along; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolScruncher"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Scruncher: a drag pulls instances together; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSizer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Sizer: a drag enlarges instances; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSpinner"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Spinner: a drag rotates instances in its direction; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolStainer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Stainer: a drag tints instances toward the fill colour; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolScreener"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Screener: a drag makes instances more transparent; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolStyler"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Styler: a drag applies the chosen graphic style to instances; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
# ── Not needed ────────────────────────────────────────────────────────────────────────────────────
|
||||
# Tools that deliberately have no cursor of their own (listed for designers so nobody draws one).
|
||||
|
||||
[[not_needed]]
|
||||
what = "Liquify tools (Warp, Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle)"
|
||||
why = "They show the Crosshair, and the app draws the brush's real size and tilt as an outline; a fixed-size cursor would disagree with it."
|
||||
|
||||
[[not_needed]]
|
||||
what = "Shape, line, grid, flare, graph, cut and gradient tools"
|
||||
why = "The generic Crosshair is the intended drawing cursor for them."
|
||||
+171
-3
@@ -33,7 +33,7 @@ use std::borrow::Cow;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
@@ -122,6 +122,16 @@ enum State {
|
||||
},
|
||||
}
|
||||
|
||||
/// How far (pt) a snap must move a dragged point for the arrow to say it snapped.
|
||||
const SNAPPED: f64 = 1e-9;
|
||||
|
||||
/// Whether a drag step snapped: the snapped move `d` left where the pointer alone put it
|
||||
/// (`unsnapped`). With Shift the constraint (General › Constrain Angle) moves it too, so only a
|
||||
/// snap's guide tells then.
|
||||
fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool {
|
||||
if shift { !guides.is_empty() } else { (d - unsnapped).hypot() > SNAPPED }
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
group: bool,
|
||||
state: State,
|
||||
@@ -139,6 +149,8 @@ pub struct DirectSelectionTool {
|
||||
handle_snap: HandleSnap,
|
||||
/// What the objects being moved snap to, gathered when the move begins.
|
||||
move_snap: Option<MoveSnap>,
|
||||
/// 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.
|
||||
hover: Option<(NodeId, Point)>,
|
||||
guide: GuideEdit,
|
||||
@@ -161,6 +173,7 @@ impl DirectSelectionTool {
|
||||
copy_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
snapped: false,
|
||||
hover: None,
|
||||
guide: GuideEdit::default(),
|
||||
click: None,
|
||||
@@ -466,6 +479,7 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![Action::Begin("Reshape Spine".into())];
|
||||
}
|
||||
self.spine = None;
|
||||
self.snapped = false;
|
||||
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
|
||||
self.state = State::Mesh;
|
||||
return self.mesh.press(g);
|
||||
@@ -574,6 +588,7 @@ impl Tool for DirectSelectionTool {
|
||||
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);
|
||||
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();
|
||||
@@ -582,6 +597,7 @@ impl Tool for DirectSelectionTool {
|
||||
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
|
||||
}
|
||||
@@ -618,6 +634,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
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) {
|
||||
@@ -627,6 +644,7 @@ impl Tool for DirectSelectionTool {
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, 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}),
|
||||
@@ -648,6 +666,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides);
|
||||
self.guides = guides;
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
@@ -803,15 +822,89 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow };
|
||||
if !self.group {
|
||||
match self.state {
|
||||
// Alt-dragging leaves a copy behind.
|
||||
State::MoveObject { began: true, .. } if m.alt => return Cursor::SelectionCopy,
|
||||
// Bending a segment keeps the cursor that offered it.
|
||||
State::Segment { began: true, .. } | State::SpineSegment { began: true, .. } => return Cursor::ReshapeSegment,
|
||||
// A drag shows the filled arrow, hollow while it snaps to a point or a guide.
|
||||
State::Handle { .. }
|
||||
| State::SpineHandle { .. }
|
||||
| State::SpinePoint { began: true, .. }
|
||||
| State::TypeArea { began: true, .. }
|
||||
| State::MoveAnchors { began: true, .. }
|
||||
| State::MoveObject { began: true, .. } => {
|
||||
return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow };
|
||||
}
|
||||
// Alt makes it the Group Selection tool for the next press, unless the press takes
|
||||
// a handle, a mesh or spine point, a type frame's edge or a ruler guide.
|
||||
State::Idle if m.alt => {
|
||||
let other = self.takes_point(cx, p)
|
||||
|| hovered_anchor(cx, p).is_none() && (hit_frame_edge(cx, p, cx.pick_tol()).is_some() || self.guide.cursor(cx, p).is_some());
|
||||
if !other {
|
||||
return Cursor::GroupSelection;
|
||||
}
|
||||
}
|
||||
State::Idle => {
|
||||
if let Some(c) = self.part_under(cx, p, m) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A press on the highlighted anchor picks it, not the guide.
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(rest)
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// A press at `p` takes a mesh point or handle, a direction handle's end, the clicked spine
|
||||
/// point's handle or a spine point, before any anchor or segment.
|
||||
fn takes_point(&self, cx: &ToolContext, p: Point) -> bool {
|
||||
let point = cx.point_tol();
|
||||
self.mesh.hit(cx, &cx.selection.objects, p).is_some()
|
||||
|| hit_handle(cx, p, point).is_some()
|
||||
|| self.spine.is_some_and(|sel| hit_spine_handle(cx, sel, p, point).is_some())
|
||||
|| hit_spine_point(cx, p, point).is_some()
|
||||
}
|
||||
|
||||
/// The cursor for the path part a press at `p` takes, in the press's order: an anchor (its
|
||||
/// square filled when its path is selected), then a curved segment a drag reshapes (a path's
|
||||
/// not selected whole, or a selected blend's spine's). Handles, mesh and spine points, frame
|
||||
/// edges, ruler guides, straight segments and fills keep the plain cursor.
|
||||
fn part_under(&self, cx: &ToolContext, p: Point, m: Mods) -> Option<Cursor> {
|
||||
let tol = cx.pick_tol();
|
||||
if self.takes_point(cx, p) {
|
||||
return None;
|
||||
}
|
||||
// A selected path's anchors or the anchors of the path under the pointer, as Highlight
|
||||
// anchors on mouse over finds them: walking every path on each hover is too slow.
|
||||
if let Some((id, _)) = hovered_anchor(cx, p) {
|
||||
return Some(if cx.selection.contains(id) { Cursor::DirectAnchorSelected } else { Cursor::DirectAnchor });
|
||||
}
|
||||
if hit_frame_edge(cx, p, tol).is_some() {
|
||||
return None;
|
||||
}
|
||||
if matches!(hit_spine_segment(cx, p, tol), Some((.., false))) {
|
||||
return Some(Cursor::ReshapeSegment);
|
||||
}
|
||||
if m.shift || self.guide.cursor(cx, p).is_some() {
|
||||
return None;
|
||||
}
|
||||
let h = hit_test(cx.doc, p, cx.hit_options())?;
|
||||
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
|
||||
let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved);
|
||||
(!whole && curved).then_some(Cursor::ReshapeSegment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1423,6 +1516,81 @@ mod tests {
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
|
||||
}
|
||||
|
||||
/// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor
|
||||
/// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment
|
||||
/// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a
|
||||
/// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool.
|
||||
#[test]
|
||||
fn hovering_shows_the_anchor_square_reshape_and_group_cursors() {
|
||||
let (d, arch) = arch_doc();
|
||||
let p = paint();
|
||||
let m = Mods::default();
|
||||
let pt = Point::new;
|
||||
let none = Selection::default();
|
||||
let c = cx(&d, &none, &p);
|
||||
let t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow);
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves");
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill");
|
||||
let alt = Mods { alt: true, ..m };
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection);
|
||||
assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection);
|
||||
let mut whole = Selection::default();
|
||||
whole.set([arch]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole");
|
||||
// One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press
|
||||
// takes the handle).
|
||||
let s = anchors_of(arch, &[(0, 0)]);
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end");
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt");
|
||||
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
|
||||
// Bending the curve keeps the Reshape Segment cursor.
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let c = cx(&d, &none, &p);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment);
|
||||
}
|
||||
|
||||
/// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the
|
||||
/// double arrowhead.
|
||||
#[test]
|
||||
fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let none = Mods::default();
|
||||
let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m);
|
||||
let one = anchors_of(b, &[(0, 0)]);
|
||||
let c = cx(&d, &one, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none));
|
||||
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none));
|
||||
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow);
|
||||
// Onto A's bottom-right corner.
|
||||
t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none));
|
||||
assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow);
|
||||
t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none));
|
||||
// Shift with a Constrain Angle of 10°: the constraint alone isn't a snap.
|
||||
let shift = Mods { shift: true, ..none };
|
||||
let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) };
|
||||
t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift));
|
||||
t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift));
|
||||
assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow);
|
||||
t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift));
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let alt = Mods { alt: true, ..none };
|
||||
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt));
|
||||
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt));
|
||||
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy);
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
|
||||
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
|
||||
@@ -475,7 +475,7 @@ impl Tool for PuppetWarpTool {
|
||||
} else if Self::pin_at(cx, &c.set, p).is_some() {
|
||||
Cursor::Move
|
||||
} else if c.set.rest_of(p).is_some() {
|
||||
Cursor::PenAdd
|
||||
Cursor::PuppetPin
|
||||
} else {
|
||||
// Off the mesh: no pin goes there.
|
||||
Cursor::NotAllowed
|
||||
@@ -733,6 +733,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_click_off_the_mesh_places_no_pin_and_says_so() {
|
||||
let mut r = Rig::new();
|
||||
assert_eq!(r.cursor(130.0, 170.0, Mods::default()), Cursor::PuppetPin, "on the mesh, away from the pins");
|
||||
r.click(130.0, 170.0, Mods::default());
|
||||
assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed);
|
||||
assert!(r.click(400.0, 400.0, Mods::default()).is_empty());
|
||||
|
||||
@@ -219,22 +219,56 @@ impl Tool for AnchorTool {
|
||||
"deleteAnchor" => Cursor::PenAdd,
|
||||
"scissors" => {
|
||||
if hit_segment(cx, p, cx.pick_tol()).is_some() {
|
||||
Cursor::Crosshair
|
||||
Cursor::Scissors
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
Cursor::ScissorsNotAllowed
|
||||
}
|
||||
}
|
||||
_ => Cursor::Pen,
|
||||
_ => match self.state {
|
||||
State::Reshape { .. } => Cursor::ReshapeSegment,
|
||||
State::Idle => press_cursor(cx, p),
|
||||
State::Convert { .. } | State::Handle { .. } => Cursor::AnchorPoint,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Anchor Point tool's cursor for a press at `p`: the Reshape Segment cursor where it takes a
|
||||
/// segment (a path's or a selected blend's spine's), else its caret. Handles and anchors win
|
||||
/// over the segments through them, as the press has it.
|
||||
pub(crate) fn press_cursor(cx: &ToolContext, p: Point) -> Cursor {
|
||||
let (point, tol) = (cx.point_tol(), cx.pick_tol());
|
||||
let segment = hit_handle(cx, p, point).is_none()
|
||||
&& hit_anchor(cx, p, point).is_none()
|
||||
&& (hit_segment(cx, p, tol).is_some() || crate::direct::hit_spine_segment(cx, p, tol).is_some());
|
||||
if segment { Cursor::ReshapeSegment } else { Cursor::AnchorPoint }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
/// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a
|
||||
/// segment the Reshape Segment cursor, kept while the segment is dragged.
|
||||
#[test]
|
||||
fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = AnchorTool::new("anchorPoint");
|
||||
let none = Mods::default();
|
||||
for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] {
|
||||
assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}");
|
||||
}
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0));
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_delete_anchor_clicks() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -621,7 +621,7 @@ impl Tool for CurvatureTool {
|
||||
Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose,
|
||||
Some(_) if m.alt => Cursor::PenConvert,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::Pen,
|
||||
None => Cursor::Curvature,
|
||||
};
|
||||
}
|
||||
let tol = point_tol(cx);
|
||||
@@ -642,7 +642,7 @@ impl Tool for CurvatureTool {
|
||||
if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() {
|
||||
return Cursor::PenAdd;
|
||||
}
|
||||
Cursor::Pen
|
||||
Cursor::Curvature
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +653,16 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Node, Selection};
|
||||
use vectorcraft_geom::{ParamCurve, PathData};
|
||||
|
||||
/// The Curvature tool has its own cursor, not the Pen's start-a-path one.
|
||||
#[test]
|
||||
fn curvature_tool_shows_its_cursor() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_click_creates_then_notify_tracks_path() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -312,7 +312,16 @@ impl Tool for GestureTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
match self.id {
|
||||
"paintbrush" => Cursor::Paintbrush,
|
||||
"blobBrush" => Cursor::BlobBrush,
|
||||
"eraser" => Cursor::Eraser,
|
||||
"knife" => Cursor::Knife,
|
||||
"smooth" => Cursor::Smooth,
|
||||
"pathEraser" => Cursor::PathEraser,
|
||||
"join" => Cursor::Join,
|
||||
_ => Cursor::Pencil,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
match self.id {
|
||||
|
||||
@@ -393,7 +393,7 @@ impl Tool for ShaperTool {
|
||||
vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Shaper
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+211
-4
@@ -427,12 +427,30 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
ArrowHollow,
|
||||
/// Direct Selection over an anchor of a path that isn't selected: the white arrow with a
|
||||
/// hollow square.
|
||||
DirectAnchor,
|
||||
/// Direct Selection over an anchor of a selected path: the white arrow with a filled square.
|
||||
DirectAnchorSelected,
|
||||
/// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt):
|
||||
/// an arrowhead with a small curve between two anchor marks.
|
||||
ReshapeSegment,
|
||||
/// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus.
|
||||
GroupSelection,
|
||||
/// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead.
|
||||
SelectionCopy,
|
||||
Move,
|
||||
Crosshair,
|
||||
ResizeH,
|
||||
ResizeV,
|
||||
ResizeNwSe,
|
||||
ResizeNeSw,
|
||||
/// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object
|
||||
/// sideways: two arrows side by side, pointing opposite ways along that side.
|
||||
ShearH,
|
||||
/// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up
|
||||
/// or down: the ShearH arrows turned upright.
|
||||
ShearV,
|
||||
Rotate,
|
||||
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
|
||||
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
|
||||
@@ -449,10 +467,14 @@ pub enum Cursor {
|
||||
PenDelete,
|
||||
PenClose,
|
||||
PenContinue,
|
||||
/// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip
|
||||
/// on the anchor it converts.
|
||||
AnchorPoint,
|
||||
/// The Curvature tool: the pen nib with a curve badge.
|
||||
Curvature,
|
||||
/// While drawing, over an end of another open path (a click joins the two).
|
||||
PenJoin,
|
||||
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
|
||||
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
|
||||
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
|
||||
PenConvert,
|
||||
Text,
|
||||
Hand,
|
||||
@@ -490,6 +512,60 @@ pub enum Cursor {
|
||||
ShapeBuilder,
|
||||
/// ...or, with Alt held, a minus (erase mode).
|
||||
ShapeBuilderErase,
|
||||
/// The Lasso tool.
|
||||
Lasso,
|
||||
/// The Magic Wand tool.
|
||||
MagicWand,
|
||||
/// The Pencil tool.
|
||||
Pencil,
|
||||
/// The Paintbrush tool.
|
||||
Paintbrush,
|
||||
/// The Blob Brush tool.
|
||||
BlobBrush,
|
||||
/// The Eraser tool.
|
||||
Eraser,
|
||||
/// The Smooth tool.
|
||||
Smooth,
|
||||
/// The Path Eraser tool.
|
||||
PathEraser,
|
||||
/// The Join tool.
|
||||
Join,
|
||||
/// The Knife tool.
|
||||
Knife,
|
||||
/// The Shaper tool.
|
||||
Shaper,
|
||||
/// The Live Paint Bucket.
|
||||
LivePaintBucket,
|
||||
/// The Measure tool.
|
||||
Measure,
|
||||
/// The Print Tiling tool.
|
||||
PrintTiling,
|
||||
/// The Symbol Sprayer tool.
|
||||
SymbolSprayer,
|
||||
/// The Symbol Shifter tool.
|
||||
SymbolShifter,
|
||||
/// The Symbol Scruncher tool.
|
||||
SymbolScruncher,
|
||||
/// The Symbol Sizer tool.
|
||||
SymbolSizer,
|
||||
/// The Symbol Spinner tool.
|
||||
SymbolSpinner,
|
||||
/// The Symbol Stainer tool.
|
||||
SymbolStainer,
|
||||
/// The Symbol Screener tool.
|
||||
SymbolScreener,
|
||||
/// The Symbol Styler tool.
|
||||
SymbolStyler,
|
||||
/// The vertical type tools: a turned I-beam.
|
||||
TypeVertical,
|
||||
/// The Scissors over a path segment (a click splits the path there)...
|
||||
Scissors,
|
||||
/// ...or away from every segment (a click does nothing).
|
||||
ScissorsNotAllowed,
|
||||
/// The Mesh tool off a mesh point: a click adds one.
|
||||
MeshAdd,
|
||||
/// Puppet Warp over the selection's mesh where a pin can go: a click adds one.
|
||||
PuppetPin,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
@@ -506,7 +582,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
|
||||
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
/// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
pub fn precise(self) -> Self {
|
||||
match self {
|
||||
Cursor::Pen
|
||||
@@ -517,16 +593,79 @@ impl Cursor {
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::AnchorPoint
|
||||
| Cursor::Curvature
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
| Cursor::Blend
|
||||
| Cursor::BlendObject
|
||||
| Cursor::BlendAnchor
|
||||
| Cursor::ShapeBuilder
|
||||
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
|
||||
| Cursor::ShapeBuilderErase
|
||||
| Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor this one borrows until it's drawn: the generic crosshair for the
|
||||
/// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of
|
||||
/// its own.
|
||||
pub fn stand_in(self) -> Option<Self> {
|
||||
match self {
|
||||
Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool state machine.
|
||||
@@ -749,3 +888,71 @@ pub(crate) mod testutil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cursor_tests {
|
||||
use super::testutil::{cx, doc_with_rect, paint};
|
||||
use super::*;
|
||||
|
||||
fn cursor_at(id: &str, d: &Document, p: Point) -> Cursor {
|
||||
let (s, paint) = (Selection::default(), paint());
|
||||
create(id).cursor(&cx(d, &s, &paint), p, Mods::default())
|
||||
}
|
||||
|
||||
/// Each of these tools has a cursor of its own (borrowing the crosshair until it's drawn), and
|
||||
/// Use Precise Cursors turns it into the crosshair.
|
||||
#[test]
|
||||
fn drawing_tools_show_their_own_cursor() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let away = Point::new(400.0, 400.0);
|
||||
for (id, c) in [
|
||||
("lasso", Cursor::Lasso),
|
||||
("magicWand", Cursor::MagicWand),
|
||||
("pencil", Cursor::Pencil),
|
||||
("paintbrush", Cursor::Paintbrush),
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("eraser", Cursor::Eraser),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("knife", Cursor::Knife),
|
||||
("shaper", Cursor::Shaper),
|
||||
("livePaintBucket", Cursor::LivePaintBucket),
|
||||
("measure", Cursor::Measure),
|
||||
("printTiling", Cursor::PrintTiling),
|
||||
("symbolSprayer", Cursor::SymbolSprayer),
|
||||
("symbolShifter", Cursor::SymbolShifter),
|
||||
("symbolScruncher", Cursor::SymbolScruncher),
|
||||
("symbolSizer", Cursor::SymbolSizer),
|
||||
("symbolSpinner", Cursor::SymbolSpinner),
|
||||
("symbolStainer", Cursor::SymbolStainer),
|
||||
("symbolScreener", Cursor::SymbolScreener),
|
||||
("symbolStyler", Cursor::SymbolStyler),
|
||||
("mesh", Cursor::MeshAdd),
|
||||
] {
|
||||
assert_eq!(cursor_at(id, &d, away), c, "{id}");
|
||||
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
|
||||
assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vertical_type_tools_show_the_turned_i_beam() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let away = Point::new(400.0, 400.0);
|
||||
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
|
||||
assert_eq!(cursor_at(id, &d, away), Cursor::TypeVertical, "{id}");
|
||||
}
|
||||
assert_eq!(cursor_at("type", &d, away), Cursor::Text);
|
||||
assert_eq!(Cursor::TypeVertical.stand_in(), Some(Cursor::Text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scissors_say_whether_a_click_cuts() {
|
||||
let (d, _) = doc_with_rect();
|
||||
assert_eq!(cursor_at("scissors", &d, Point::new(100.0, 150.0)), Cursor::Scissors, "over a segment");
|
||||
assert_eq!(cursor_at("scissors", &d, Point::new(400.0, 400.0)), Cursor::ScissorsNotAllowed, "away from paths");
|
||||
assert_eq!(Cursor::ScissorsNotAllowed.stand_in(), Some(Cursor::NotAllowed));
|
||||
assert_eq!(Cursor::ScissorsNotAllowed.precise(), Cursor::ScissorsNotAllowed, "nothing to aim at");
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ impl Tool for MeshTool {
|
||||
match self.grab_at(cx, p) {
|
||||
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::PenAdd,
|
||||
None => Cursor::MeshAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> serde_json::Value {
|
||||
|
||||
+21
-4
@@ -152,6 +152,17 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|
||||
|| crate::draw2::segment_in(cx, crate::draw2::shown_paths(cx), p, cx.pick_tol()).is_some())
|
||||
}
|
||||
|
||||
/// The cursor where [`alt_converts`]: Alt lends the Anchor Point tool, so its caret over a handle
|
||||
/// end or an anchor, the Reshape Segment cursor over a segment.
|
||||
fn alt_cursor(cx: &ToolContext, p: Point) -> Cursor {
|
||||
let point = cx.point_tol();
|
||||
if hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, crate::draw2::shown_paths(cx), p, point).is_some() {
|
||||
Cursor::AnchorPoint
|
||||
} else {
|
||||
Cursor::ReshapeSegment
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
|
||||
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
|
||||
Action::Preview(
|
||||
@@ -390,6 +401,10 @@ impl Tool for PenTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
// An Alt gesture under way keeps the Anchor Point tool's cursor.
|
||||
if let Some(t) = &self.convert {
|
||||
return t.cursor(cx, p, m);
|
||||
}
|
||||
let tol = cx.tol(5.0).max(cx.point_tol());
|
||||
let active = self.active(cx);
|
||||
if let Some((id, first, last, _)) = active {
|
||||
@@ -404,7 +419,7 @@ impl Tool for PenTool {
|
||||
}
|
||||
}
|
||||
if alt_converts(cx, p, m) {
|
||||
return Cursor::PenConvert;
|
||||
return alt_cursor(cx, p);
|
||||
}
|
||||
// These show whenever no path is being drawn, also after another path was selected while
|
||||
// the Pen was drawing.
|
||||
@@ -1212,8 +1227,9 @@ mod tests {
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let t = PenTool::default();
|
||||
let cx0 = cx(&d, &s, &p);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
|
||||
// Alt lends the Anchor Point tool, and its caret with it.
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
|
||||
// A drag of the handle moves it alone, as one gesture.
|
||||
@@ -1249,10 +1265,11 @@ mod tests {
|
||||
let cx = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape");
|
||||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
|
||||
|
||||
@@ -87,7 +87,7 @@ impl Tool for PrintTilingTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::PrintTiling
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -82,6 +82,8 @@ pub struct SelectionTool {
|
||||
/// What a bounding-box handle being dragged snaps to.
|
||||
targets: Option<Targets>,
|
||||
moving: Option<MoveSnap>,
|
||||
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
|
||||
snapped: bool,
|
||||
guide: GuideEdit,
|
||||
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
|
||||
hover: Option<(Point, &'static str)>,
|
||||
@@ -425,9 +427,12 @@ impl Tool for SelectionTool {
|
||||
self.moving = Some(MoveSnap::new(cx));
|
||||
}
|
||||
let mut d = move_delta(start, p, m.shift);
|
||||
let unsnapped = d;
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = snap.snap(cx, start, 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, 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 })));
|
||||
@@ -553,7 +558,10 @@ impl Tool for SelectionTool {
|
||||
match self.state {
|
||||
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
// Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move
|
||||
// snaps to a point or a guide.
|
||||
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
|
||||
State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
@@ -868,6 +876,33 @@ mod tests {
|
||||
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
|
||||
}
|
||||
|
||||
/// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right
|
||||
/// one); Alt, which leaves a copy behind, the double arrowhead.
|
||||
#[test]
|
||||
fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
|
||||
let l = d.layers[0].id;
|
||||
let b = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
|
||||
d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(b);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let none = Mods::default();
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0));
|
||||
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow);
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0));
|
||||
assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A");
|
||||
let alt = Mods { alt: true, ..none };
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt));
|
||||
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy);
|
||||
}
|
||||
|
||||
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
|
||||
/// whichever of its edges or its centre is nearest a gridline, on each axis.
|
||||
#[test]
|
||||
|
||||
@@ -170,7 +170,16 @@ impl Tool for SymbolismTool {
|
||||
vec![Overlay::Path { path: circle, color: FEEDBACK, width: 1.0, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
match self.id {
|
||||
"symbolShifter" => Cursor::SymbolShifter,
|
||||
"symbolScruncher" => Cursor::SymbolScruncher,
|
||||
"symbolSizer" => Cursor::SymbolSizer,
|
||||
"symbolSpinner" => Cursor::SymbolSpinner,
|
||||
"symbolStainer" => Cursor::SymbolStainer,
|
||||
"symbolScreener" => Cursor::SymbolScreener,
|
||||
"symbolStyler" => Cursor::SymbolStyler,
|
||||
_ => Cursor::SymbolSprayer,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
json!({"diameter": self.diameter, "intensity": self.intensity, "density": self.density, "symbol": self.symbol, "style": self.style})
|
||||
|
||||
+50
-17
@@ -305,26 +305,59 @@ impl TypeTool {
|
||||
}
|
||||
|
||||
fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
if !lay.overflow {
|
||||
return;
|
||||
if lay.overflow {
|
||||
overflow_port(t, cx, o);
|
||||
}
|
||||
// The out port: bottom-right of the frame (end of the path for type on a path).
|
||||
let port = match &t.kind {
|
||||
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
|
||||
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
|
||||
TextKind::Point => None,
|
||||
};
|
||||
let Some(c) = port else { return };
|
||||
let h = cx.tol(4.5);
|
||||
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
|
||||
let red = [230, 30, 30];
|
||||
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
|
||||
let (k, m) = (cx.tol(2.5), r.center());
|
||||
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
|
||||
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
|
||||
}
|
||||
}
|
||||
|
||||
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the
|
||||
/// bottom right of the frame, at the end of the path for type on a path.
|
||||
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
let port = match &t.kind {
|
||||
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
|
||||
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
|
||||
TextKind::Point => None,
|
||||
};
|
||||
let Some(c) = port else { return };
|
||||
let h = cx.tol(4.5);
|
||||
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
|
||||
let red = [230, 30, 30];
|
||||
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
|
||||
let (k, m) = (cx.tol(2.5), r.center());
|
||||
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
|
||||
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
|
||||
}
|
||||
|
||||
/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept
|
||||
/// with the object and the fonts it was made from, as overlays run every frame.
|
||||
#[derive(Default)]
|
||||
pub struct OverflowMarks {
|
||||
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
|
||||
}
|
||||
|
||||
/// The overflow markers of the selected area type and type on a path, for every tool but the Type
|
||||
/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of
|
||||
/// its frame, whatever tool is active.
|
||||
pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let fonts = FontDb::global().generation();
|
||||
let mut o = Vec::new();
|
||||
let mut kept = std::collections::HashMap::new();
|
||||
for id in &cx.selection.objects {
|
||||
let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue };
|
||||
let over = match marks.seen.remove(id) {
|
||||
Some(((g, was), over)) if g == fonts && &was == t => over,
|
||||
_ => vectorcraft_text::layout(FontDb::global(), t).overflow,
|
||||
};
|
||||
if over {
|
||||
overflow_port(t, cx, &mut o);
|
||||
}
|
||||
kept.insert(*id, ((fonts, t.clone()), over));
|
||||
}
|
||||
marks.seen = kept;
|
||||
o
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking;
|
||||
@@ -814,7 +847,7 @@ impl Tool for TypeTool {
|
||||
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
|
||||
return Cursor::Crosshair;
|
||||
}
|
||||
Cursor::Text
|
||||
if self.vertical { Cursor::TypeVertical } else { Cursor::Text }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -400,3 +400,21 @@ fn escape_returns_to_previous_tool_when_set() {
|
||||
let acts2 = key(&mut tool2, &d2, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into())));
|
||||
}
|
||||
|
||||
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
|
||||
/// only the Type tools (#2323); text that fits shows none.
|
||||
#[test]
|
||||
fn overflowing_selected_type_shows_its_marker_without_the_type_tool() {
|
||||
let (mut d, id) = crate::testutil::doc_with_area_type();
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
let mut marks = OverflowMarks::default();
|
||||
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits");
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })];
|
||||
}
|
||||
let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p));
|
||||
assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}");
|
||||
sel.clear();
|
||||
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it");
|
||||
}
|
||||
@@ -315,7 +315,9 @@ impl Tool for FreeTransformTool {
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
|
||||
Some(Op::Move) => Cursor::Move,
|
||||
Some(Op::Scale(h) | Op::Shear(h)) => match h {
|
||||
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
|
||||
Some(Op::Shear(_)) => Cursor::ShearV,
|
||||
Some(Op::Scale(h)) => match h {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||||
@@ -401,6 +403,27 @@ mod tests {
|
||||
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_over_a_side_shows_the_shear_cursor_along_it() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = FreeTransformTool::default();
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
let (top, left) = (Point::new(150.0, 100.0), Point::new(100.0, 150.0));
|
||||
assert_eq!(t.cursor(&cx, top, cmd), Cursor::ShearH);
|
||||
assert_eq!(t.cursor(&cx, left, cmd), Cursor::ShearV);
|
||||
// Without Cmd the sides scale; corners never shear.
|
||||
assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeV);
|
||||
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), cmd), Cursor::Crosshair);
|
||||
// The cursor stays while the shear is dragged.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
||||
assert_eq!(t.cursor(&cx, Point::new(170.0, 100.0), cmd), Cursor::ShearH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Tool for MeasureTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Measure
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
||||
@@ -41,7 +41,7 @@ impl Tool for MagicWandTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::MagicWand
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ impl Tool for LassoTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Lasso
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+160
-16
@@ -82,9 +82,12 @@ enum Drag {
|
||||
held: f32,
|
||||
moved: bool,
|
||||
},
|
||||
/// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate
|
||||
/// View tool), Shift rounds the angle to 15°.
|
||||
RotateView {
|
||||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
snaps: bool,
|
||||
},
|
||||
/// A Selection tool move dragged off the canvas: the panels get the art
|
||||
/// ([`widgets::PanelDrag::Art`]).
|
||||
@@ -236,7 +239,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let xf = Xf::new(rect, &v);
|
||||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
context_menu(app, &resp, &xf);
|
||||
// In Presentation Mode a right click or a long touch opens no context menu.
|
||||
if app.ui.screen_mode < 3 {
|
||||
context_menu(app, &resp, &xf);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
@@ -302,6 +308,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
hidden: vec![],
|
||||
rot: v.rotation,
|
||||
anti_alias: app.session.prefs.anti_aliased_artwork,
|
||||
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
|
||||
placed: vectorcraft_render::placed_document::generation(),
|
||||
pixel,
|
||||
smooth_images,
|
||||
@@ -354,6 +361,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
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,
|
||||
// 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 },
|
||||
@@ -520,12 +529,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if resp.hovered() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let panning = matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Pan { .. }));
|
||||
let drag = ui.data(|d| d.get_temp::<Drag>(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" {
|
||||
@@ -533,7 +548,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cur = if let Some(c) = view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))) {
|
||||
// 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(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))));
|
||||
let cur = if let Some(c) = tool_cursor.flatten() {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
|
||||
// The loaded place cursor carries the file's thumbnail.
|
||||
if app.session.tool_id() == "place"
|
||||
@@ -562,7 +580,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
&& 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) =>
|
||||
{
|
||||
egui::CursorIcon::None
|
||||
// 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),
|
||||
}
|
||||
@@ -576,11 +595,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget => C::Default,
|
||||
Cursor::Arrow
|
||||
| Cursor::ArrowHollow
|
||||
| Cursor::DirectAnchor
|
||||
| Cursor::DirectAnchorSelected
|
||||
| Cursor::ReshapeSegment
|
||||
| Cursor::GroupSelection
|
||||
| Cursor::CornerRadius
|
||||
| Cursor::PathBracket
|
||||
| Cursor::TypeWidget => C::Default,
|
||||
Cursor::SelectionCopy => C::Copy,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH => C::ResizeHorizontal,
|
||||
Cursor::ResizeV => C::ResizeVertical,
|
||||
Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal,
|
||||
Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical,
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||||
@@ -591,7 +619,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert => C::Crosshair,
|
||||
| Cursor::PenConvert
|
||||
| Cursor::AnchorPoint
|
||||
| Cursor::Curvature => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
@@ -606,6 +636,34 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||||
Cursor::TypeVertical => C::VerticalText,
|
||||
// Not drawn yet: the cursor each borrows.
|
||||
Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,10 +744,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
}
|
||||
|
||||
let tool = app.session.tool_id();
|
||||
// Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming
|
||||
// out), whatever the tool.
|
||||
// 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 pan_mode = if space { !m.command } else { tool == "hand" };
|
||||
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.
|
||||
@@ -712,9 +771,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
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 tool == "rotateView" {
|
||||
} 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 }
|
||||
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, true) };
|
||||
@@ -746,11 +805,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
vm.center = center - d;
|
||||
}
|
||||
}
|
||||
Drag::RotateView { start_angle, start_rot } => {
|
||||
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 m.shift {
|
||||
if snaps && m.shift {
|
||||
deg = (deg / 15.0).round() * 15.0;
|
||||
}
|
||||
if let Some(vm) = app.view_mut() {
|
||||
@@ -1167,7 +1226,10 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, taps));
|
||||
if let Some(cmd) = tap {
|
||||
// In Presentation Mode a tap of two or three fingers undoes or redoes nothing.
|
||||
if let Some(cmd) = tap
|
||||
&& app.ui.screen_mode < 3
|
||||
{
|
||||
crate::menus::invoke(app, cmd, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
@@ -5065,6 +5127,88 @@ mod tests {
|
||||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_shift_drag_rotates_the_view_whatever_the_tool() {
|
||||
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.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
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 };
|
||||
// `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`.
|
||||
let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| {
|
||||
let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] };
|
||||
frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat());
|
||||
frame(app, &ctx, vec![button(from, true, held)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false, held)]);
|
||||
frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat());
|
||||
};
|
||||
// `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right.
|
||||
let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin());
|
||||
let before = *app.view().unwrap();
|
||||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0));
|
||||
let after = *app.view().unwrap();
|
||||
assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}");
|
||||
assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center");
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||||
// Shift does not round the angle: 90° and 20° more make 110°, not 105°.
|
||||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0));
|
||||
assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view());
|
||||
// Space alone still pans, and Cmd+Shift+Space still zooms.
|
||||
let v = *app.view().unwrap();
|
||||
drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0));
|
||||
let panned = *app.view().unwrap();
|
||||
assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom));
|
||||
assert_ne!(panned.center, v.center, "Space pans");
|
||||
drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c);
|
||||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in");
|
||||
assert_eq!(app.view().unwrap().rotation, v.rotation);
|
||||
// The Rotate View tool's Shift still rounds to 15°.
|
||||
app.select_tool("rotateView");
|
||||
app.view_mut().unwrap().rotation = 0.0;
|
||||
drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0));
|
||||
assert_eq!(app.view().unwrap().rotation, 15.0);
|
||||
drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0));
|
||||
assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() {
|
||||
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.select_tool("rectangle");
|
||||
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. On macOS
|
||||
// the window paints the glyph only with the focus, and on the other desktop builds (not
|
||||
// Wayland, which the tests do not set) the window sets the system cursor next to the bitmap.
|
||||
let shown = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||||
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, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
|
||||
assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias);
|
||||
// Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand.
|
||||
shown(&mut app, vec![button(c, true)]);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias);
|
||||
assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab);
|
||||
// Cmd+Shift+Space zooms.
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn);
|
||||
assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias);
|
||||
app.select_tool("rotateView");
|
||||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
|
||||
use egui::{Event, Modifiers};
|
||||
|
||||
+267
-44
@@ -95,6 +95,13 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
}
|
||||
"-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)),
|
||||
"/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)),
|
||||
"~" => {
|
||||
// A quarter circle bulging up-left, from the badge's bottom-left to its top-right.
|
||||
let c = b + vec2(7.0, 7.0);
|
||||
let arc: Vec<Pos2> = (0..=8).map(|i| c + egui::Vec2::angled(std::f32::consts::PI * (1.0 + i as f32 / 16.0)) * 6.0).collect();
|
||||
p.add(Shape::line(arc.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::line(arc, Stroke::new(1.2, INK)));
|
||||
}
|
||||
// Join: a segment running into a square end point.
|
||||
"j" => {
|
||||
line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5));
|
||||
@@ -114,6 +121,79 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Anchor Point tool's caret (as its toolbar icon): a small hollow square on the anchor at
|
||||
/// the hotspot, legs spreading down from its lower corners. Every halo goes under every stroke, so
|
||||
/// the legs join the square.
|
||||
fn anchor_point(p: &mut Ink, o: Pos2) {
|
||||
let s = 2.5;
|
||||
let square = vec![o + vec2(-s, -s), o + vec2(s, -s), o + vec2(s, s), o + vec2(-s, s)];
|
||||
let legs = [[o + vec2(-s, s), o + vec2(-7.5, 13.0)], [o + vec2(s, s), o + vec2(7.5, 13.0)]];
|
||||
for leg in legs {
|
||||
p.line_segment(leg, Stroke::new(3.0, HALO));
|
||||
}
|
||||
p.add(Shape::closed_line(square.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::convex_polygon(square.clone(), HALO, Stroke::NONE));
|
||||
for leg in legs {
|
||||
p.line_segment(leg, Stroke::new(1.2, INK));
|
||||
}
|
||||
p.add(Shape::closed_line(square, Stroke::new(1.0, INK)));
|
||||
}
|
||||
|
||||
/// Halos under every stroke first, then the ink, so crossing strokes don't cut each other.
|
||||
fn strokes(p: &mut Ink, parts: &[Vec<Pos2>]) {
|
||||
for s in parts {
|
||||
p.add(Shape::line(s.clone(), Stroke::new(3.0, HALO)));
|
||||
}
|
||||
for s in parts {
|
||||
p.add(Shape::line(s.clone(), Stroke::new(1.2, INK)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A stemless arrowhead, tip at `o`.
|
||||
fn arrowhead(o: Pos2) -> Vec<Pos2> {
|
||||
vec![o, o + vec2(0.0, 14.0), o + vec2(4.2, 10.4), o + vec2(10.0, 10.0)]
|
||||
}
|
||||
|
||||
/// Direct Selection over an anchor: the white arrow with a small square at its lower right,
|
||||
/// filled when the anchor's path is selected.
|
||||
fn arrow_square(p: &mut Ink, o: Pos2, filled: bool) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(11.0, 13.0);
|
||||
let square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)].map(|(x, y)| b + vec2(x, y)).to_vec();
|
||||
poly(p, square, if filled { INK } else { HALO }, INK);
|
||||
}
|
||||
|
||||
/// The Reshape Segment cursor: a black arrowhead with a small curve between two anchor marks at
|
||||
/// its lower right.
|
||||
fn reshape_segment(p: &mut Ink, o: Pos2) {
|
||||
poly(p, arrowhead(o), INK, INK);
|
||||
let (a, c) = (o + vec2(10.0, 19.0), o + vec2(19.0, 19.0));
|
||||
let curve: Vec<Pos2> = (0..=12)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 12.0;
|
||||
a + (c - a) * t - vec2(0.0, 4.5 * (std::f32::consts::PI * t).sin())
|
||||
})
|
||||
.collect();
|
||||
strokes(p, &[curve]);
|
||||
for m in [a, c] {
|
||||
let mark = [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec();
|
||||
poly(p, mark, INK, INK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Group Selection: the white arrow with a plus at its lower right.
|
||||
fn arrow_plus(p: &mut Ink, o: Pos2) {
|
||||
arrow(p, o, true);
|
||||
let c = o + vec2(14.0, 15.0);
|
||||
strokes(p, &[vec![c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], vec![c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)]]);
|
||||
}
|
||||
|
||||
/// An Alt-drag copy: a black arrowhead in front of a white one set down and to the right.
|
||||
fn double_arrowhead(p: &mut Ink, o: Pos2) {
|
||||
poly(p, arrowhead(o + vec2(4.0, 4.0)), HALO, INK);
|
||||
poly(p, arrowhead(o), INK, INK);
|
||||
}
|
||||
|
||||
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
let d = dir.normalized() * 8.0;
|
||||
let n = vec2(-d.y, d.x) * 0.45;
|
||||
@@ -123,6 +203,19 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two single arrows side by side across the hotspot, pointing opposite ways along `dir`: a side
|
||||
/// sliding past its opposite one, as a shear does.
|
||||
fn shear(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
let d = dir.normalized() * 7.0;
|
||||
let n = vec2(-d.y, d.x) / 7.0;
|
||||
for (side, way) in [(-3.5, 1.0), (3.5, -1.0)] {
|
||||
let (c, d) = (o + n * side, d * way);
|
||||
line(p, c - d, c + d * 0.55);
|
||||
let back = c + d * 0.4;
|
||||
poly(p, vec![c + d, back + n * 2.6, back - n * 2.6], INK, INK);
|
||||
}
|
||||
}
|
||||
|
||||
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
|
||||
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
|
||||
/// bounding box it wraps the corner the pointer is at.
|
||||
@@ -314,13 +407,102 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 74] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
Cursor::ZoomOut,
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::DirectAnchor,
|
||||
Cursor::DirectAnchorSelected,
|
||||
Cursor::ReshapeSegment,
|
||||
Cursor::GroupSelection,
|
||||
Cursor::SelectionCopy,
|
||||
Cursor::Move,
|
||||
Cursor::Crosshair,
|
||||
Cursor::ResizeH,
|
||||
Cursor::ResizeV,
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::ShearH,
|
||||
Cursor::ShearV,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenDrawing,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenJoin,
|
||||
Cursor::PenConvert,
|
||||
Cursor::AnchorPoint,
|
||||
Cursor::Curvature,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
Cursor::AddStop,
|
||||
Cursor::RemoveStop,
|
||||
Cursor::Slice,
|
||||
Cursor::SliceSelect,
|
||||
Cursor::Width,
|
||||
Cursor::WidthAdd,
|
||||
Cursor::WidthPoint,
|
||||
Cursor::Blend,
|
||||
Cursor::BlendObject,
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::Lasso,
|
||||
Cursor::MagicWand,
|
||||
Cursor::Pencil,
|
||||
Cursor::Paintbrush,
|
||||
Cursor::BlobBrush,
|
||||
Cursor::Eraser,
|
||||
Cursor::Smooth,
|
||||
Cursor::PathEraser,
|
||||
Cursor::Join,
|
||||
Cursor::Knife,
|
||||
Cursor::Shaper,
|
||||
Cursor::LivePaintBucket,
|
||||
Cursor::Measure,
|
||||
Cursor::PrintTiling,
|
||||
Cursor::SymbolSprayer,
|
||||
Cursor::SymbolShifter,
|
||||
Cursor::SymbolScruncher,
|
||||
Cursor::SymbolSizer,
|
||||
Cursor::SymbolSpinner,
|
||||
Cursor::SymbolStainer,
|
||||
Cursor::SymbolScreener,
|
||||
Cursor::SymbolStyler,
|
||||
Cursor::TypeVertical,
|
||||
Cursor::Scissors,
|
||||
Cursor::MeshAdd,
|
||||
Cursor::PuppetPin,
|
||||
];
|
||||
|
||||
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
|
||||
/// (the busy states).
|
||||
/// (the busy states): the one drawn here, else the glyph of the cursor it borrows
|
||||
/// ([`Cursor::stand_in`]).
|
||||
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
let ink = &mut Ink::default();
|
||||
// A cursor not drawn yet: the one it borrows.
|
||||
if let Some(stand_in) = c.stand_in() {
|
||||
return glyph(stand_in, p);
|
||||
}
|
||||
match c {
|
||||
Cursor::Arrow => arrow(ink, p, false),
|
||||
Cursor::ArrowHollow => arrow(ink, p, true),
|
||||
Cursor::DirectAnchor => arrow_square(ink, p, false),
|
||||
Cursor::DirectAnchorSelected => arrow_square(ink, p, true),
|
||||
Cursor::ReshapeSegment => reshape_segment(ink, p),
|
||||
Cursor::GroupSelection => arrow_plus(ink, p),
|
||||
Cursor::SelectionCopy => double_arrowhead(ink, p),
|
||||
Cursor::Move => {
|
||||
arrow(ink, p, false);
|
||||
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
|
||||
@@ -331,6 +513,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
|
||||
Cursor::ShearH => shear(ink, p, vec2(1.0, 0.0)),
|
||||
Cursor::ShearV => shear(ink, p, vec2(0.0, -1.0)),
|
||||
Cursor::Rotate => rotate(ink, p, 0),
|
||||
Cursor::RotateToward(octant) => rotate(ink, p, octant),
|
||||
Cursor::CornerRadius => corner_radius(ink, p),
|
||||
@@ -340,6 +524,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::PenDelete => pen(ink, p, "-"),
|
||||
Cursor::PenClose => pen(ink, p, "o"),
|
||||
Cursor::PenContinue => pen(ink, p, "/"),
|
||||
Cursor::AnchorPoint => anchor_point(ink, p),
|
||||
Cursor::Curvature => pen(ink, p, "~"),
|
||||
Cursor::PenJoin => pen(ink, p, "j"),
|
||||
Cursor::PenConvert => pen(ink, p, "^"),
|
||||
Cursor::Text => ibeam(ink, p),
|
||||
@@ -395,6 +581,13 @@ pub fn os_cursors(build: bool, wayland: bool) -> bool {
|
||||
build && !wayland
|
||||
}
|
||||
|
||||
/// Does the canvas hide the system pointer to show a painted tool glyph alone? Not when there is no
|
||||
/// cursor theme on Wayland ([`crate::Services::no_cursor_theme`]): winit sets no pointer then, and a
|
||||
/// hidden one would not come back over the panels (#2450). The system pointer stays beside the glyph.
|
||||
pub fn hide_system_pointer(no_cursor_theme: bool) -> bool {
|
||||
!no_cursor_theme
|
||||
}
|
||||
|
||||
/// Does the window hide the pointer only while it is the key window? macOS does: winit hides it with
|
||||
/// a cursor rect holding a transparent cursor, and macOS applies the rect only in the key window.
|
||||
/// Over a window behind another app or under a sheet, the system pointer shows (#2020).
|
||||
@@ -519,55 +712,68 @@ mod tests {
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 39] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
Cursor::ZoomOut,
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
Cursor::Crosshair,
|
||||
Cursor::ResizeH,
|
||||
Cursor::ResizeV,
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenDrawing,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenJoin,
|
||||
Cursor::PenConvert,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
Cursor::AddStop,
|
||||
Cursor::RemoveStop,
|
||||
Cursor::Slice,
|
||||
Cursor::SliceSelect,
|
||||
Cursor::Width,
|
||||
Cursor::WidthAdd,
|
||||
Cursor::WidthPoint,
|
||||
Cursor::Blend,
|
||||
Cursor::BlendObject,
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
];
|
||||
|
||||
/// The straight RGBA of pixel (x, y).
|
||||
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * img.size[0] as usize + x) * 4;
|
||||
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
|
||||
}
|
||||
|
||||
/// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the
|
||||
/// square's ink a few points above it.
|
||||
#[test]
|
||||
fn anchor_point_hotspot_is_inside_its_square() {
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap");
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x");
|
||||
let reach = (4.0 * ppp).round() as usize;
|
||||
let inked = (1..=reach.min(hy)).any(|d| {
|
||||
let p = pixel(&img, hx, hy - d);
|
||||
p[0] < 64 && p[3] > 200
|
||||
});
|
||||
assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x");
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Selection's anchor square is hollow over an unselected path and filled over a
|
||||
/// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and
|
||||
/// Group Selection's arrow white, just inside their tips.
|
||||
#[test]
|
||||
fn selection_arrows_are_white_or_black() {
|
||||
let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| {
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize)
|
||||
};
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
let tip = at(&img, 1.5, 6.0, ppp);
|
||||
assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}");
|
||||
}
|
||||
// Same arrow, so the filled square shows as more ink than the hollow one.
|
||||
let ink = |c| {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count()
|
||||
};
|
||||
let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected));
|
||||
assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}");
|
||||
for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
let tip = at(&img, 1.5, 6.0, ppp);
|
||||
assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}");
|
||||
assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() {
|
||||
for c in GLYPHS {
|
||||
let Some(stand_in) = c.stand_in() else { continue };
|
||||
assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
|
||||
@@ -590,6 +796,7 @@ mod tests {
|
||||
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
|
||||
}
|
||||
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
|
||||
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
|
||||
}
|
||||
@@ -727,6 +934,22 @@ mod tests {
|
||||
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
|
||||
}
|
||||
|
||||
/// Without a cursor theme the canvas keeps the system pointer visible (#2450).
|
||||
#[test]
|
||||
fn no_cursor_theme_keeps_the_system_pointer() {
|
||||
assert!(!hide_system_pointer(true));
|
||||
assert!(hide_system_pointer(false));
|
||||
let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() });
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
let over = app.canvas_rect.unwrap().center();
|
||||
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||||
assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None);
|
||||
}
|
||||
|
||||
/// On macOS a window without the focus shows the system pointer over the canvas, and the glyph
|
||||
/// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again
|
||||
/// on the next frame, without a pointer move.
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true});
|
||||
let fields = json!({"__swatch": name, "name": name, "__gradient": g, "preview": true});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fn edit_params(d: &Dialog) -> Value {
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::Gradient>(g.clone()).ok()) {
|
||||
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::GradientPaint>(g.clone()).ok()) {
|
||||
Some(g) => {
|
||||
let changed = grid(ui, |ui| name_row(ui, d));
|
||||
ui.add_space(6.0);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -94,7 +94,7 @@ fn plural_polish(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 20] = [
|
||||
pub static LANGUAGES: [LangInfo; 23] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Greek: the whole interface and the status and error messages, keeping the same names in
|
||||
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
|
||||
@@ -232,6 +232,26 @@ pub static LANGUAGES: [LangInfo; 20] = [
|
||||
plural: plural_none,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
}, // Dutch; every `nl-*` locale (`nl-NL`, `nl-BE`, `nl_NL.UTF-8`…) resolves here.
|
||||
LangInfo {
|
||||
code: "nl",
|
||||
name: "Nederlands",
|
||||
source: include_str!("nl.tsv"),
|
||||
plural: plural_one_other,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here.
|
||||
LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts
|
||||
// have one form, so `@plural` entries list a single form.
|
||||
LangInfo {
|
||||
code: "id",
|
||||
name: "Bahasa Indonesia",
|
||||
source: include_str!("id.tsv"),
|
||||
plural: plural_none,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Finnish: the whole interface and the status and error messages, keeping the same names in
|
||||
// English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa").
|
||||
@@ -437,19 +457,33 @@ fn detect_system_lang() -> Lang {
|
||||
Lang::EN
|
||||
}
|
||||
|
||||
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame.
|
||||
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. The app keeps one
|
||||
/// index for the whole process. Unit tests keep one per thread, as `ui_mirror!` keeps the
|
||||
/// shortcut and workspace mirrors, so a language one test sets does not change the language of
|
||||
/// the tests running beside it on other threads.
|
||||
fn current_index() -> &'static AtomicUsize {
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||
&CURRENT
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
thread_local!(static CURRENT: &'static AtomicUsize = Box::leak(Box::default()));
|
||||
CURRENT.with(|c| *c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the UI language for drawing (the shell calls this once per frame from the preference), so
|
||||
/// widgets can translate without every call site carrying a language around.
|
||||
pub fn set_current(lang: Lang) {
|
||||
let i = LANGUAGES.iter().position(|l| l.code == lang.code()).unwrap_or(0);
|
||||
CURRENT.store(i, Ordering::Relaxed);
|
||||
current_index().store(i, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The language the UI is drawn in.
|
||||
pub fn current() -> Lang {
|
||||
Lang(LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0]))
|
||||
Lang(LANGUAGES.get(current_index().load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0]))
|
||||
}
|
||||
|
||||
/// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("nl_NL"), None);
|
||||
assert_eq!(lang_from_tag("qaa-QM"), None, "the private-use tag names no language");
|
||||
// German: Germany, Austria, Switzerland and the rest share the one catalog.
|
||||
for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(de()), "{tag}");
|
||||
@@ -67,7 +67,7 @@ fn candidates_walk_from_specific_to_general() {
|
||||
#[test]
|
||||
fn os_language_lists_are_parsed() {
|
||||
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
|
||||
assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN));
|
||||
assert_eq!(first_supported("(\n \"qaa-QM\",\n \"en-US\"\n)\n"), Some(Lang::EN), "the private-use tag falls through to the next entry");
|
||||
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de()));
|
||||
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
|
||||
assert_eq!(first_supported("("), None);
|
||||
@@ -100,6 +100,15 @@ fn current_language_is_a_frame_setting() {
|
||||
assert_eq!(t("Layers"), "Layers");
|
||||
}
|
||||
|
||||
/// Unit tests run on parallel threads, and each one draws in its own language: a language set on
|
||||
/// another thread (by a test running beside this one) leaves this thread's text in English.
|
||||
#[test]
|
||||
fn a_language_set_by_another_test_leaves_this_one_in_english() {
|
||||
std::thread::spawn(|| set_current(ZH())).join().unwrap();
|
||||
assert_eq!(current(), Lang::EN);
|
||||
assert_eq!(t("Layers"), "Layers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_fall_back_to_english() {
|
||||
assert_eq!(tr(Lang::EN, "Layers"), "Layers");
|
||||
@@ -197,8 +206,7 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() {
|
||||
}
|
||||
|
||||
/// The engine learns the language the UI is drawn in each frame: new type takes the Japanese
|
||||
/// defaults while it is Japanese (#432). The frames stay in English: the drawing language is
|
||||
/// process-wide, and a Japanese frame would translate the tests running beside this one.
|
||||
/// defaults while it is Japanese (#432). The frames stay in English.
|
||||
#[test]
|
||||
fn the_engine_follows_the_interface_language() {
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -275,6 +275,10 @@ pub struct Services {
|
||||
/// The window is on Wayland: the tool cursors are painted into it instead of handed to the
|
||||
/// compositor as bitmaps ([`cursors::os_cursors`]).
|
||||
pub wayland: bool,
|
||||
/// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's
|
||||
/// default one would never come back once the canvas hid it: the canvas keeps the system
|
||||
/// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450).
|
||||
pub no_cursor_theme: bool,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
@@ -332,6 +336,8 @@ pub struct CacheKey {
|
||||
pub rot: f64,
|
||||
/// General › Anti-aliased Artwork.
|
||||
pub anti_alias: bool,
|
||||
/// Type › Highlight Alternate Glyphs (#394).
|
||||
pub highlight_alternate_glyphs: bool,
|
||||
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
|
||||
pub placed: u64,
|
||||
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
|
||||
|
||||
@@ -1242,8 +1242,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"view.screenMode" => {
|
||||
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
|
||||
Some(m) => m.min(3) as u8,
|
||||
// F cycles the three screen modes; from Presentation Mode (not in the cycle) it
|
||||
// goes back to Normal.
|
||||
// F cycles the three screen modes. Without a mode, the command also goes from
|
||||
// Presentation Mode (not in the cycle) back to Normal.
|
||||
None if app.ui.screen_mode >= 2 => 0,
|
||||
None => app.ui.screen_mode + 1,
|
||||
};
|
||||
|
||||
@@ -625,6 +625,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
display
|
||||
.floating
|
||||
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
|
||||
// Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one
|
||||
// before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421).
|
||||
let fills: Vec<_> = display
|
||||
.floating
|
||||
.iter()
|
||||
.filter_map(|group| group.panels.first())
|
||||
.map(|first| {
|
||||
let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first)));
|
||||
(layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop))
|
||||
})
|
||||
.collect();
|
||||
let mut draw = |ui: &mut egui::Ui| {
|
||||
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
|
||||
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
|
||||
@@ -644,7 +655,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
}
|
||||
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
|
||||
style.tab_height = 33.0;
|
||||
// The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour.
|
||||
// The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour
|
||||
// (square fills would cover its rounded corners). Floating groups get the fills below.
|
||||
style.background = egui::Color32::TRANSPARENT;
|
||||
style.tab_background = egui::Color32::TRANSPARENT;
|
||||
style.active_background = t.button;
|
||||
@@ -659,7 +671,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
style.resize_label = tl!("Resize panels").to_string();
|
||||
style.resize_window_label = tl!("Resize panel window").to_string();
|
||||
let mut content = PanelContent { app, commands: Vec::new() };
|
||||
let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized(
|
||||
let output = craft_ui::docking::DockArea::new(area).show_customized(
|
||||
ui,
|
||||
&display,
|
||||
&style,
|
||||
@@ -754,6 +766,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
|
||||
}
|
||||
}
|
||||
for (layer, fill) in fills {
|
||||
if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) {
|
||||
ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel));
|
||||
}
|
||||
}
|
||||
icon_rail(app, ui);
|
||||
crate::floating::docked_columns(app, ui);
|
||||
true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
@@ -614,3 +614,29 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() {
|
||||
app.run("window.panel", json!({"panel":"properties"})).unwrap();
|
||||
assert!(shown(&app), "without the menu's toggle the command only shows the panel");
|
||||
}
|
||||
|
||||
/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint
|
||||
/// their own opaque background (#2421).
|
||||
#[test]
|
||||
fn floating_group_paints_an_opaque_background() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap();
|
||||
let h = harness(app, vec2(1280.0, 800.0));
|
||||
// Paint order: the last big filled rectangle over a point decides what the user sees there
|
||||
// (outlines, small widgets and fully transparent fills, which paint nothing, don't count).
|
||||
fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option<egui::Color32>) {
|
||||
match shape {
|
||||
egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let opaque_at = |p: Pos2| {
|
||||
let mut found = None;
|
||||
h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found));
|
||||
found.is_some_and(|c| c.a() == 255)
|
||||
};
|
||||
// Tab strip to the right of the tabs, and the body's empty bottom-right corner.
|
||||
assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent");
|
||||
assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent");
|
||||
}
|
||||
@@ -782,10 +782,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
|
||||
widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px")));
|
||||
}
|
||||
Value::String(s) => {
|
||||
let mut buf = s.clone();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0));
|
||||
if r.lost_focus() && buf != *s {
|
||||
change = Some((key.clone(), json!(buf)));
|
||||
if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) {
|
||||
change = Some((key.clone(), json!(typed)));
|
||||
}
|
||||
}
|
||||
// Free Distort's corners are dragged in its dialog.
|
||||
@@ -1059,6 +1057,72 @@ mod tests {
|
||||
assert_eq!(editor_params(&e).get("angle"), Some(&json!(270)));
|
||||
}
|
||||
|
||||
/// A text field of the inline editor keeps what is typed from one frame to the next: a Drop
|
||||
/// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step.
|
||||
/// Escape drops what is typed.
|
||||
#[test]
|
||||
fn the_effect_editor_keeps_text_typed_across_frames() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
set_pstate(&ctx, "ap-fx-open-None-0", true);
|
||||
let mut time = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
time += 0.1;
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
// Every text drawn, with its center.
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let drawn = |out: &egui::FullOutput| {
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
};
|
||||
run(&mut app, vec![]);
|
||||
let shown = drawn(&run(&mut app, vec![]));
|
||||
let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p);
|
||||
let Some(at) = at else { panic!("no color field: {shown:?}") };
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
|
||||
run(&mut app, events);
|
||||
}
|
||||
run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
for ch in "#ff0000".chars() {
|
||||
run(&mut app, vec![egui::Event::Text(ch.to_string())]);
|
||||
}
|
||||
let shown = drawn(&run(&mut app, vec![]));
|
||||
assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}");
|
||||
run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
run(&mut app, vec![]);
|
||||
let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone();
|
||||
assert_eq!(color(&app), json!("#ff0000"));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// Escape drops what is typed.
|
||||
for events in [vec![button(true)], vec![button(false)]] {
|
||||
run(&mut app, events);
|
||||
}
|
||||
run(&mut app, vec![egui::Event::Text("0".into())]);
|
||||
let shown = drawn(&run(&mut app, vec![]));
|
||||
assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}");
|
||||
run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
run(&mut app, vec![]);
|
||||
assert_eq!(color(&app), json!("#ff0000"));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_pick_the_command() {
|
||||
use vectorcraft_doc::{Appearance, FillLayer};
|
||||
|
||||
@@ -147,6 +147,28 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
range_style(app, p);
|
||||
}
|
||||
|
||||
/// Set the Kerning field's `value` (a number or "auto"). At a caret in text the Type tool edits
|
||||
/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it
|
||||
/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like
|
||||
/// the others: the selected range, or all the text.
|
||||
fn set_kerning(app: &mut VectorcraftApp, value: Value) {
|
||||
let Some((id, a, b)) = text_editing(app) else {
|
||||
format(app, json!({"kerning": value}));
|
||||
return;
|
||||
};
|
||||
if b > a {
|
||||
range_style(app, json!({"kerning": value}));
|
||||
return;
|
||||
}
|
||||
let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return };
|
||||
let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a);
|
||||
if start >= a {
|
||||
return;
|
||||
}
|
||||
end_typing(app);
|
||||
app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok();
|
||||
}
|
||||
|
||||
/// The faces Type › Bold and Type › Italic switch to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Face {
|
||||
@@ -450,7 +472,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
||||
cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| {
|
||||
let kerning = shared(app, |s| s.kerning.unwrap_or(0.0));
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) {
|
||||
format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) });
|
||||
set_kerning(app, if v == 0.0 { json!("auto") } else { json!(v) });
|
||||
}
|
||||
});
|
||||
cell(ui, "VA↔", tl!("Tracking"), |ui| {
|
||||
@@ -783,6 +805,30 @@ mod tests {
|
||||
assert_eq!(text_style(&app).unwrap().0.size, 5.0);
|
||||
}
|
||||
|
||||
/// The Kerning field at a caret kerns that pair, not the whole text (#2217).
|
||||
#[test]
|
||||
fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone();
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
app.session.set_tool_option("select", &json!({"start": 2, "end": 2}));
|
||||
set_kerning(&mut app, json!(-50));
|
||||
let kerning = |app: &VectorcraftApp, at: usize| {
|
||||
let st = app.session.active().unwrap();
|
||||
let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") };
|
||||
vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at`
|
||||
};
|
||||
assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret");
|
||||
assert_eq!(kerning(&app, 3), None, "the rest is untouched");
|
||||
assert_eq!(kerning(&app, 0), None);
|
||||
// A selected range still takes it whole.
|
||||
app.session.set_tool_option("select", &json!({"start": 2, "end": 4}));
|
||||
set_kerning(&mut app, json!(30));
|
||||
assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0)));
|
||||
}
|
||||
|
||||
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
|
||||
#[test]
|
||||
fn bold_and_italic_pick_the_familys_own_faces() {
|
||||
|
||||
@@ -165,14 +165,14 @@ fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) {
|
||||
}
|
||||
|
||||
/// The document's gradient swatches (groups included): name and gradient.
|
||||
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> {
|
||||
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, GradientPaint)> {
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
d.swatches
|
||||
.iter()
|
||||
.chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
|
||||
.filter_map(|s| match &s.paint {
|
||||
Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())),
|
||||
Paint::Gradient(g) => Some((s.name.clone(), (**g).clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@@ -370,7 +370,7 @@ fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) {
|
||||
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag());
|
||||
widgets::gradient_chip(ui, r, &g.gradient);
|
||||
widgets::gradient_chip(ui, r, g);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text(tl!("Gradient Fill: click to apply, drag onto art"));
|
||||
if resp.clicked() && !is_grad {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Graphic Styles panel: the document's styles as rendered thumbnails (Thumbnail View) or rows
|
||||
//! (Small and Large List View), shown on a square or on type. Clicking a style applies it to the
|
||||
//! selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click opens
|
||||
//! Graphic Style Options. Dragging a style moves it in the list, or applies it to the art it is
|
||||
//! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's
|
||||
//! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it
|
||||
//! large, on the selected object when there is one. The Control bar's Style picker lists the same
|
||||
//! tiles ([`picker`]). Graphic style libraries open in the library panel
|
||||
//! selection (Alt-click adds it on top); Shift-click adds to the panel's selection every style from
|
||||
//! the last one clicked without Shift through this one; Cmd/Ctrl-click adds or removes one;
|
||||
//! double-click opens Graphic Style Options. Dragging a style moves it in the list, or applies it
|
||||
//! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance
|
||||
//! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style
|
||||
//! shows it large, on the selected object when there is one. The Control bar's Style picker lists
|
||||
//! the same tiles ([`picker`]). Graphic style libraries open in the library panel
|
||||
//! ([`GraphicStyleLibraries`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -270,8 +271,8 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize)
|
||||
|
||||
/// What a click on a style does.
|
||||
enum Click {
|
||||
Apply(String),
|
||||
Toggle(String),
|
||||
/// A click with the modifiers held.
|
||||
Pick(String, egui::Modifiers),
|
||||
Options(String),
|
||||
}
|
||||
|
||||
@@ -294,8 +295,7 @@ fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option<Click> {
|
||||
if resp.double_clicked() {
|
||||
Some(Click::Options(name.to_string()))
|
||||
} else if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) })
|
||||
Some(Click::Pick(name.to_string(), ui.input(|i| i.modifiers)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -419,24 +419,19 @@ fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) {
|
||||
run(app, "graphicStyle.new", json!({ "id": id.0 }));
|
||||
}
|
||||
match ev.click {
|
||||
Some(Click::Apply(name) | Click::Toggle(name)) if picker => {
|
||||
Some(Click::Pick(name, _)) if picker => {
|
||||
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
|
||||
ui.close();
|
||||
}
|
||||
Some(Click::Apply(name)) => {
|
||||
// With nothing selected the next object drawn takes the style.
|
||||
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
|
||||
set_pstate(ui.ctx(), "gs-sel", vec![name]);
|
||||
}
|
||||
Some(Click::Toggle(name)) => {
|
||||
let mut sel = selected(ui.ctx(), s.d);
|
||||
match sel.iter().position(|n| *n == name) {
|
||||
Some(i) => {
|
||||
sel.remove(i);
|
||||
}
|
||||
None => sel.push(name),
|
||||
}
|
||||
Some(Click::Pick(name, m)) => {
|
||||
let order: Vec<&str> = s.d.graphic_styles.iter().map(|g| g.name.as_str()).collect();
|
||||
let sel = super::swatches::click_selection(ui, "gs-anchor", selected(ui.ctx(), s.d), &order, &name, m);
|
||||
set_pstate(ui.ctx(), "gs-sel", sel);
|
||||
// A plain click also applies the style (with nothing selected the next object drawn
|
||||
// takes it); Shift and Cmd/Ctrl clicks only select.
|
||||
if !(m.shift || m.command) {
|
||||
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
|
||||
}
|
||||
}
|
||||
Some(Click::Options(name)) => {
|
||||
app.run("ui.graphicStyleOptions", json!({ "name": name })).ok();
|
||||
@@ -938,6 +933,53 @@ mod tests {
|
||||
assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_selects_a_range_and_cmd_click_adds_or_removes_one_style() {
|
||||
let mut app = app();
|
||||
app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap();
|
||||
let n = names(&app);
|
||||
assert_eq!(n.len(), 5);
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "gs-view", View::SmallList);
|
||||
frame(&ctx, |ui| show(&mut app, ui));
|
||||
// A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held,
|
||||
// a second after the last one so that two clicks on a row are not a double-click.
|
||||
let mut time = 0.0;
|
||||
let mut click = |app: &mut VectorcraftApp, i: usize, modifiers: egui::Modifiers| {
|
||||
let at = pos2(60.0, 10.0 + 21.0 * i as f32);
|
||||
time += 1.0;
|
||||
for pressed in [true, false] {
|
||||
let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0))),
|
||||
events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), button],
|
||||
time: Some(time + if pressed { 0.0 } else { 0.05 }),
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
}
|
||||
selected(&ctx, &app.session.active().unwrap().doc.clone())
|
||||
};
|
||||
let linked = |app: &VectorcraftApp| app.session.selection_graphic_style().map(|(g, _)| g.name.clone());
|
||||
// A plain click applies the style and selects it alone.
|
||||
assert_eq!(click(&mut app, 0, egui::Modifiers::NONE), [n[0].clone()]);
|
||||
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
|
||||
// Shift-click selects from that style to the clicked one, and applies nothing.
|
||||
assert_eq!(click(&mut app, 3, egui::Modifiers::SHIFT), n[..4]);
|
||||
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
|
||||
// Cmd/Ctrl-click removes a selected style and adds it back.
|
||||
assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone()]);
|
||||
assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone(), n[2].clone()]);
|
||||
// Shift-click extends from the style Cmd/Ctrl-clicked last.
|
||||
click(&mut app, 0, egui::Modifiers::NONE);
|
||||
click(&mut app, 2, egui::Modifiers::COMMAND);
|
||||
assert_eq!(click(&mut app, 4, egui::Modifiers::SHIFT), [n[0].clone(), n[2].clone(), n[3].clone(), n[4].clone()]);
|
||||
assert_eq!(linked(&app).as_ref(), Some(&n[0]));
|
||||
// A plain click applies the next style.
|
||||
assert_eq!(click(&mut app, 4, egui::Modifiers::NONE), [n[4].clone()]);
|
||||
assert_eq!(linked(&app).as_ref(), Some(&n[4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_button_shows_a_large_preview_and_the_picker_applies_styles() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -274,10 +274,10 @@ fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K
|
||||
});
|
||||
});
|
||||
if let Some((name, folder, m)) = clicked {
|
||||
// A plain click on an item adds and applies it; folders and modifier clicks select.
|
||||
// A plain click on an item also adds and applies it; folders and modifier clicks only select.
|
||||
let only_select = folder || m.shift || m.command;
|
||||
let order: Vec<&str> = rows.iter().map(|r| r.name).collect();
|
||||
let sel = if only_select { click_selection(ui, &key::<K>("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] };
|
||||
let sel = click_selection(ui, &key::<K>("anchor"), selected, &order, &name, m);
|
||||
set_selection::<K>(ui.ctx(), id, sel);
|
||||
if !only_select {
|
||||
K::click(app, ui, id, &name);
|
||||
@@ -485,6 +485,26 @@ mod tests {
|
||||
assert!(selection::<SwatchLibraries>(&ctx, "brights").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_click_sets_where_a_shift_click_range_starts() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap();
|
||||
let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap();
|
||||
let order: Vec<String> = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect();
|
||||
click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0);
|
||||
click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0);
|
||||
assert_eq!(selection::<SwatchLibraries>(&ctx, "pastels"), order[1..=4], "the range starts at the plain click");
|
||||
// A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item.
|
||||
click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0);
|
||||
click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0);
|
||||
click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0);
|
||||
assert_eq!(selection::<SwatchLibraries>(&ctx, "pastels"), order[2..=3]);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch");
|
||||
assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_filters_list_view_and_arrows_step_through_the_libraries() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Links panel: every image in the document, linked files with their status (missing, modified)
|
||||
//! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects
|
||||
//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link
|
||||
//! Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find
|
||||
//! Linked Files: `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort,
|
||||
//! thumbnail size and Show in Folder.
|
||||
//! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows
|
||||
//! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink,
|
||||
//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the
|
||||
//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files:
|
||||
//! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, thumbnail size
|
||||
//! and Show in Folder.
|
||||
//!
|
||||
//! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI
|
||||
//! commands: they hand the file to the system).
|
||||
@@ -245,6 +247,31 @@ fn status_tip(r: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the row of image `id` among the shown `rows`: plain selects the image alone,
|
||||
/// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked
|
||||
/// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A
|
||||
/// Shift-click while that row is not shown, or after the last click was in another document, acts
|
||||
/// as a click without Shift.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) {
|
||||
let Some(uid) = app.session.active().map(|st| st.uid) else { return };
|
||||
let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor");
|
||||
let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id));
|
||||
if m.shift
|
||||
&& let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid)
|
||||
&& let (Some(i), Some(j)) = (at(a), at(id))
|
||||
{
|
||||
let range: Vec<u64> = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect();
|
||||
app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok();
|
||||
return;
|
||||
}
|
||||
set_pstate(ctx, "links-anchor", Some((uid, id)));
|
||||
if m.command {
|
||||
app.run("select.toggle", json!({ "id": id })).ok();
|
||||
} else {
|
||||
app.run("select.set", json!({ "ids": [id] })).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let o = options(ui.ctx());
|
||||
@@ -257,7 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let thumbs = super::layers::ThumbSource::of(app, st, &doc);
|
||||
let selected = selected_images(app);
|
||||
let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice);
|
||||
let mut clicked: Option<(u64, bool, bool)> = None;
|
||||
let mut clicked: Option<(u64, egui::Modifiers, bool)> = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.set_width(ui.available_width());
|
||||
@@ -317,21 +344,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
);
|
||||
let resp = resp.on_hover_text(status_tip(r));
|
||||
if resp.double_clicked() {
|
||||
clicked = Some((id, false, true));
|
||||
clicked = Some((id, egui::Modifiers::NONE, true));
|
||||
} else if resp.clicked() {
|
||||
clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false));
|
||||
clicked = Some((id, ui.input(|i| i.modifiers), false));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
match clicked {
|
||||
Some((id, _, true)) => go_to(app, id),
|
||||
Some((id, true, _)) => {
|
||||
app.run("select.toggle", json!({ "id": id })).ok();
|
||||
}
|
||||
Some((id, false, _)) => {
|
||||
app.run("select.set", json!({ "ids": [id] })).ok();
|
||||
}
|
||||
Some((id, m, false)) => click(app, ui.ctx(), rows, id, m),
|
||||
None => {}
|
||||
}
|
||||
if o.info {
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user