diff --git a/CLAUDE.md b/CLAUDE.md index cbf9de129..0460b6192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input. - Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort. - Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible. + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it. - Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them. - Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why. - Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that. diff --git a/Cargo.toml b/Cargo.toml index d75f62eee..c134a3c19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,9 @@ webbrowser = "1" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see # AGENTS.md › Robustness). Tests may unwrap (clippy.toml). diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index c45c5d867..9caf4bc92 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() { let mut stdin = child.stdin.take().unwrap(); let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); let mut notes = vec![]; - - fn send(stdin: &mut std::process::ChildStdin, v: Value) { - writeln!(stdin, "{v}").unwrap(); + exercise_mcp_core(&mut stdin, &mut lines, &mut notes); + exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes); + exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes); + exercise_mcp_logging(&mut stdin, &mut lines, &mut notes); + exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes); + drop(stdin); + for line in lines { + let v: Value = serde_json::from_str(&line.unwrap()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + notes.push(v); } - // Next reply with this id; notifications (no id) are stashed for later. - fn recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { - loop { - let line = lines.next().unwrap().unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - if v.get("id").and_then(|i| i.as_u64()) == Some(id) { - return v; - } - notes.push(v); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); + let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); + assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); +} + +fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) { + writeln!(stdin, "{v}").unwrap(); +} + +fn mcp_recv(lines: &mut std::io::Lines>, notes: &mut Vec, id: u64) -> Value { + loop { + let line = lines.next().unwrap().unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["jsonrpc"], "2.0", "{v}"); + if v.get("id").and_then(|i| i.as_u64()) == Some(id) { + return v; } + notes.push(v); } +} - send( - &mut stdin, +fn exercise_mcp_core( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), ); - let r = recv(&mut lines, &mut notes, 1); + let r = mcp_recv(lines, notes, 1); assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft"); assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); - send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); - let r = recv(&mut lines, &mut notes, 2); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}})); + let r = mcp_recv(lines, notes, 2); assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); - let r = recv(&mut lines, &mut notes, 3); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}})); + let r = mcp_recv(lines, notes, 3); assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}"); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}), ); - let r = recv(&mut lines, &mut notes, 4); + let r = mcp_recv(lines, notes, 4); assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); - send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}})); + assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3)); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}})); + assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4)); - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}), ); - let r = recv(&mut lines, &mut notes, 7); + let r = mcp_recv(lines, notes, 7); assert_eq!(r["result"]["isError"], false, "{r}"); let id: i64 = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap(); - send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); - let r = recv(&mut lines, &mut notes, 8); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}})); + let r = mcp_recv(lines, notes, 8); let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(body["id"].as_i64(), Some(id), "{body}"); +} +fn exercise_mcp_resource_errors( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Unknown resources (-32002) and bad template values (-32602) stay apart. - send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); - assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002); - send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); - assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }})); + assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); + assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602); +} +fn exercise_mcp_document_resources( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Large containers slice through run_command document.node: a truncated level reports // childCount, and a bad limit is a tool error, not a crash or a full read. - send( - &mut stdin, + mcp_send( + stdin, json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}), ); - assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false); - let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}); - send(&mut stdin, run(21, "select.all", json!({}))); - recv(&mut lines, &mut notes, 21); - send(&mut stdin, run(22, "object.group", json!({}))); - let r = recv(&mut lines, &mut notes, 22); + assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false); + mcp_send(stdin, run_command(21, "select.all", json!({}))); + mcp_recv(lines, notes, 21); + mcp_send(stdin, run_command(22, "object.group", json!({}))); + let r = mcp_recv(lines, notes, 22); let group = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); - send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); - let r = recv(&mut lines, &mut notes, 23); + mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); + let r = mcp_recv(lines, notes, 23); let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}"); assert_eq!(sliced["childCount"], 2, "{sliced}"); - send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); - assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true); + mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); + assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true); // Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and // document.find locates nodes without dumping the tree. - send(&mut stdin, run(25, "document.inspect", json!({"depth": 0}))); - let r = recv(&mut lines, &mut notes, 25); + mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0}))); + let r = mcp_recv(lines, notes, 25); let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert!(skel["layers"][0].get("children").is_none(), "{skel}"); assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}"); - send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"}))); - let r = recv(&mut lines, &mut notes, 26); + mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"}))); + let r = mcp_recv(lines, notes, 26); let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(found["total"], 1, "{found}"); assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}"); - send(&mut stdin, run(27, "document.find", json!({}))); - assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true); + mcp_send(stdin, run_command(27, "document.find", json!({}))); + assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true); +} +fn exercise_mcp_logging( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // The binary installed its logger: records arrive as notifications/message, and only // after the client asked for them. - send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); - assert!(recv(&mut lines, &mut notes, 11).get("error").is_none()); - send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); - assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}})); + assert!(mcp_recv(lines, notes, 11).get("error").is_none()); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}})); + assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty())); +} +fn exercise_mcp_parse_error( + stdin: &mut std::process::ChildStdin, + lines: &mut std::io::Lines>, + notes: &mut Vec, +) { // Malformed input is a parse error, and the server keeps serving afterwards. stdin.write_all(b"{not json\n").unwrap(); let parse_err: Value = loop { @@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() { }; assert_eq!(parse_err["error"]["code"], -32700); assert!(parse_err["id"].is_null(), "{parse_err}"); - send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); - assert!(recv(&mut lines, &mut notes, 13).get("error").is_none()); - drop(stdin); - // Drain whatever the debug window queued; every line off the protocol stream parses. - for line in lines.by_ref() { - let line = line.unwrap(); - let v: Value = serde_json::from_str(&line).unwrap(); - assert_eq!(v["jsonrpc"], "2.0", "{v}"); - notes.push(v); - } - let out = child.wait_with_output().unwrap(); - assert!(out.status.success()); - assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); - let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count(); - assert!(messages > 0, "no log records after logging/setLevel: {notes:?}"); + mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}})); + assert!(mcp_recv(lines, notes, 13).get("error").is_none()); +} + +fn run_command(id: u64, command: &str, params: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}) } /// `vectorcraft-cli commands | head -1` panicked with "failed printing to diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index c7d6ada32..d7432ff96 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,5 +1,7 @@ use super::prefs::save_prefs; -use super::*; +#[cfg(all(windows, not(target_vendor = "win7")))] +use super::windows_stylus; +use super::{App, VectorcraftApp, file_access, window}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 957d302d5..0b228f62c 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -164,7 +164,9 @@ mod win { use clipboard_win::{formats, options::NoClear, raw}; - use super::*; + use super::{ + BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats, + }; /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the /// other or a `CF_BITMAP` is: the system converts between them. @@ -309,7 +311,7 @@ mod win { mod portable { use std::borrow::Cow; - use super::*; + use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; /// What our last write put on the clipboard. enum Ours { diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index aa7d7f42c..054dec69f 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,4 +1,10 @@ -use super::*; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +use super::portal_save; +#[cfg(target_os = "linux")] +use super::tablet; +#[cfg(all(windows, not(target_vendor = "win7")))] +use super::windows_stylus; +use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; /// The window file dialogs belong to. pub(crate) type Parent = Option>; diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index 67a5a4a08..56ea30f21 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, fileio, prefs_dir}; /// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), /// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index c9e6539a2..5464bb959 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -38,7 +38,7 @@ impl PrintService for SystemPrint { #[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))] mod system { - use super::*; + use super::{Command, PrintJob, Printer}; pub fn printers() -> Vec { craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect() } diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 3d538ec88..6ebac2bea 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -1,4 +1,6 @@ -use super::*; +use super::window; +#[cfg(target_os = "linux")] +use super::xwayland; /// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets /// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches diff --git a/clippy.toml b/clippy.toml index d02d2c40c..6cf0a13be 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,3 +6,4 @@ allow-panic-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs index 0a48193e2..7ef9fff0b 100644 --- a/crates/affinity/src/model/bounds.rs +++ b/crates/affinity/src/model/bounds.rs @@ -1,5 +1,5 @@ use super::read::MAX_TREE_DEPTH; -use super::*; +use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag}; /// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames /// and tables), in document pixels. diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 755e93745..daf5d778c 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -1,5 +1,8 @@ use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; -use super::*; +use super::{ + Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream, + Tag, paint, stream, +}; /// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). const MAX_PROFILE_BYTES: usize = 32 << 20; diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 6103e7dfd..dd5c41325 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -1,6 +1,6 @@ use super::profile::{PressureProfile, WidthProfile}; use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; -use super::*; +use super::{BlendMode, Deserialize, Paint, Serialize}; /// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 56df51ef6..ccae9666b 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), /// in order along the path. Two points at the same position make a discontinuous point: the diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 3e6a29a32..acd4d8189 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum LineCap { diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index bdd20f4c8..b78b8eb01 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a17b430be..478e54ca8 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -3,7 +3,10 @@ use super::graphic_style::default_graphic_styles; use super::guides::{GridPrefs, RulerMode}; use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID}; use super::units::{ColorMode, Unit}; -use super::*; +use super::{ + Arc, BTreeMap, ColorProfiles, DocMetadata, DocSetup, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, PatternDef, Places, Point, + RasterEffectsSettings, Rect, Vec2, +}; impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index 9d17f689a..c0c85b124 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -1,7 +1,10 @@ use super::graphic_style::GraphicStyle; use super::limits::MAX_NEXT_ID; use super::records::{ImageBlob, SavedSelection, SavedView}; -use super::*; +use super::{ + Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice, + SymbolRegistration, +}; impl Document { /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 96af5c75b..43e1c4c92 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,6 @@ use super::document_ext::free_id; use super::guides::Guide; -use super::*; +use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol diff --git a/crates/doc/src/graphic_style.rs b/crates/doc/src/graphic_style.rs index 95c61b6eb..789684736 100644 --- a/crates/doc/src/graphic_style.rs +++ b/crates/doc/src/graphic_style.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Appearance, Deserialize, Knockout, Node, Rect, Serialize, skip}; /// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 0c10efff2..08f828391 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, NodeId, Serialize, Vec2}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index e030f3d71..027148ade 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -1,4 +1,4 @@ -use super::*; +use super::NodeId; /// Coordinates beyond this many points exceed the supported document range. pub const MAX_COORD: f64 = 4.0e6; diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 79c129030..75df5eee1 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, PathData, Serialize}; // ===================================================================================== // Data diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 13d30cef0..6604c86d1 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -1,6 +1,6 @@ use super::expand::{Hooks, Outliner, Warper, map_node}; use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; -use super::*; +use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 09eada113..61b0b91a9 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -1,7 +1,10 @@ use super::envelope::{EnvelopeOptions, expand_envelope}; use super::mesh::GradientMesh; use super::warp::map_nonlinear; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2, + blend_expand, +}; /// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 2cbe7602c..783e32556 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,6 +1,9 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; -use super::*; +use super::{ + Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, + one, +}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MeshPoint { diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index cacf26ec5..6d7392ac5 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Anchor, BezPath, CubicBez, PI, ParamCurve, PathData, Point, SubPath}; // ===================================================================================== // Warp maps (shared with vectorcraft-effects' Warp effects) diff --git a/crates/doc/src/node/attributes.rs b/crates/doc/src/node/attributes.rs index 70653d778..42e41654f 100644 --- a/crates/doc/src/node/attributes.rs +++ b/crates/doc/src/node/attributes.rs @@ -1,6 +1,6 @@ use super::kind::NodeKind; use super::live_shape::LiveShape; -use super::*; +use super::{Deserialize, Node, Serialize}; /// Window → Attributes: an object's settings that don't change how it prints. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 959b20a5b..208bdda7f 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -1,5 +1,5 @@ use super::kind::NodeKind; -use super::*; +use super::{BezPath, FillRule, Node, Outliner, Rect, shapes}; /// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans /// live above this crate (`vectorcraft-pathops`), so callers supply it. diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index ee1165303..468930348 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -2,7 +2,7 @@ use super::attributes::ObjectAttributes; use super::kind::NodeKind; use super::live_shape::LiveShape; use super::opacity::OpacityMask; -use super::*; +use super::{Node, PathData}; /// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without /// them stay small ([`Node::extras`]). diff --git a/crates/doc/src/node/ids.rs b/crates/doc/src/node/ids.rs index ea4b1b484..9e09a47a9 100644 --- a/crates/doc/src/node/ids.rs +++ b/crates/doc/src/node/ids.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Stable per-document object id. Never reused. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 5bebc4379..0994cf390 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -1,6 +1,6 @@ use super::ids::LayerColor; use super::live_shape::LiveShape; -use super::*; +use super::{Affine, Arc, BlendSpec, Deserialize, EnvelopeKind, FillRule, GradientMesh, Node, PathData, RepeatSpec, Serialize, TextObject, yes}; /// Embedded or linked raster image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs index 65dad904e..93f2022e0 100644 --- a/crates/doc/src/node/live_shape.rs +++ b/crates/doc/src/node/live_shape.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, CornerKind, Deserialize, PathData, Point, Rect, Serialize, cut_corners, shapes}; /// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these /// while the shape stays live; editing anchors directly converts it to a plain path. diff --git a/crates/doc/src/node/opacity.rs b/crates/doc/src/node/opacity.rs index 0996b8c65..903c84b14 100644 --- a/crates/doc/src/node/opacity.rs +++ b/crates/doc/src/node/opacity.rs @@ -1,5 +1,5 @@ use super::kind::NodeKind; -use super::*; +use super::{Arc, Color, Deserialize, Node, Paint, Serialize, yes}; /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 6cc0cb3be..e02958a8d 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -3,7 +3,7 @@ use super::ids::{LayerColor, NodeId}; use super::kind::NodeKind; use super::live_shape::{Scaling, moves_only}; use super::opacity::Knockout; -use super::*; +use super::{Affine, Appearance, Arc, BlendMode, EnvelopeKind, FillRule, Node, PathData, Point, Rect, RepeatSpec, SYMBOL_SQUARE, Shape, SymbolBoxes}; impl Node { pub fn new(id: NodeId, kind: NodeKind) -> Self { diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 43fcfea92..0af451316 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -3,7 +3,7 @@ use super::extras::{Extras, NO_EXTRAS, NodeExtras}; use super::ids::NodeId; use super::kind::NodeKind; use super::opacity::{Knockout, OpacityMask}; -use super::*; +use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes}; /// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from /// the node and its extras (nothing is copied). The node reads back through `NodeWire`. diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index e9af0e682..b13a1e955 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Node, NodeId, Point, Serialize}; /// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in /// place, so a selection change that sets it doesn't replace the document. diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 5897d957a..b30afd412 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -1,5 +1,5 @@ use super::para_style::FirstBaseline; -use super::*; +use super::{Deserialize, PathData, Serialize}; /// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index a19b5f71c..0e3aea70e 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -1,5 +1,5 @@ use super::para_style::ParaDirection; -use super::*; +use super::{Appearance, AppearanceItem, Color, Dash, Deserialize, FillLayer, LineCap, LineJoin, Paint, Serialize, StrokeAlign, StrokeLayer}; /// Character attributes (the Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 58caa1602..de77da298 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -3,7 +3,7 @@ use super::char_style::CharStyle; use super::para_style::ParaStyle; use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; use super::tabs_lists::ParaList; -use super::*; +use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; impl TextObject { /// None for collapsed or unrepresentable transforms, whose type dimensions cannot diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index 6a01a1815..a257e2e0a 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -1,6 +1,6 @@ use super::char_style::CharStyle; use super::tabs_lists::{ParaList, TabStop}; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index 4e3de9338..a540ba210 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -1,5 +1,5 @@ use super::char_style::CharStyle; -use super::*; +use super::{Affine, Deserialize, PathData, Point, Rect, Serialize, Vec2, yes}; /// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or /// [`ParaStyle`] fields, by their serialized names). Text using it records the name. diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 763a82c69..47106d788 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -1,5 +1,5 @@ use super::char_style::{CharPosition, CharStyle}; -use super::*; +use super::{Deserialize, Serialize}; /// Tab stop alignment (Tabs panel). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 1b09c4e7b..2c1fc8653 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Measurement units (display only; the model is always in points). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 14fd90c13..5ad48a1ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index b7430e0c7..5b0c14c62 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -4,7 +4,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; -use crate::util::*; +use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; fn diag(b: Rect) -> f64 { b.width().hypot(b.height()).max(1e-6) diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 7d25d1955..a4cb2e1d8 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -1,5 +1,8 @@ use super::params::{is_geometry, merged_params}; -use super::*; +use super::{ + Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize, + util, warp, +}; /// Context the geometry effects may need from the object's appearance. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -167,7 +170,7 @@ fn builds_within(id: &str) -> bool { /// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { - use util::*; + use util::{join, num}; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index dc54e8b51..d64ac7b78 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -1,5 +1,5 @@ use super::catalog::{catalog_index, plugin_info}; -use super::*; +use super::{CROP_MARKS, Effect, Map, Node, PIXEL_EFFECTS, RASTERIZE, Value, is_adjustment, is_pathfinder, is_three_d, json, util}; /// Dialog defaults for `id` (`None` for unknown effects). pub fn default_params(id: &str) -> Option { diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index fc3497ae6..ef4d9d7ab 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1755,6 +1755,10 @@ fn brush_strokes_params_take_defaults_and_stay_in_range() { fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})), PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 } ); + brush_strokes_outsets(); +} + +fn brush_strokes_outsets() { // Outsets: the strokes and the shifts reach out of the object as far as into it; Accented // Edges and Sumi-e keep the object's shape. let b = Rect::new(0.0, 0.0, 100.0, 50.0); @@ -2561,6 +2565,10 @@ fn artistic_params_take_defaults_and_stay_in_range() { fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})), PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 } ); + artistic_outsets_and_docs(); +} + +fn artistic_outsets_and_docs() { for id in [ "artistic.coloredPencil", "artistic.cutout", diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 79ad9c25e..f8590bbb2 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -9,7 +9,7 @@ use vectorcraft_geom::{Point, Rect}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; -use crate::util::*; +use crate::util::{flag, num, text}; /// Effect › Rasterize's id. pub const RASTERIZE: &str = "rasterize"; diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 98bf2dd67..f7dd503d2 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -5,7 +5,7 @@ use vectorcraft_geom::kurbo; use vectorcraft_geom::shapes::{self, KAPPA}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners}; -use crate::util::*; +use crate::util::{catmull_rom, flag, noise, num, seed}; /// Convert to Shape (rectangle / rounded rectangle / ellipse) around the bounds. pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData { diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index f3b7efea9..fb0279ede 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; -use crate::util::*; +use crate::util::{map_nonlinear_within, num, text}; pub use vectorcraft_doc::live::{WarpStyle, warp_point}; diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 3e1173d8b..9eee69d27 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -28,8 +28,11 @@ use vectorcraft_geom::Rect; use super::edit::{outermost, selected_roots}; use super::opacitymask::percent; use super::paint::paint_from; -use super::*; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; +use super::{ + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, + ok, paint_targets, str_param, switch_or, targets, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index c560a35c8..975b8e93f 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs index 7fdc5f1bf..0265b4ccd 100644 --- a/crates/engine/src/cmd/attributes.rs +++ b/crates/engine/src/cmd/attributes.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::FillRule; use super::overprint::{AttributesInfo, apply_overprint, edit_counted, overprint_jobs}; use super::path::paths_in; -use super::*; +use super::{CommandSpec, NodeId, Result, Session, bad, cmd, has_doc, str_param, targets}; /// How many URLs the Attributes panel's recent list keeps. const RECENT_URLS: usize = 10; diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 1c7de9978..9bb04dac9 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -22,7 +22,7 @@ use super::clipboard::resources::SwatchChoices; use super::patterncmds::add_pattern; use super::swatch::str_list; use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; /// The extension of saved brush libraries: native documents. pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b677b3003..f3c5b3e5b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -18,7 +18,10 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, ids_param, number_or, ok, + str_param, switch_or, +}; use crate::EngineError; mod brushes; diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 18f744b1d..66dd546e9 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value, + anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, + str_param, +}; // ---------- brushes ---------- diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 63eeed6b5..8e8ba163e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, + SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, + name_param, number_or, ok, remove_each, roots, selection_art, separate, set_unknown, str_param, switch_or, +}; // ---------- symbols ---------- diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 8d72ce335..c7a7c39b5 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -27,7 +27,10 @@ use super::draw2::replace_with_pieces; use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, ids_param, + number_or, point_param, str_param, switch_or, +}; mod live_paint; mod shape_builder; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index f9b224ce8..207182d40 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, + is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, +}; /// `options` as stored on a Live Paint group: none for the defaults. fn stored_gap_options(options: GapOptions) -> Option> { diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 07d4949a0..068146a2a 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, + f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids, + shape_node, switch_or, +}; pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "shapeBuilder.merge"; diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 2eb4bcfcc..c1e66d27c 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, +}; // ---------- Image Trace ---------- diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index a01f095fc..887c5b47a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -25,7 +25,7 @@ pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, P pub(crate) use resources::SwatchChoices; pub use resources::{ArtResources, art_resources}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, point_param, str_param}; use crate::DocState; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 08098705f..b7b046824 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{Affine, Point}; use super::super::fileio; use super::super::place::pt_per_px; -use super::*; +use super::{Clipboard, CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, looks_like_svg, point_param, str_param}; /// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code). pub const TEXT: &str = "text/plain"; diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 6fdc10f40..539785ab1 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -18,7 +18,9 @@ use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, color_value, f64_or, has_selection, ids_param, str_param, unique_name, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 40144f13e..5c31deb1c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -17,7 +17,9 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; use vectorcraft_render::proof; -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::EngineError; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, color_value, has_doc, str_param, targets}; /// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated /// to `Document::color_profiles` on load). diff --git a/crates/engine/src/cmd/colortheme.rs b/crates/engine/src/cmd/colortheme.rs index 68ec94d6e..9321fc12b 100644 --- a/crates/engine/src/cmd/colortheme.rs +++ b/crates/engine/src/cmd/colortheme.rs @@ -9,7 +9,7 @@ use vectorcraft_color::harmony::{Harmony, THEME_SIZE}; use vectorcraft_color::recolor::ColorKey; use vectorcraft_tools::params::color_json; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, color_value, has_doc, str_param}; /// A saved colour theme ([`crate::Prefs::color_themes`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index d5588ff44..11c479023 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -5,7 +5,10 @@ use vectorcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObje use vectorcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes}; use super::newart::NewArt; -use super::*; +use super::{ + CommandSpec, NodeId, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, f64_or, f64_req, finite_numbers, has_doc, + number_or, str_param, switch_or, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index d31656b04..1780cf2be 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index faf905052..90bd276c9 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -15,7 +15,7 @@ use vectorcraft_pathops::BoolOp; use super::draw2::{components, erase_outline, ids_json, replace_with_pieces}; use super::edit::selected_roots; use super::pathops::{Beside, node_path}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, finite_numbers, has_selection, point_param, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index ec1f91f20..a26eee473 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -1,5 +1,5 @@ //! Linked dimensions: generated groups share the existing text/path render and export pipeline. -use super::*; +use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc}; use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 674d44362..9b008500a 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -14,7 +14,10 @@ use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Pl use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, outermost, selected_roots}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, + ids_param, matrix_param, number_or, ok, point_indices, point_param, str_param, switch_or, targets, +}; use crate::EngineError; mod perspective; diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index a655e3e3f..848177027 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, Arc, Document, EngineError, Homography, NodeId, NodeKind, PerspectiveGrid, Plane, Point, Result, Session, Value, bad, check_numbers, + check_switches, collect_points, duplicate_in, finite_numbers, id_param, ids_param, json, matrix_param, number_or, ok, outermost, persp, + point_param, project_node, selected_roots, str_param, switch_or, +}; // ---------- perspective grid ---------- diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 0400ab123..7131dd9d4 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind, RepeatKind, R use vectorcraft_geom::{Affine, Rect}; use vectorcraft_text::embed::Embedding; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, has_selection, str_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 88da38361..f1cd09107 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -12,7 +12,10 @@ use vectorcraft_geom::Vec2; use super::edit::selected_roots; use super::pathops::Beside; use super::select::{selectable_ids, walk_selectable}; -use super::*; +use super::{ + CommandSpec, EngineError, Point, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok, + str_param, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 0dc6fbad3..3f7b63225 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, E use super::flatten::{FlattenOptions, FlattenerPreset}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, color_value, has_doc}; const C: &str = "document.setup"; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 5b884b3ee..dba46b480 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -17,7 +17,10 @@ use vectorcraft_tools::draw2::{eraser_area, eraser_tip}; use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, + switch_or, +}; use crate::EngineError; mod anchors; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 540627fe0..25a403cfb 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,5 +1,8 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; -use super::*; +use super::{ + Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, + add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, +}; // ---------- Curvature ---------- diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 84337aa27..95959f35f 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -2,7 +2,7 @@ use super::anchors::{convert_anchor, curvature, curvature_edit, remove_anchor, r use super::cut::{erase_region, erase_segments, knife, split}; use super::freehand::{blob, freehand}; use super::smooth::{join_scrub, smooth_region}; -use super::*; +use super::{CommandSpec, cmd, has_anchors, has_doc, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index db5d7e372..2812b2fdc 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Cap, Color, Document, EngineError, FillRule, Join, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, + Rect, Result, Selection, Session, SubPath, Value, Vec2, bad, index, json, po, +}; // ---------- helpers ---------- diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 69306ce8f..4747ef783 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,7 +1,10 @@ use super::common::{ dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, }; -use super::*; +use super::{ + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, +}; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index e6df1a60b..aeda2106d 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -2,7 +2,10 @@ use super::common::{ brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, within_input_bound, }; -use super::*; +use super::{ + Anchor, Appearance, BTreeSet, BoolOp, Color, EngineError, FillRule, NewArt, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result, + Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, +}; // ---------- Pencil / Paintbrush ---------- diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index e20665447..fd783aa4f 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -1,5 +1,8 @@ use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; -use super::*; +use super::{ + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + number_or, po, +}; // ---------- Smooth ---------- diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fa4878d2c..9a3b296fc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,7 +8,11 @@ use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; -use super::*; +use super::{ + CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req, + finite_numbers, has_clipboard, has_doc, has_selection, has_selection_or_artboard_tool, has_selection_or_guides, id_param, index, ok, point_param, + str_param, +}; use crate::{Clipboard, EngineError, inspect}; /// Register editing commands, including document-to-document artwork drops and their parameter contracts. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e91a621c4..933acc41b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -15,7 +15,7 @@ use vectorcraft_render::effects; use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_target, item_target_at, shared_stack}; use super::expand::has_seeded; use super::rasterfx; -use super::*; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index efcccd241..b0df586cf 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -22,7 +22,7 @@ use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_selection, str_param}; const C: &str = "object.expand"; /// Expand Gradient To → Specify … Objects: the default and the most. diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 549d0982c..36ccd7cdb 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document}; -use super::*; +use super::{Result, Session, bad}; const C: &str = "file.info"; /// Longest text field, in characters. diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6e5c9402e..6893c6f84 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use svg::options_map as svg_options; #[cfg(not(target_arch = "wasm32"))] pub use vectorcraft_format::write_atomic; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, has_selection}; use crate::EngineError; mod access; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 8dfbead2d..ffe34acfd 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::write_atomic; +use super::{EngineError, Result, Session, Value, bad, json}; // ---------- the file system (none on the web, where commands take and return bytes) ---------- // diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 90856aaa4..d1db60302 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -9,7 +9,9 @@ use serde_json::Value; #[cfg(not(target_arch = "wasm32"))] use serde_json::json; -use super::super::*; +use super::super::{Result, Session, bad}; +#[cfg(not(target_arch = "wasm32"))] +use super::super::{bool_or, find_command, str_param}; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs index 0d74d1d81..e87e9ac0f 100644 --- a/crates/engine/src/cmd/fileio/affinity/document.rs +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -1,7 +1,7 @@ use super::layout::{master_instances, master_prefix, page_masters}; use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost}; use super::nodes::rect_of; -use super::*; +use super::{Affine, Arc, Artboard, Blend, Builder, Document, Kind, Node, NodeKind, Result, af, err}; impl Builder { pub(super) fn new(k: f64) -> Self { diff --git a/crates/engine/src/cmd/fileio/affinity/import.rs b/crates/engine/src/cmd/fileio/affinity/import.rs index 625df1e3b..0b0d587d8 100644 --- a/crates/engine/src/cmd/fileio/affinity/import.rs +++ b/crates/engine/src/cmd/fileio/affinity/import.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Builder, Cursor, Document, Result, err, raster_doc}; /// What an Affinity file opened as. pub(in super::super) struct Imported { diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs index 157a38836..8b6d4c4a8 100644 --- a/crates/engine/src/cmd/fileio/affinity/layout.rs +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -1,5 +1,5 @@ use super::nodes::MAX_DEPTH; -use super::*; +use super::{AfAlign, CharStyle, Cursor, Justify, Kind, Rect, TextKind, TextObject, VerticalAlign, af, shapes}; /// The master page instances among `nodes` (also inside layers and groups): (master, first page, /// first master page, page count). diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs index bc55dbc25..91b0ce53c 100644 --- a/crates/engine/src/cmd/fileio/affinity/master_copies.rs +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -1,5 +1,5 @@ use super::nodes::MAX_DEPTH; -use super::*; +use super::{Kind, af}; /// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index a3867d0bd..0375ff39e 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, Blend, BlendMode, Builder, FillRule, Kind, LayerColor, Node, NodeKind, OpacityMask, Rect, af}; /// Deepest node tree mapped (the reader bounds trees well below this). pub(super) const MAX_DEPTH: usize = 160; diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs index a60911e0d..ce798dcfb 100644 --- a/crates/engine/src/cmd/fileio/affinity/paint.rs +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -1,7 +1,11 @@ use super::layout::png; use super::nodes::affine; use super::text::Placement; -use super::*; +use super::{ + AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind, + GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer, + TextObject, Vec2, af, ap, json, +}; impl Builder { /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's diff --git a/crates/engine/src/cmd/fileio/affinity/text.rs b/crates/engine/src/cmd/fileio/affinity/text.rs index cae76dc7b..beaaac541 100644 --- a/crates/engine/src/cmd/fileio/affinity/text.rs +++ b/crates/engine/src/cmd/fileio/affinity/text.rs @@ -1,6 +1,9 @@ use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell}; use super::nodes::affine; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, CharStyle, FillRule, FirstBaseline, Node, NodeKind, Paint, PathData, Point, Rect, + TextKind, TextObject, TextRun, Vec2, VerticalAlign, af, ap, shapes, +}; /// Where text goes: a rigid placement (translation and rotation) and the scale that maps node /// space into it (applied to the frame's geometry, not the type), plus the type's own scale. diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index ff8b8aece..d5aa6d144 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::Document; -use super::super::*; +use super::super::{Result, Session, bad, str_param}; use crate::{DocState, EngineError}; pub(super) fn batch(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0a0a8549f..4be6d8ccb 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -1,6 +1,6 @@ use super::access::file_list; use super::formats::formats; -use super::*; +use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, save, screens}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index f0bb1f400..a569f1b4c 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -10,7 +10,7 @@ use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; -use super::super::*; +use super::super::{CommandSpec, Result, Session, bad, cmd, has_doc}; use super::{ArtboardPick, Encoded, FormatOption}; const C: &str = "document.export"; diff --git a/crates/engine/src/cmd/fileio/dxfimport.rs b/crates/engine/src/cmd/fileio/dxfimport.rs index 3d2b66640..96fb71510 100644 --- a/crates/engine/src/cmd/fileio/dxfimport.rs +++ b/crates/engine/src/cmd/fileio/dxfimport.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_cad::ImportOptions; use vectorcraft_doc::{Document, Unit}; -use super::super::*; +use super::super::{CommandSpec, Result, Session, always, bad, cmd}; use super::{err, source}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index fbc18b422..30f9729ad 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -14,7 +14,7 @@ use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tga}; -use super::super::*; +use super::super::{EngineError, Result, bad, color_value, parse_range}; use super::Format; use super::imagemap::{self, MapKind}; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index d33037715..dd6bbb7fe 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -13,7 +13,7 @@ use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; use super::super::flatten::{FlattenOptions, flatten_document}; -use super::super::*; +use super::super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, str_param}; use super::{ArtboardPick, Encoded, FormatOption}; const C: &str = "document.export"; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index dd7307e84..2a3ff0e78 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes}; use crate::DocState; -use super::super::*; +use super::super::{Result, Session, bad, bool_or, edit, f64_or, str_param}; use super::encode::single_artboard; use super::{ ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return, diff --git a/crates/engine/src/cmd/fileio/export_names.rs b/crates/engine/src/cmd/fileio/export_names.rs index c512c4c4c..66bd6d395 100644 --- a/crates/engine/src/cmd/fileio/export_names.rs +++ b/crates/engine/src/cmd/fileio/export_names.rs @@ -1,5 +1,5 @@ use super::access::write_file; -use super::*; +use super::{Encoded, EngineError, Result, Session, Value, json}; /// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index a9d6f88ac..da62d3e20 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, svg, text, unsupported}; /// One option a format's encoder reads from the export params. #[derive(Clone, Copy, Debug)] diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 36b65d29e..c7c467dc8 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -37,7 +37,7 @@ use vectorcraft_color::BlendMode; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath}; -use super::super::*; +use super::super::{Affine, EngineError, NodeId, Result, bad}; use super::{ArtboardPick, Encoded, Format}; /// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 56f5aa0d7..59e784073 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; -use super::super::*; +use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file}; use crate::cmd::links::Resolved; diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs index 2c54d72e4..6bc658a4a 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -7,7 +7,7 @@ use serde_json::Value; use vectorcraft_doc::Document; use vectorcraft_metafile::Kind; -use super::super::*; +use super::super::{Result, bad}; use super::{ArtboardPick, Encoded, FormatOption}; const C: &str = "document.export"; diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index bcca24c34..d7dbbe5a6 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -12,7 +12,7 @@ use vectorcraft_color::cms; use vectorcraft_doc::Document; use vectorcraft_format::{PREVIEW_MAX, SaveOptions}; -use super::super::*; +use super::super::{EngineError, Result, bad}; use super::{Format, FormatOption}; /// The options `document.save` and the native encoder read. diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index df512f629..7902a0fed 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail}; use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; -use super::super::*; +use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; use super::{ArtboardPick, FormatOption, write_or_return}; use crate::EngineError; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index e747be71f..80575c23f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{ColorMode, Document}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs}; use super::super::colormgmt::Grays; -use super::super::*; +use super::super::{Result, Session, bad, f64_or, parse_range, str_param}; use super::{err, source}; /// The `document.open` options besides the file. The PDF ones (pages, box, password, text, diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 486777a24..7bd605b70 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; -use super::super::*; +use super::super::{CommandSpec, Result, Session, always, bad, bool_or, clock_date, cmd, date_param, has_doc, str_param}; use super::{ ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index ab8a0c432..328b1ca05 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -16,7 +16,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use vectorcraft_geom::Rect; -use super::super::*; +use super::super::{NodeId, Result, Session, bad, str_param}; use super::encode::single_artboard; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 2cbfdd9b7..f2b770682 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -386,8 +386,8 @@ fn open_exts_cover_every_readable_format() { #[test] fn library_open_filters_list_library_formats_first() { - let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect(); - assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS)); + let filters: Vec<_> = library_open_filters("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS).collect(); + assert_eq!(filters[0], ("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)); assert!(filters[0].1.contains(&"ase")); assert_eq!(filters[1], ("All readable files", OPEN_EXTS)); } diff --git a/crates/engine/src/cmd/fileio/tests_metafile.rs b/crates/engine/src/cmd/fileio/tests_metafile.rs index 80c15ace3..b02d16508 100644 --- a/crates/engine/src/cmd/fileio/tests_metafile.rs +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -140,7 +140,7 @@ fn metafiles_open_and_place() { let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone(); assert_eq!(placed.name.as_deref(), Some("pic.emf")); assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. })); - let ext = fileio::PLACE_EXTS; + let ext = crate::cmd::fileio::PLACE_EXTS; assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf")); } diff --git a/crates/engine/src/cmd/fileio/text.rs b/crates/engine/src/cmd/fileio/text.rs index 7e01a775a..85246456b 100644 --- a/crates/engine/src/cmd/fileio/text.rs +++ b/crates/engine/src/cmd/fileio/text.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use serde_json::Value; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; -use super::super::*; +use super::super::{Result, bad}; use super::FormatOption; const C: &str = "document.export"; diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index b9b0c8bf2..c7ddd8bc5 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -38,7 +38,7 @@ use vectorcraft_render::effects; use super::edit::roots_of; use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key}; use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, targets, unique_name}; mod analysis; mod apply; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index c90ff8aa5..cad3b9384 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Document, Effect, ImageBlob, Knockout, Node, NodeKind, Rect, effects, outline_strokes}; // ---------- plain art ---------- diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 04923b6d4..884351dea 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,7 +1,11 @@ use super::analysis::{Scratch, outlined_text, overlapping, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; use super::options::FlattenOptions; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks, + MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate, + shape_node, shapes, unique_key, +}; // ---------- plan ---------- diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index d252c2ff4..a2edde1f0 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -1,7 +1,7 @@ use super::options::flatten; use super::presets::{presets_delete, presets_export, presets_import, presets_list, presets_save}; use super::report::preview; -use super::*; +use super::{CommandSpec, always, cmd, has_doc}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs index ac8fcf41c..059890534 100644 --- a/crates/engine/src/cmd/flatten/compose.rs +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -1,5 +1,5 @@ use super::analysis::{has_effects, masked, reach}; -use super::*; +use super::{BlendMode, Color, FillRule, Knockout, Node, NodeKind, Paint, PathData, Rect, cmyk_planes, composite, effects, node_path, po, shapes}; // ---------- the compositor ---------- diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 242f9f259..57331660a 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -2,7 +2,7 @@ use super::analysis::draws_image; use super::apply::{apply, kept_work, plan}; use super::presets::{FlattenerPreset, saved_preset}; use super::report::target_roots; -use super::*; +use super::{BTreeSet, Deserialize, EngineError, Node, Result, Serialize, Session, Value, bad, json, str_param}; /// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/flatten/presets.rs b/crates/engine/src/cmd/flatten/presets.rs index 6e92f4ac5..28b5dfd10 100644 --- a/crates/engine/src/cmd/flatten/presets.rs +++ b/crates/engine/src/cmd/flatten/presets.rs @@ -1,5 +1,5 @@ use super::options::FlattenOptions; -use super::*; +use super::{Deserialize, Result, Serialize, Session, Value, bad, bool_or, json, str_param, unique_name}; // ---------- presets ---------- diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 125281d5c..3cac1ffe8 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -1,6 +1,9 @@ use super::apply::{kept_work, opaque, plan, stroked}; use super::options::FlattenOptions; -use super::*; +use super::{ + Appearance, Color, Deserialize, Document, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Serialize, Session, Value, bad, isolated_doc, + json, roots_of, shape_node, str_param, targets, +}; /// The top-level objects among `ids` (or the selection), in paint order: layers stand for their /// contents; hidden and locked objects and clipping paths are left alone. diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index c981c24bc..cadbd6509 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::Document; use vectorcraft_text::{FontDb, FontMatch, WantedFont, WantedFonts}; use super::findfiles::{self, Limits, Visitor}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc}; use crate::DocState; /// The most fonts listed, or looked for at once. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index f1f7c2dfa..0aaa8ba39 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind use vectorcraft_geom::Rect; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index acefd6102..ac04dc94c 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::appearance::{edit_items, edits_stroke, index_param, item_target}; use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, color_value, has_doc, index, number_or, point_indices, point_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index c015ffaa2..c13687880 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -51,7 +51,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, Nod use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape}; use super::wand::{fill_of, stroke_of}; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc}; const C: &str = "select.globalEdit"; const OPTIONS: &str = "select.globalEditOptions"; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index a861eeb34..92ce8723b 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -10,7 +10,10 @@ use vectorcraft_tools::params::color_json; use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target}; use super::edit::{outermost, selected_roots}; use super::paint::swatch_solid; -use super::*; +use super::{ + CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, f64_or, has_doc, index, + leaf_targets, point_param, str_param, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4ae431bd7..b0d3f92ef 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::{ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok, str_param}; mod axis; mod columns; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index aa9198163..52e49dbee 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Value; pub(super) const LABEL_SIZE: f64 = 9.0; diff --git a/crates/engine/src/cmd/graph/columns.rs b/crates/engine/src/cmd/graph/columns.rs index 19a0e71e7..34addd90b 100644 --- a/crates/engine/src/cmd/graph/columns.rs +++ b/crates/engine/src/cmd/graph/columns.rs @@ -4,7 +4,10 @@ use super::edit::{regenerate, spec_of, target}; use super::generate::generate_with; use super::markers::{design_frame, fit_affine}; use super::series::{picked_series, reselect_series}; -use super::*; +use super::{ + Affine, Arc, ColumnDesign, ColumnType, Document, FillRule, Fractions, GraphSpec, Node, NodeKind, Point, Rect, Result, Session, TextObject, Value, + bad, json, refresh_bounds, shapes, +}; /// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy). const COLUMN_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/commands.rs b/crates/engine/src/cmd/graph/commands.rs index fc799b9a3..5df961ee3 100644 --- a/crates/engine/src/cmd/graph/commands.rs +++ b/crates/engine/src/cmd/graph/commands.rs @@ -3,7 +3,7 @@ use super::data::set_data; use super::designs::design; use super::edit::{create, set_type}; use super::markers::set_marker; -use super::*; +use super::{CommandSpec, cmd, has_doc, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/graph/data.rs b/crates/engine/src/cmd/graph/data.rs index f991a7d93..e81f24feb 100644 --- a/crates/engine/src/cmd/graph/data.rs +++ b/crates/engine/src/cmd/graph/data.rs @@ -1,6 +1,6 @@ use super::edit::{regenerate, spec_of, target}; use super::series::{switch_xy, transpose}; -use super::*; +use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, bool_or, json, str_param}; // ---------- data ---------- diff --git a/crates/engine/src/cmd/graph/designs.rs b/crates/engine/src/cmd/graph/designs.rs index 716810b07..1009bcceb 100644 --- a/crates/engine/src/cmd/graph/designs.rs +++ b/crates/engine/src/cmd/graph/designs.rs @@ -1,5 +1,5 @@ use super::edit::regenerate; -use super::*; +use super::{Arc, Document, GraphDesign, GraphSpec, Result, Session, Value, bad, json, ok, str_param}; /// The document's graph design names, in order. pub(super) fn design_names(d: &Document) -> Vec { diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index d6204c687..525f43721 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -2,7 +2,10 @@ use super::axis::{MAX_TICK_MARKS, count_param, one_line}; use super::data::apply_data; use super::generate::{children_bounds, generate}; use super::series::{picked_series, reselect_series}; -use super::*; +use super::{ + Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, bool_or, f64_or, + f64_req, id_param, json, str_param, +}; /// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last /// generation, which is recorded in the spec's `placed` bounds). @@ -74,6 +77,60 @@ pub(super) fn spec_of(s: &Session, id: NodeId) -> Result { s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } +fn query_type_options(spec: &GraphSpec, count: usize, picked: &[usize]) -> Value { + // With series picked, the type and value axis they share, so the dialog's OK keeps them. + let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); + let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind); + // Series on both axes give no value axis (null), so OK leaves each where it is. + let sides: Vec = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect(); + let value_axis = match sides.first() { + None => Some(spec.value_axis.id()), + Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }), + Some(_) => None, + }; + let mut fields = json!({ + "type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend, + "markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks, + "axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales, + "rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max, + "tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(), + "rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(), + "categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels, + "prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix), + "rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top, + "firstRowInFront": spec.first_row_in_front, "firstColumnInFront": spec.first_column_in_front, "filledLines": spec.filled_lines, + }); + // Only the options this graph's types use: overlap order for columns and bars, filled lines for lines. + let kinds: Vec = (0..count.max(1)).map(|i| spec.series_kind(i)).collect(); + let has = |ks: &[GraphKind]| kinds.iter().any(|k| ks.contains(k)); + if let Some(o) = fields.as_object_mut() { + if !has(&[GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]) { + o.remove("firstRowInFront"); + o.remove("firstColumnInFront"); + } + if !has(&[GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]) { + o.remove("filledLines"); + } + } + // Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox). + if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) { + o.remove("legend"); + o.insert( + "pieLegend".into(), + json!(if !spec.legend { + "none" + } else if spec.pie_legend_in_wedges { + "wedges" + } else { + "standard" + }), + ); + o.insert("piePosition".into(), json!(spec.pie_position.id())); + o.insert("pieSort".into(), json!(spec.pie_sort.id())); + } + fields +} + pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { const C: &str = "graph.setType"; let id = target(s, p, C)?; @@ -127,57 +184,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { "pieSort", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { - // With series picked, the type and value axis they share, so the dialog's OK keeps them. - let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); - let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind); - // Series on both axes give no value axis (null), so OK leaves each where it is. - let sides: Vec = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect(); - let value_axis = match sides.first() { - None => Some(spec.value_axis.id()), - Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }), - Some(_) => None, - }; - let mut fields = json!({ - "type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend, - "markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks, - "axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales, - "rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max, - "tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(), - "rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(), - "categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels, - "prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix), - "rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top, - "firstRowInFront": spec.first_row_in_front, "firstColumnInFront": spec.first_column_in_front, "filledLines": spec.filled_lines, - }); - // Only the options this graph's types use: overlap order for columns and bars, filled lines for lines. - let kinds: Vec = (0..count.max(1)).map(|i| spec.series_kind(i)).collect(); - let has = |ks: &[GraphKind]| kinds.iter().any(|k| ks.contains(k)); - if let Some(o) = fields.as_object_mut() { - if !has(&[GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]) { - o.remove("firstRowInFront"); - o.remove("firstColumnInFront"); - } - if !has(&[GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]) { - o.remove("filledLines"); - } - } - // Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox). - if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) { - o.remove("legend"); - o.insert( - "pieLegend".into(), - json!(if !spec.legend { - "none" - } else if spec.pie_legend_in_wedges { - "wedges" - } else { - "standard" - }), - ); - o.insert("piePosition".into(), json!(spec.pie_position.id())); - o.insert("pieSort".into(), json!(spec.pie_sort.id())); - } - return Ok(fields); + return Ok(query_type_options(&spec, count, &picked)); } // Series picked with Group Selection stay selected through the regeneration (their groups are new nodes). let reselect: Vec = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 336b0857d..8efdc2df9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,9 @@ use super::labels::{ }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::series_marks; -use super::*; +use super::{ + Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, +}; /// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it, /// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects. @@ -20,584 +22,892 @@ pub(super) fn generate(d: &mut Document, g: &GraphSpec) -> Vec> { /// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects. pub(super) fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option]) -> Option>> { - let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width); - // Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all - // when the designs would draw more than MARKER_BUDGET objects. - let mut designs = marker_designs(d, g); - if marker_cost(g, &designs) > MARKER_BUDGET { - designs.clear(); - } - let design = |s: usize| designs.get(s).and_then(Option::as_ref); - let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref); - let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }; - // The labels' sizes (9 pt unless formatted), which their spacing follows. - let (vs, cs, ls) = - (label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend)); - let r = g.rect; - let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); - let ncat = g.rows.len().max(1); - let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default(); - let mut series: Vec>> = vec![vec![]; nser]; - // A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points - // break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. - let cells = g.cells(); - let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten(); - let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0); - // Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. - let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); - let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind); - // Series drawn as columns or bars, which column designs apply to. - let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar); - let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar); - // Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. - let mut right_edge = r.x1; - - // Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. - let mut wedge_labels = vec![]; - let mut on_top = vec![]; - // Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). - let filled = g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET; - // Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. - let mut bars_reach: Option = None; - // Add Drop Shadow: the columns, bars, lines and wedges to cast one. - let mut shadowed: Vec<(usize, Arc)> = vec![]; + let mut p = Plot::new(d, g, column_looks); match g.kind { - GraphKind::Pie => { - // One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size - // (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. - let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| val(c, s).max(0.0)).sum()).collect(); - let biggest = totals.iter().copied().fold(0.0, f64::max); - let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; - let w = if stacked { r.width() } else { r.width() / ncat as f64 }; - let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); - let radius = |total: f64| match g.pie_position { - vectorcraft_doc::PiePosition::Even => full, - _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), - _ => full, - }; - // Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. - let order = |c: usize| { - let mut o: Vec = (0..nser).collect(); - let by = match g.pie_sort { - vectorcraft_doc::PieSort::None => return o, - vectorcraft_doc::PieSort::All => c, - vectorcraft_doc::PieSort::First => 0, - }; - o.sort_by(|a, b| val(by, *b).max(0.0).total_cmp(&val(by, *a).max(0.0))); - o - }; - let mut pies: Vec = (0..ncat).collect(); - if stacked { - pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); + GraphKind::Pie => p.pie(), + GraphKind::Radar => p.radar(), + _ => p.cartesian(), + } + let labels = p.legend(); + p.finish(labels) +} + +/// The `s`th entry of a per-series list of optional designs. +fn nth(list: &[Option], s: usize) -> Option<&T> { + list.get(s).and_then(Option::as_ref) +} + +/// One graph being generated: its data, the nodes drawn so far and what the legend and shadows still need. +struct Plot<'a> { + g: &'a GraphSpec, + b: Gen<'a>, + column_looks: &'a [Option], + /// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all + /// when the designs would draw more than MARKER_BUDGET objects. + designs: Vec>>, + r: Rect, + nser: usize, + ncat: usize, + /// The labels' sizes (9 pt unless formatted), which their spacing follows. + vs: f64, + cs: f64, + ls: f64, + /// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points + /// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. + cells: Vec>>, + /// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. + kinds: Vec, + horizontal: bool, + /// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. + right_edge: f64, + /// Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. + wedge_labels: Vec>, + on_top: Vec>, + /// Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). + filled: bool, + /// Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. + bars_reach: Option, + /// Add Drop Shadow: the columns, bars, lines and wedges to cast one. + shadowed: Vec<(usize, Arc)>, + series: Vec>>, +} + +/// A pie (or the ring of a stacked pie) to draw. +#[derive(Clone, Copy)] +struct Ring { + centre: Point, + rad: f64, + hole: f64, +} + +/// The geometry of a plot with axes: the value scales and where the categories sit. +struct Geo { + r: Rect, + horizontal: bool, + ncat: usize, + /// Which series use the right axis' own scale. + on_right: Vec, + left: (f64, f64, f64), + right: (f64, f64, f64), + cat_w: f64, + label_edges: bool, + left_side: bool, + right_side: bool, +} + +impl Geo { + fn axis(&self, s: usize) -> usize { + usize::from(self.on_right.get(s).copied().unwrap_or(false)) + } + + fn scale(&self, a: usize) -> (f64, f64, f64) { + if a == 1 { self.right } else { self.left } + } + + /// Value → coordinate along value axis `a` (y for columns/lines, x for bars). + fn vpos(&self, a: usize, v: f64) -> f64 { + let (lo, hi, _) = self.scale(a); + let t = (v - lo) / (hi - lo); + if self.horizontal { self.r.x0 + t * self.r.width() } else { self.r.y1 - t * self.r.height() } + } + + /// That axis' zero (or the end nearest it). + fn zero(&self, a: usize) -> f64 { + let (lo, hi, _) = self.scale(a); + self.vpos(a, 0.0f64.clamp(lo, hi)) + } + + /// Plot points per unit of value along value axis `a`. + fn per_unit(&self, a: usize) -> f64 { + let (lo, hi, _) = self.scale(a); + (if self.horizontal { self.r.width() } else { self.r.height() }) / (hi - lo) + } + + fn cat_start(&self, c: usize) -> f64 { + if self.horizontal { self.r.y0 + c as f64 * self.cat_w } else { self.r.x0 + c as f64 * self.cat_w } + } + + fn mid(&self, c: usize, edges: bool) -> f64 { + let r = self.r; + if edges && self.ncat > 1 { r.x0 + r.width() * c as f64 / (self.ncat - 1) as f64 } else { self.cat_start(c) + self.cat_w / 2.0 } + } + + fn cat_mid(&self, c: usize) -> f64 { + self.mid(c, self.label_edges) + } + + /// The ends of a value tick mark at coordinate `q` of axis `a`. + fn tick_ends(&self, len: TickLength, a: usize, q: f64) -> Option<(Point, Point)> { + let r = self.r; + match (len, self.horizontal, a == 1) { + (TickLength::None, ..) => None, + (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), + (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), + (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), + (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), + (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), + } + } +} + +/// How columns (or bars) and stacks share a category's width. +struct Cluster { + /// Columns of one category side by side; stacked columns take one slot of that cluster, or, with no plain + /// columns, a width of their own. + columns: Vec, + stacks: Vec, + /// One stack per value axis that has stacked series, each in a slot of its own. + stack_axes: Vec, + own_width: bool, + /// The widths past 100% make the columns (or clusters) overlap. + col_frac: f64, + clus_frac: f64, + cluster: f64, + slot: f64, + bar: f64, +} + +impl Cluster { + /// Where a stack on axis `ax` starts across the category `c` (starting at `c0`), and how wide it is. + fn place(&self, geo: &Geo, c: usize, c0: f64, ax: usize) -> (f64, f64) { + if self.own_width { + // Up to the category's width, or past it when the widths are set past 100%. + let cat_w = geo.cat_w; + let w = (cat_w * self.col_frac * self.clus_frac * 1.2).min(cat_w * (self.col_frac * self.clus_frac).max(1.0)); + (geo.cat_start(c) + (cat_w - w) / 2.0, w) + } else { + let i = self.columns.len() + self.stack_axes.iter().position(|a| *a == ax).unwrap_or(0); + (c0 + self.slot * i as f64 + (self.slot - self.bar) / 2.0, self.bar) + } + } +} + +/// A column, bar or stacked segment to draw. +struct Bar { + s: usize, + rect: Rect, + value: f64, + /// It grows towards the axis' start. + down: bool, + per_unit: f64, +} + +fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect { + if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } +} + +/// White text on a dark wedge. +fn is_dark(fill: &Paint) -> bool { + fill.color().is_some_and(|c| { + let [r, g, b] = c.to_rgb(); + 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 + }) +} + +fn dir(a: f64) -> vectorcraft_geom::Vec2 { + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) +} + +/// A wedge of `ring` from angle `a0` over `sweep`; a ring segment when the ring has a hole. +fn wedge_path(ring: Ring, a0: f64, sweep: f64) -> BezPath { + let Ring { centre, rad, hole } = ring; + let mut bp = BezPath::new(); + let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); + if hole > 0.0 { + // A ring segment: out along the outer arc, back along the inner one. + bp.move_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + bp.line_to(centre + dir(a0 + sweep) * hole); + let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); + back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } else { + bp.move_to(centre); + bp.line_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } + bp.close_path(); + bp +} + +/// The radius of the pie with `total`: one size (Even) or sized by the totals (Ratio, by area). +fn pie_radius(g: &GraphSpec, full: f64, biggest: f64, total: f64) -> f64 { + match g.pie_position { + vectorcraft_doc::PiePosition::Even => full, + _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), + _ => full, + } +} + +impl<'a> Plot<'a> { + fn new(d: &'a mut Document, g: &'a GraphSpec, column_looks: &'a [Option]) -> Self { + let mut designs = marker_designs(d, g); + if marker_cost(g, &designs) > MARKER_BUDGET { + designs.clear(); + } + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + Plot { + g, + b: Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }, + column_looks, + designs, + r: g.rect, + nser, + ncat: g.rows.len().max(1), + vs: label_size(&g.label_styles, Role::Value), + cs: label_size(&g.label_styles, Role::Category), + ls: label_size(&g.label_styles, Role::Legend), + cells: g.cells(), + kinds, + horizontal: matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar), + right_edge: g.rect.x1, + wedge_labels: vec![], + on_top: vec![], + filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET, + bars_reach: None, + shadowed: vec![], + series: vec![vec![]; nser], + } + } + + fn cell(&self, c: usize, s: usize) -> Option { + self.cells.get(c).and_then(|r| r.get(s)).copied().flatten() + } + + fn val(&self, c: usize, s: usize) -> f64 { + self.cell(c, s).unwrap_or(0.0) + } + + fn kind(&self, s: usize) -> GraphKind { + self.kinds.get(s).copied().unwrap_or(self.g.kind) + } + + /// Series drawn as columns or bars, which column designs apply to. + fn columnar(&self, s: usize) -> bool { + matches!(self.kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar) + } + + /// The series drawn as `kind`. + fn series_of(&self, kind: GraphKind) -> Vec { + (0..self.nser).filter(|s| self.kind(*s) == kind).collect() + } + + fn mark(&self, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) { + series_marks(self.g, i, series_fill, stroke, width) + } + + fn series_label(&self, i: usize) -> String { + self.g.series.get(i).cloned().unwrap_or_default() + } + + fn push_series(&mut self, s: usize, n: Arc) { + if let Some(items) = self.series.get_mut(s) { + items.push(n); + } + } + + /// Add `n` to series `s` and, for the drop shadow, to the shadowed marks. + fn push_shadowed(&mut self, s: usize, n: Arc) { + self.shadowed.push((s, n.clone())); + self.push_series(s, n); + } + + fn reach(&mut self, rect: Rect) { + self.bars_reach = Some(self.bars_reach.map_or(rect, |r| r.union(rect))); + } + + // ---- Pie ---- + + /// One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size + /// (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. + fn pie(&mut self) { + let (g, r, ncat, nser) = (self.g, self.r, self.ncat, self.nser); + let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| self.val(c, s).max(0.0)).sum()).collect(); + let biggest = totals.iter().copied().fold(0.0, f64::max); + let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; + let w = if stacked { r.width() } else { r.width() / ncat as f64 }; + let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); + let radius = |total: f64| pie_radius(g, full, biggest, total); + let mut pies: Vec = (0..ncat).collect(); + if stacked { + pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); + } + // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller + // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). + let inner: Vec = if stacked { + (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() + } else { + vec![0.0; pies.len()] + }; + for (k, c) in pies.into_iter().enumerate() { + let total = totals.get(c).copied().unwrap_or(0.0); + let rad = radius(total); + let hole = inner.get(k).copied().unwrap_or(0.0); + if hole >= rad - 1e-9 { + continue; } - // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller - // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). - let inner: Vec = if stacked { - (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() - } else { - vec![0.0; pies.len()] - }; - for (k, c) in pies.into_iter().enumerate() { - let total = totals.get(c).copied().unwrap_or(0.0); - let rad = radius(total); - let hole = inner.get(k).copied().unwrap_or(0.0); - if hole >= rad - 1e-9 { - continue; - } - let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; - let mut a0 = -std::f64::consts::FRAC_PI_2; - for s in order(c) { - let v = val(c, s).max(0.0); - if total <= 0.0 || v <= 0.0 { - continue; - } - let sweep = v / total * std::f64::consts::TAU; - let mut bp = BezPath::new(); - let dir = |a: f64| vectorcraft_geom::Vec2::new(a.cos(), a.sin()); - let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); - if hole > 0.0 { - // A ring segment: out along the outer arc, back along the inner one. - bp.move_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - bp.line_to(centre + dir(a0 + sweep) * hole); - let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); - back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } else { - bp.move_to(centre); - bp.line_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } - bp.close_path(); - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.5); - // White text on a dark wedge. - let dark = fill.color().is_some_and(|c| { - let [r, g, b] = c.to_rgb(); - 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 - }); - let n = b.path(PathData::from_bezpath(&bp), fill, stroke, sw); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. - if g.legend && g.pie_legend_in_wedges { - let label = series_label(s); - if !blank_label(&label) { - // Two thirds of the way out (of the ring, for a stacked pie). - let mid = a0 + sweep / 2.0; - let at = centre + dir(mid) * (hole + (rad - hole) * 0.62); - let t = b.label(Role::Legend, Point::new(at.x, at.y + ls * 0.35), &label, Justify::Center, true); - // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); - } - } - a0 += sweep; - } - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - if stacked { - // Just above its own outline, all its lines, and drawn over the pies. - let lines = cat.split('|').count() as f64; - let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); - let t = b.label(Role::Category, at, cat, Justify::Center, false); - on_top.push(t); - } else { - let t = b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); - b.out.push(t); - } - } + let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; + let ring = Ring { centre, rad, hole }; + self.pie_wedges(c, ring, total); + self.pie_name(c, ring, stacked); + } + } + + /// Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. + fn pie_order(&self, c: usize) -> Vec { + let mut o: Vec = (0..self.nser).collect(); + let by = match self.g.pie_sort { + vectorcraft_doc::PieSort::None => return o, + vectorcraft_doc::PieSort::All => c, + vectorcraft_doc::PieSort::First => 0, + }; + o.sort_by(|a, b| self.val(by, *b).max(0.0).total_cmp(&self.val(by, *a).max(0.0))); + o + } + + fn pie_wedges(&mut self, c: usize, ring: Ring, total: f64) { + let mut a0 = -std::f64::consts::FRAC_PI_2; + for s in self.pie_order(c) { + let v = self.val(c, s).max(0.0); + if total <= 0.0 || v <= 0.0 { + continue; + } + let sweep = v / total * std::f64::consts::TAU; + let bp = wedge_path(ring, a0, sweep); + let (fill, stroke, sw) = self.mark(s, true, Paint::solid(Color::WHITE), 0.5); + let dark = is_dark(&fill); + let n = self.b.path(PathData::from_bezpath(&bp), fill, stroke, sw); + self.push_shadowed(s, n); + // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. + if self.g.legend && self.g.pie_legend_in_wedges { + self.wedge_label(s, ring, a0 + sweep / 2.0, dark); + } + a0 += sweep; + } + } + + fn wedge_label(&mut self, s: usize, ring: Ring, mid: f64, dark: bool) { + let label = self.series_label(s); + if blank_label(&label) { + return; + } + // Two thirds of the way out (of the ring, for a stacked pie). + let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62); + let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true); + // White on a dark wedge, unless the legend labels were given a colour of their own. + let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); + self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + } + + /// The pie's category name. + fn pie_name(&mut self, c: usize, ring: Ring, stacked: bool) { + let Some(cat) = self.g.categories.get(c).filter(|c| !c.is_empty()) else { return }; + let (centre, rad, cs) = (ring.centre, ring.rad, self.cs); + if stacked { + // Just above its own outline, all its lines, and drawn over the pies. + let lines = cat.split('|').count() as f64; + let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); + let t = self.b.label(Role::Category, at, cat, Justify::Center, false); + self.on_top.push(t); + } else { + let t = self.b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); + self.b.out.push(t); + } + } + + // ---- Radar ---- + + fn radar(&mut self) { + let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs); + let centre = r.center(); + let rad = (r.width().min(r.height()) / 2.0).max(1.0); + let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + let at = |c: usize, v: f64| { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; + centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) + }; + let mut axes = vec![]; + for c in 0..ncat { + axes.push(self.b.line(centre, at(c, hi))); + if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { + let p = at(c, hi) + (at(c, hi) - centre).normalize() * (cs * 1.2); + axes.push(self.b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); } } - GraphKind::Radar => { - let centre = r.center(); - let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); - let at = |c: usize, v: f64| { - let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; - centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) - }; - let mut axes = vec![]; - for c in 0..ncat { - axes.push(b.line(centre, at(c, hi))); - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - let p = at(c, hi) + (at(c, hi) - centre).normalize() * (cs * 1.2); - axes.push(b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); - } - } - for v in tick_values(lo, hi, step).into_iter().skip(1) { - let ring: Vec = (0..ncat).map(|c| at(c, v)).collect(); - axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25)); - } - let ax = b.group("Axes", axes); - b.out.push(ax); - for (s, items) in series.iter_mut().enumerate() { - let pts: Vec = (0..ncat).map(|c| at(c, val(c, s))).collect(); - if g.connect_points && pts.len() > 1 { - let n = b.data_line(g, s, polyline(&pts, true), filled); - shadowed.push((s, n.clone())); - items.push(n); - } - if g.mark_points { - for p in pts { - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint)); - } - } - } + for v in tick_values(lo, hi, step).into_iter().skip(1) { + let ring: Vec = (0..ncat).map(|c| at(c, v)).collect(); + axes.push(self.b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25)); } - _ => { - let scatter = g.kind == GraphKind::Scatter; - // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale - // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. - let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; - let mut on_right = vec![false; nser]; - if separate { - for i in &g.right_series { - if let Some(x) = on_right.get_mut(*i) { - *x = true; - } - } + let ax = self.b.group("Axes", axes); + self.b.out.push(ax); + for s in 0..self.series.len() { + let pts: Vec = (0..ncat).map(|c| at(c, self.val(c, s))).collect(); + if g.connect_points && pts.len() > 1 { + let n = self.b.data_line(g, s, polyline(&pts, true), self.filled); + self.push_shadowed(s, n); } - let axis = |s: usize| usize::from(on_right.get(s).copied().unwrap_or(false)); - // Value range per axis (stacked graphs stack positives and negatives separately). - let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); - let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); - for c in 0..ncat { - if !scatter { - for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { - // Stacked columns (or bars) stack together, and so do areas; other series count one value each. - for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { - let members = || (0..nser).filter(|s| axis(*s) == a && stack.contains(&kind(*s))); - *hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum()); - *lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum()); - } - let single = |s: &usize| axis(*s) == a && matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); - for v in (0..nser).filter(single).filter_map(|s| cell(c, s)) { - *hi = hi.max(v); - *lo = lo.min(v); - } - } - } else { - for s in (0..nser).step_by(2) { - if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) { - hi[0] = hi[0].max(y); - lo[0] = lo[0].min(y); - xlo = xlo.min(x); - xhi = xhi.max(x); - } - } - } - } - // An axis with no series of its own shows the other one's scale. - let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); - let used = |a: usize| (0..nser).any(|s| axis(s) == a); - let (left, right) = if !separate || !used(1) { - (left, left) - } else { - let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); - if used(0) { (left, right) } else { (right, right) } - }; - let scale = |a: usize| if a == 1 { right } else { left }; - // Value → coordinate along value axis `a` (y for columns/lines, x for bars), and that axis' zero (or the - // end nearest it). - let vpos = |a: usize, v: f64| { - let (lo, hi, _) = scale(a); - let t = (v - lo) / (hi - lo); - if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() } - }; - let zero = |a: usize| { - let (lo, hi, _) = scale(a); - vpos(a, 0.0f64.clamp(lo, hi)) - }; - // Plot points per unit of value along value axis `a`. - let per_unit = |a: usize| { - let (lo, hi, _) = scale(a); - (if horizontal { r.width() } else { r.height() }) / (hi - lo) - }; - let mut axes = vec![]; - // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right - // one shows the right scale). - let (on_left_side, on_right_side) = - if horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; - // Add Labels text per axis, kept to one short line whatever the file holds. - let affix = |s: &str| one_line(s); - let affixes = [(affix(&g.prefix), affix(&g.suffix)), (affix(&g.right_prefix), affix(&g.right_suffix))]; - // Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). - let value_ticks = |b: &mut Gen, axes: &mut Vec>, a: usize, v: f64, step: f64, hi: f64| { - let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; - let n = marks.clamp(1, MAX_TICK_MARKS); - for k in 0..n { - let w = v + step * k as f64 / n as f64; - if k > 0 && w >= hi - step * 1e-9 { - break; - } - let q = vpos(a, w); - // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. - let repeat = a == 1 && on_left_side && g.tick_length == TickLength::Full && right == left; - let len = if len == TickLength::Full && ((q - zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; - let ends = match (len, horizontal, a == 1) { - (TickLength::None, ..) => None, - (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), - (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), - (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), - (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), - (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), - }; - if let Some((p0, p1)) = ends { - axes.push(b.line(p0, p1)); - } - if k == 0 { - // The label goes right after its own tick mark. - let (prefix, suffix) = affixes.get(a).map_or(("", ""), |(p, s)| (p.as_str(), s.as_str())); - let label = format!("{prefix}{}{suffix}", fmt_value(v)); - let t = if horizontal { - b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) - } else if a == 1 { - b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) - } else { - b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) - }; - axes.push(t); - } - } - }; - if on_left_side { - let (lo, hi, step) = left; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 0, v, step, hi); - } - } - if on_right_side { - let (lo, hi, step) = right; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 1, v, step, hi); - } - } - // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar - // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). - for t in &axes { - if matches!(t.kind, NodeKind::Text(_)) - && let Some(bb) = t.geometric_bounds() - && bb.x1 > r.x1 - { - right_edge = right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); - } - } - if horizontal { - axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); - axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1))); - } else { - if on_left_side { - axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); - } - if on_right_side { - axes.push(b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); - } - axes.push(b.line(Point::new(r.x0, zero(0)), Point::new(r.x1, zero(0)))); - // The right scale's zero, where its columns start, when it isn't the left one's. - if (zero(1) - zero(0)).abs() > 1e-9 { - axes.push(b.line(Point::new(r.x0, zero(1)), Point::new(r.x1, zero(1)))); - } - } - // Category positions. - let span = if horizontal { r.height() } else { r.width() }; - let cat_w = span / ncat as f64; - let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w }; - // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every - // series puts its points: at the edges when all of them span the plot, else at the category centres. - let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); - let mid = |c: usize, edges: bool| { - if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 } - }; - let label_edges = kinds.iter().all(|k| spans(*k)); - let cat_mid = |c: usize| mid(c, label_edges); - if !scatter { - for c in 0..ncat { - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - let t = if horizontal { - b.label(Role::Category, Point::new(r.x0 - 6.0, cat_mid(c) + cs * 0.35), cat, Justify::Right, true) - } else { - b.label(Role::Category, Point::new(cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) - }; - axes.push(t); - } - } - // Category tick marks (none by default): at the labels, or between them (at the category edges), - // with the extra tick marks of each division between those. - let len = g.category_tick_length; - if len != TickLength::None { - let shown = ncat.min(MAX_CATEGORY_TICKS); - let base: Vec = match (g.ticks_between_labels, label_edges) { - // Labels at the plot's edges: halfway between neighbouring labels. - (true, true) => (1..shown).map(|c| (cat_mid(c - 1) + cat_mid(c)) / 2.0).collect(), - (true, false) => (0..=shown).map(cat_start).collect(), - (false, _) => (0..shown).map(cat_mid).collect(), - }; - // Fewer per division on a long axis, so the cap still ticks all of it. - let n = g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); - let at = base.iter().enumerate().flat_map(|(i, q)| { - let next = base.get(i + 1).copied(); - (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) - }); - // A full-length tick on a drawn axis line adds nothing. - let on_axis_line = |q: f64| { - if horizontal { - (q - r.y1).abs() < 1e-9 - } else { - ((q - r.x0).abs() < 1e-9 && on_left_side) || ((q - r.x1).abs() < 1e-9 && on_right_side) - } - }; - for q in at.take(MAX_CATEGORY_TICKS) { - if len == TickLength::Full && on_axis_line(q) { - continue; - } - let (p0, p1) = match (len, horizontal) { - (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), - (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), - (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), - (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), - }; - axes.push(b.line(p0, p1)); - } - } - } else { - // Scatter: a horizontal value axis too. - // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. - let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; - let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; - let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); - for x in tick_values(xl, xh, xs) { - let q = r.x0 + (x - xl) / (xh - xl) * r.width(); - axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))); - axes.push(b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); - } - for (si, s) in (0..nser).step_by(2).enumerate() { - let pts: Vec> = (0..ncat) - .map(|c| match (cell(c, s), cell(c, s + 1)) { - (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))), - _ => None, - }) - .collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, si, polyline(&run, false), filled); - shadowed.push((si, n.clone())); - series[si].push(n); - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(si, true, Paint::None, 0.0); - let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint); - series[si].push(n); - } - } - } - } - let ax = b.group("Axes", axes); - b.out.push(ax); - // Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with - // no plain columns, a width of their own. - let columns: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); - let stacks: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); - // One stack per value axis that has stacked series, each in a slot of its own. - let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| axis(*s) == *a)).collect(); - let own_width = columns.is_empty() && stack_axes.len() < 2; - let slots = columns.len() + stack_axes.len(); - // Widths past 100% make the columns (or clusters) overlap. - let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); - let slot = cluster / slots.max(1) as f64; - let bar = slot * (g.column_width / 100.0).clamp(0.01, 10.0); - let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| { - if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } - }; - // First Row in Front: the first category drawn last, over the next. - let cats: Vec = if g.first_row_in_front { (0..ncat).rev().collect() } else { (0..ncat).collect() }; - for c in cats { - let c0 = cat_start(c) + (cat_w - cluster) / 2.0; - for (i, &s) in columns.iter().enumerate() { - let Some(v) = cell(c, s) else { continue }; - let a = c0 + slot * i as f64 + (slot - bar) / 2.0; - let (fill, stroke, w) = mark(s, true, Paint::None, 0.0); - let (v0, v1) = (zero(axis(s)), vpos(axis(s), v)); - let rect = bar_rect(a, bar, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - // A column drawn with a design casts no shadow. - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) - { - Some(n) => { - // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, w); - shadowed.push((s, n.clone())); - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - let place = |ax: usize| { - if own_width { - // Up to the category's width, or past it when the widths are set past 100%. - let (col, clus) = ((g.column_width / 100.0).clamp(0.01, 10.0), (g.cluster_width / 100.0).clamp(0.01, 10.0)); - let w = (cat_w * col * clus * 1.2).min(cat_w * (col * clus).max(1.0)); - (cat_start(c) + (cat_w - w) / 2.0, w) - } else { - let i = columns.len() + stack_axes.iter().position(|a| *a == ax).unwrap_or(0); - (c0 + slot * i as f64 + (slot - bar) / 2.0, bar) - } - }; - // Each axis stacks its own series. - let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); - for &s in &stacks { - let Some(v) = cell(c, s) else { continue }; - let ax = axis(s); - let (a, w) = place(ax); - let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; - let (v0, v1) = (vpos(ax, *base), vpos(ax, *base + v)); - *base += v; - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let rect = bar_rect(a, w, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) { - Some(n) => { - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, sw); - // A segment of 0 has no area to shade. - if v != 0.0 { - shadowed.push((s, n.clone())); - } - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - // Cumulative area bands, each from the previous area total on its axis up to its own. - let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) { - let a = axis(s); - let Some(below) = belows.get_mut(a) else { continue }; - let above: Vec = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect(); - // Mixed with series at the category centres, areas take their points there too. - let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(a, v.get(c).copied().unwrap_or(0.0))); - let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); - pts.extend((0..ncat).rev().map(|c| at(c, below))); - let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let n = b.path(polyline(&pts, true), fill, stroke, w); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - *below = above; - } - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) { - let pts: Vec> = - (0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(axis(s), v)))).collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, s, polyline(&run, false), filled); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(s, true, Paint::None, 0.0); - let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } + if g.mark_points { + for p in pts { + let paint = self.mark(s, true, Paint::None, 0.0); + let n = self.b.marker(nth(&self.designs, s), Rect::from_center_size(p, (4.0, 4.0)), paint); + self.push_series(s, n); } } } } - // Legend: a swatch + label per series, right of the plot; the swatch joins its series group. - let legend_series = if g.kind == GraphKind::Scatter { nser.div_ceil(2) } else { nser }; - let mut labels = vec![]; - let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); - if legend_on && g.legend_across_top { - // Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row - // just above it. - let entries: Vec<(usize, String)> = (0..legend_series) - .map(|s| (s, if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) })) - .filter(|(_, l)| !blank_label(l)) - .collect(); + + // ---- Columns, bars, lines, areas and scatter ---- + + fn cartesian(&mut self) { + let g = self.g; + let scatter = g.kind == GraphKind::Scatter; + // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale + // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !self.horizontal && !scatter; + let mut on_right = vec![false; self.nser]; + if separate { + for i in &g.right_series { + if let Some(x) = on_right.get_mut(*i) { + *x = true; + } + } + } + let mut geo = self.geo(on_right); + let (lo, hi, xlo, xhi) = self.value_range(&geo, scatter); + // An axis with no series of its own shows the other one's scale. + let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); + let used = |a: usize| (0..self.nser).any(|s| geo.axis(s) == a); + let (left, right) = if !separate || !used(1) { + (left, left) + } else { + let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); + if used(0) { (left, right) } else { (right, right) } + }; + (geo.left, geo.right) = (left, right); + let mut axes = vec![]; + self.value_axes(&geo, &mut axes); + if scatter { + self.scatter(&geo, (xlo, xhi), &mut axes); + } else { + self.category_axis(&geo, &mut axes); + } + let ax = self.b.group("Axes", axes); + self.b.out.push(ax); + self.columns(&geo); + self.areas(&geo); + self.lines(&geo); + } + + /// The plot's geometry, its value scales still to come. + fn geo(&self, on_right: Vec) -> Geo { + let (g, r) = (self.g, self.r); + // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right + // one shows the right scale). + let (left_side, right_side) = + if self.horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; + let span = if self.horizontal { r.height() } else { r.width() }; + // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every + // series puts its points: at the edges when all of them span the plot, else at the category centres. + let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); + Geo { + r, + horizontal: self.horizontal, + ncat: self.ncat, + on_right, + left: (0.0, 1.0, 1.0), + right: (0.0, 1.0, 1.0), + cat_w: span / self.ncat as f64, + label_edges: self.kinds.iter().all(|k| spans(*k)), + left_side, + right_side, + } + } + + /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. + fn value_range(&self, geo: &Geo, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { + let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); + let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); + for c in 0..self.ncat { + if !scatter { + for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { + self.extend_range(geo, c, a, (lo, hi)); + } + } else { + for s in (0..self.nser).step_by(2) { + if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { + hi[0] = hi[0].max(y); + lo[0] = lo[0].min(y); + xlo = xlo.min(x); + xhi = xhi.max(x); + } + } + } + } + (lo, hi, xlo, xhi) + } + + /// Widen the range of axis `a` by category `c`'s values. + fn extend_range(&self, geo: &Geo, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { + let nser = self.nser; + // Stacked columns (or bars) stack together, and so do areas; other series count one value each. + for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { + let members = || (0..nser).filter(|s| geo.axis(*s) == a && stack.contains(&self.kind(*s))); + *hi = hi.max(members().map(|s| self.val(c, s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| self.val(c, s).min(0.0)).sum()); + } + let single = |s: &usize| geo.axis(*s) == a && matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); + for v in (0..nser).filter(single).filter_map(|s| self.cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } + + /// The value axes with ticks and labels, the legend's start after them, and the axis lines. + fn value_axes(&mut self, geo: &Geo, axes: &mut Vec>) { + let r = self.r; + if geo.left_side { + let (lo, hi, step) = geo.left; + for v in tick_values(lo, hi, step) { + self.value_tick(geo, axes, 0, v, (step, hi)); + } + } + if geo.right_side { + let (lo, hi, step) = geo.right; + for v in tick_values(lo, hi, step) { + self.value_tick(geo, axes, 1, v, (step, hi)); + } + } + // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar + // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). + for t in axes.iter() { + if matches!(t.kind, NodeKind::Text(_)) + && let Some(bb) = t.geometric_bounds() + && bb.x1 > r.x1 + { + self.right_edge = self.right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); + } + } + if geo.horizontal { + axes.push(self.b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); + axes.push(self.b.line(Point::new(geo.zero(0), r.y0), Point::new(geo.zero(0), r.y1))); + } else { + if geo.left_side { + axes.push(self.b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); + } + if geo.right_side { + axes.push(self.b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); + } + axes.push(self.b.line(Point::new(r.x0, geo.zero(0)), Point::new(r.x1, geo.zero(0)))); + // The right scale's zero, where its columns start, when it isn't the left one's. + if (geo.zero(1) - geo.zero(0)).abs() > 1e-9 { + axes.push(self.b.line(Point::new(r.x0, geo.zero(1)), Point::new(r.x1, geo.zero(1)))); + } + } + } + + /// Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). + fn value_tick(&mut self, geo: &Geo, axes: &mut Vec>, a: usize, v: f64, (step, hi): (f64, f64)) { + let g = self.g; + let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; + let n = marks.clamp(1, MAX_TICK_MARKS); + for k in 0..n { + let w = v + step * k as f64 / n as f64; + if k > 0 && w >= hi - step * 1e-9 { + break; + } + let q = geo.vpos(a, w); + // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. + let repeat = a == 1 && geo.left_side && g.tick_length == TickLength::Full && geo.right == geo.left; + let len = if len == TickLength::Full && ((q - geo.zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; + if let Some((p0, p1)) = geo.tick_ends(len, a, q) { + axes.push(self.b.line(p0, p1)); + } + if k == 0 { + axes.push(self.value_label(geo, a, q, v)); + } + } + } + + /// The label after a tick mark: with the Add Labels text of its axis, kept to one short line whatever the file holds. + fn value_label(&mut self, geo: &Geo, a: usize, q: f64, v: f64) -> Arc { + let (r, vs) = (self.r, self.vs); + let (prefix, suffix) = if a == 1 { (&self.g.right_prefix, &self.g.right_suffix) } else { (&self.g.prefix, &self.g.suffix) }; + let label = format!("{}{}{}", one_line(prefix), fmt_value(v), one_line(suffix)); + if geo.horizontal { + self.b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) + } else if a == 1 { + self.b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) + } else { + self.b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) + } + } + + /// Category labels and tick marks. + fn category_axis(&mut self, geo: &Geo, axes: &mut Vec>) { + let (r, cs) = (self.r, self.cs); + for c in 0..self.ncat { + if let Some(cat) = self.g.categories.get(c).filter(|c| !c.is_empty()) { + let t = if geo.horizontal { + self.b.label(Role::Category, Point::new(r.x0 - 6.0, geo.cat_mid(c) + cs * 0.35), cat, Justify::Right, true) + } else { + self.b.label(Role::Category, Point::new(geo.cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) + }; + axes.push(t); + } + } + // Category tick marks (none by default): at the labels, or between them (at the category edges), + // with the extra tick marks of each division between those. + let len = self.g.category_tick_length; + if len == TickLength::None { + return; + } + let at = self.category_tick_positions(geo); + // A full-length tick on a drawn axis line adds nothing. + let on_axis_line = |q: f64| { + if geo.horizontal { + (q - r.y1).abs() < 1e-9 + } else { + ((q - r.x0).abs() < 1e-9 && geo.left_side) || ((q - r.x1).abs() < 1e-9 && geo.right_side) + } + }; + for q in at.into_iter().take(MAX_CATEGORY_TICKS) { + if len == TickLength::Full && on_axis_line(q) { + continue; + } + let (p0, p1) = match (len, geo.horizontal) { + (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), + (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), + (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), + (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), + }; + axes.push(self.b.line(p0, p1)); + } + } + + /// Where the category tick marks go: at the labels, or between them, each division with its extra marks. + fn category_tick_positions(&self, geo: &Geo) -> Vec { + let shown = self.ncat.min(MAX_CATEGORY_TICKS); + let base: Vec = match (self.g.ticks_between_labels, geo.label_edges) { + // Labels at the plot's edges: halfway between neighbouring labels. + (true, true) => (1..shown).map(|c| (geo.cat_mid(c - 1) + geo.cat_mid(c)) / 2.0).collect(), + (true, false) => (0..=shown).map(|c| geo.cat_start(c)).collect(), + (false, _) => (0..shown).map(|c| geo.cat_mid(c)).collect(), + }; + // Fewer per division on a long axis, so the cap still ticks all of it. + let n = self.g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); + base.iter() + .enumerate() + .flat_map(|(i, q)| { + let next = base.get(i + 1).copied(); + (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) + }) + .collect() + } + + /// Scatter: a horizontal value axis too, and the series' points. + fn scatter(&mut self, geo: &Geo, (xlo, xhi): (f64, f64), axes: &mut Vec>) { + let (g, r, vs) = (self.g, self.r, self.vs); + // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. + let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; + let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; + let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); + for x in tick_values(xl, xh, xs) { + let q = r.x0 + (x - xl) / (xh - xl) * r.width(); + axes.push(self.b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))); + axes.push(self.b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); + } + for (si, s) in (0..self.nser).step_by(2).enumerate() { + let pts: Vec> = (0..self.ncat) + .map(|c| match (self.cell(c, s), self.cell(c, s + 1)) { + (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), geo.vpos(0, y))), + _ => None, + }) + .collect(); + if g.connect_points { + for run in runs(&pts) { + let n = self.b.data_line(g, si, polyline(&run, false), self.filled); + self.shadowed.push((si, n.clone())); + self.series[si].push(n); + } + } + self.point_markers(si, &pts); + } + } + + /// A marker on each point of series `s`, when Mark Data Points is on. + fn point_markers(&mut self, s: usize, pts: &[Option]) { + if !self.g.mark_points { + return; + } + for p in pts.iter().flatten() { + let paint = self.mark(s, true, Paint::None, 0.0); + let n = self.b.marker(nth(&self.designs, s), Rect::from_center_size(*p, (5.0, 5.0)), paint); + self.push_series(s, n); + } + } + + /// A column, bar or stacked segment: with its column design when it has one (which casts no shadow), else a plain + /// rectangle in `paint`, shadowed when `shadow`. + fn bar_node(&mut self, bar: Bar, paint: (Paint, Paint, f64), shadow: bool) -> Arc { + let Bar { s, rect, value, down, per_unit } = bar; + self.reach(rect); + let horizontal = self.horizontal; + let art = nth(self.column_looks, s).and_then(|c| column_art(self.b.d, c, (rect, value), (horizontal, down), per_unit, &mut self.b.columns)); + match art { + Some(n) => { + // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. + if let Some(art) = n.geometric_bounds() { + self.reach(art); + } + n + } + None => { + let (fill, stroke, w) = paint; + let n = self.b.path(shapes::rectangle(rect), fill, stroke, w); + if shadow { + self.shadowed.push((s, n.clone())); + } + n + } + } + } + + /// Columns (or bars) of one category side by side, and the stacks. + fn columns(&mut self, geo: &Geo) { + let g = self.g; + let nser = self.nser; + let columns: Vec = (0..nser).filter(|s| matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); + let stacks: Vec = (0..nser).filter(|s| matches!(self.kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); + let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| geo.axis(*s) == *a)).collect(); + let col_frac = (g.column_width / 100.0).clamp(0.01, 10.0); + let clus_frac = (g.cluster_width / 100.0).clamp(0.01, 10.0); + let cluster = geo.cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); + let slot = cluster / (columns.len() + stack_axes.len()).max(1) as f64; + let cl = Cluster { + own_width: columns.is_empty() && stack_axes.len() < 2, + columns, + stacks, + stack_axes, + col_frac, + clus_frac, + cluster, + slot, + bar: slot * col_frac, + }; + // First Row in Front: the first category drawn last, over the next. + let cats: Vec = if g.first_row_in_front { (0..self.ncat).rev().collect() } else { (0..self.ncat).collect() }; + for c in cats { + let c0 = geo.cat_start(c) + (geo.cat_w - cl.cluster) / 2.0; + self.cluster_columns(geo, &cl, c, c0); + self.cluster_stacks(geo, &cl, c, c0); + } + } + + fn cluster_columns(&mut self, geo: &Geo, cl: &Cluster, c: usize, c0: f64) { + for (i, &s) in cl.columns.iter().enumerate() { + let Some(v) = self.cell(c, s) else { continue }; + let a = c0 + cl.slot * i as f64 + (cl.slot - cl.bar) / 2.0; + let paint = self.mark(s, true, Paint::None, 0.0); + let (v0, v1) = (geo.zero(geo.axis(s)), geo.vpos(geo.axis(s), v)); + let rect = bar_rect(geo.horizontal, a, cl.bar, v0, v1); + let n = self.bar_node(Bar { s, rect, value: v, down: v1 < v0, per_unit: geo.per_unit(geo.axis(s)) }, paint, true); + self.push_series(s, n); + } + } + + fn cluster_stacks(&mut self, geo: &Geo, cl: &Cluster, c: usize, c0: f64) { + // Each axis stacks its own series. + let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); + for &s in &cl.stacks { + let Some(v) = self.cell(c, s) else { continue }; + let ax = geo.axis(s); + let (a, w) = cl.place(geo, c, c0, ax); + let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; + let (v0, v1) = (geo.vpos(ax, *base), geo.vpos(ax, *base + v)); + *base += v; + let paint = self.mark(s, true, Paint::solid(Color::WHITE), 0.25); + let rect = bar_rect(geo.horizontal, a, w, v0, v1); + // A segment of 0 has no area to shade. + let n = self.bar_node(Bar { s, rect, value: v, down: v1 < v0, per_unit: geo.per_unit(ax) }, paint, v != 0.0); + self.push_series(s, n); + } + } + + /// Cumulative area bands, each from the previous area total on its axis up to its own. + fn areas(&mut self, geo: &Geo) { + let ncat = self.ncat; + let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; + for s in self.series_of(GraphKind::Area) { + let a = geo.axis(s); + let Some(below) = belows.get_mut(a) else { continue }; + let above: Vec = below.iter().enumerate().map(|(c, b)| b + self.val(c, s)).collect(); + // Mixed with series at the category centres, areas take their points there too. + let at = |c: usize, v: &[f64]| Point::new(geo.cat_mid(c), geo.vpos(a, v.get(c).copied().unwrap_or(0.0))); + let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); + pts.extend((0..ncat).rev().map(|c| at(c, below))); + let (fill, stroke, w) = self.mark(s, true, Paint::solid(Color::WHITE), 0.25); + let n = self.b.path(polyline(&pts, true), fill, stroke, w); + self.push_series(s, n); + *below = above; + } + } + + fn lines(&mut self, geo: &Geo) { + let g = self.g; + for s in self.series_of(GraphKind::Line) { + let pts: Vec> = (0..self.ncat) + .map(|c| self.cell(c, s).map(|v| Point::new(geo.mid(c, g.edge_to_edge && geo.label_edges), geo.vpos(geo.axis(s), v)))) + .collect(); + if g.connect_points { + for run in runs(&pts) { + let n = self.b.data_line(g, s, polyline(&run, false), self.filled); + self.push_shadowed(s, n); + } + } + self.point_markers(s, &pts); + } + } + + // ---- Legend and layering ---- + + /// Legend: a swatch + label per series, right of the plot; the swatch joins its series group. Returns the labels. + fn legend(&mut self) -> Vec> { + let g = self.g; + let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); + if !legend_on { + return vec![]; + } + if g.legend_across_top { self.legend_across_top() } else { self.legend_beside() } + } + + fn legend_series(&self) -> usize { + if self.g.kind == GraphKind::Scatter { self.nser.div_ceil(2) } else { self.nser } + } + + fn legend_text(&self, s: usize) -> String { + if self.g.kind == GraphKind::Scatter { self.series_label(s * 2) } else { self.series_label(s) } + } + + /// The swatch of series `s` at `(x, y)`: series drawn with markers show their marker design in the legend, and + /// series drawn with a column design that design. + fn legend_swatch(&mut self, s: usize, (x, y): (f64, f64)) { + let marked = matches!(self.g.kind, GraphKind::Scatter | GraphKind::Radar) || self.kind(s) == GraphKind::Line; + let paint = self.mark(s, true, Paint::None, 0.0); + let column = nth(self.column_looks, s).filter(|_| self.columnar(s)); + let swatch = self.b.swatch(column, nth(&self.designs, s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); + self.push_series(s, swatch); + } + + fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { + let ls = self.ls; + self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) + } + + /// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row + /// just above it. + fn legend_across_top(&mut self) -> Vec> { + let (g, r, ls) = (self.g, self.r, self.ls); + let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { @@ -614,89 +924,94 @@ pub(super) fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Opt } let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; // Above the plot and anything drawn over it (radar and stacked-pie labels). - let top = b.out.iter().chain(on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); - let top = bars_reach.map_or(top, |b| top.min(b.y0)); + let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); + let top = self.bars_reach.map_or(top, |b| top.min(b.y0)); let mut y = top - 8.0 - rows.iter().map(|row| height(row)).sum::(); + let mut labels = vec![]; for row in &rows { let mut x = r.x0; for (s, label, w) in row { - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line; - let paint = mark(*s, true, Paint::None, 0.0); - let swatch = - b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); - if let Some(items) = series.get_mut(*s) { - items.push(swatch); - } - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)); + self.legend_swatch(*s, (x, y)); + labels.push(self.legend_label((x, y), label)); x += w; } y += height(row); } - } else if legend_on { - let x = bars_reach.map_or(right_edge, |b| right_edge.max(b.x1)) + 14.0; + labels + } + + fn legend_beside(&mut self) -> Vec> { + let (r, ls) = (self.r, self.ls); + let x = self.bars_reach.map_or(self.right_edge, |b| self.right_edge.max(b.x1)) + 14.0; // Each row as tall as its label's lines (a `|` in a label starts a new line). let mut y = r.y0; - for (s, items) in series.iter_mut().enumerate().take(legend_series) { - let label = if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) }; + let mut labels = vec![]; + for s in 0..self.legend_series() { + let label = self.legend_text(s); let row_y = y; if blank_label(&label) { y += ls * 1.8; continue; } y += ls * 1.8 * label.split('|').count() as f64; - let y = row_y; - // Series drawn with markers show their marker design in the legend, and series drawn with a column design - // that design. - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line; - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint)); - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false)); + self.legend_swatch(s, (x, row_y)); + labels.push(self.legend_label((x, row_y), &label)); + } + labels + } + + /// Areas at the back, lines and points in front of columns; one graph type keeps the series order. + fn depth(&self, s: usize) -> u8 { + match self.kind(s) { + GraphKind::Area => 0, + GraphKind::Line => 2, + _ => 1, } } - // Areas at the back, lines and points in front of columns; one graph type keeps the series order. - let depth = |s: usize| match kind(s) { - GraphKind::Area => 0, - GraphKind::Line => 2, - _ => 1, - }; - // Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just - // behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one - // behind. Left out past SHADOW_BUDGET marks. - if !g.drop_shadow || shadowed.len() > SHADOW_BUDGET { - shadowed.clear(); - } - let mut series: Vec<(usize, Vec>)> = series.into_iter().enumerate().collect(); - // First Column in Front: the first series drawn last in its layer, over the others. - if g.first_column_in_front { - series.reverse(); - } - series.sort_by_key(|(s, _)| depth(*s)); - let mut layer = None; - for (s, items) in series { - if items.is_empty() { - continue; + + /// Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just + /// behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one + /// behind. Left out past SHADOW_BUDGET marks. + fn finish(mut self, mut labels: Vec>) -> Option>> { + let g = self.g; + if !g.drop_shadow || self.shadowed.len() > SHADOW_BUDGET { + self.shadowed.clear(); } - if layer != Some(depth(s)) { - layer = Some(depth(s)); - let copies: Vec> = shadowed.iter().filter(|(t, _)| depth(*t) == depth(s)).map(|(_, n)| shadow_of(b.d, n)).collect(); - if !copies.is_empty() { - let mut grp = Node::group(b.d.alloc_id(), copies); - grp.name = Some("Drop Shadow".into()); - grp.opacity = SHADOW_OPACITY; - b.out.push(Arc::new(grp)); + let mut series: Vec<(usize, Vec>)> = std::mem::take(&mut self.series).into_iter().enumerate().collect(); + // First Column in Front: the first series drawn last in its layer, over the others. + if g.first_column_in_front { + series.reverse(); + } + series.sort_by_key(|(s, _)| self.depth(*s)); + let mut layer = None; + for (s, items) in series { + if items.is_empty() { + continue; } + let depth = self.depth(s); + if layer != Some(depth) { + layer = Some(depth); + let casting: Vec> = self.shadowed.iter().filter(|(t, _)| self.depth(*t) == depth).map(|(_, n)| n.clone()).collect(); + let copies: Vec> = casting.iter().map(|n| shadow_of(self.b.d, n)).collect(); + if !copies.is_empty() { + let mut grp = Node::group(self.b.d.alloc_id(), copies); + grp.name = Some("Drop Shadow".into()); + grp.opacity = SHADOW_OPACITY; + self.b.out.push(Arc::new(grp)); + } + } + let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); + let grp = self.b.series(&name, s, items); + self.b.out.push(grp); } - let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = b.series(&name, s, items); - b.out.push(grp); + self.b.out.append(&mut self.on_top); + labels.append(&mut self.wedge_labels); + if !labels.is_empty() { + let grp = self.b.group("Legend", labels); + self.b.out.push(grp); + } + (!self.b.columns.short).then_some(self.b.out) } - b.out.append(&mut on_top); - labels.append(&mut wedge_labels); - if !labels.is_empty() { - let grp = b.group("Legend", labels); - b.out.push(grp); - } - (!b.columns.short).then_some(b.out) } pub(super) fn children_bounds(children: &[Arc]) -> Option { diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 9e53c8ddd..664356499 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -2,7 +2,10 @@ use super::axis::LABEL_SIZE; use super::columns::{Allowance, ColumnArt}; use super::markers::fit_design; use super::series::{default_series_paint, series_marks}; -use super::*; +use super::{ + Affine, Appearance, Arc, BezPath, CharStyle, Color, Document, GraphSpec, Justify, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, + TextObject, refresh_bounds, shapes, +}; pub(super) struct Gen<'a> { pub(super) d: &'a mut Document, diff --git a/crates/engine/src/cmd/graph/markers.rs b/crates/engine/src/cmd/graph/markers.rs index 91da6088d..95a738ff7 100644 --- a/crates/engine/src/cmd/graph/markers.rs +++ b/crates/engine/src/cmd/graph/markers.rs @@ -1,7 +1,7 @@ use super::designs::design_names; use super::edit::{regenerate, spec_of, target}; use super::series::{picked_series, reselect_series}; -use super::*; +use super::{Affine, Arc, Document, GraphSpec, Node, NodeKind, Rect, Result, Session, Value, bad, json}; /// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design). pub(super) const MARKER_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 56ed7eee5..0f035b55c 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,5 +1,5 @@ use super::labels::{Role, label_style}; -use super::*; +use super::{CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index 7dcb818ab..b97d92ee4 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -19,7 +19,7 @@ use vectorcraft_pathops::BoolOp; use super::edit::selected_roots; use super::menucmds::isolated_doc; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param}; pub fn specs() -> Vec { vec![cmd!( diff --git a/crates/engine/src/cmd/help.rs b/crates/engine/src/cmd/help.rs index aba4d608a..e88f00df2 100644 --- a/crates/engine/src/cmd/help.rs +++ b/crates/engine/src/cmd/help.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, Result, always, cmd}; /// The app's id on getartcraft.com and GitHub. pub const APP_ID: &str = "vectorcraft"; @@ -39,6 +39,7 @@ fn url(u: String) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::Session; #[test] fn links() { diff --git a/crates/engine/src/cmd/importmatches.rs b/crates/engine/src/cmd/importmatches.rs index d227b84ed..70d9f461f 100644 --- a/crates/engine/src/cmd/importmatches.rs +++ b/crates/engine/src/cmd/importmatches.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, cmd, str_param}; use crate::Prefs; /// The most matches kept. diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 68538a5cd..14264fc80 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun}; use vectorcraft_text::edit; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param, str_param}; pub fn specs() -> Vec { vec![cmd!( diff --git a/crates/engine/src/cmd/interactivity.rs b/crates/engine/src/cmd/interactivity.rs index bde136a74..4bcc123dc 100644 --- a/crates/engine/src/cmd/interactivity.rs +++ b/crates/engine/src/cmd/interactivity.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS}; use super::overprint::edit_counted; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 8947a97b5..41e4a467d 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -26,7 +26,7 @@ use vectorcraft_tools::intertwine::{Face, drawn_order, face_at, faces, faces_in, use super::edit::selected_roots; use super::pathops::Beside; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, checked_id, checked_ids_param, cmd, has_selection, point_param, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 7b56c2f46..83df1ff3f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -13,7 +13,10 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Rect}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, + has_selection, id_param, ids_param, index, ok, str_param, +}; use crate::EngineError; /// Most rows one panel command takes (ids are untrusted input). diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 5a0e2ec80..39bcdb03f 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::layer::{NewLayers, row_roots, rows_param}; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc, id_param, ok}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0752c215e..d51c5a916 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -15,7 +15,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::TextStyleDef; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param}; use crate::Clipboard; /// The extension of a library file. diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index bfa7e779e..c874a70ee 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::findfiles::{self, Limits, Visitor}; -use super::*; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, checked_ids_param, cmd}; /// The most linked files one search looks for. const MAX_LINKS: usize = 1_000; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index aef05cc8a..b26cfb557 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -31,7 +31,7 @@ use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 1276bdc2a..843445a2b 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -19,7 +19,10 @@ use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; use super::edit::selected_roots; use super::expand::gradient_sampler; use super::pathops::Beside; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, check_switches, cmd, color_value, f64_or, f64_req, finite_numbers, + has_doc, has_multi, has_selection, id_param, ids_param, number_or, ok, str_param, switch_or, +}; mod blend; mod commands; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 1ab018a8f..73f092214 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -3,7 +3,10 @@ use super::common::{ spacing_param, starts_param, wrap, }; use super::mesh::index_param; -use super::*; +use super::{ + Affine, Anchor, Appearance, Arc, Beside, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, Document, EngineError, Node, NodeId, NodeKind, + Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, id_param, json, live, number_or, ok, str_param, switch_or, +}; /// The first blend among `roots`. fn first_blend(d: &Document, roots: &[NodeId]) -> Option { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index d46e198e3..82105b00e 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -8,7 +8,7 @@ use super::envelope::{ env_set_mesh_point, }; use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color}; -use super::*; +use super::{CommandSpec, always, cmd, has_doc, has_multi, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 4237f48d3..48f335610 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -1,5 +1,8 @@ use super::mesh::whole; -use super::*; +use super::{ + Arc, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, Document, EngineError, Node, NodeId, NodeKind, Result, Selection, Session, Value, + bad, check_numbers, has_selection, ids_param, json, selected_roots, str_param, switch_or, +}; pub(super) fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 27e41509a..e247530c1 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -1,6 +1,10 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; use super::mesh::{handle_param, index_param, whole}; -use super::*; +use super::{ + Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, check_switches, f64_or, f64_req, id_param, json, live, ok, str_param, + switch_or, +}; // ---------- Envelope ---------- diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 570ef6c92..1d11080fb 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -1,6 +1,9 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of}; use super::envelope::{outline_of, rows_cols}; -use super::*; +use super::{ + Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeKind, Paint, Point, Result, Session, Value, bad, + check_numbers, color_value, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, +}; // ---------- Gradient mesh ---------- diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index e9283f075..c8dbf8c4d 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param}; /// Name of the temporary mask-editing layer. pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing"; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 904e75b4e..6d8f9fd40 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -14,7 +14,10 @@ use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::edit::{duplicate_in, selected_roots}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selection, + switch_or, +}; /// Session-level (not saved) state owned by the menu commands. #[derive(Clone, Debug, Default)] diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs index 40424da5a..9401402c5 100644 --- a/crates/engine/src/cmd/newart.rs +++ b/crates/engine/src/cmd/newart.rs @@ -16,7 +16,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, use vectorcraft_tools::PaintDefaults; use super::gradient::unplaced; -use super::*; +use super::{CommandSpec, Result, Session, always, cmd}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 588307f20..d8f0681cc 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; use vectorcraft_geom::{Point, Rect}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 97e4b3c2d..a2c37a09a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -14,7 +14,10 @@ use vectorcraft_render::effects; use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform}; use super::opacitymask::percent; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection, + has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets, +}; use crate::{EngineError, LastTransform}; mod align; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 7a0c073d9..873dc08ba 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -1,5 +1,8 @@ use super::transform::transform_patterns; -use super::*; +use super::{ + Affine, Document, EngineError, NodeId, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, check_numbers, check_switches, + effects, has_multi, ok, selected_roots, str_param, +}; impl Session { /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index b60c11f4c..48061fbe3 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots}; pub(super) enum Arrange { Front, diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 073fda3dd..2e0747170 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -1,6 +1,9 @@ use super::spans::Spans; use super::transform::{TRANSFORM_SWITCHES, about, apply_transform, scaling_options, transforms_objects}; -use super::*; +use super::{ + Affine, Document, EngineError, NodeId, OrientedBox, Point, Rect, Result, Scaling, Session, Value, bool_or, check_numbers, check_switches, + effects, f64_or, json, selected_roots_to_transform, +}; pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result { match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 1ac0b1102..4bf072749 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -5,7 +5,7 @@ use super::compound::{clip_edit, clip_make, clip_release, compound_make, compoun use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; -use super::*; +use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs index 66206ef55..1dabc76db 100644 --- a/crates/engine/src/cmd/object/compound.rs +++ b/crates/engine/src/cmd/object/compound.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Appearance, Arc, Document, EngineError, FillRule, Node, NodeId, NodeKind, Paint, PathData, Result, Session, Value, json, ok, selected_roots, +}; /// A closed contour smaller than this (in square document units) has no usable winding direction, /// so Make Compound Path leaves its orientation alone rather than flipping it on noise. diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 7f566ef4f..716181b1e 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BlendMode, EngineError, Knockout, Node, Result, Session, Value, bad, check_numbers, check_switches, ok, percent, str_param, targets}; /// The most data entries `object.setProps` takes at once. const MAX_DATA: usize = 256; diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index e29591b5a..c035a163d 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -1,5 +1,8 @@ use super::spans::Span; -use super::*; +use super::{ + BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches, + convert_radii_relative, ok, selected_roots, set_corners, targets, +}; /// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`, /// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across diff --git a/crates/engine/src/cmd/object/spans.rs b/crates/engine/src/cmd/object/spans.rs index 48bc9f28d..57fe9aeb2 100644 --- a/crates/engine/src/cmd/object/spans.rs +++ b/crates/engine/src/cmd/object/spans.rs @@ -1,5 +1,5 @@ use super::shape::scale_across; -use super::*; +use super::{Affine, Document, Node, NodeId, NodeKind, OrientedBox, Point, Rect}; /// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs /// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index ecb6b3a11..007ffae4f 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -1,5 +1,9 @@ use super::align::choice; -use super::*; +use super::{ + Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, + check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, + selected_roots_to_transform, +}; fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 52f63ffe0..cbdba5a80 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -26,7 +26,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7a97ab871..05e81260f 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -14,7 +14,7 @@ use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection}; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::pathops::{Beside, separate}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, ids_param, ok, targets}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 233554aa4..7cab59481 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::FillRule; use super::appearance::{ItemTarget, item_target}; use super::edit::selected_roots; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, f64_or, has_doc, ids_param, targets}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 91579a1b9..57f9accae 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -14,7 +14,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; use super::fileio::{create_dir, write_file}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name}; const C: &str = "file.package"; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b22d77ac9..d1601420b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -6,7 +6,10 @@ use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; -use super::*; +use super::{ + Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok, + paint_targets, str_param, switch_or, +}; use crate::{DocState, EngineError}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 5b814b10b..969daf4f6 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::*; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index f56abc8f3..9230cfd3f 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -9,7 +9,10 @@ use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; use super::draw2::usize_opt; use super::pathops::Beside; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selection, + id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 8f3e0f117..7589cab65 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -14,7 +14,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index b25b427d3..f412452ba 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -19,7 +19,10 @@ use vectorcraft_geom::Rect; use super::edit::{outermost, selected_roots}; use super::pathops::Beside; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param, + str_param, targets, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index faa986795..c9c94d808 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_pdf::{PdfPreset, builtin_preset, builtin_presets}; use super::fileio::pdf::{find_preset, resolve}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param, unique_name}; /// The `format` of a PDF presets file, also its extension. pub const PRESET_FORMAT: &str = "vcpdfpresets"; diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 77f5d158f..5b622f430 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -20,7 +20,7 @@ use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid}; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; use super::distortcmds::{grid_of, silent, store_grid}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 7089c2feb..ed4d0aba4 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -44,7 +44,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; use super::fileio::{self, Format, RasterImage}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name}; use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 650ff9992..25dcd1538 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -25,7 +25,7 @@ use vectorcraft_geom::{Affine, Rect}; use vectorcraft_pdf::CropTo; use super::super::fileio; -use super::super::*; +use super::super::{EngineError, NodeId, Result, bad}; /// The largest file placed (bytes). pub(crate) const MAX_FILE: usize = 256 << 20; diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index c67c77ca4..063444442 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -43,7 +43,7 @@ use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; use super::text::{self, Story}; -use super::*; +use super::{EngineError, Paint, Result, bad, fileio}; use crate::cmd::fileio::zip::{self, Archive, ReadError}; /// Word documents as `file.place` reports them (not a format `document.open` reads). diff --git a/crates/engine/src/cmd/place/rtf.rs b/crates/engine/src/cmd/place/rtf.rs index 9bfeb67f7..b28797061 100644 --- a/crates/engine/src/cmd/place/rtf.rs +++ b/crates/engine/src/cmd/place/rtf.rs @@ -31,7 +31,7 @@ use std::collections::HashMap; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; use super::text::{self, Story}; -use super::*; +use super::{Paint, Result, bad, fileio}; /// RTF files as `file.place` reports them (not a format `document.open` reads). pub(super) const FORMAT: fileio::Format = fileio::Format { diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 78302890c..15c5f2575 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -3,7 +3,7 @@ use serde_json::Value; -use super::*; +use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param}; /// Text files as `file.place` reports them (not a format `document.open` reads). pub(super) const FORMAT: fileio::Format = fileio::Format { diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index ecddf7cae..610b50c2d 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -20,7 +20,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; -use super::*; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param}; /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index bb7fce7c6..9dce65851 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param}; use crate::Prefs; use crate::units::Measure; use vectorcraft_doc::{GridPrefs, Unit}; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index be17271c0..0face3aa8 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -11,7 +11,7 @@ use vectorcraft_pdf::{PrintLayers, PrintOptions, PrintPreview, PrintSettings}; use super::fileio::pdf::{merge, pdf_error}; use super::fileio::{extension, write_or_return}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; const SETUP: &str = "print.setup"; diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index 301f696df..d181541c3 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -14,7 +14,7 @@ use vectorcraft_render::{AntiAlias, RenderOptions}; use super::fileio::pdf::pdf_error; use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document}; use super::menucmds::{MAX_PIXELS, unique_key}; -use super::*; +use super::{EngineError, Result, bad}; /// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming /// `cmd` for a preset that is neither built in nor in `saved`. diff --git a/crates/engine/src/cmd/printpresets.rs b/crates/engine/src/cmd/printpresets.rs index a27c5fa82..8f0dd298d 100644 --- a/crates/engine/src/cmd/printpresets.rs +++ b/crates/engine/src/cmd/printpresets.rs @@ -8,7 +8,7 @@ use vectorcraft_pdf::PrintSettings; use super::fileio::pdf::changed; use super::print::settings_over; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param, unique_name}; /// The built-in preset's name. pub const DEFAULT_PRESET: &str = "[Default]"; diff --git a/crates/engine/src/cmd/printtiling.rs b/crates/engine/src/cmd/printtiling.rs index 690ef6196..6c683ac79 100644 --- a/crates/engine/src/cmd/printtiling.rs +++ b/crates/engine/src/cmd/printtiling.rs @@ -10,7 +10,7 @@ use vectorcraft_pdf::{TileOrigin, TilingPage}; use super::fileio::pdf::pdf_error; use super::print::{settings, to_json}; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, index}; const SET: &str = "print.tiling.set"; diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index ae88da1fa..0d673aac5 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -26,7 +26,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_render::effects::{self, RasterFx}; use vectorcraft_render::{AntiAlias, RenderOptions}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc}; mod report; use report::FlattenReport; diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 52431090c..8a4fbb09d 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, checked_ids_param, cmd, color_value, has_doc, index, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 28f7bf592..753fad69c 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -43,7 +43,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob}; use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, str_param}; use crate::DocState; /// Documents with more objects than this are complex: with Preferences → Turn off Data Recovery diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index bf3149bcb..d175b4469 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,7 +11,7 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, str_param}; /// The extensions of the scripts File › Open shows a check for. pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"]; diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 26f90aa1f..b8b04482f 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -7,7 +7,9 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::wand::{fill_of, stroke_of}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param, +}; use crate::DocState; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index a65ab3e47..0ba4ebc0d 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,7 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param}; use serde_json::{Value, json}; use std::{collections::BTreeMap, sync::Arc}; use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec}; diff --git a/crates/engine/src/cmd/shortcutset.rs b/crates/engine/src/cmd/shortcutset.rs index cda276b6d..ad36b989c 100644 --- a/crates/engine/src/cmd/shortcutset.rs +++ b/crates/engine/src/cmd/shortcutset.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, importmatches, str_param}; /// The most bytes of a file read. const MAX_BYTES: usize = 4 << 20; diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 57e3b30bb..cad1335c1 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -12,7 +12,10 @@ use vectorcraft_doc::slices::valid_rect; use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKind, Slice, SliceKind, SliceOptions}; use vectorcraft_geom::{Rect, shapes}; -use super::*; +use super::{ + CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index, + targets, +}; /// The most slices one Divide Slices makes. const MAX_PIECES: usize = 1000; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index fccb1588c..03b927cd3 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{ use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, ok, str_param}; use crate::DocState; use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke, scale_layer}; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 486aa0d4e..a15ea9ff4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -20,7 +20,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, index, ok, str_param, unique_name}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index d1905142f..cb36fb407 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -16,7 +16,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary}; use super::menucmds::squash; use super::swatch::str_list; use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, unique_name}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 8c78d49e0..8de986583 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -7,7 +7,10 @@ use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or, + unique_name, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 36c67f696..b6dbde7fc 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -18,7 +18,7 @@ use super::fileio::{create_dir, extension, read_file, write_file, write_new_file use super::menucmds::squash; use super::package::free_name; use super::swatch::{map_default_paints, str_list, swatch_json}; -use super::*; +use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 557eb9ba6..3f0c80944 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -20,7 +20,7 @@ use super::clipboard::Clipboard; use super::clipboard::resources::SwatchChoices; use super::swatch::str_list; use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, number_or, str_param}; /// The extension of saved symbol libraries: native documents. pub const SYMBOLS_EXT: &str = vectorcraft_format::EXTENSION; diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 766335ca6..193ced2f7 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, TabAlign, TabStop}; use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 4b8b368f4..92a2758ee 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -9,7 +9,10 @@ use vectorcraft_text::edit; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, + index, number_or, point_param, str_param, switch_or, targets, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 3667169a4..08426e6c6 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -11,7 +11,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{CharStyle, Document, NodeId, NodeKind, ParaStyle, TextStyleDef}; use super::typecmd::{refresh_bounds, text_targets}; -use super::*; +use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param}; pub const NORMAL_CHAR: &str = "[Normal Character Style]"; pub const NORMAL_PARA: &str = "[Normal Paragraph Style]"; diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index 862d447d2..1315f6da6 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, Wrap use vectorcraft_geom::{PathData, shapes}; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 4382ae0b2..3bb35b979 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextObject, Te use super::edit::selected_roots; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, checked_id, cmd, finite_numbers, has_doc, has_multi, has_selection, ok, switch_or}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index e0cb73a1c..aedbd35a4 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -9,7 +9,10 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{len_param, num_param, shape_node}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, + str_param, switch_or, targets, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index a844d0083..4bde140a7 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -536,6 +536,24 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } +/// The text object `id`. +fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + } +} + +/// How many list markers text object `id` draws. +fn marker_count(s: &Session, id: u64) -> usize { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text_at(s, id)).markers.len() +} + +/// The tab stops of paragraph `k` of text object `id`. +fn tab_stops(s: &Session, id: u64, k: usize) -> Vec<(f64, vectorcraft_doc::TabAlign, String)> { + text_at(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect() +} + /// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the /// transform it is drawn with (in thousandths), from the top line down and left to right. fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> { @@ -566,13 +584,7 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { use vectorcraft_doc::text::{Bullet, ParaList}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); - let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => (**t).clone(), - _ => panic!("text"), - }; - let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len(); - let tabs = - |s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::>(); + let (text, markers, tabs) = (text_at, marker_count, tab_stops); let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill"; let id = s .execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}})) @@ -691,14 +703,19 @@ fn convert_to_text_writes_the_markers_where_they_were_drawn() { s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt"); } + point_and_vertical_type_convert(&mut s); +} + +/// Point type and vertical type convert too, and type with no list paragraph has nothing to convert. +fn point_and_vertical_type_convert(s: &mut Session) { // Point type and vertical type. for vertical in [false, true] { let id = s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); - let before = drawn_glyphs(&s, id); + let before = drawn_glyphs(s, id); s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); - assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}"); - assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo"); + assert_eq!(drawn_glyphs(s, id), before, "vertical {vertical}"); + assert_eq!(text_at(s, id).plain_text(), "1.\tOne\n2.\tTwo"); } // Type with no list paragraph: nothing to convert and no undo step. let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 74d94ed80..6df2ee6ce 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,7 +7,9 @@ use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, Te use vectorcraft_geom::{Affine, Rect, shapes}; use super::typecmd::refresh_bounds; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, find_command, has_clipboard, has_doc, has_selection, ids_param, str_param, targets, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index be5fec64a..116149ec7 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -19,7 +19,7 @@ use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKi use super::edit::selected_roots; use super::fileio::{absolute_path, read_file_head, write_file}; use super::textedit::set_plain_text; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selection, links, str_param}; mod csv; mod library; diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 6cb10284b..95d80ce28 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, Variables, absolute_path, bad, + json, read_file_head, str_param, variables_unlocked, write_file, +}; /// Cap on a graph variable's CSV (MCP and files are untrusted). pub(super) const MAX_GRAPH_CSV: usize = 256 * 1024; diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 83374a16d..937d4155e 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, + read_file_head, str_param, variables_unlocked, +}; /// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how /// deep its elements nest, and the entities, variables and data sets it declares. diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 0364c2958..8a688b05b 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::SavedView; use vectorcraft_geom::Point; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, cmd, f64_req, finite_numbers, has_doc, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 362043c3d..6e89a7544 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index a2e854ac9..c67d9428f 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -21,7 +21,7 @@ use vectorcraft_render::encode::quantize::{self, Dither, Indexed, PaletteOptions use vectorcraft_render::encode::web::{self, ColorSort}; use vectorcraft_render::encode::{gif, jpeg, png}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_or, has_doc, str_param, unique_name}; const C: &str = "document.exportForWeb"; diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 67c480133..579dda124 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -8,7 +8,10 @@ use vectorcraft_doc::{Node, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::{outermost, selected_roots}; -use super::*; +use super::{ + CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, + find_command, finite_numbers, has_doc, index, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 8f3f35528..3044e17bf 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{NodeId, Value, cmd}; /// The commands that act on the active artboard (#693), which the app passes as their `artboard`: /// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 403d771d7..299f2f000 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,7 +1,7 @@ use super::error::{EngineError, Result}; use super::history::{History, HistoryEntry}; use super::interaction::{Interaction, LastTransform, UndoGroup}; -use super::*; +use super::{Arc, Document, NodeId, NodeKind, Selection, Value, cmd, file_access}; /// Per-document editing state. #[derive(Clone, Debug)] diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index f894e516a..8d939db77 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{NodeId, cancel}; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index f82ba3079..ec53acd5b 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Document, Selection}; /// One undo step. #[derive(Clone, Debug)] diff --git a/crates/engine/src/interaction.rs b/crates/engine/src/interaction.rs index 986e9e566..b1d11a479 100644 --- a/crates/engine/src/interaction.rs +++ b/crates/engine/src/interaction.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, Document, NodeId, Selection, Value}; /// An in-progress drag: the snapshot the previews are applied to. #[derive(Clone, Debug)] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index fad473449..c6e76b4e8 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color, EyedropperOptions, PathfinderOptions, Value, cmd}; /// Where new art goes (Illustrator's drawing modes, Shift+D cycles). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index 0d06450a8..b28cc23ca 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, MAX_COORD, NodeId, NodeKind, Selection, cmd}; /// Is `id` a path made into a guide (View → Guides → Make Guides)? pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 2dd1bc889..1eaf15b88 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -5,7 +5,10 @@ use super::history::NamedStep; use super::interaction::{BatchSteps, Interaction, LastTransform, UndoGroup, UndoScope}; use super::prefs::{DrawMode, Prefs}; use super::sanity::{TOO_DEEP, deselect_hidden, doc_sane, objects_in_range}; -use super::*; +use super::{ + Arc, Clipboard, Color, CommandInfo, CommandSpec, Document, GradientPaint, Journal, NodeId, NodeKind, PaintDefaults, Selection, Session, Value, + ViewInfo, cmd, command_specs, file_access, find_command, guard, +}; mod documents; mod edit; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b0bf0f792..89d846330 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result, + Session, ViewInfo, cmd, command_specs, file_access, +}; impl Session { /// A session with no document open, the default preferences and the Selection tool. It also diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 6e9267720..78ba405be 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Document, EngineError, Interaction, LastTransform, NamedStep, Result, Selection, Session, TOO_DEEP, Value, cmd, deselect_hidden, doc_sane, + objects_in_range, +}; impl Session { /// Run `f` on a mutable copy of the active document. Outside an interaction this records one diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 45ede81e5..bede24c7e 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, +}; impl Session { /// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back. diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index f9f06135f..2f750dc13 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BatchSteps, Document, Session, UndoGroup, UndoScope, Value, cmd, deselect_hidden}; impl Session { // ---------- undo groups (scrubbed numeric fields) ---------- diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 383fe2143..1be9fa5fb 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2601,13 +2601,18 @@ fn guides_and_art_move_together_when_selected() { assert!(s.doc().unwrap().doc.node(copied_id).is_none()); assert!(s.doc().unwrap().doc.node(a).is_some()); + guides_and_art_later_cases(&mut s, a); +} + +/// Cases 5 to 8 of [`guides_and_art_move_together_when_selected`]: explicit ids, preview copies and moving a guide. +fn guides_and_art_later_cases(s: &mut Session, a: NodeId) { // 5. Explicit ids: only the specified art moves, guides stay put. - sel(&mut s, &[a]); + sel(s, &[a]); s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); s.execute("object.transform", &json!({"ids": [a.0], "matrix": [1, 0, 0, 1, 40, 50]})).unwrap(); let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); assert_eq!((b.x0, b.y0), (50.0, 60.0)); - assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + assert_eq!(guides(s), vec![(true, 200.0), (false, 300.0)]); s.execute("edit.undo", &json!({})).unwrap(); // 6. Interactive preview copies: dragging with Alt previews the copies and commit keeps them. @@ -2615,32 +2620,32 @@ fn guides_and_art_move_together_when_selected() { s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 10, 20], "copy": true})).unwrap(); s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 40], "copy": true})).unwrap(); s.commit_interaction().unwrap(); - assert_eq!(guides(&s).len(), 4); - assert_eq!(guides(&s)[2], (true, 230.0)); - assert_eq!(guides(&s)[3], (false, 340.0)); + assert_eq!(guides(s).len(), 4); + assert_eq!(guides(s)[2], (true, 230.0)); + assert_eq!(guides(s)[3], (false, 340.0)); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + assert_eq!(guides(s), vec![(true, 200.0), (false, 300.0)]); // 7. Dragging a guide (guide.move) when art is also selected moves both art and guides. - sel(&mut s, &[a]); + sel(s, &[a]); s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); s.execute("guide.move", &json!({"dx": 10, "dy": 20})).unwrap(); let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); assert_eq!((b.x0, b.y0), (20.0, 30.0)); - assert_eq!(guides(&s), vec![(true, 210.0), (false, 320.0)]); + assert_eq!(guides(s), vec![(true, 210.0), (false, 320.0)]); s.execute("edit.undo", &json!({})).unwrap(); let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); assert_eq!((b.x0, b.y0), (10.0, 10.0)); - assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + assert_eq!(guides(s), vec![(true, 200.0), (false, 300.0)]); // 8. Copy-dragging a guide (guide.move { copy: true }) when art is also selected duplicates both. s.execute("guide.move", &json!({"dx": 10, "dy": 20, "copy": true})).unwrap(); - assert_eq!(guides(&s).len(), 4); - assert_eq!(guides(&s)[2], (true, 210.0)); - assert_eq!(guides(&s)[3], (false, 320.0)); + assert_eq!(guides(s).len(), 4); + assert_eq!(guides(s)[2], (true, 210.0)); + assert_eq!(guides(s)[3], (false, 320.0)); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(guides(&s).len(), 2); + assert_eq!(guides(s).len(), 2); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); } diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 725bdd0b9..44ff73563 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -656,23 +656,30 @@ fn new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it() { assert_eq!(selected(&s), pasted); s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); assert_eq!(left(&s, pasted[0]), 1160.0); + batches_and_isolation_keep_new_art_protected(&mut s, pasted); +} + +/// The batch, isolation and group cases of [`new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it`], +/// with the pasted art `pasted` selected. +fn batches_and_isolation_keep_new_art_protected(s: &mut Session, pasted: Vec) { + let selected = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::>(); let editable = |s: &Session| s.doc().unwrap().doc.is_editable(NodeId(pasted[0])); // A batch that deselects it and fails brings it back selected and editable, and so does // undoing a batch that deselects it and draws. let failed = json!({"commands": [{"command": "select.none"}, {"command": "no.such.command"}]}); assert!(s.execute("command.batch", &failed).is_err()); - assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "after a failed batch"); + assert_eq!((selected(s), editable(s)), (pasted.clone(), true), "after a failed batch"); let drawn = json!({"commands": [{"command": "select.none"}, {"command": "shape.rectangle", "params": {"x": 300, "y": 300, "width": 20, "height": 20}}]}); s.execute("command.batch", &drawn).unwrap(); - assert!(!editable(&s), "deselected by the batch"); + assert!(!editable(s), "deselected by the batch"); // Undo inside a later batch gives the document the new art of the selection it brings back: // the unlocked artboard over that art then moves it along. let up = json!({"command": "artboard.move", "params": {"index": 2, "dx": 0, "dy": 1000, "moveArt": true}}); let r = s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}, up.clone()]})).unwrap(); assert!(ids(&r["results"][1]["moved"]).contains(&pasted[0]), "undone inside a batch: {r}"); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "the batch undone"); + assert_eq!((selected(s), editable(s)), (pasted.clone(), true), "the batch undone"); // Isolation mode inside a batch protects it before the next step. let isolated = json!({"commands": [{"command": "object.isolate", "params": {"id": pasted[0]}}, up]}); let r = s.execute("command.batch", &isolated).unwrap(); @@ -681,21 +688,21 @@ fn new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it() { s.execute("object.exitIsolation", &json!({})).unwrap(); // Isolation mode starts with nothing selected, so leaving it doesn't select the art again, // and the art, protected again, isn't isolated, as locked art isn't. - assert!(selected(&s).is_empty(), "after isolation mode"); - assert!(!editable(&s), "after isolation mode"); + assert!(selected(s).is_empty(), "after isolation mode"); + assert!(!editable(s), "after isolation mode"); assert!(s.execute("object.isolate", &json!({"id": pasted[0]})).is_err(), "isolating it again"); // A member of a new group can be selected while the group is new art. let copy = ids(&s.execute("edit.paste", &json!({"center": [1200, 150]})).unwrap()["ids"]); s.execute("object.group", &json!({})).unwrap(); - let group = selected(&s); + let group = selected(s); s.execute("select.set", &json!({"ids": copy})).unwrap(); - assert_eq!(selected(&s), copy, "a member of a new group"); + assert_eq!(selected(s), copy, "a member of a new group"); s.execute("select.set", &json!({"ids": group})).unwrap(); - assert_eq!(selected(&s), group, "from the member back to the group"); + assert_eq!(selected(s), group, "from the member back to the group"); s.execute("object.ungroup", &json!({})).unwrap(); - assert_eq!(selected(&s), copy, "ungrouped"); + assert_eq!(selected(s), copy, "ungrouped"); s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); - assert_eq!(selected(&s), copy, "the ungrouped art moves"); + assert_eq!(selected(s), copy, "the ungrouped art moves"); } /// When an artboard moves, scales or is cut with its art, the art inside it that is also on a @@ -933,7 +940,9 @@ fn artboard_display_marks_stay_out_of_exports() { let on = doc(&s); assert_ne!(on.artboards, off.artboards); assert_eq!(vectorcraft_svg::export(&on, &Default::default()), vectorcraft_svg::export(&off, &Default::default())); - assert_eq!(crate::export_pdf(&on, &Default::default()).unwrap(), crate::export_pdf(&off, &Default::default()).unwrap()); + // Fix the export date so metadata does not change between the two exports. + let options = vectorcraft_pdf::PdfOptions { created: Some(0), ..Default::default() }; + assert_eq!(crate::export_pdf(&on, &options).unwrap(), crate::export_pdf(&off, &options).unwrap()); } /// Artboard Options' video display options: `artboard.setProps {showVideoSafeAreas, diff --git a/crates/engine/tests/it/pen_endpoint_picking.rs b/crates/engine/tests/it/pen_endpoint_picking.rs index b2e77e52e..49eb7feef 100644 --- a/crates/engine/tests/it/pen_endpoint_picking.rs +++ b/crates/engine/tests/it/pen_endpoint_picking.rs @@ -303,59 +303,63 @@ fn another_path_join_keeps_snapped_target_when_raw_pointer_misses() { fn snapped_only_continue(preselected: bool) { for first in [true, false] { - let mut s = session(); - let mut prefs = s.prefs.clone(); - prefs.gridline_every = 100.0; - prefs.grid_subdivisions = 1; - s.apply_prefs(prefs); - assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); - assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); - let id = line(&mut s, (100.0, 300.0), (200.0, 300.0)); - let original = node(&s, id).clone(); - s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); - let v = ViewInfo { snap_to_grid: true, ..view() }; - s.select_tool("pen", v).unwrap(); - let at = if first { (63.0, 303.0) } else { (237.0, 303.0) }; - let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) }; - assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible"); - assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends"); - let journal_start = s.journal.len(); - let before_continue = s.doc().unwrap().history.undo.len(); - gesture(&mut s, v, &[at], Mods::default()); - - assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}"); - assert_eq!(s.doc().unwrap().selection.objects, vec![id]); - let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; - assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); - assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); - assert!(!s.in_interaction() && !s.tool_busy()); - assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); - let continued = node(&s, id).clone(); - if !first { - assert_eq!(continued, original, "continuing from the last end does not edit the path"); - } - - let before_append = s.doc().unwrap().history.undo.len(); - let next = if first { (0.0, 300.0) } else { (300.0, 300.0) }; - gesture(&mut s, v, &[next], Mods::default()); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(s.doc().unwrap().selection.objects, vec![id]); - assert_eq!(points(&s, id), [expected[0], expected[1], next]); - assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); - assert!(!s.in_interaction() && !s.tool_busy()); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); - let extended = node(&s, id).clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(node(&s, id), &continued); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(node(&s, id), &extended); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + snapped_only_case(preselected, first); } } +fn snapped_only_case(preselected: bool, first: bool) { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let id = line(&mut s, (100.0, 300.0), (200.0, 300.0)); + let original = node(&s, id).clone(); + s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); + let v = ViewInfo { snap_to_grid: true, ..view() }; + s.select_tool("pen", v).unwrap(); + let at = if first { (63.0, 303.0) } else { (237.0, 303.0) }; + let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) }; + assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible"); + assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends"); + let journal_start = s.journal.len(); + let before_continue = s.doc().unwrap().history.undo.len(); + gesture(&mut s, v, &[at], Mods::default()); + + assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; + assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); + let continued = node(&s, id).clone(); + if !first { + assert_eq!(continued, original, "continuing from the last end does not edit the path"); + } + + let before_append = s.doc().unwrap().history.undo.len(); + let next = if first { (0.0, 300.0) } else { (300.0, 300.0) }; + gesture(&mut s, v, &[next], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert_eq!(points(&s, id), [expected[0], expected[1], next]); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + let extended = node(&s, id).clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &continued); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &extended); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); +} + #[test] fn selected_snapped_only_endpoint_continues_when_raw_pointer_misses() { snapped_only_continue(true); diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index fcc2cb3c4..52bd7e685 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -1,7 +1,10 @@ use super::containers::close; use super::document::TEXT_SLOT; use super::types::{FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place}; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, NodeKind, + Obj, OpacityMask, Paint, PathData, Point, Reader, Rect, StrokeLayer, V, ps, +}; impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index b1168b2bc..09ccd14aa 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -1,7 +1,7 @@ use super::document::slot_of; use super::ops::{paints_apart, path_rule}; use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES}; -use super::*; +use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj}; impl<'a> Reader<'a> { // ---- frames ---------------------------------------------------------------------------- diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index b3e7d6801..47eef88dd 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,6 +1,6 @@ use super::art::{four, sane, two}; use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED}; -use super::*; +use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker}; impl<'a> Reader<'a> { pub(super) fn push(&mut self, v: V) -> Result<(), String> { diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index b61b74953..e78d91c82 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1,7 +1,7 @@ use super::art::{element_name, elements, join_and, sane}; use super::containers::drop_commented_copies; use super::types::{FLIP, Kind, MAX_DEPTH}; -use super::*; +use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). fn ruler_unit(v: u8) -> Option { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 24105cf28..9431061fe 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1,11 +1,53 @@ use super::containers::close; use super::document::slot_of; use super::types::{BLENDS, FLIP, Frame, IGNORED, Kind, MAX_DEPTH}; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, HashMap, HashSet, Instance, Knockout, + LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, +}; impl<'a> Reader<'a> { // ---- operators ------------------------------------------------------------------------- + fn end_array(&mut self) -> Result<(), String> { + // The innermost `[` still on the stack (the stack is cut back by operators). + let mut at = self.stack.len(); + while let Some(m) = self.marks.pop() { + if matches!(self.stack.get(m), Some(V::Mark)) { + at = m; + break; + } + } + let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); + self.push(V::Arr(items)) + } + + fn add_object_entry(&mut self) { + if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { + obj::add_entry(o, &mut self.stack, *base); + // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's + // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s + // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line + // of it may start with `%_` and read as hidden tokens: a `(` there would open + // a string that swallows the rest of the layer. + let key = o.entries.last().and_then(|(k, _)| k.as_deref()); + if o.ty == "Binary" && key == Some("ASCII85Decode") { + self.lex.skip_past(b"~>"); + } else if o.ty == "ForeignObject" && key == Some("Data") { + self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); + } + } + } + + fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { + // `X=` also introduces legacy text frame art, read by `text_frames`. + return Ok(()); + } + self.end_path_quietly(); + self.open(Kind::Mask, hidden) + } + pub(super) fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { if self.blend_record.is_some() { self.end_blend(); @@ -18,45 +60,14 @@ impl<'a> Reader<'a> { self.marks.push(self.stack.len()); return self.push(V::Mark); } - "]" => { - // The innermost `[` still on the stack (the stack is cut back by operators). - let mut at = self.stack.len(); - while let Some(m) = self.marks.pop() { - if matches!(self.stack.get(m), Some(V::Mark)) { - at = m; - break; - } - } - let items: Vec = self.stack.split_off(at).into_iter().skip(1).collect(); - return self.push(V::Arr(items)); - } + "]" => return self.end_array(), ":" => return self.begin_obj(hidden), "," => { - if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { - obj::add_entry(o, &mut self.stack, *base); - // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's - // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s - // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line - // of it may start with `%_` and read as hidden tokens: a `(` there would open - // a string that swallows the rest of the layer. - let key = o.entries.last().and_then(|(k, _)| k.as_deref()); - if o.ty == "Binary" && key == Some("ASCII85Decode") { - self.lex.skip_past(b"~>"); - } else if o.ty == "ForeignObject" && key == Some("Data") { - self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); - } - } + self.add_object_entry(); return Ok(()); } ";" => return self.end_obj(), - "X=" => { - if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { - // `X=` also introduces legacy text frame art, read by `text_frames`. - return Ok(()); - } - self.end_path_quietly(); - return self.open(Kind::Mask, hidden); - } + "X=" => return self.open_mask_object(hidden), "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), "X+" => return Ok(()), _ => {} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 4c15b04fa..309061f3c 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V}; /// Deepest nesting of layers, groups and dictionaries. pub(super) const MAX_DEPTH: usize = 256; diff --git a/crates/eps/src/import/data/filters.rs b/crates/eps/src/import/data/filters.rs index 088dd1b3a..d049e39a9 100644 --- a/crates/eps/src/import/data/filters.rs +++ b/crates/eps/src/import/data/filters.rs @@ -1,5 +1,8 @@ use super::ops::MAX_DATA; -use super::*; +use super::{ + DecodeSettings, Decoder, DecoderContext, DictRef, EncodingMode, ImageBlob, Key, Obj, PsError, Rc, Res, find, hex_decode, jpeg_components, ps, + ps_err, +}; /// A file object: the program itself, or data a filter decoded. #[derive(Debug)] diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index 63db3611f..3b4046de9 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -1,6 +1,9 @@ use super::filters::{Filter, Predictor, Stream, decode, fax_settings, io}; use super::pixels::{Pixels, Spec, by_rows, by_samples, masked, source}; -use super::*; +use super::{ + Affine, Color, DictRef, HashMap, ImageBlob, ImageObject, Inks, Interp, Key, Lexer, Node, NodeId, NodeKind, Obj, Op, OpacityMask, Paint, PsError, + Rc, RefCell, Res, Shared, Space, find, matrix_of, process, ps_err, +}; /// Most bytes one filter or image reads. pub(super) const MAX_DATA: usize = 1 << 28; diff --git a/crates/eps/src/import/data/pixels.rs b/crates/eps/src/import/data/pixels.rs index f61d10448..67ced02af 100644 --- a/crates/eps/src/import/data/pixels.rs +++ b/crates/eps/src/import/data/pixels.rs @@ -1,5 +1,5 @@ use super::ops::Source; -use super::*; +use super::{Affine, Obj, Res, Space, ps_err}; /// An image to decode. pub(super) struct Spec { diff --git a/crates/eps/src/import/graphics/ops.rs b/crates/eps/src/import/graphics/ops.rs index 608a62413..78a214b41 100644 --- a/crates/eps/src/import/graphics/ops.rs +++ b/crates/eps/src/import/graphics/ops.rs @@ -1,10 +1,100 @@ use super::paint::is_mesh; use super::state::{GState, Space}; -use super::*; +use super::{ + Affine, Color, FillRule, Interp, Key, LineCap, LineJoin, MAX_DASH_ENTRIES, MAX_DASHES, MAX_GSAVE, MAX_PATH, Obj, Op, Paint, Point, PsError, Rc, + Res, Shape, Vec2, matrix_obj, ps_err, +}; impl Interp<'_> { // ---------- the operators ---------- + fn replace_path_with_stroke_outline(&mut self) -> Res { + let bp = self.take_path(); + let mut st = self.stroke_layer()?; + // A dash pattern far finer than the path would make countless pieces. + let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); + let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; + if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { + st.dash = None; + } + let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); + let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); + // The path, each entry of the dash pattern in each period, and the outline count as elements. + let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; + self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; + if outline.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); + self.g.start = self.g.cur; + self.g.path = outline; + Ok(()) + } + + fn push_hsb_color(&mut self) -> Res { + let [r, g, b] = self.current_rgb(); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + ((g - b) / d).rem_euclid(6.0) / 6.0 + } else if max == g { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + let s = if max > 0.0 { d / max } else { 0.0 }; + for v in [h, s, max] { + self.push_num(f64::from(v))?; + } + Ok(()) + } + + fn set_current_color(&mut self) -> Res { + if let Space::Pattern(base) = &*self.g.space { + self.set_pattern(base.clone())?; + } else { + let mut components = vec![0.0; self.g.space.n()]; + for slot in components.iter_mut().rev() { + *slot = self.pop_num()?; + } + self.set_comps(components)?; + } + Ok(()) + } + + fn transform_point(&mut self, op: Op) -> Res { + let m = match self.stack.last() { + Some(Obj::Array { .. }) => self.pop_matrix()?, + _ => self.g.ctm, + }; + let [x, y] = self.nums()?; + let inverse = matches!(op, Op::ITransform | Op::IDTransform); + if inverse && m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let m = if inverse { m.inverse() } else { m }; + let p = if matches!(op, Op::DTransform | Op::IDTransform) { + let v = m * Point::new(x, y) - m * Point::ZERO; + Point::new(v.x, v.y) + } else { + m * Point::new(x, y) + }; + self.push_num(p.x)?; + self.push_num(p.y) + } + + // `[/Pattern ] setcolorspace setcolor`, unless already in a pattern space. + fn set_pattern_color(&mut self) -> Res { + if !matches!(*self.g.space, Space::Pattern(_)) { + self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); + self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); + } + let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; + self.set_pattern(base.clone()) + } + pub fn graphics_op(&mut self, op: Op) -> Res { use Op::*; match op { @@ -26,28 +116,7 @@ impl Interp<'_> { self.g.clips.clear(); } ExecForm => self.exec_form()?, - StrokePath => { - let bp = self.take_path(); - let mut st = self.stroke_layer()?; - // A dash pattern far finer than the path would make countless pieces. - let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum()); - let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 }; - if st.dash.is_some() && !(period > 0.0 && periods < MAX_DASHES) { - st.dash = None; - } - let tol = (0.01 / self.scale().max(1e-9)).clamp(1e-4, 1.0); - let outline = vectorcraft_effects::stroke::line_outline(&bp, &st, st.width, tol); - // The path, each entry of the dash pattern in each period, and the outline count - // as elements. - let dashes = st.dash.as_ref().map_or(0.0, |d| periods * d.pattern.len() as f64) as usize; - self.spend_elements(bp.elements().len().saturating_add(dashes).saturating_add(outline.elements().len()))?; - if outline.elements().len() >= MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - self.g.cur = outline.elements().iter().rev().find_map(|e| e.end_point()); - self.g.start = self.g.cur; - self.g.path = outline; - } + StrokePath => self.replace_path_with_stroke_outline()?, PathForAll => self.path_for_all()?, UFill | UEoFill | UStroke => self.paint_user_path(op)?, UAppend => { @@ -58,24 +127,7 @@ impl Interp<'_> { SetBBox => { self.nums::<4>()?; } - CurrentHsbColor => { - let [r, g, b] = self.current_rgb(); - let (max, min) = (r.max(g).max(b), r.min(g).min(b)); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - ((g - b) / d).rem_euclid(6.0) / 6.0 - } else if max == g { - ((b - r) / d + 2.0) / 6.0 - } else { - ((r - g) / d + 4.0) / 6.0 - }; - let s = if max > 0.0 { d / max } else { 0.0 }; - for v in [h, s, max] { - self.push_num(f64::from(v))?; - } - } + CurrentHsbColor => self.push_hsb_color()?, CurrentColorRendering => { let d = crate::import::obj::Dict::from([(Key::name("ColorRenderingType"), Obj::Int(1))]); self.push(Obj::dict(d))?; @@ -201,32 +253,13 @@ impl Interp<'_> { }; self.push(o)?; } - SetColor => { - if let Space::Pattern(base) = &*self.g.space { - self.set_pattern(base.clone())?; - } else { - let n = self.g.space.n(); - let mut c = vec![0.0; n]; - for slot in c.iter_mut().rev() { - *slot = self.pop_num()?; - } - self.set_comps(c)?; - } - } + SetColor => self.set_current_color()?, CurrentColor => { for v in self.g.comps.clone() { self.push_num(v)?; } } - // `[/Pattern ] setcolorspace setcolor`, unless in a pattern space already. - SetPattern => { - if !matches!(*self.g.space, Space::Pattern(_)) { - self.g.space = Rc::new(Space::Pattern(Some(self.g.space.clone()))); - self.g.space_obj = Obj::array(vec![Obj::name("Pattern"), self.g.space_obj.clone()]); - } - let Space::Pattern(base) = &*self.g.space else { return ps_err("typecheck", "") }; - self.set_pattern(base.clone())?; - } + SetPattern => self.set_pattern_color()?, MakePattern => { let m = self.pop_matrix()?; let d = self.pop_dict()?; @@ -347,26 +380,7 @@ impl Interp<'_> { None => self.g.ctm *= m, } } - Transform | ITransform | DTransform | IDTransform => { - let m = match self.stack.last() { - Some(Obj::Array { .. }) => self.pop_matrix()?, - _ => self.g.ctm, - }; - let [x, y] = self.nums()?; - let inverse = matches!(op, ITransform | IDTransform); - if inverse && m.determinant().abs() < 1e-12 { - return ps_err("undefinedresult", ""); - } - let m = if inverse { m.inverse() } else { m }; - let p = if matches!(op, DTransform | IDTransform) { - let v = m * Point::new(x, y) - m * Point::ZERO; - Point::new(v.x, v.y) - } else { - m * Point::new(x, y) - }; - self.push_num(p.x)?; - self.push_num(p.y)?; - } + Transform | ITransform | DTransform | IDTransform => self.transform_point(op)?, InvertMatrix => { let out = self.pop_array()?; let m = self.pop_matrix()?; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index f2e343858..8b0a729ee 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -1,5 +1,9 @@ use super::state::{GState, Space, process, tiling_hash}; -use super::*; +use super::{ + Affine, Arc, Color, DictRef, FUNCTION_OPS, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, Interp, Key, LIMIT, + MAX_APART, MAX_FUNCTION_VISITS, MAX_HIVAL, MAX_INKS, MAX_STITCH_STOPS, NESTED_PATTERNS, Node, NodeId, NodeKind, Obj, Paint, PatternDef, Point, + PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, +}; impl Interp<'_> { // ---------- colour ---------- diff --git a/crates/eps/src/import/graphics/path.rs b/crates/eps/src/import/graphics/path.rs index 3403e9add..f9580077e 100644 --- a/crates/eps/src/import/graphics/path.rs +++ b/crates/eps/src/import/graphics/path.rs @@ -1,5 +1,8 @@ use super::state::{ccw_turn, turn}; -use super::*; +use super::{ + Affine, BezPath, Dash, FillRule, HAIRLINE, Interp, Key, MAX_GSAVE, MAX_NEST, MAX_PATH, Obj, Op, PathEl, Point, PsError, Rect, Res, Shape, + StrokeLayer, Vec2, deep_clips, ps_err, +}; impl Interp<'_> { /// User space → document space. diff --git a/crates/eps/src/import/graphics/state.rs b/crates/eps/src/import/graphics/state.rs index 6aa25d5a9..489f675bf 100644 --- a/crates/eps/src/import/graphics/state.rs +++ b/crates/eps/src/import/graphics/state.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, Clip, Color, DefaultHasher, DictRef, Document, Drawn, FAR_AWAY, FillLayer, FillRule, Hash, + HashMap, HashSet, Hasher, LIMIT, LineCap, LineJoin, MAX_DRAWN, MAX_NEST, MAX_NODES, Node, NodeId, NodeKind, Obj, Paint, PathData, Point, Rc, + Rect, Shared, StrokeLayer, TOO_MUCH, +}; /// A colour space. #[derive(Clone, Debug)] diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 744431fad..5e8d12661 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -502,6 +502,138 @@ impl<'a> Interp<'a> { }) } + fn arithmetic(&mut self, op: Op) -> Res { + let (b, a) = (self.pop()?, self.pop()?); + let integer = match (&a, &b) { + (Obj::Int(x), Obj::Int(y)) => match op { + Op::Add => x.checked_add(*y).map(Obj::Int), + Op::Sub => x.checked_sub(*y).map(Obj::Int), + _ => x.checked_mul(*y).map(Obj::Int), + }, + _ => None, + }; + let result = match integer { + Some(value) => value, + None => { + let (x, y) = (a.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, b.as_num().ok_or(PsError::Ps("typecheck", String::new()))?); + let value = match op { + Op::Add => x + y, + Op::Sub => x - y, + _ => x * y, + }; + if !value.is_finite() { + return ps_err("undefinedresult", ""); + } + Obj::Real(value) + } + }; + self.push(result) + } + + fn math_function(&mut self, op: Op) -> Res { + let result = match op { + Op::Sqrt => self.pop_num()?.sqrt(), + Op::Sin => self.pop_num()?.to_radians().sin(), + Op::Cos => self.pop_num()?.to_radians().cos(), + Op::Ln => self.pop_num()?.ln(), + Op::Log => self.pop_num()?.log10(), + Op::Exp => { + let [base, exponent] = self.nums()?; + base.powf(exponent) + } + _ => { + let [num, den] = self.nums()?; + num.atan2(den).to_degrees().rem_euclid(360.0) + } + }; + if !result.is_finite() { + return ps_err("undefinedresult", ""); + } + self.push(Obj::Real(result)) + } + + fn logical_op(&mut self, op: Op) -> Res { + let (b, a) = (self.pop()?, self.pop()?); + let result = match (a, b) { + (Obj::Bool(x), Obj::Bool(y)) => Obj::Bool(match op { + Op::And => x && y, + Op::Or => x || y, + _ => x ^ y, + }), + (Obj::Int(x), Obj::Int(y)) => Obj::Int(match op { + Op::And => x & y, + Op::Or => x | y, + _ => x ^ y, + }), + _ => return ps_err("typecheck", ""), + }; + self.push(result) + } + + fn for_loop(&mut self) -> Res { + let p = self.pop_proc()?; + let (limit, inc, init) = (self.pop()?, self.pop()?, self.pop()?); + match (&init, &inc, &limit) { + (Obj::Int(i0), Obj::Int(d), Obj::Int(l)) => { + let (mut i, d, l) = (*i0, *d, *l); + while (d >= 0 && i <= l) || (d < 0 && i >= l) { + self.push(Obj::Int(i))?; + if !self.body(&p)? { + break; + } + i = i.checked_add(d).ok_or(PsError::Ps("rangecheck", String::new()))?; + if d == 0 { + self.tick()?; + } + } + } + _ => { + let n = |o: &Obj| o.as_num().filter(|v| v.is_finite()).ok_or(PsError::Ps("typecheck", String::new())); + let (i0, d, l) = (n(&init)?, n(&inc)?, n(&limit)?); + let mut k = 0.0; + loop { + let i = i0 + d * k; + if !((d >= 0.0 && i <= l) || (d < 0.0 && i >= l)) { + break; + } + self.push(Obj::Real(i))?; + if !self.body(&p)? { + break; + } + k += 1.0; + } + } + } + Ok(()) + } + + fn search_string(&mut self, op: Op) -> Res { + let seek = self.pop_str()?.to_vec(); + let s = self.pop_str()?; + let text = s.to_vec(); + self.spend((text.len() / 64) as u64)?; + let at = if op == Op::Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) }; + match at { + Some(i) => { + let o = Obj::Str(s); + if op == Op::Search { + self.push_interval(o.clone(), i + seek.len(), text.len() - i - seek.len())?; + self.push_interval(o.clone(), i, seek.len())?; + self.push_interval(o, 0, i)?; + } else { + self.push_interval(o.clone(), seek.len(), text.len() - seek.len())?; + self.push_interval(o, 0, seek.len())?; + } + self.push(Obj::Bool(true))?; + } + None => { + self.push(Obj::Str(s))?; + self.push(Obj::Bool(false))?; + } + } + Ok(()) + } + fn op_inner(&mut self, op: Op) -> Res { use Op::*; match op { @@ -558,34 +690,7 @@ impl<'a> Interp<'a> { let n = self.stack.iter().rev().position(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; self.push(Obj::Int(n as i64))?; } - Add | Sub | Mul => { - let (b, a) = (self.pop()?, self.pop()?); - let r = match (&a, &b) { - (Obj::Int(x), Obj::Int(y)) => match op { - Add => x.checked_add(*y).map(Obj::Int), - Sub => x.checked_sub(*y).map(Obj::Int), - _ => x.checked_mul(*y).map(Obj::Int), - }, - _ => None, - }; - let r = match r { - Some(r) => r, - None => { - let (x, y) = - (a.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, b.as_num().ok_or(PsError::Ps("typecheck", String::new()))?); - let r = match op { - Add => x + y, - Sub => x - y, - _ => x * y, - }; - if !r.is_finite() { - return ps_err("undefinedresult", ""); - } - Obj::Real(r) - } - }; - self.push(r)?; - } + Add | Sub | Mul => self.arithmetic(op)?, Div => { let [a, b] = self.nums()?; if b == 0.0 { @@ -603,27 +708,7 @@ impl<'a> Interp<'a> { Obj::Real(r) => self.push(Obj::Real(if op == Neg { -r } else { r.abs() }))?, _ => return ps_err("typecheck", ""), }, - Sqrt | Sin | Cos | Exp | Ln | Log | Atan => { - let r = match op { - Sqrt => self.pop_num()?.sqrt(), - Sin => self.pop_num()?.to_radians().sin(), - Cos => self.pop_num()?.to_radians().cos(), - Ln => self.pop_num()?.ln(), - Log => self.pop_num()?.log10(), - Exp => { - let [b, e] = self.nums()?; - b.powf(e) - } - _ => { - let [num, den] = self.nums()?; - num.atan2(den).to_degrees().rem_euclid(360.0) - } - }; - if !r.is_finite() { - return ps_err("undefinedresult", ""); - } - self.push(Obj::Real(r))?; - } + Sqrt | Sin | Cos | Exp | Ln | Log | Atan => self.math_function(op)?, Round | Floor | Ceiling | Truncate => match self.pop()? { Obj::Int(i) => self.push(Obj::Int(i))?, Obj::Real(r) => self.push(Obj::Real(match op { @@ -678,23 +763,7 @@ impl<'a> Interp<'a> { }; self.push(Obj::Bool(r))?; } - And | Or | Xor => { - let (b, a) = (self.pop()?, self.pop()?); - let r = match (a, b) { - (Obj::Bool(x), Obj::Bool(y)) => Obj::Bool(match op { - And => x && y, - Or => x || y, - _ => x ^ y, - }), - (Obj::Int(x), Obj::Int(y)) => Obj::Int(match op { - And => x & y, - Or => x | y, - _ => x ^ y, - }), - _ => return ps_err("typecheck", ""), - }; - self.push(r)?; - } + And | Or | Xor => self.logical_op(op)?, Not => match self.pop()? { Obj::Bool(b) => self.push(Obj::Bool(!b))?, Obj::Int(i) => self.push(Obj::Int(!i))?, @@ -723,41 +792,7 @@ impl<'a> Interp<'a> { let pick = if self.pop_bool()? { yes } else { no }; self.call(pick)?; } - For => { - let p = self.pop_proc()?; - let (limit, inc, init) = (self.pop()?, self.pop()?, self.pop()?); - match (&init, &inc, &limit) { - (Obj::Int(i0), Obj::Int(d), Obj::Int(l)) => { - let (mut i, d, l) = (*i0, *d, *l); - while (d >= 0 && i <= l) || (d < 0 && i >= l) { - self.push(Obj::Int(i))?; - if !self.body(&p)? { - break; - } - i = i.checked_add(d).ok_or(PsError::Ps("rangecheck", String::new()))?; - if d == 0 { - self.tick()?; - } - } - } - _ => { - let n = |o: &Obj| o.as_num().filter(|v| v.is_finite()).ok_or(PsError::Ps("typecheck", String::new())); - let (i0, d, l) = (n(&init)?, n(&inc)?, n(&limit)?); - let mut k = 0.0; - loop { - let i = i0 + d * k; - if !((d >= 0.0 && i <= l) || (d < 0.0 && i >= l)) { - break; - } - self.push(Obj::Real(i))?; - if !self.body(&p)? { - break; - } - k += 1.0; - } - } - } - } + For => self.for_loop()?, Repeat => { let p = self.pop_proc()?; let n = self.pop_int()?; @@ -955,31 +990,7 @@ impl<'a> Interp<'a> { self.stack.truncate(at); self.push(Obj::Array { items, exec: false })?; } - Search | AnchorSearch => { - let seek = self.pop_str()?.to_vec(); - let s = self.pop_str()?; - let text = s.to_vec(); - self.spend((text.len() / 64) as u64)?; - let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) }; - match at { - Some(i) => { - let o = Obj::Str(s); - if op == Search { - self.push_interval(o.clone(), i + seek.len(), text.len() - i - seek.len())?; - self.push_interval(o.clone(), i, seek.len())?; - self.push_interval(o, 0, i)?; - } else { - self.push_interval(o.clone(), seek.len(), text.len() - seek.len())?; - self.push_interval(o, 0, seek.len())?; - } - self.push(Obj::Bool(true))?; - } - None => { - self.push(Obj::Str(s))?; - self.push(Obj::Bool(false))?; - } - } - } + Search | AnchorSearch => self.search_string(op)?, NewDict => { self.pop_count()?; self.push(Obj::dict(Default::default()))?; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 9441da025..995e8e8d3 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -4,7 +4,7 @@ use super::slots::{ ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, text_objects, }; -use super::*; +use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai}; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index ceaa584e2..cf86445e5 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -1,6 +1,6 @@ use super::build::Frame; use super::slots::OUTLINE_REACH; -use super::*; +use super::{Arc, Document, Node, NodeKind, Rect}; /// `doc` with its layers that don't print hidden, when it has any. fn as_printed(doc: &Document) -> Option { diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index e0e7eacc0..4566afe0d 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,6 +1,6 @@ use super::build::{Page, build}; use super::slots::LAYERS_UNREAD; -use super::*; +use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; /// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or /// its program in the legacy format, see [`editing_copy`]): its layers (the @@ -217,7 +217,8 @@ pub(super) fn closed_where_ends_meet(mut path: PathData) -> PathData { #[cfg(test)] mod closed_ends_tests { - use super::*; + use super::{PathData, closed_where_ends_meet}; + use vectorcraft_geom::Point; /// A path drawn round to its start (the last anchor on the first) is closed there, its first /// anchor taking the last one's incoming handle; one whose ends are apart stays open. diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index aa86d57b6..a89ad0dd9 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -1,6 +1,44 @@ use super::layers::closed_where_ends_meet; use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type}; -use super::*; +use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind}; + +/// Match page objects to the nearest slot whose story describes them, including hidden slots. +fn match_page_objects(infos: &[SlotInfo], lines: &[Option>], shown: &[Vec>]) -> Vec> { + let mut pairs: Vec<(f64, usize, usize)> = vec![]; + for (k, _) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; + for (j, object) in shown.iter().enumerate() { + if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { + pairs.push((d, k, j)); + } + } + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut owner = vec![None; shown.len()]; + for (_, k, j) in pairs { + if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { + *o = Some(k); + } + } + owner +} + +fn frame_text_from_art(info: &SlotInfo, node: &mut Node, region: &mut Option) { + if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { + let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + match &mut text.kind { + TextKind::Area { frame } => { + *frame = path.clone(); + *region = path.bounds(); + } + TextKind::OnPath { path: on, .. } => { + *on = closed_where_ends_meet(path.clone()); + *region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); + } + TextKind::Point => {} + } + } +} /// What goes into each of `infos`' slots: the page's type that the file's text document puts where the /// page has it, else type made from the text document, else (for a file whose text document can't say) @@ -21,24 +59,7 @@ pub(super) fn plan( Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) }) .collect(); - // The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show - // has them too: a file's page may draw what its layers have hidden since. - let mut pairs: Vec<(f64, usize, usize)> = vec![]; - for (k, _) in infos.iter().enumerate() { - let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue }; - for (j, object) in shown.iter().enumerate() { - if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { - pairs.push((d, k, j)); - } - } - } - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut owner: Vec> = vec![None; shown.len()]; - for (_, k, j) in pairs { - if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { - *o = Some(k); - } - } + let owner = match_page_objects(infos, &lines, shown); // Type the text document makes, shown or not: the page's pieces of it aren't needed. let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); // Where the made type that doesn't show lies, by slot. @@ -51,20 +72,7 @@ pub(super) fn plan( let mut region = story.region(t, to_doc); // Area type and type on a path in the object's own frame: the text document's frame // of a copied object keeps its original's path. - if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { - let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); - match &mut text.kind { - TextKind::Area { frame } => { - *frame = path.clone(); - region = path.bounds(); - } - TextKind::OnPath { path: on, .. } => { - *on = closed_where_ends_meet(path.clone()); - region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); - } - TextKind::Point => {} - } - } + frame_text_from_art(info, &mut node, &mut region); if let (false, Some(r)) = (info.shown, region) { hidden_at.push((k, r)); } diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 07f7ef13d..524fbcc81 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -1,5 +1,5 @@ use super::build::MADE; -use super::*; +use super::{AppearanceItem, Arc, BTreeSet, BlendMode, Document, Node, NodeId, NodeKind, PathData, Point, Rect, ai, slot_of}; /// The notes that say the import left out something the file has (see [`is_loss`]). pub(super) const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index b67296e72..afa6a9b35 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,5 +1,8 @@ use super::writer::Writer; -use super::*; +use super::{ + Affine, AreaOptions, BezPath, CharPosition, CharStyle, Color, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, + TextObject, Write, latin, ps, stroke, +}; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops /// (`colorSpec colorStyle midPoint rampPoint %_Bs`, each with its midpoint to the next stop), `BD`, diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index eb10f1440..554447cec 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Write, latin}; /// The setup's fonts (§3.1, §3.3): `%%IncludeFont` for each, the encoding strings are in (`TE`), /// and each font re-encoded to it as `_name` (`TZ`; a TrueType font takes the encoding as its own, diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 31caf715f..89df7701b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -1,7 +1,13 @@ use super::encoding::{ Ink, average, direct_fill, direct_stroke, finite, font_of, invertible, latin_string, lost_attributes, segment_point, unit, writable, }; -use super::*; +use super::{ + Affine, AnchorKind, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, + Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, LineCap, LineJoin, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, + Paint, ParaDirection, ParaStyle, PathData, Point, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, + TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke, +}; pub(super) struct Writer<'a> { doc: &'a Document, diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 89432e9fc..154bf18c3 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -1,6 +1,6 @@ use super::dispatch::{BRIEF, MAX_SIDE_WINDOW, MENU_FILTER, TRACE_VIEWS}; use super::handlers::MAX_DIALOG_FIELDS; -use super::*; +use super::{FORMATS, OPEN_EXTS, SAVE_FORMATS, Value, format, json}; // ---------- schemas ---------- diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index d5c11e85c..42d497c1f 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -3,7 +3,7 @@ use super::handlers::{ Args, absolute_path, add_text, answer_dialog, apply_paint, draw_path, draw_shape, exec, image_result, max_side, need_ui, req_str, screenshot, select_ids, transform, }; -use super::*; +use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, format, json, save_reads_option, writable_format}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index 0448e122f..c9d203c6b 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -1,5 +1,5 @@ use super::dispatch::trace_views; -use super::*; +use super::{Backend, Map, ToolResult, Value, json}; // ---------- implementation ---------- diff --git a/crates/metafile/src/import/emf.rs b/crates/metafile/src/import/emf.rs index 2b97a5a37..ece16c786 100644 --- a/crates/metafile/src/import/emf.rs +++ b/crates/metafile/src/import/emf.rs @@ -166,24 +166,154 @@ pub(super) fn play(bytes: &[u8]) -> Result { Ok(p.finish()) } +/// PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms. +fn rec_poly(p: &mut Player, kind: u32, r: &mut Reader) -> Option<()> { + let wide = kind < 85; + r.skip(16)?; + let n = r.u32()?; + let pts = points(r, n, wide)?; + match if wide { kind } else { kind - 83 } { + 2 => p.poly(&pts, false, true), + 3 => p.poly(&pts, true, false), + 4 => p.poly(&pts, false, false), + 5 => p.poly_to(&pts, true), + _ => p.poly_to(&pts, false), + } + Some(()) +} + +/// CreatePen (38). +fn rec_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { + let ih = r.u32()?; + let style = r.u32()?; + let width = f64::from(r.i32()?); + r.skip(4)?; + p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(r)?, entries: vec![] })); + Some(()) +} + +/// ExtCreatePen (95). +fn rec_ext_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { + let ih = r.u32()?; + r.skip(16)?; + let style = r.u32()?; + let width = f64::from(r.u32()?); + let brush = r.u32()?; + let c = color(r)?; + r.skip(4)?; + let n = (r.u32()? as usize).min(64); + let entries: Vec = (0..n).map_while(|_| r.u32()).map(f64::from).collect(); + // A pen painted with the null brush draws nothing. + let style = if brush == 1 { super::PS_NULL } else { style }; + p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries })); + Some(()) +} + +/// GdiComment (70): EMF+ records ride in comments starting "EMF+". +fn rec_comment(p: &mut Player, r: &mut Reader) -> Option<()> { + r.skip(4)?; + p.emf_plus |= r.u32()? == 0x2B46_4D45; + Some(()) +} + +/// ExtSelectClipRgn (75). +fn rec_clip_region(p: &mut Player, r: &mut Reader) -> Option<()> { + let cb = r.u32()?; + let mode = r.u32()?; + let data = r.bytes(cb as usize)?; + let mut d = Reader::new(data); + let rects = if cb >= 32 { + d.skip(8)?; + let n = d.u32()? as usize; + d.skip(20)?; + if n > MAX_POINTS || d.rest().len() < n * 16 { + return None; + } + (0..n).map(|_| rect(&mut d)).collect::>>()? + } else { + vec![] + }; + p.clip_region(&rects, mode); + Some(()) +} + +/// The rectangle at `(x, y)` of size `(w, h)`. +fn at_size(x: i32, y: i32, w: i32, h: i32) -> Rect { + Rect::new(f64::from(x), f64::from(y), f64::from(x) + f64::from(w), f64::from(y) + f64::from(h)) +} + +/// BitBlt (76) and StretchBlt (77). +fn rec_blt(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { + r.skip(16)?; + let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let rop = r.u32()?; + let (xs, ys) = (r.i32()?, r.i32()?); + r.skip(24 + 4 + 4)?; + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) }; + let d = at_size(dest.0, dest.1, dest.2, dest.3); + if cb_bmi == 0 { + p.pattern_blit(d, rop); + } else { + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); + let src = source(xs, ys, ws, hs, &img, false); + p.image(img, src, d, rop, 1.0); + } + Some(()) +} + +/// StretchDIBits (81). +fn rec_stretch_dibits(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { + r.skip(16)?; + let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + r.skip(4)?; + let rop = r.u32()?; + let (wd, hd) = (r.i32()?, r.i32()?); + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); + let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi)); + p.image(img, src, at_size(xd, yd, wd, hd), rop, 1.0); + Some(()) +} + +/// AlphaBlend (114). +fn rec_alpha_blend(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { + r.skip(16)?; + let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let blend = r.bytes(4)?; + let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0)); + let (xs, ys) = (r.i32()?, r.i32()?); + r.skip(24 + 4 + 4)?; + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + let (ws, hs) = (r.i32()?, r.i32()?); + let alpha = if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore }; + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, alpha, &mut p.pixels_left); + let src = source(xs, ys, ws, hs, &img, false); + p.image(img, src, at_size(xd, yd, wd, hd), crate::import::SRCCOPY, f64::from(constant) / 255.0); + Some(()) +} + +/// ExtTextOutA (83) and ExtTextOutW (84). +fn rec_text(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { + r.skip(16 + 4 + 8)?; + let at = point32(r)?; + let n = r.u32()? as usize; + let off = r.u32()?; + let s = if kind == 84 { + let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; + String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::>()) + } else { + slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() + }; + p.text(at, &s); + Some(()) +} + /// Play one record (`rec`: all of it, with its type and size). `None`: unknown or damaged. fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { let mut r = Reader::new(rec.get(8..)?); match kind { - // PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms. - 2..=6 | 85..=89 => { - let wide = kind < 85; - r.skip(16)?; - let n = r.u32()?; - let pts = points(&mut r, n, wide)?; - match if wide { kind } else { kind - 83 } { - 2 => p.poly(&pts, false, true), - 3 => p.poly(&pts, true, false), - 4 => p.poly(&pts, false, false), - 5 => p.poly_to(&pts, true), - _ => p.poly_to(&pts, false), - } - } + 2..=6 | 85..=89 => rec_poly(p, kind, &mut r)?, 7 | 90 => p.poly_poly(&polys(&mut r, kind == 7)?, false), 8 | 91 => p.poly_poly(&polys(&mut r, kind == 8)?, true), 9 => p.set_window_ext(f64::from(r.i32()?), f64::from(r.i32()?)), @@ -209,13 +339,7 @@ fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { p.world(x, r.u32()?); } 37 => p.select(r.u32()?), - 38 => { - let ih = r.u32()?; - let style = r.u32()?; - let width = f64::from(r.i32()?); - r.skip(4)?; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] })); - } + 38 => rec_create_pen(p, &mut r)?, 39 => { let ih = r.u32()?; let style = r.u32()?; @@ -249,107 +373,18 @@ fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { 64 => p.paint_path(false, true), 67 => p.clip_path(r.u32()?), 68 => p.abort_path(), - 70 => { - // EMF+ records ride in comments starting "EMF+". - r.skip(4)?; - if r.u32()? == 0x2B46_4D45 { - p.emf_plus = true; - } - } - 75 => { - let cb = r.u32()?; - let mode = r.u32()?; - let data = r.bytes(cb as usize)?; - let mut d = Reader::new(data); - let rects = if cb >= 32 { - d.skip(8)?; - let n = d.u32()? as usize; - d.skip(20)?; - if n > MAX_POINTS || d.rest().len() < n * 16 { - return None; - } - (0..n).map(|_| rect(&mut d)).collect::>>()? - } else { - vec![] - }; - p.clip_region(&rects, mode); - } - 76 | 77 => { - r.skip(16)?; - let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let rop = r.u32()?; - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) }; - let d = Rect::new(f64::from(dest.0), f64::from(dest.1), f64::from(dest.0) + f64::from(dest.2), f64::from(dest.1) + f64::from(dest.3)); - if cb_bmi == 0 { - p.pattern_blit(d, rop); - } else { - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - p.image(img, src, d, rop, 1.0); - } - } - 81 => { - r.skip(16)?; - let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - r.skip(4)?; - let rop = r.u32()?; - let (wd, hd) = (r.i32()?, r.i32()?); - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi)); - let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd)); - p.image(img, src, d, rop, 1.0); - } + 70 => rec_comment(p, &mut r)?, + 75 => rec_clip_region(p, &mut r)?, + 76 | 77 => rec_blt(p, kind, rec, &mut r)?, + 81 => rec_stretch_dibits(p, rec, &mut r)?, 82 => { let ih = r.u32()?; p.create_at(ih, Obj::Font(font(&mut r)?)); } - 83 | 84 => { - r.skip(16 + 4 + 8)?; - let at = point32(&mut r)?; - let n = r.u32()? as usize; - let off = r.u32()?; - let s = if kind == 84 { - let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; - String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::>()) - } else { - slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() - }; - p.text(at, &s); - } + 83 | 84 => rec_text(p, kind, rec, &mut r)?, 93 | 94 => p.create_at(r.u32()?, Obj::Brush(BrushObj::Pattern)), - 95 => { - let ih = r.u32()?; - r.skip(16)?; - let style = r.u32()?; - let width = f64::from(r.u32()?); - let brush = r.u32()?; - let c = color(&mut r)?; - r.skip(4)?; - let n = (r.u32()? as usize).min(64); - let entries: Vec = (0..n).map_while(|_| r.u32()).map(f64::from).collect(); - // A pen painted with the null brush draws nothing. - let style = if brush == 1 { super::PS_NULL } else { style }; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries })); - } - 114 => { - r.skip(16)?; - let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let blend = r.bytes(4)?; - let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0)); - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = (r.i32()?, r.i32()?); - let alpha = if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore }; - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, alpha, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd)); - p.image(img, src, d, crate::import::SRCCOPY, f64::from(constant) / 255.0); - } + 95 => rec_ext_create_pen(p, &mut r)?, + 114 => rec_alpha_blend(p, rec, &mut r)?, k if IGNORED.contains(&k) => {} _ => return None, } diff --git a/crates/metafile/src/import/wmf.rs b/crates/metafile/src/import/wmf.rs index d0fbd190d..7a682e59a 100644 --- a/crates/metafile/src/import/wmf.rs +++ b/crates/metafile/src/import/wmf.rs @@ -174,6 +174,109 @@ fn blit(p: &mut Player, rop: u32, src: (i16, i16, i16, i16), dest: (i16, i16, i1 p.image(img, Rect::new(xs, top, xs + ws, top + hs), d, rop, 1.0); } +/// PolyPolygon (0x0538). +fn rec_poly_polygon(p: &mut Player, r: &mut Reader) -> Option<()> { + let n = usize::from(r.u16()?); + if r.rest().len() < n * 2 { + return None; + } + let counts: Vec = (0..n).map(|_| r.u16().map(usize::from)).collect::>()?; + let mut polys = Vec::with_capacity(n); + for c in counts { + polys.push(points(r, c)?); + } + p.poly_poly(&polys, true); + Some(()) +} + +/// Arc (0x0817), Pie (0x081A) and Chord (0x0830). +fn rec_arc(p: &mut Player, function: u16, r: &mut Reader) -> Option<()> { + let (end, start) = (point_yx(r)?, point_yx(r)?); + let b = rect_brtl(r)?; + let kind = match function { + 0x0817 => ArcKind::Open, + 0x081A => ArcKind::Pie, + _ => ArcKind::Chord, + }; + p.arc(b, start, end, kind); + Some(()) +} + +/// CreateFontIndirect (0x02FB). +fn rec_create_font(p: &mut Player, r: &mut Reader) -> Option<()> { + let height = f64::from(r.i16()?); + r.skip(2)?; + let escapement = f64::from(r.i16()?); + r.skip(2)?; + let weight = i32::from(r.i16()?); + let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); + r.skip(7)?; + let face: Vec = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect(); + p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) })); + Some(()) +} + +/// TextOut (0x0521). +fn rec_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { + let n = usize::try_from(r.i16()?).ok()?; + let s = ansi(r.bytes(n)?); + r.skip(n % 2)?; + let at = point_yx(r)?; + p.text(at, &s); + Some(()) +} + +/// ExtTextOut (0x0A32). +fn rec_ext_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { + let at = point_yx(r)?; + let n = usize::try_from(r.i16()?).ok()?; + let opts = r.u16()?; + // ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first. + if opts & 0x6 != 0 { + r.skip(8)?; + } + let s = ansi(r.bytes(n)?); + p.text(at, &s); + Some(()) +} + +/// StretchDIB (0x0F43). +fn rec_stretch_dib(p: &mut Player, r: &mut Reader) -> Option<()> { + let rop = r.u32()?; + r.skip(2)?; + let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest())); + Some(()) +} + +/// StretchBlt (0x0B41). +fn rec_stretch_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { + let rop = r.u32()?; + let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + // Without a bitmap, a reserved word comes before the destination. + let with_bitmap = body.len() > 22; + if !with_bitmap { + r.skip(2)?; + } + let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest())); + Some(()) +} + +/// BitBlt (0x0940). +fn rec_bit_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { + let rop = r.u32()?; + let (ys, xs) = (r.i16()?, r.i16()?); + let with_bitmap = body.len() > 20; + if !with_bitmap { + r.skip(2)?; + } + let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest())); + Some(()) +} + /// Play one record (`r`: its parameters). `None`: unknown or damaged. fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { let mut r = Reader::new(body); @@ -193,34 +296,14 @@ fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { let pts = points(&mut r, n)?; p.poly(&pts, function == 0x0324, false); } - 0x0538 => { - let n = usize::from(r.u16()?); - if r.rest().len() < n * 2 { - return None; - } - let counts: Vec = (0..n).map(|_| r.u16().map(usize::from)).collect::>()?; - let mut polys = Vec::with_capacity(n); - for c in counts { - polys.push(points(&mut r, c)?); - } - p.poly_poly(&polys, true); - } + 0x0538 => rec_poly_polygon(p, &mut r)?, 0x041B => p.figure(shapes::rectangle(rect_brtl(&mut r)?.abs()).to_bezpath(), true), 0x0418 => p.figure(shapes::ellipse(rect_brtl(&mut r)?.abs()).to_bezpath(), true), 0x061C => { let (h, w) = (f64::from(r.i16()?), f64::from(r.i16()?)); p.figure(super::round_rect(rect_brtl(&mut r)?, w, h), true); } - 0x0817 | 0x081A | 0x0830 => { - let (end, start) = (point_yx(&mut r)?, point_yx(&mut r)?); - let b = rect_brtl(&mut r)?; - let kind = match function { - 0x0817 => ArcKind::Open, - 0x081A => ArcKind::Pie, - _ => ArcKind::Chord, - }; - p.arc(b, start, end, kind); - } + 0x0817 | 0x081A | 0x0830 => rec_arc(p, function, &mut r)?, 0x0416 => p.clip_rect(rect_brtl(&mut r)?), 0x0415 => p.exclude_clip(), 0x012D => p.select(u32::from(r.u16()?)), @@ -235,65 +318,14 @@ fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { let style = u32::from(r.u16()?); p.create(Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); } - 0x02FB => { - let height = f64::from(r.i16()?); - r.skip(2)?; - let escapement = f64::from(r.i16()?); - r.skip(2)?; - let weight = i32::from(r.i16()?); - let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); - r.skip(7)?; - let face: Vec = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect(); - p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) })); - } + 0x02FB => rec_create_font(p, &mut r)?, 0x00F7 | 0x06FF => p.create(Obj::Other), 0x01F9 | 0x0142 => p.create(Obj::Brush(BrushObj::Pattern)), - 0x0521 => { - let n = usize::try_from(r.i16()?).ok()?; - let s = ansi(r.bytes(n)?); - r.skip(n % 2)?; - let at = point_yx(&mut r)?; - p.text(at, &s); - } - 0x0A32 => { - let at = point_yx(&mut r)?; - let n = usize::try_from(r.i16()?).ok()?; - let opts = r.u16()?; - // ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first. - if opts & 0x6 != 0 { - r.skip(8)?; - } - let s = ansi(r.bytes(n)?); - p.text(at, &s); - } - 0x0F43 => { - let rop = r.u32()?; - r.skip(2)?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest())); - } - 0x0B41 => { - let rop = r.u32()?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - // Without a bitmap, a reserved word comes before the destination. - let with_bitmap = body.len() > 22; - if !with_bitmap { - r.skip(2)?; - } - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest())); - } - 0x0940 => { - let rop = r.u32()?; - let (ys, xs) = (r.i16()?, r.i16()?); - let with_bitmap = body.len() > 20; - if !with_bitmap { - r.skip(2)?; - } - let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest())); - } + 0x0521 => rec_text_out(p, &mut r)?, + 0x0A32 => rec_ext_text_out(p, &mut r)?, + 0x0F43 => rec_stretch_dib(p, &mut r)?, + 0x0B41 => rec_stretch_blt(p, body, &mut r)?, + 0x0940 => rec_bit_blt(p, body, &mut r)?, 0x061D => { let rop = r.u32()?; let (h, w, y, x) = (f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)); diff --git a/crates/pathops/src/planar/map.rs b/crates/pathops/src/planar/map.rs index 311f7f889..2b673c3a3 100644 --- a/crates/pathops/src/planar/map.rs +++ b/crates/pathops/src/planar/map.rs @@ -68,143 +68,8 @@ fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option { if !crate::budget::sweep_fits(inputs.iter().map(|p| &p.0)) { return None; } - // The sweep can panic on rare near-degenerate input; that must never take the app down. - let sweep = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); - let top = sweep.ok()?.ok()?; let tidy = Tidy::keeping(precision, inputs.iter().map(|p| &p.0)); - - // The planar graph: vertices are the sweep's points, edges its segments. Points closer than - // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge - // a rounding error away from where it cuts that edge). - let positions = top.compute_positions(); - let cell = 4.0 * eps; - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - let mut points: Vec = Vec::new(); - let mut vertex_id = |p: Point| { - let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); - let near = (cx - 1..=cx + 1) - .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) - .filter_map(|k| grid.get(&k)) - .flatten() - .copied() - .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); - near.unwrap_or_else(|| { - points.push(p); - grid.entry((cx, cy)).or_default().push(points.len() - 1); - points.len() - 1 - }) - }; - let mut pieces: Vec<(Vec, [usize; 2])> = Vec::new(); - for idx in top.segment_indices() { - let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); - let ends = [vertex_id(top.point(idx.first_half()).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; - if !segs.is_empty() && ends[0] != ends[1] { - pieces.push((segs, ends)); - } - } - // The sweep can cut the paths through one crossing at points more than `eps` apart, so copies of - // one edge (such as an open path's two passes) can end on different vertices. Pieces whose - // middles and ends lie within `tol` of each other are such copies, and their ends are one vertex. - let tol = 16.0 * eps; - let cell_of = |p: Point| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); - let mids: Vec> = pieces.iter().map(|(segs, _)| mid(segs)).collect(); - let mut by_mid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (k, m) in mids.iter().enumerate() { - if let Some(m) = m { - by_mid.entry(cell_of(*m)).or_default().push(k); - } - } - let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); - let mut joined: Vec = (0..points.len()).collect(); - // Where two passes of one path cross another (an open path's way there and back, coincident - // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that - // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are - // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path - // that runs nearly level in it (where the height a cut is found at pins its position down - // least) a cut can slide further, here up to a small fraction of the precision results are - // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds - // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real - // geometry, not one stub after another, and must not collapse into one vertex. - let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); - let mut extent: Vec = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); - for (segs, [v0, v1]) in &pieces { - let Some(&p0) = points.get(*v0) else { continue }; - if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { - continue; - } - let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); - let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; - let both = ra.union(rb); - if a == b || both.width() > stub_x || both.height() > stub_y { - continue; - } - let (root, child) = (a.min(b), a.max(b)); - if let Some(p) = joined.get_mut(child) { - *p = root; - } - if let Some(r) = extent.get_mut(root) { - *r = both; - } - } - for (k, (_, [a, b])) in pieces.iter().enumerate() { - let Some(&Some(m)) = mids.get(k) else { continue }; - let (cx, cy) = cell_of(m); - let others = (cx - 1..=cx + 1).flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))).filter_map(|c| by_mid.get(&c)).flatten(); - for &j in others.filter(|&&j| j > k) { - let (Some((_, [c, d])), Some(&Some(n))) = (pieces.get(j), mids.get(j)) else { continue }; - if m.distance(n) > tol { - continue; - } - let pairs = if close(*a, *c) && close(*b, *d) { - [(*a, *c), (*b, *d)] - } else if close(*a, *d) && close(*b, *c) { - [(*a, *d), (*b, *c)] - } else { - continue; - }; - for (x, y) in pairs { - let (x, y) = (find(&mut joined, x), find(&mut joined, y)); - if let Some(r) = joined.get_mut(x.max(y)) { - *r = x.min(y); - } - } - } - } - let mut edges: Vec = Vec::new(); - let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); - let outlines = Outlines::new(&inputs, eps); - for (mut segs, ends) in pieces { - let ends = ends.map(|v| find(&mut joined, v)); - let (Some(&p0), Some(&p1)) = (points.get(ends[0]), points.get(ends[1])) else { continue }; - if ends[0] == ends[1] { - continue; - } - // Every edge at a vertex starts or ends exactly on it, so faces meeting there share its - // coordinates: merging them (a union) then cancels their common edge, which it can't when - // each face carries its own rounding of the corner (#893). - pin_ends(&mut segs, p0, p1); - let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; - // Coincident pieces the sweep left apart are one edge (the front-most input's). - let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); - let here = mid(&segs); - let twin_of = between.get(&key).and_then(|v| { - v.iter().copied().find(|&e| { - let other = edges.get(e).and_then(|e| mid(&e.segs)); - matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision) - }) - }); - if let Some(e) = twin_of { - if let Some(e) = edges.get_mut(e) - && source > e.source - { - e.source = source; - } - continue; - } - between.entry(key).or_default().push(edges.len()); - edges.push(Edge { segs, ends, source }); - } + let (points, edges) = sweep_graph(&inputs, eps, precision)?; let nv = points.len(); // Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is @@ -323,6 +188,145 @@ fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option { Some(Map { edges, around, slot_of, faces, face_index, slivers, holes, face_holes, tidy, anchors, snap }) } +fn sweep_graph(inputs: &[(BezPath, usize)], eps: f64, precision: f64) -> Option<(Vec, Vec)> { + // The sweep can panic on rare near-degenerate input; that must never take the app down. + let sweep = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); + let top = sweep.ok()?.ok()?; + // The planar graph: vertices are the sweep's points, edges its segments. Points closer than + // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge + // a rounding error away from where it cuts that edge). + let positions = top.compute_positions(); + let cell = 4.0 * eps; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut points: Vec = Vec::new(); + let mut vertex_id = |p: Point| { + let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let near = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|k| grid.get(&k)) + .flatten() + .copied() + .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); + near.unwrap_or_else(|| { + points.push(p); + grid.entry((cx, cy)).or_default().push(points.len() - 1); + points.len() - 1 + }) + }; + let mut pieces: Vec<(Vec, [usize; 2])> = Vec::new(); + for idx in top.segment_indices() { + let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); + let ends = [vertex_id(top.point(idx.first_half()).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; + if !segs.is_empty() && ends[0] != ends[1] { + pieces.push((segs, ends)); + } + } + // The sweep can cut the paths through one crossing at points more than `eps` apart, so copies of + // one edge (such as an open path's two passes) can end on different vertices. Pieces whose + // middles and ends lie within `tol` of each other are such copies, and their ends are one vertex. + let tol = 16.0 * eps; + let cell_of = |p: Point| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); + let mids: Vec> = pieces.iter().map(|(segs, _)| mid(segs)).collect(); + let mut by_mid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (k, m) in mids.iter().enumerate() { + if let Some(m) = m { + by_mid.entry(cell_of(*m)).or_default().push(k); + } + } + let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); + let mut joined: Vec = (0..points.len()).collect(); + // Where two passes of one path cross another (an open path's way there and back, coincident + // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that + // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are + // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path + // that runs nearly level in it (where the height a cut is found at pins its position down + // least) a cut can slide further, here up to a small fraction of the precision results are + // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds + // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real + // geometry, not one stub after another, and must not collapse into one vertex. + let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); + let mut extent: Vec = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); + for (segs, [v0, v1]) in &pieces { + let Some(&p0) = points.get(*v0) else { continue }; + if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { + continue; + } + let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); + let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; + let both = ra.union(rb); + if a == b || both.width() > stub_x || both.height() > stub_y { + continue; + } + let (root, child) = (a.min(b), a.max(b)); + if let Some(p) = joined.get_mut(child) { + *p = root; + } + if let Some(r) = extent.get_mut(root) { + *r = both; + } + } + for (k, (_, [a, b])) in pieces.iter().enumerate() { + let Some(&Some(m)) = mids.get(k) else { continue }; + let (cx, cy) = cell_of(m); + let others = (cx - 1..=cx + 1).flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))).filter_map(|c| by_mid.get(&c)).flatten(); + for &j in others.filter(|&&j| j > k) { + let (Some((_, [c, d])), Some(&Some(n))) = (pieces.get(j), mids.get(j)) else { continue }; + if m.distance(n) > tol { + continue; + } + let pairs = if close(*a, *c) && close(*b, *d) { + [(*a, *c), (*b, *d)] + } else if close(*a, *d) && close(*b, *c) { + [(*a, *d), (*b, *c)] + } else { + continue; + }; + for (x, y) in pairs { + let (x, y) = (find(&mut joined, x), find(&mut joined, y)); + if let Some(r) = joined.get_mut(x.max(y)) { + *r = x.min(y); + } + } + } + } + let mut edges: Vec = Vec::new(); + let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); + let outlines = Outlines::new(inputs, eps); + for (mut segs, ends) in pieces { + let ends = ends.map(|v| find(&mut joined, v)); + let (Some(&p0), Some(&p1)) = (points.get(ends[0]), points.get(ends[1])) else { continue }; + if ends[0] == ends[1] { + continue; + } + // Every edge at a vertex starts or ends exactly on it, so faces meeting there share its + // coordinates: merging them (a union) then cancels their common edge, which it can't when + // each face carries its own rounding of the corner (#893). + pin_ends(&mut segs, p0, p1); + let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; + // Coincident pieces the sweep left apart are one edge (the front-most input's). + let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); + let here = mid(&segs); + let twin_of = between.get(&key).and_then(|v| { + v.iter().copied().find(|&e| { + let other = edges.get(e).and_then(|e| mid(&e.segs)); + matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision) + }) + }); + if let Some(e) = twin_of { + if let Some(e) = edges.get_mut(e) + && source > e.source + { + e.source = source; + } + continue; + } + between.entry(key).or_default().push(edges.len()); + edges.push(Edge { segs, ends, source }); + } + Some((points, edges)) +} + impl Map { /// The union of the faces that hold `inside` (document points, one per face), traced along the /// map's own edges: those between two merged faces drop out exactly, where a boolean union of diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 46fb58d58..6a4294c69 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -1,5 +1,5 @@ use super::writer::{Sheet, Writer, date_time}; -use super::*; +use super::{Affine, Document, ExportReport, Exporter, HashSet, PageBoxes, PdfError, PdfOptions, Rect, write}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index 156ad49e4..c7d21793d 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -1,5 +1,8 @@ use super::entry::RgbInCmyk; -use super::*; +use super::{ + Affine, Arc, BezPath, BlendMode, Color, Compatibility, Document, Exporter, Fill, FillRule, HashMap, HashSet, Node, NormalizedF32, Path, + PathBuilder, PathEl, Rect, Shape, Standard, Surface, Transform, cmyk, krilla, luma, rgb, +}; /// How deep inline graphics nest before they are left out. pub(super) const MAX_INLINE_DEPTH: u32 = 4; diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 946cb686e..fbbf6c71c 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -1,5 +1,5 @@ use super::exporter::{UNDECODED, writable, xf}; -use super::*; +use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface}; impl Exporter<'_> { /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 80b1dab7c..b4e64c58f 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -1,5 +1,8 @@ use super::exporter::{address, blend, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable}; -use super::*; +use super::{ + Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect, + Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, +}; impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 2c0316086..348f2d803 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -1,5 +1,8 @@ use super::exporter::{MAX_FIELD_PX, MAX_GRADIENT_PX, MAX_PATTERN_DEPTH, color, ctm, in_range, norm, q, to_path, writable, xf}; -use super::*; +use super::{ + Affine, Color, Exporter, GradientKind, LinearGradient, Paint, RadialGradient, Rect, SepColor, SeparationColorant, SeparationSpace, Shape, Size, + SpreadMethod, Stop, Surface, Vec2, cmyk, krilla, rgb, +}; impl Exporter<'_> { /// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index c8214127a..d7670a389 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -1,6 +1,9 @@ use super::exporter::{GlyphLine, MAX_INLINE_DEPTH, in_range, norm, to_path, writable, xf}; use super::images::embed; -use super::*; +use super::{ + Affine, AppearanceItem, BezPath, BlendMode, Color, Cow, Embedding, Exporter, Fill, FillRule, Glyph, GlyphId, KrillaGlyph, ListMarker, Mark, Node, + NormalizedF32, Paint, Path, Rect, Shape, Size, Surface, TextLayout, TextObject, krilla, write, +}; impl Exporter<'_> { /// diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 372e1f927..b2ca788eb 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -1,6 +1,9 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; -use super::*; +use super::{ + Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, + Shape, Size, Standard, find, krilla, rfind, write, +}; /// A PDF being written, shared by export and print: the file's configuration and metadata, then /// its pages ([`Self::page`]), each drawn by an [`Exporter`]. diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 4c9428dd9..28e855ca1 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -1,7 +1,12 @@ use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; use super::entry::{MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE}; use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, + FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, + PathData, PathDrawMode, Pattern, PatternDef, Placement, Rect, Scan, Shape, SoftMask, StrokeProps, TextAs, TextLine, TextOverprint, TilingPattern, + Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient, +}; impl<'p> Builder<'p> { pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { diff --git a/crates/pdf/src/import/cid.rs b/crates/pdf/src/import/cid.rs index ee6684d22..60a28d15e 100644 --- a/crates/pdf/src/import/cid.rs +++ b/crates/pdf/src/import/cid.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Affine, Arc, BezPath, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, Page, PathDrawMode, PathEl, Rect, Shape, SoftMask, +}; /// How many paths the interpreter draws on `page` (each fill and each stroke), leaving out those /// of soft masks, pattern cells and type 3 glyphs, which [`Builder`] reads nested. `None` when the diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index d9721f01f..60b00fb2e 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -2,7 +2,10 @@ use super::entry::{INVISIBLE_TEXT_NOTE, MAX_NESTED, OUTLINED_TEXT_NOTE, UNSORTED use super::state::{ Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, line_width, mean_scale, resize_alpha, rgba_png, same_outline, stroke_layer, }; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BezPath, BlendMode, ClipPath, Device, FillLayer, FillRule, Glyph, GlyphDrawMode, Image, ImageBlob, + ImageData, Name, Node, NodeKind, PathData, PathDrawMode, Pattern, Shape, SoftMask, clipped, extend_clip, mesh_shading, tag_key, +}; impl<'a> Device<'a> for Builder<'_> { fn set_soft_mask(&mut self, mask: Option>) { diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index d52cd576e..d128b2956 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -1,6 +1,10 @@ use super::cid::drawn_paths; use super::state::Builder; -use super::*; +use super::{ + Affine, Arc, Artboard, AtomicBool, BlendMode, ColorMode, Colors, Context, CropTo, Document, FillRule, HashMap, HashSet, ImportOptions, + ImportReport, InterpreterCache, InterpreterSettings, InterpreterWarning, LayerColor, MAX_NESTING, Mutex, Node, NodeKind, Ocgs, Ordering, Paint, + PdfError, Rect, Shape, Swatch, all_on, contains, hides_forms, scan_page, white_cover, +}; /// Mixed into an image's cache key when it is read white inside an alpha soft mask. pub(super) const WHITE_KEY: u128 = 0x7717_e000_a1fa; @@ -182,54 +186,12 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p let picked = crate::pages::picked(opts, original.pages().len())?; let mut ocgs = Ocgs::read(&original); let route = opts.layers && !ocgs.list.is_empty(); - // Content that is off imports as hidden layers: an update of the file turns every group on - // (unless a form is hidden by its own group: its art couldn't be told apart). - let hide = route && ocgs.skips_any(); - let mut hidden_form = false; - if hide { - for page in picked.iter().filter_map(|&n| original.pages().get(n)) { - hidden_form |= hides_forms(page, &mut ocgs, original.decode_limits().max_decoded_stream())?; - if hidden_form { - break; - } - } - } - let turned_on = (hide && !hidden_form) - .then(|| all_on(bytes, &original)) - .flatten() - .and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok()) - .filter(|p| Ocgs::read(p).list.is_empty()); - let mut notes = vec![]; - if hide && turned_on.is_none() { - notes.push("the art of hidden layers couldn't be read and was left out".to_string()); - } - let all_on = turned_on.is_some(); - let pdf = turned_on.unwrap_or(original); + let (pdf, all_on, notes) = reopen_with_groups_on(bytes, opts, reader, original, &picked, &mut ocgs, route)?; let pages = pdf.pages(); let sink: Arc>> = Arc::default(); - let sink2 = sink.clone(); let incomplete = Arc::new(AtomicBool::new(false)); - let incomplete_sink = incomplete.clone(); - let settings = InterpreterSettings { - warning_sink: Arc::new(move |w| { - let msg = match w { - InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped", - InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", - InterpreterWarning::ContentTruncated => { - incomplete_sink.store(true, Ordering::Relaxed); - return; - } - }; - if let Ok(mut v) = sink2.lock() - && !v.iter().any(|x| x == msg) - { - v.push(msg.to_string()); - } - }), - render_annotations: false, - ..Default::default() - }; + let settings = interpreter_settings(sink.clone(), incomplete.clone()); let mut doc = Document::new(1.0, 1.0); doc.title = "Imported PDF".into(); @@ -316,17 +278,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p // crossing the artboard edge isn't clipped, so it opens whole (#646). let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)); let page_box = shown.inflate(0.01, 0.01); - let mut clipped = false; - for (_, art) in parts.iter_mut().filter(|_| !ai) { - for n in art.iter_mut() { - if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) { - let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero)); - *n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true })); - clipped = true; - } - } - } - if clipped { + if !ai && clip_past_page(&mut parts, &mut b, shown, page_box) { b.warn(PAST_PAGE_NOTE); } // A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art @@ -336,24 +288,8 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p // art clears the art before it. let mut art_right = f64::NEG_INFINITY; if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) { - // Art at a coordinate that isn't a number (a damaged file) moves nothing. - let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0); - if dx > 0.0 { - let shift = Affine::translate((dx, 0.0)); - for (_, v) in &mut parts { - for n in v.iter_mut() { - Arc::make_mut(n).transform(shift, false); - } - } - // What the page's lines of type were is told where their glyphs are (the outlines - // stroked on them are looked for there), so that moves with them. - for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) { - for ink in &mut facts.ink { - *ink = *ink + kurbo::Vec2::new(dx, 0.0); - } - } - ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1); - } + let dx = shift_ai_page(&mut parts, &mut b, first_id, clear, art); + ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1); art_right = art.x1 + dx; } let children: Vec> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); @@ -416,15 +352,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p } // The groups' layers stack as the pages mark them (the art outside them stays where it was // drawn): a group that first paints on a later page doesn't go on top of the others. - let rank: HashMap = order.iter().enumerate().map(|(i, &g)| (g, i)).collect(); - let at: Vec = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect(); - let mut stacked: Vec = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect(); - stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX)); - for (i, g) in at.into_iter().zip(stacked) { - if let Some(s) = slots.get_mut(i) { - *s = Slot::Group(g); - } - } + stack_groups(&mut slots, &order); for (n, slot) in slots.into_iter().enumerate() { let mut layer = match slot { Slot::Page(l) => *l, @@ -528,3 +456,114 @@ fn is_only_text(nodes: &[Arc]) -> bool { let mut text = false; walk(nodes, &mut text) && text } + +/// Opens the file again with every layer group turned on when the pages hide art in groups that are +/// off, and tells whether it did. Gives the file to read, whether it is the turned-on copy, and the +/// notes for the import's report. +fn reopen_with_groups_on( + bytes: &[u8], + opts: &ImportOptions, + reader: &craft_pdf::OpenOptions, + original: craft_pdf::syntax::Pdf, + picked: &[usize], + ocgs: &mut Ocgs, + route: bool, +) -> Result<(craft_pdf::syntax::Pdf, bool, Vec), PdfError> { + // Content that is off imports as hidden layers: an update of the file turns every group on + // (unless a form is hidden by its own group: its art couldn't be told apart). + let hide = route && ocgs.skips_any(); + let mut hidden_form = false; + if hide { + for page in picked.iter().filter_map(|&n| original.pages().get(n)) { + hidden_form |= hides_forms(page, ocgs, original.decode_limits().max_decoded_stream())?; + if hidden_form { + break; + } + } + } + let turned_on = (hide && !hidden_form) + .then(|| all_on(bytes, &original)) + .flatten() + .and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok()) + .filter(|p| Ocgs::read(p).list.is_empty()); + let mut notes = vec![]; + if hide && turned_on.is_none() { + notes.push("the art of hidden layers couldn't be read and was left out".to_string()); + } + let all_on = turned_on.is_some(); + Ok((turned_on.unwrap_or(original), all_on, notes)) +} + +/// The interpreter's settings, its warnings noted once each in `sink` (truncated content in `incomplete`). +fn interpreter_settings(sink: Arc>>, incomplete: Arc) -> InterpreterSettings { + InterpreterSettings { + warning_sink: Arc::new(move |w| { + let msg = match w { + InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped", + InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", + InterpreterWarning::ContentTruncated => { + incomplete.store(true, Ordering::Relaxed); + return; + } + }; + if let Ok(mut v) = sink.lock() + && !v.iter().any(|x| x == msg) + { + v.push(msg.to_string()); + } + }), + render_annotations: false, + ..Default::default() + } +} + +/// Moves a `.ai` page's art (`art`, its bounds) right until it clears the art before it (at +/// `clear`), and its lines of type with it. Gives how far. +fn shift_ai_page(parts: &mut [(Option, Vec>)], b: &mut Builder<'_>, first_id: u64, clear: f64, art: Rect) -> f64 { + // Art at a coordinate that isn't a number (a damaged file) moves nothing. + let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0); + if dx > 0.0 { + let shift = Affine::translate((dx, 0.0)); + for (_, v) in parts.iter_mut() { + for n in v.iter_mut() { + Arc::make_mut(n).transform(shift, false); + } + } + // What the page's lines of type were is told where their glyphs are (the outlines + // stroked on them are looked for there), so that moves with them. + for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) { + for ink in &mut facts.ink { + *ink = *ink + kurbo::Vec2::new(dx, 0.0); + } + } + } + dx +} + +/// Clips each object reaching past `page_box` to `shown`. Tells whether any did. +fn clip_past_page(parts: &mut [(Option, Vec>)], b: &mut Builder<'_>, shown: Rect, page_box: Rect) -> bool { + let mut clipped = false; + for (_, art) in parts.iter_mut() { + for n in art.iter_mut() { + if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) { + let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero)); + *n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true })); + clipped = true; + } + } + } + clipped +} + +/// The groups' layers stack as the pages mark them (`order`, bottom first). +fn stack_groups(slots: &mut [Slot], order: &[usize]) { + let rank: HashMap = order.iter().enumerate().map(|(i, &g)| (g, i)).collect(); + let at: Vec = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect(); + let mut stacked: Vec = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect(); + stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX)); + for (i, g) in at.into_iter().zip(stacked) { + if let Some(s) = slots.get_mut(i) { + *s = Slot::Group(g); + } + } +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index cbe9f7a02..13803c98d 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -1,5 +1,8 @@ use super::cid::{CidText, FontInfo}; -use super::*; +use super::{ + Affine, Arc, BezPath, BlendMode, Colors, Dash, Families, FillRule, FontNames, FunctionBudget, HashMap, HashSet, ImageBlob, LineCap, LineFacts, + LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, +}; /// Decoded RGBA pixels, width, height and hayro's scale factors. pub(super) type Decoded = (Vec, u32, u32, (f32, f32)); diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index 680845722..9a4659ddc 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -135,6 +135,16 @@ struct Linearised { encrypted: bool, } +/// The main cross-reference table at `main`, covering the objects before the first page's `first`. +fn check_main_table(pdf: &[u8], lin: &Indirect, main: usize, first: u32) { + // /T: the white space before the main table's first entry. + let entries = main + format!("xref\n0 {first}\n").len(); + assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}"); + assert_eq!(int(&lin.value, b"T"), entries - 1); + let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap(); + assert_eq!(int(&main_trailer, b"Size") as u32, first); +} + /// Check `pdf` is linearised as ISO 32000-1 Annex F says, as a linearisation checker does: the /// linearization dictionary, both cross-reference tables and their trailers, and (unless the file /// is encrypted) the hint tables against where every page's objects are and what each page uses. @@ -160,12 +170,7 @@ fn check_linearised(pdf: &[u8]) -> Linearised { assert_eq!(all.len() as u32, size - 1, "the tables cover every object"); let first = lin.num; assert!(first_part.iter().all(|n| (first..size).contains(n)) && first_part.len() as u32 == size - first); - // /T: the white space before the main table's first entry. - let entries = main + format!("xref\n0 {first}\n").len(); - assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}"); - assert_eq!(int(&lin.value, b"T"), entries - 1); - let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap(); - assert_eq!(int(&main_trailer, b"Size") as u32, first); + check_main_table(pdf, &lin, main, first); // Every object where its table says, read as the file's objects. let objects: HashMap = all.iter().map(|(n, off)| (*n, indirect(pdf, *off).unwrap())).collect(); @@ -205,7 +210,41 @@ fn check_linearised(pdf: &[u8]) -> Linearised { let data = &pdf[data_at..data_at + int(&hint.value, b"Length")]; let mut r = BitReader { data, at: 0 }; - // The page offset hint table. + let (counts, ids) = check_page_hints(&mut r, &pages, &adjusted, &span, first_page_objects); + + // The shared object hint table. + let s = int(&hint.value, b"S"); + let mut r = BitReader { data, at: 8 * s }; + let (first_shared, first_shared_at, nfirst, ntotal) = read_shared_header(&mut r); + let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 }; + if ntotal > nfirst { + assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location"); + } + check_shared_hints(&mut r, ntotal, nfirst, first_page_objects, &shared_object, &span); + + // What each page uses lies in its own objects, the first part or its shared objects. + let page_set: HashSet = pages.iter().copied().collect(); + let references = PageReferenceHints { + pages: &pages, + page_set: &page_set, + first_part: &first_part, + objects: &objects, + nodes: &nodes, + shared_object: &shared_object, + }; + for (i, &page) in pages.iter().enumerate() { + check_page_references(&references, i, page, counts[i], &ids[i]); + } + Linearised { pages, first_part, encrypted } +} + +fn check_page_hints( + r: &mut BitReader<'_>, + pages: &[u32], + adjusted: &impl Fn(u32) -> usize, + span: &impl Fn(u32) -> usize, + first_page_objects: u32, +) -> (Vec, Vec>) { let (min_objects, first_page_at, objects_bits) = (r.get(32), r.get(32) as usize, r.get(16)); let (min_len, len_bits) = (r.get(32), r.get(16)); let (_content_at, content_at_bits, _content_len, content_len_bits) = (r.get(32), r.get(16), r.get(32), r.get(16)); @@ -230,42 +269,58 @@ fn check_linearised(pdf: &[u8]) -> Linearised { assert_eq!(len as u64, lengths[i], "page {i}'s length"); at += len; } + (counts, ids) +} - // The shared object hint table. - let s = int(&hint.value, b"S"); - let mut r = BitReader { data, at: 8 * s }; - let (first_shared, first_shared_at, nfirst, ntotal) = (r.get(32) as u32, r.get(32) as usize, r.get(32), r.get(32)); - let (group_bits, min_group, group_len_bits) = (r.get(16), r.get(32), r.get(16)); +fn read_shared_header(r: &mut BitReader<'_>) -> (u32, usize, u64, u64) { + let first_shared = r.get(32) as u32; + let first_shared_at = r.get(32) as usize; + let nfirst = r.get(32); + let ntotal = r.get(32); + let group_bits = r.get(16); assert_eq!(group_bits, 0, "one object a group"); + (first_shared, first_shared_at, nfirst, ntotal) +} + +fn check_shared_hints( + r: &mut BitReader<'_>, + ntotal: u64, + nfirst: u64, + first_page_objects: u32, + shared_object: &impl Fn(u64) -> u32, + span: &impl Fn(u32) -> usize, +) { assert_eq!(nfirst as u32, first_page_objects); - let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 }; - if ntotal > nfirst { - assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location"); - } - let groups = r.column(ntotal as usize, group_len_bits); - for (id, g) in groups.iter().enumerate() { + let (min_group, group_len_bits) = (r.get(32), r.get(16)); + for (id, g) in r.column(ntotal as usize, group_len_bits).iter().enumerate() { assert_eq!((g + min_group) as usize, span(shared_object(id as u64)), "shared object {id}'s length"); } assert!(r.column(ntotal as usize, 1).iter().all(|s| *s == 0), "no signatures"); +} - // What each page uses lies in its own objects, the first part or its shared objects. - let page_set: HashSet = pages.iter().copied().collect(); - for (i, &page) in pages.iter().enumerate() { - let own: HashSet = (page..page + counts[i] as u32).collect(); - let listed: HashSet = ids[i].iter().map(|id| shared_object(*id)).collect(); - let (mut seen, mut queue) = (HashSet::from([page]), vec![page]); - while let Some(o) = queue.pop() { - assert!( - own.contains(&o) || first_part.contains(&o) && (i == 0 || o < pages[0] || listed.contains(&o)) || listed.contains(&o), - "page {i} uses object {o}, which isn't among its objects or listed" - ); - let mut links = vec![]; - objects[&o].value.refs(&[b"Parent", b"Thumb"], &mut links); - queue.extend(links.into_iter().filter(|m| !page_set.contains(m) && !nodes.contains(m) && seen.insert(*m))); - } - assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses"); +struct PageReferenceHints<'a> { + pages: &'a [u32], + page_set: &'a HashSet, + first_part: &'a HashSet, + objects: &'a HashMap>, + nodes: &'a HashSet, + shared_object: &'a dyn Fn(u64) -> u32, +} + +fn check_page_references(hints: &PageReferenceHints<'_>, i: usize, page: u32, count: u64, ids: &[u64]) { + let own: HashSet = (page..page + count as u32).collect(); + let listed: HashSet = ids.iter().map(|id| (hints.shared_object)(*id)).collect(); + let (mut seen, mut queue) = (HashSet::from([page]), vec![page]); + while let Some(o) = queue.pop() { + assert!( + own.contains(&o) || hints.first_part.contains(&o) && (i == 0 || o < hints.pages[0] || listed.contains(&o)) || listed.contains(&o), + "page {i} uses object {o}, which isn't among its objects or listed" + ); + let mut links = vec![]; + hints.objects[&o].value.refs(&[&b"Parent"[..], &b"Thumb"[..]], &mut links); + queue.extend(links.into_iter().filter(|m| !hints.page_set.contains(m) && !hints.nodes.contains(m) && seen.insert(*m))); } - Linearised { pages, first_part, encrypted } + assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses"); } #[test] diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs index ea2f41b26..2cecb1324 100644 --- a/crates/render/src/blobs.rs +++ b/crates/render/src/blobs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, BezPath, Ink, Node, Pixmap, Shape, images, ink, paint}; /// `key`'s pixels in the shared image store; `touch` marks them used. pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option> { diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index 05fb6f0f4..dec8db1c5 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BezPath, Document, FillRule, Node, NodeKind, Rect, SymbolBoxes, brush_fx, fx}; /// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and /// geometry effects, and the shadows and glows of it and its members. Symbol instances are diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 29b317ce4..2191bc06a 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -1,6 +1,6 @@ use super::options::{AntiAlias, RenderOptions}; use super::threads::single_threaded_context; -use super::*; +use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, SymbolBoxes, TextObject, live}; /// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). #[derive(Default, Clone, Copy)] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index f19270140..41c9eca2b 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -1,6 +1,8 @@ use super::predicates::{has_fx, has_object_fx}; use super::raster::doc_raster_ppi; -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, Rect, Renderer, Shape, SymbolBoxes, effects, +}; fn node_bezpath(n: &Node) -> Option { match &n.kind { diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index f933f2307..cd9acb109 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -1,5 +1,8 @@ use super::raster::ViewCrop; -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, Channels, Cms, DefaultHasher, FillRule, Hash, Hasher, Node, NodeId, NodeKind, PathEl, Point, RasterFx, + Rect, Size, Weak, effects, +}; /// What [`Renderer::raster_fx`] paints effects around. pub(super) struct Content<'a> { diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 27fb7f588..e73e708dd 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -6,7 +6,10 @@ use super::raster::{ DRAG_PIXEL_BUDGET, PIXEL_BUDGET, PixelEntry, ViewCrop, content_changing, doc_raster_ppi, drag_scale, draw_placed, draw_scaled, filter_region, filter_region_within, hard_edged, image_size, linear_key, looks_around, pan_margin, }; -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, Channels, DefaultHasher, Document, FillRule, Frame, GeomContext, Hash, Hasher, Ink, Node, PixelFx, + RasterFx, Rect, RenderContext, Renderer, Shape, blend_mode, effects, fill_rule, paint, peniko, +}; impl Renderer { /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 2f4d66adb..f54d996a9 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -1,5 +1,5 @@ use super::bounds::drawn_bounds; -use super::*; +use super::{Affine, AppearanceItem, Effect, Frame, Node, NodeKind, RasterFx, Rect, SymbolBoxes, Vec2, effects}; fn visible(effects: &[Effect]) -> bool { effects.iter().any(|e| e.visible) diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index 20d0ddcdd..6648b321d 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -1,5 +1,5 @@ use super::entries::{Drawn, Inputs, Made, raster_bytes}; -use super::*; +use super::{Affine, Document, EdgeMode, Filter, FilterPrimitive, Ink, RasterFx, Rect, RenderContext, Vec2, effects, peniko}; pub(super) fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { Filter::from_primitive(FilterPrimitive::DropShadowOnly { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index ccd8d9ad7..7924f51a6 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -5,7 +5,10 @@ use super::raster::{ DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade, shade_filter, shadow_filter, silhouette, }; -use super::*; +use super::{ + Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, Vec2, blend_mode, + effects, fill_rule, peniko, +}; impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs index 90053c8cd..c9c472727 100644 --- a/crates/render/src/geometry.rs +++ b/crates/render/src/geometry.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BezPath, Rect, kurbo}; /// Most dashes a dotted guide is drawn with (1.5 on, 2.5 off on screen); a longer one is solid. pub(crate) const MAX_GUIDE_DASHES: f64 = 100_000.0; diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index a85854443..6e2be3f2e 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, AppearanceItem, Arc, BezPath, Node, NodeKind, StrokeLayer, brush_fx, effects}; /// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be /// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom. diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 7aaf365b2..036c85da0 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -1,5 +1,5 @@ use super::geometry::region_pixels; -use super::*; +use super::{Arc, NodeId, Rect, encode, proof}; /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 5dfb6307c..7b3b3ec72 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -5,7 +5,7 @@ use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; -use super::*; +use super::{BlendMode, Compose, FillRule, Mix, vello_cpu}; use crate::Frame; /// Set the context paint (colours as frame `f` paints them). Returns false if nothing should be diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e713841b7..81fb0c573 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -12,7 +12,11 @@ use super::options::{AntiAlias, ISOLATION_DIM, MAX_CONTEXT_SIDE, RenderOptions, use super::paint::{SUBSTITUTED, blend_mode, fill_rule}; use super::text_geom::{TextGeom, text_geom, text_geom_snapped}; use super::threads::{default_threads, single_threaded_context}; -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, BlendMode, Compose, Composite, Document, FillRule, FrameCache, HashMap, Ink, Mix, Node, NodeId, NodeKind, + Pixmap, Rect, RenderContext, Renderer, Resources, Shape, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView, brush_fx, effects, fx, + group, ink, kurbo, live, lru, peniko, proof, vello_cpu, +}; mod images; mod nodes; diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index f87a1edb9..372b6103b 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap, fill_rule, + image_mip, ink, lookup, mip_level, peniko, vello_cpu, +}; use crate::images; impl Renderer { diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 6eedf3da1..7e1fd1f11 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -1,4 +1,9 @@ -use super::*; +use super::{ + Affine, Arc, BezPath, ClipEntry, ClipPaintEntry, Compose, Composite, Document, FillRule, Frame, GeomEntry, ISOLATION_DIM, Ink, MAX_GUIDE_DASHES, + Node, NodeId, NodeKind, Rect, RenderContext, RenderOptions, Renderer, Shape, SymbolBoxes, TraceView, blend_mode, brush_fx, effects, fx, group, + holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, single_plain_fill, + text_geom_snapped, +}; impl Renderer { /// Cached cull bounds of a node (containers union their cached children), symbol instances diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 36e3e53e9..9a6a48142 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, BlendMode, Compose, FillRule, Frame, Ink, Mix, Node, Rect, RenderContext, Renderer, Shape, SliceEntry, + Slices, StrokeAlign, StrokeEntry, StrokeLayer, TextGeom, blend_mode, drawable, effects, fill_rule, fx, kurbo, lru, owns_stroke, peniko, + rects_overlap, +}; use crate::paint; impl Renderer { diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index f5536a667..c478861ea 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BezPath, BlendMode, FillRule, Frame, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, Node, NodeKind, PlacedEntry, + PlacedKey, Rect, RenderContext, Renderer, SUBSTITUTED, Shape, TextGeom, TextObject, blend_mode, blob_pixmap, depends_on_document, fx, holds_type, + image_mip, ink, kurbo, lru, peniko, place, proof, stain_bits, text_geom, text_geom_snapped, vello_cpu, +}; use crate::paint; impl Renderer { diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 9c2ddad2f..1408b397b 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, BezPath, Node, Rect, Shape, TextObject, lru}; /// The whole text's glyph outlines as one path, and which of them each run draws. pub(crate) struct TextGeom { diff --git a/crates/render/src/threads.rs b/crates/render/src/threads.rs index 13a833a3b..eb1fb66ea 100644 --- a/crates/render/src/threads.rs +++ b/crates/render/src/threads.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{RenderContext, vello_cpu}; /// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a /// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur). diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index f16b86c57..1e5b21fc2 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -27,6 +27,7 @@ use crate::catalog::{self, FeatureId}; use crate::directives; use crate::lex::{Lexer, T, Token, expr_after}; use crate::report::{self, Acc, OutsideId}; +use sig::{COMPOUND, LineBreaks, NEWER, Read, Sig, ends_value, for_in, goes_on_after, goes_on_with, is, member_mark, newer_number, unquote, word}; mod declared; mod features; @@ -34,7 +35,6 @@ mod sig; pub(crate) use declared::{Declared, declarations}; use features::platform_name; -use sig::*; /// An `#include` line: what it names, its line, and the folders `#includepath` named at that point /// (one list, shared by every line it applies to). diff --git a/crates/scriptcheck/src/analyze/declared.rs b/crates/scriptcheck/src/analyze/declared.rs index 2f790a347..d0e55cbb1 100644 --- a/crates/scriptcheck/src/analyze/declared.rs +++ b/crates/scriptcheck/src/analyze/declared.rs @@ -1,6 +1,8 @@ //! The names declared, read before the analysis. -use super::*; +use std::collections::HashSet; + +use super::{Acc, Brackets, Lexer, LineBreaks, Read, Sig, T, for_in, goes_on_after, goes_on_with, is, member_mark, word}; /// The names a file's function bodies and its global code declare, read before the analysis. A /// function's `var` statements and the function declarations directly in its body make the names diff --git a/crates/scriptcheck/src/analyze/features.rs b/crates/scriptcheck/src/analyze/features.rs index 0a8ba97d8..a0fdc084a 100644 --- a/crates/scriptcheck/src/analyze/features.rs +++ b/crates/scriptcheck/src/analyze/features.rs @@ -1,6 +1,6 @@ //! The platform features, menu commands and later syntax the analysis recognises. -use super::*; +use super::{Analyzer, FeatureId, NEWER, OutsideId, Pending, Sig, T, catalog, is, member_mark, newer_number, report, unquote, word}; /// A global name of the platform the report looks for (`$`, `File`, `Socket`, `alert`, `eval`…). pub(super) fn platform_name(n: &str) -> bool { diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 266e54a9e..be66dd4b1 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -699,6 +699,64 @@ impl<'s> Lexer<'s> { } } +impl<'s> Lexer<'s> { + /// Update expression, statement and nesting state after reading a significant token. + fn update_context(&mut self, kind: T, text: &str) { + self.line_start = false; + // A name after `.`, `..`, `.@` or `::` is a property's, also when it is a keyword: no + // expression or statement starts after it. + let property = kind == T::Ident && self.member; + // After the `)` of a condition comes a statement, which may start with a regular + // expression or an XML literal. + let condition = kind == T::Punct && self.paren(text); + // A statement may also start after the `}` of a block. After the `}` of an object + // literal or of a function expression's body, `/` divides. + let brace = if kind == T::Punct { self.brace(text) } else { None }; + self.expr = condition + || match (text, brace) { + ("}", Some(brace)) => brace == Brace::Block, + _ => !property && expr_after(kind, text), + }; + if kind == T::Ident && !property && text == "function" { + self.function = Some(!self.stmt); + } + if kind == T::Punct { + match text { + "(" | "[" | "{" => self.open_level(), + ")" | "]" | "}" => self.close_level(), + _ => {} + } + } + let ternary = kind == T::Punct && self.conditional(text); + // A statement may start inside a block or a function's body, after `;`, `=>`, a + // preprocessor line, `else`, `do`, `try` and `finally`, and after the `:` of a label or + // a `case` (not a conditional's, and outside an object literal). + self.stmt = condition + || match (text, brace) { + ("{", Some(brace)) => brace != Brace::Object, + ("}", Some(brace)) => brace == Brace::Block, + _ => false, + } + || kind == T::Directive + || (kind == T::Punct && matches!(text, ";" | "=>")) + || (kind == T::Punct && text == ":" && !ternary && self.braces.last() != Some(&Brace::Object)) + || (kind == T::Ident && !property && matches!(text, "else" | "do" | "try" | "finally")); + self.cond = kind == T::Ident && !property && (matches!(text, "if" | "while" | "for" | "with") || (text == "each" && self.cond)); + self.restricted = kind == T::Ident && !property && matches!(text, "return" | "throw" | "break" | "continue"); + self.member = kind == T::Punct && matches!(text, "." | ".." | ".@" | "::"); + // The braces inside the code of an XML literal. + if kind == T::Punct + && let Some(Level::Code { braces }) = self.xml.last_mut() + { + match text { + "{" => *braces = braces.saturating_add(1), + "}" => *braces = braces.saturating_sub(1), + _ => {} + } + } + } +} + impl<'s> Iterator for Lexer<'s> { type Item = Token<'s>; @@ -789,60 +847,7 @@ impl<'s> Iterator for Lexer<'s> { self.line_ended(); } } - _ => { - self.line_start = false; - // A name after `.`, `..`, `.@` or `::` is a property's, also when it is a keyword: no - // expression or statement starts after it. - let property = kind == T::Ident && self.member; - // After the `)` of a condition comes a statement, which may start with a regular - // expression or an XML literal. - let condition = kind == T::Punct && self.paren(text); - // A statement may also start after the `}` of a block. After the `}` of an object - // literal or of a function expression's body, `/` divides. - let brace = if kind == T::Punct { self.brace(text) } else { None }; - self.expr = condition - || match (text, brace) { - ("}", Some(brace)) => brace == Brace::Block, - _ => !property && expr_after(kind, text), - }; - if kind == T::Ident && !property && text == "function" { - self.function = Some(!self.stmt); - } - if kind == T::Punct { - match text { - "(" | "[" | "{" => self.open_level(), - ")" | "]" | "}" => self.close_level(), - _ => {} - } - } - let ternary = kind == T::Punct && self.conditional(text); - // A statement may start inside a block or a function's body, after `;`, `=>`, a - // preprocessor line, `else`, `do`, `try` and `finally`, and after the `:` of a label or - // a `case` (not a conditional's, and outside an object literal). - self.stmt = condition - || match (text, brace) { - ("{", Some(brace)) => brace != Brace::Object, - ("}", Some(brace)) => brace == Brace::Block, - _ => false, - } - || kind == T::Directive - || (kind == T::Punct && matches!(text, ";" | "=>")) - || (kind == T::Punct && text == ":" && !ternary && self.braces.last() != Some(&Brace::Object)) - || (kind == T::Ident && !property && matches!(text, "else" | "do" | "try" | "finally")); - self.cond = kind == T::Ident && !property && (matches!(text, "if" | "while" | "for" | "with") || (text == "each" && self.cond)); - self.restricted = kind == T::Ident && !property && matches!(text, "return" | "throw" | "break" | "continue"); - self.member = kind == T::Punct && matches!(text, "." | ".." | ".@" | "::"); - // The braces inside the code of an XML literal. - if kind == T::Punct - && let Some(Level::Code { braces }) = self.xml.last_mut() - { - match text { - "{" => *braces = braces.saturating_add(1), - "}" => *braces = braces.saturating_sub(1), - _ => {} - } - } - } + _ => self.update_context(kind, text), } Some(Token { kind, text, line, problem, stmt, expr, start }) } diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 69d700844..6ce6a5427 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,7 +1,10 @@ use super::output::{sanitize_id, valid_xml_value, xml_attr_escape}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; -use super::*; +use super::{ + Affine, AppearanceItem, BezPath, BlendMode, Document, FillRule, Node, NodeKind, Paint, PathData, Props, Rect, StrokeLayer, Writer, Written, + WrittenShape, blend_css, css, fmt_num, stroke, xml_escape, +}; /// A symbol's `` def and the instance transforms that can `` it. pub(super) struct SymbolDef { diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index 412098b72..6662978b0 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -1,6 +1,10 @@ use super::nodes::{MASK, doc_path}; use super::output::sanitize_id; -use super::*; +use super::{ + Affine, BezPath, BlendMode, Color, FillLayer, FillRule, GradientKind, GradientPaint, HashMap, ImageBlob, ImageMode, LineCap, LineJoin, + LinkedImage, Node, NodeKind, Paint, PathData, Point, Props, Rect, StrokeAlign, StrokeLayer, Writer, Written, base64_encode, blend_css, fmt_num, + stroke, xml_escape, +}; /// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a /// CMYK TIFF) as PNG. diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 7b5e55d84..20d87e2d1 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,5 +1,9 @@ use super::output::xml_text; -use super::*; +use super::{ + Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, + NodeKind, Paint, PathData, Props, Rect, StrokeAlign, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, font_descriptor, + xml_escape, +}; /// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`). fn unicode_range(chars: &BTreeSet) -> String { diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index 18e8b756b..40c2cf577 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Arc, BezPath, BlendMode, Color, FillRule, GradientStop, HashMap, Node, Point, XNode, roxmltree}; /// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art. pub(super) fn without_ids(n: &Node) -> Node { diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index f487eb4ee..82cbfd7da 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -1,7 +1,11 @@ use super::convert::{aff, bezpath, blend, clip_shapes, gradient_stops, is_plain, made_up, rule, uncut, unique_name, without_ids}; use super::layers::filter_names; use super::prepass::link; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, Dash, FillLayer, FillRule, Gradient, GradientGeom, GradientKind, GradientPaint, + GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse, StrokeLayer, + Vec2, fnv1a, fx, shapes, spread, text, vector_effect, +}; /// How many clip paths deep (``) an object's clip is read. const MAX_CLIP_NEST: usize = 16; diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 8dad45368..04e388794 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -1,6 +1,6 @@ use super::convert::made_up; use super::importer::{CSS_PX_PER_IN, PT_PER_IN}; -use super::*; +use super::{Affine, Arc, FromStr, HashSet, Node, NodeId, NodeKind, Rect, Unit, XNode, roxmltree}; /// What marks a nested `` as a sublayer rather than a group: our export writes it on the layers /// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.) diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index eb9519567..8018138dc 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -1,6 +1,9 @@ use super::convert::{MADE_UP_ID, href}; use super::layers::{INKSCAPE, marked_layer}; -use super::*; +use super::{ + Cow, ForeignAttribute, HashMap, HashSet, ImportOptions, MAX_SVG_SCRIPTS, Node, SVG_EVENTS, XNode, css, css_transform, files, roxmltree, + symbol_units, vector_effect, +}; /// What usvg would lose, found in the XML before it runs ([`prepass`]). #[derive(Default)] diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index ac1cd959f..dbfa37431 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -87,6 +87,33 @@ fn fitness(r: f64, ragged: bool) -> u8 { } } +/// Adjustment ratio for a candidate line; `None` means a ragged line is already too long. +fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: bool, p: &Params) -> Option { + if p.ragged { + if natural > width + EPS { + return None; + } + return Some(if last && !p.justify_last { + ((width * SHORT_LAST - natural) / (width * SHORT_LAST_ZONE).max(1.0)).max(0.0) + } else { + (width - natural).max(0.0) / (width * RAG_ZONE).max(1.0) + }); + } + Some(if natural < width { + if last && !p.justify_last { + 0.0 + } else if stretch > EPS { + (width - natural) / stretch + } else { + f64::INFINITY + } + } else if natural > width { + if shrink > EPS { (width - natural) / shrink } else { f64::NEG_INFINITY } + } else { + 0.0 + }) +} + /// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`; /// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets /// an opening bracket there flush with the line's start); `cands` are the break opportunities @@ -146,32 +173,7 @@ pub(crate) fn compose( let natural = w[t] - w[a.pos] - credit + bp.hyphen; let lw = width(a.line); // Adjustment ratio, badness and whether the line ends the active node's reach. - let ratio = if p.ragged { - if natural > lw + EPS { - // Ragged lines never shrink; longer lines from this node only get worse. - continue; - } - if last && !p.justify_last { - ((lw * SHORT_LAST - natural) / (lw * SHORT_LAST_ZONE).max(1.0)).max(0.0) - } else { - (lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0) - } - } else { - let (stretch, shrink) = (st[t] - st[a.pos], sh[t] - sh[a.pos]); - if natural < lw { - if last && !p.justify_last { - 0.0 - } else if stretch > EPS { - (lw - natural) / stretch - } else { - f64::INFINITY - } - } else if natural > lw { - if shrink > EPS { (lw - natural) / shrink } else { f64::NEG_INFINITY } - } else { - 0.0 - } - }; + let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; if ratio < -1.0 { // Too long already; longer lines from this node only get worse. continue; diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index a4745e56d..128cd49b3 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -1,7 +1,7 @@ use super::face::{FontBytes, FontFace, Variation}; use super::names::{name, norm}; use super::traits::FaceStyle; -use super::*; +use super::{HashMap, Location, MetadataProvider, NEXT_ID, Ordering, Size, StringId, Tag}; /// `face` at the axis settings `variations` (user units), as face `id`: its names, kind, italic /// and file, and its weight the `wght` setting when there is one. diff --git a/crates/text/src/fontdb/cache.rs b/crates/text/src/fontdb/cache.rs index e6d711075..09b03c410 100644 --- a/crates/text/src/fontdb/cache.rs +++ b/crates/text/src/fontdb/cache.rs @@ -1,5 +1,5 @@ use super::traits::FontTraits; -use super::*; +use super::{Arc, ColourContent, GlyphImage, HashMap, IMAGE_CACHE_BYTES, IMAGE_CACHE_MAX, PathBuf}; /// One installed family found by the system font scan: its name and the file of each style. #[derive(Debug, Default)] diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 84b3e7e51..0d8eb0e3f 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -17,7 +17,9 @@ use super::scan::{file_face_names, is_font_file}; use super::traits::faces_published; #[cfg(not(target_arch = "wasm32"))] use super::traits::{Alias, FaceStyle}; -use super::*; +use super::{Arc, AtomicU64, FontDb, FontPaths, HashMap, Mutex, Ordering, Path, PathBuf, RwLock, StringId}; +#[cfg(not(target_arch = "wasm32"))] +use super::{ScanStamp, modified}; impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders, diff --git a/crates/text/src/fontdb/dirs.rs b/crates/text/src/fontdb/dirs.rs index 2b39edbc1..0ff448e5d 100644 --- a/crates/text/src/fontdb/dirs.rs +++ b/crates/text/src/fontdb/dirs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Path, PathBuf}; /// A font file holding `tables` (tag, data), sorted by tag as table directories are. #[cfg(any(test, not(target_arch = "wasm32")))] diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5f1973ccd..6276f86cd 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -1,7 +1,7 @@ use super::build::base_values; use super::names::{localized, name}; use super::traits::{FaceKeys, FontTraits}; -use super::*; +use super::{Arc, GlyphId, HashMap, Location, LocationRef, MetadataProvider, Size, StringId}; /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; diff --git a/crates/text/src/fontdb/glyph_pen.rs b/crates/text/src/fontdb/glyph_pen.rs index 1a45e0b95..7f0251703 100644 --- a/crates/text/src/fontdb/glyph_pen.rs +++ b/crates/text/src/fontdb/glyph_pen.rs @@ -1,5 +1,5 @@ use super::face::FontFace; -use super::*; +use super::{BezPath, OutlinePen}; /// Is glyph `gid` of `face` (as `font`) drawn from an SVG document, if it has one: the face has /// SVG glyphs, and the glyph no COLR layers, which come first. diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index a5758b884..e01a47493 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::PathBuf; use super::build::{instanced, style_distance, style_italic, style_weight}; use super::cache::Catalog; use super::face::{FALLBACK_FAMILY, FontFace, MAX_LIVE_INSTANCES, Variation, VariationAxis}; @@ -8,7 +10,10 @@ use super::names::{norm, norm_chars, script_families}; #[cfg(not(target_arch = "wasm32"))] use super::scan::{enumerate_faces, font_files}; use super::traits::{Alias, FontMatch, FontTraits}; -use super::*; +use super::{ + Arc, BezPath, CANONICAL_CACHE_MAX, Color, ColourContent, DrawSettings, FontDb, GlyphId, GlyphImage, MetadataProvider, NEXT_ID, OUTLINE_CACHE_MAX, + Ordering, Size, +}; impl FontDb { /// The family's own name and the style for a name given in a document: any of a family's diff --git a/crates/text/src/fontdb/names.rs b/crates/text/src/fontdb/names.rs index b027e2a37..fe0e24cc0 100644 --- a/crates/text/src/fontdb/names.rs +++ b/crates/text/src/fontdb/names.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MetadataProvider, StringId}; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] diff --git a/crates/text/src/fontdb/scan.rs b/crates/text/src/fontdb/scan.rs index 321b282ea..ebe4d4d44 100644 --- a/crates/text/src/fontdb/scan.rs +++ b/crates/text/src/fontdb/scan.rs @@ -4,7 +4,7 @@ use super::dirs::sfnt_of; use super::face::{MAX_AXES, MAX_INSTANCES}; use super::names::{all_names, name, norm}; use super::traits::{FaceKeys, FaceStyle, FontTraits, face_styles}; -use super::*; +use super::{FileRef, MetadataProvider, Path, StringId, Tag}; /// The named instances of a variable font face (its `fvar` table) as styles of the face's family, /// named by their subfamily names, without the default instance (the face itself) and without diff --git a/crates/text/src/fontdb/traits.rs b/crates/text/src/fontdb/traits.rs index 2d6b8c6e1..b6244b81d 100644 --- a/crates/text/src/fontdb/traits.rs +++ b/crates/text/src/fontdb/traits.rs @@ -1,7 +1,7 @@ use super::face::{MAX_AXES, Variation}; use super::names::{MAX_LEGACY_NAMES, all_names, localized, name, native_family_name, norm}; use super::scan::named_instances; -use super::*; +use super::{MetadataProvider, StringId}; /// The names a face answers to besides its (English) family and style, normalized ([`norm`]): /// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise diff --git a/crates/text/src/fontdb/wanted.rs b/crates/text/src/fontdb/wanted.rs index d06f129e4..31539737a 100644 --- a/crates/text/src/fontdb/wanted.rs +++ b/crates/text/src/fontdb/wanted.rs @@ -2,7 +2,7 @@ use super::names::norm; #[cfg(not(target_arch = "wasm32"))] use super::scan::{file_face_names_within, is_font_file}; use super::traits::FaceStyle; -use super::*; +use super::{HashMap, Path}; /// A font a document names that isn't available, to look for in font files ([`WantedFonts`]). #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/text/src/layout/bidi.rs b/crates/text/src/layout/bidi.rs index 5512725a6..e4b626324 100644 --- a/crates/text/src/layout/bidi.rs +++ b/crates/text/src/layout/bidi.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BidiInfo, Level, ParaDirection, Range, SGlyph}; /// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, /// without one, from its first strong character. `None` for the usual paragraph with nothing right diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 6dea3d9c6..0b9251e83 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,8 @@ use super::region::Region; -use super::*; +use super::{ + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, + hyphen_points_with, no_line_end, no_line_start, soft_line_start, +}; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 2c8f74b0c..33d25f34f 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -3,7 +3,9 @@ use super::on_path::on_path; use super::region::Region; use super::ruby::ruby_layout; use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds}; -use super::*; +use super::{ + Affine, BezPath, CharStyle, Ctx, FontDb, LayoutOptions, OtFeatures, Point, Rect, Shape, TextKind, TextLayout, TextObject, VerticalAlign, +}; /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index b923328f7..957aa913c 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -5,7 +5,11 @@ use super::breaking::{ }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; -use super::*; +use super::{ + Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle, + Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, + is_word_space, kashida, mark_word_ends, style_metrics, +}; /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -130,451 +134,668 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg no_room_for: f64::INFINITY, }; let markers = list_markers(t, paras); - 'paras: for (pi, pr) in paras.iter().enumerate() { - let para = t.para_at(pi); + for (pi, pr) in paras.iter().enumerate() { let marker_text = markers.get(pi).and_then(Option::as_deref); - // The leading model is a paragraph attribute: this paragraph's lines (and the space to - // its first line) follow its own. - pen.model = para.leading_model; - let text = cx.text; - let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); - let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); - // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. - mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); - // Japanese composition: consecutive punctuation shares one half-em space. - let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; - // …and a quarter em between Japanese and Latin text. - let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; - // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); - // Threaded text: a first paragraph that continues from the frame before has its first - // line, first-line indent and list marker there. - let continued = pi == 0 && t.first_para_continues; - // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. - // Justification and Optical Margin Alignment leave them as they are. - let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; - if marker > 0 { - flex.splice(0..0, vec![Flex::default(); marker]); - if !compressed.is_empty() { - compressed.splice(0..0, vec![false; marker]); - } - if !wakan.is_empty() { - wakan.splice(0..0, vec![0.0; marker]); - } + if !flow_paragraph(cx, &mut pen, t, pi, pr.clone(), marker_text, regions) { + break; } - // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. - let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); - if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { - for g in sg.iter_mut().filter(|g| g.inline.is_none()) { - g.leading = auto_leading(cx.style_at(g.byte)); - } + } +} + +fn flow_paragraph( + cx: &mut Ctx<'_>, + pen: &mut Pen<'_>, + t: &TextObject, + pi: usize, + pr: Range, + marker_text: Option<&str>, + regions: Option<&[Region]>, +) -> bool { + let paragraph = prepare_paragraph(cx, pen, t, pi, pr, marker_text, regions); + run_paragraph_lines(cx, pen, t, paragraph) +} + +struct ParagraphData<'a, 'text> { + para: &'a ParaStyle, + pr: Range, + marker_text: Option<&'a str>, + bidi: Option>, + rtl: bool, + continued: bool, + sg: Vec, + compressed: Vec, + wakan: Vec, + flex: Vec, + marker: usize, + pm: Metrics, + composed: Option>, + zone: f64, + hyphen_limit: usize, + mirror: bool, + regions: Option<&'a [Region]>, +} + +fn prepare_paragraph<'a, 'text>( + cx: &mut Ctx<'text>, + pen: &mut Pen<'_>, + t: &'a TextObject, + pi: usize, + pr: Range, + marker_text: Option<&'a str>, + regions: Option<&'a [Region]>, +) -> ParagraphData<'a, 'text> { + let para = t.para_at(pi); + // The leading model is a paragraph attribute: this paragraph's lines (and the space to + // its first line) follow its own. + pen.model = para.leading_model; + let text = cx.text; + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(&pr)); + let rtl = is_rtl(bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. + mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Japanese composition: consecutive punctuation shares one half-em space. + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + // …and a quarter em between Japanese and Latin text. + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how + // far a justified line may move each one from it. + let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // Threaded text: a first paragraph that continues from the frame before has its first + // line, first-line indent and list marker there. + let continued = pi == 0 && t.first_para_continues; + // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. + // Justification and Optical Margin Alignment leave them as they are. + let marker = if continued { 0 } else { cx.prepend_marker(&pr, para, marker_text, rtl, &mut sg) }; + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); + } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } + // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. + let auto_leading = |style: &CharStyle| style.leading.unwrap_or_else(|| para.justification.auto_leading_for(style.size)); + if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { + for glyph in sg.iter_mut().filter(|glyph| glyph.inline.is_none()) { + glyph.leading = auto_leading(cx.style_at(glyph.byte)); + } + } + let pm = { + let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let lead = auto_leading(cx.style_at(pr.start)); + let style = cx.style_at(pr.start); + let (cap, xh) = cap_x_heights(cx.db, style); + let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } + }; + if pi > 0 { + pen.pending += para.space_before; + } + let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued); + // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the + // Hyphen Limit counts the lines in a row that end with a hyphen. + let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); + let mirror = mirrors_indents(para, rtl, cx.vertical); + ParagraphData { + para, + pr, + marker_text, + bidi, + rtl, + continued, + sg, + compressed, + wakan, + flex, + marker, + pm, + composed, + zone, + hyphen_limit, + mirror, + regions, + } +} + +struct PlacementContext<'a> { + para: &'a ParaStyle, + pr: &'a Range, + sg: &'a mut [SGlyph], + continued: bool, + rtl: bool, + mirror: bool, + pm: Metrics, + composed: &'a Option>, + hyphen_run: usize, + hyphen_limit: usize, + zone: f64, + index: usize, + para_line: usize, +} + +struct LinePosition { + baseline: f64, + x0: f64, + x1: f64, + end: usize, + hyph: bool, + m: Metrics, + ind_l: f64, + ind_r: f64, +} + +fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { + let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let n = sg.len(); + let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; + let first_line = *para_line == 0 && !*continued; + let (ind_l, ind_r) = line_indents(para, first_line, *mirror); + // Measured before Mojikumi narrows an opening bracket, as the composer measures it. + let word = word_width(sg, *i); + // Mojikumi: an opening bracket starting a line is set flush with the line's start (the + // space before it goes), and the line has that much more room. At a paragraph's start + // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). + if !*rtl + && para.mojikumi == Mojikumi::LineEndHalf + && let Some(glyph) = sg.get_mut(*i) + && glyph.lead <= 0.0 + && let Some(half) = punct_half(glyph, Punct::Opening) + { + glyph.adv -= half; + glyph.lead += half; + } + // Place, break, then settle the baseline on the line's real metrics (moving on to the + // next row/column if it no longer fits). + loop { + let first_in_region = pen.prev.is_none(); + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; + return None; + }; + let width = x1 - x0 - ind_l - ind_r; + let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); + let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, hyph)) if end > *i => (end, hyph), + _ if *i < n => break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), + _ => (n, false), + }; + let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); + baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + 0.0 + } else if pen.model == LeadingModel::EmBoxTop { + // The em box top is fixed (the frame's top, or the line above's): the baseline + // hangs from it by this line's tallest em box. + if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } + } else if first_in_region { + m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) + } else { + m.lead - est.lead + }; + if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { + // Try the next row/column; overflow if there is none. + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + pen.queued.clear(); + continue; } - let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); - let lead = auto_leading(cx.style_at(pr.start)); - let st = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre()); - Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } + return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + } +} + +struct LineGeometry { + position: LinePosition, + index: usize, + trimmed: usize, + body: usize, + end_trim: f64, + hyphen: Option, + ax0: f64, + ax1: f64, + width: f64, + render_width: f64, + order: Vec, + drawn: Vec, + opt_l: f64, + opt_r: f64, + held: Vec, + hung: Vec, + last_of_para: bool, +} + +struct LineJustification { + per_space: f64, + per_gap: f64, + per_cjk: f64, + fit: LineFit, + start_x: f64, + after: Vec>, + kashidas: Vec<(usize, f64)>, +} + +fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { + let n = paragraph.sg.len(); + let mut hyphen_run = 0; + let mut line_in_paragraph = 0; + let mut index = 0; + loop { + let mut placement = PlacementContext { + para: paragraph.para, + pr: ¶graph.pr, + sg: &mut paragraph.sg, + continued: paragraph.continued, + rtl: paragraph.rtl, + mirror: paragraph.mirror, + pm: paragraph.pm, + composed: ¶graph.composed, + hyphen_run, + hyphen_limit: paragraph.hyphen_limit, + zone: paragraph.zone, + index, + para_line: line_in_paragraph, }; - if pi > 0 { - pen.pending += para.space_before; + let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false }; + hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 }; + pen.pending = 0.0; + let geometry = prepare_line_geometry(cx, t, ¶graph, position, index); + let justification = justify_flow_line(cx.vertical, cx.text, ¶graph, &geometry); + emit_flow_line(cx, pen, ¶graph, &geometry, &justification); + line_in_paragraph += 1; + index = geometry.position.end; + if index >= n { + break; + } + } + pen.pending += paragraph.para.space_after; + true +} + +fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { + let para = paragraph.para; + let sg = ¶graph.sg; + let n = sg.len(); + let (ax0, ax1) = + if paragraph.regions.is_some() { (position.x0 + position.ind_l, position.x1 - position.ind_r) } else { (position.x0, position.x1) }; + let width = ax1 - ax0; + let mut trimmed = position.end; + while trimmed > index && sg[trimmed - 1].is_space() { + trimmed -= 1; + } + let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); + // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and + // no Japanese–Latin space is left at the end of a line. + let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| { + let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; + half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0) + }); + let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; + let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); + let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); + let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); + // The glyphs before `stop` in visual order, each with the advance it is set with. A list + // marker does not hang: it is counted with no width. + let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { + let indices: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < stop).collect(); + let glyphs = indices + .iter() + .filter_map(|&j| { + sg.get(j).map(|g| { + ( + g, + if j < paragraph.marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + }, + ) + }) + }) + .collect(); + (indices, glyphs) + }; + // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. + let share = (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); + // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: + // when it doesn't fit, counting what hangs at the line's start as room; Forced: + // always). The rest of the line is aligned and justified without it, and it follows + // the line's last character. + let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); + let hang = (paragraph.regions.is_some() && !paragraph.rtl && hyphen.is_none() && trimmed > index + 1) + .then(|| sg.get(trimmed - 1)) + .flatten() + .filter(|g| hangs(g)) + .map(|g| g.adv - end_trim) + .filter(|_| match para.burasagari { + Burasagari::None => false, + Burasagari::Standard => measured_width > width + room_at_start() + EPS, + Burasagari::Forced => true, + }); + let body = if hang.is_some() { trimmed - 1 } else { trimmed }; + let render_width = measured_width - hang.unwrap_or(0.0); + // The glyphs before the line's trailing spaces (and its burasagari mark), in the order + // they are drawn. + let (drawn, mut visible) = visible(body); + // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins + // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much + // wider at each end. A left-aligned line then hangs only at its left, a right-aligned + // line hangs only at its right, and centered and justified lines hang at both; Auto + // aligns a right-to-left line at its right. A right-to-left line is read from right to + // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its + // left. Justification adds no space after a punctuation mark hanging at the left, nor + // before one hanging at the right (`held`). A letter that hangs by part of its width + // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so + // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph + // attribute) hangs the same punctuation by the same amounts, and no letters; with + // Optical Margin Alignment on as well, the letters hang too. + let (opt_l, opt_r, held, hung) = if let Some(share) = share { + if let Some(hyphen) = &hyphen { + visible.push((hyphen, hyphen.adv)); } - let n = sg.len(); - let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); - // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the - // Hyphen Limit counts the lines in a row that end with a hyphen. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; - let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); - let mut hyphen_run = 0; - let mut li_para = 0; - let mut i = 0; - let mirror = mirrors_indents(para, rtl, cx.vertical); - loop { - let est = if i < n { Metrics::of(&sg[i]) } else { pm }; - let first_line = li_para == 0 && !continued; - let (ind_l, ind_r) = line_indents(para, first_line, mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(&sg, i); - // Mojikumi: an opening bracket starting a line is set flush with the line's start (the - // space before it goes), and the line has that much more room. At a paragraph's start - // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). - if !rtl - && para.mojikumi == Mojikumi::LineEndHalf - && let Some(g) = sg.get_mut(i) - && g.lead <= 0.0 - && let Some(h) = punct_half(g, Punct::Opening) - { - g.adv -= h; - g.lead += h; + let (left, right, left_count, right_count) = + if paragraph.rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; + let (right, right_count) = if hang.is_some() { (0.0, 0) } else { (right, right_count) }; + let visible_count = visible.len(); + let punctuation = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); + let held = drawn + .iter() + .enumerate() + .filter(|&(k, _)| (k < left_count && punctuation(k)) || (right_count > 0 && k + 1 + right_count >= visible_count && punctuation(k + 1))) + .map(|(_, &j)| j) + .collect(); + let hung = drawn + .iter() + .enumerate() + .filter(|&(k, _)| k < left_count || (right_count > 0 && k + right_count >= visible_count)) + .map(|(_, &j)| j) + .collect(); + (left, right, held, hung) + } else { + (0.0, 0.0, Vec::new(), Vec::new()) + }; + // Justification adds nothing to the list marker's glyphs. + let mut held = held; + held.extend(index..paragraph.marker.min(body)); + let last_of_para = position.end >= n; + LineGeometry { + position, + index, + trimmed, + body, + end_trim, + hyphen, + ax0, + ax1, + width, + render_width, + order, + drawn, + opt_l, + opt_r, + held, + hung, + last_of_para, + } +} + +fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option { + glyph.checked_sub(index).and_then(|offset| after.get(offset).copied().flatten()) +} + +// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the +// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. +fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph]) -> bool { + !held.contains(&glyph) + && drawn_after(index, glyph, after) + .and_then(|next| sg.get(glyph).zip(sg.get(next))) + .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) +} + +// What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph +// scaling of a `hung` one. +fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex { + if held.contains(&glyph) { + return Flex::default(); + } + let mut value = flex.get(glyph).copied().unwrap_or_default(); + if hung.contains(&glyph) { + value.glyph = (0.0, 0.0); + } + value +} + +fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { + let para = paragraph.para; + let sg = ¶graph.sg; + let (held, hung) = (&line.held, &line.hung); + let room = line.width + line.opt_l + line.opt_r; + let (mut align, mut justify) = match para.justify { + Justify::Auto => (if paragraph.rtl { 2 } else { 0 }, false), + Justify::Left => (0, false), + Justify::Center => (1, false), + Justify::Right => (2, false), + Justify::JustifyLeft => (0, !line.last_of_para), + Justify::JustifyCenter => (1, !line.last_of_para), + Justify::JustifyRight => (2, !line.last_of_para), + Justify::JustifyAll => (0, true), + }; + justify &= paragraph.regions.is_some(); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count(); + // A gap that justification adds between two glyphs goes into the advance of the one + // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a + // right-to-left line comes before it in the text. + let mut after: Vec> = vec![None; line.body.saturating_sub(line.index)]; + for (&a, &b) in line.drawn.iter().zip(line.drawn.iter().skip(1)) { + if let Some(slot) = after.get_mut(a - line.index) { + *slot = Some(b); + } + } + let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + // Single Word Justification: a justified line holding one word (no word space) is + // spread between its letters (Full Justify) or aligned. + let single_word = justify && spaces == 0 && cjk_gaps == 0; + (align, justify) = match (single_word, para.justification.single_word) { + (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), + (true, SingleWordJustify::AlignLeft) => (0, false), + (true, SingleWordJustify::AlignCenter) => (1, false), + (true, SingleWordJustify::AlignRight) => (2, false), + }; + // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, + // and the word spaces and gaps take what is left. + let kashidas = if justify && !vertical && room > line.render_width + EPS { + kashida::plan(text, sg, line.index..line.body, para.kashida, room - line.render_width) + } else { + Vec::new() + }; + let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); + let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); + let mut fit = LineFit::default(); + if justify && (room - width).abs() > EPS { + if cjk_gaps > 0 && room > width { + // Spread over the CJK gaps and the word spaces alike. + per_space = (room - width) / (cjk_gaps + spaces) as f64; + per_cjk = per_space; + } else if spaces > 0 { + // Word spacing, letter spacing and glyph scaling within their ranges, then the + // word spaces alone; composed lines may shrink word spaces (never below zero). + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + fit = fit_line(&flex, room - width, spaces, floor); + } else if room > width { + // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + fit = fit_line(&flex, room - width, 0, 0.0); + let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); + let rest = room - width - used; + let gaps = (line.index..line.body) + .filter(|&j| { + !held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) + }) + .count(); + if gaps > 0 && rest > EPS { + per_gap = rest / gaps as f64; } - // Place, break, then settle the baseline on the line's real metrics (moving on to the - // next row/column if it no longer fits). - let (baseline, x0, x1, end, hyph, m) = loop { - let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - }; - let width = x1 - x0 - ind_l - ind_r; - let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); - let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { - Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), - }; - let m = Metrics::max(&sg[i..end]).unwrap_or(pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { - 0.0 - } else if pen.model == LeadingModel::EmBoxTop { - // The em box top is fixed (the frame's top, or the line above's): the baseline - // hangs from it by this line's tallest em box. - if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } - } else if first_in_region { - m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) - } else { - m.lead - est.lead - }; - if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { - // Try the next row/column; overflow if there is none. - pen.ri += 1; - pen.prev = None; - pen.pending = 0.0; - pen.queued.clear(); - continue; - } - break (baseline, x0, x1, end, hyph, m); - }; - hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; - let width = ax1 - ax0; - pen.pending = 0.0; - let last_of_para = end >= n; - let mut trimmed = end; - while trimmed > i && sg[trimmed - 1].is_space() { - trimmed -= 1; + } + } + let position = &line.position; + let start_x = if justify { + line.ax0 - line.opt_l + } else if paragraph.regions.is_none() { + match align { + 0 => position.ind_l - line.opt_l, + 1 => (position.ind_l - line.opt_l - position.ind_r + line.opt_r - width) * 0.5, + _ => -position.ind_r + line.opt_r - width, + } + } else { + match align { + 0 => line.ax0 - line.opt_l, + 1 => line.ax0 - line.opt_l + (room - width) * 0.5, + _ => line.ax1 + line.opt_r - width, + } + }; + LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } +} + +fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { + let para = paragraph.para; + let sg = ¶graph.sg; + let index = line.index; + let trimmed = line.trimmed; + let body = line.body; + let order = &line.order; + let start_x = justification.start_x; + let (ax0, ax1) = (line.ax0, line.ax1); + let position = &line.position; + let line_index = cx.out.lines.len(); + let glyph_start = cx.out.glyphs.len(); + // Character Alignment lines smaller characters up with the line's largest em box. + let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max)); + // Tab stops are measured from the frame's left edge (point type: the origin). + let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 }; + // A line with tabs is measured as if it started at the left (see `set_width`), then + // aligned as a whole: its tabs stop where they were measured, however it is aligned. + let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l }; + // RTL's logical trailing spaces precede the visible content. Keep them outside + // the aligned content extent, just as LTR trailing spaces extend to its right. + let leading_space_width: f64 = + order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum(); + let mut x = start_x - leading_space_width; + let mut x_end = start_x; + // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; + for (order_index, &offset) in order.iter().enumerate() { + let glyph_index = index + offset; + let Some(glyph) = sg.get(glyph_index) else { continue }; + let mut advance = glyph.adv; + if glyph_index + 1 == trimmed { + advance -= line.end_trim; + } + // Glyph scaling a justified line adds to the glyph (1: none). + let mut scale = 1.0; + let mut leader = ""; + if glyph.ch == '\t' { + // The tab after a list marker keeps the width `prepend_marker` gave it. + if glyph.len > 0 { + (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); } - let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { - Some(k) => { - let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0) - } - None => 0.0, - }; - let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; - let line_start = sg.get(i).map_or(pr.start, |g| g.byte); - let line_end = sg.get(end).map_or(pr.end, |g| g.byte); - let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); - // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. - let share: Option f64> = - (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); - // The glyphs before `stop` in visual order, each with the advance it is set with. A list - // marker does not hang: it is counted with no width. - let set = |j: usize, g: &SGlyph| { - if j < marker { - 0.0 - } else if j + 1 == trimmed { - g.adv - end_trim - } else { - g.adv - } - }; - let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { - let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); - let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); - (at, glyphs) - }; - // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: - // when it doesn't fit, counting what hangs at the line's start as room; Forced: - // always). The rest of the line is aligned and justified without it, and it follows - // the line's last character. - let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); - let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) - .then(|| sg.get(trimmed - 1)) - .flatten() - .filter(|g| hangs(g)) - .map(|g| g.adv - end_trim) - .filter(|_| match para.burasagari { - Burasagari::None => false, - Burasagari::Standard => w > width + room_at_start() + EPS, - Burasagari::Forced => true, - }); - let body = if hang.is_some() { trimmed - 1 } else { trimmed }; - let w = w - hang.unwrap_or(0.0); - // The glyphs before the line's trailing spaces (and its burasagari mark), in the order - // they are drawn. - let (drawn, mut visible) = visible(body); - // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins - // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much - // wider at each end. A left-aligned line then hangs only at its left, a right-aligned - // line hangs only at its right, and centered and justified lines hang at both; Auto - // aligns a right-to-left line at its right. A right-to-left line is read from right to - // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its - // left. Justification adds no space after a punctuation mark hanging at the left, nor - // before one hanging at the right (`held`). A letter that hangs by part of its width - // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so - // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph - // attribute) hangs the same punctuation by the same amounts, and no letters; with - // Optical Margin Alignment on as well, the letters hang too. - let (opt_l, opt_r, held, hung) = if let Some(share) = share { - if let Some(h) = &hyphen { - visible.push((h, h.adv)); - } - let (l, r, left_glyphs, right_glyphs) = - if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; - let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; - let n_visible = visible.len(); - let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let held: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) - .map(|(_, &j)| j) - .collect(); - let hung: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) - .map(|(_, &j)| j) - .collect(); - (l, r, held, hung) + } else if glyph_index < body { + let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); + if is_word_space(glyph) { + advance += justification.fit.space(&flex); } else { - (0.0, 0.0, vec![], vec![]) - }; - // Justification adds nothing to the list marker's glyphs. - let mut held = held; - held.extend(i..marker.min(body)); - // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph - // scaling of a `hung` one. - let line_flex = |j: usize| { - if held.contains(&j) { - return Flex::default(); - } - let mut f = flex.get(j).copied().unwrap_or_default(); - if hung.contains(&j) { - f.glyph = (0.0, 0.0); - } - f - }; - let room = width + opt_l + opt_r; - let (align, justify) = match para.justify { - Justify::Auto => (if rtl { 2 } else { 0 }, false), - Justify::Left => (0, false), - Justify::Center => (1, false), - Justify::Right => (2, false), - Justify::JustifyLeft => (0, !last_of_para), - Justify::JustifyCenter => (1, !last_of_para), - Justify::JustifyRight => (2, !last_of_para), - Justify::JustifyAll => (0, true), - }; - let justify = justify && regions.is_some(); - let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); - let mut fit = LineFit::default(); - let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); - // A gap that justification adds between two glyphs goes into the advance of the one - // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a - // right-to-left line comes before it in the text. - let mut after: Vec> = vec![None; body.saturating_sub(i)]; - for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { - if let Some(slot) = after.get_mut(a - i) { - *slot = Some(b); - } + advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); + scale = justification.fit.glyph_scale(&flex); } - let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); - // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the - // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. - let cjk_gap = |j: usize| { - !held.contains(&j) - && drawn_after(j) - .and_then(|k| sg.get(j).zip(sg.get(k))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) - }; - let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); - // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; - let (align, justify) = match (single_word, para.justification.single_word) { - (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), - (true, SingleWordJustify::AlignLeft) => (0, false), - (true, SingleWordJustify::AlignCenter) => (1, false), - (true, SingleWordJustify::AlignRight) => (2, false), - }; - // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, - // and the word spaces and gaps take what is left. - let kashidas = - if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; - let w = w + kashidas.iter().map(|k| k.1).sum::(); - if justify && (room - w).abs() > EPS { - if cjk_gaps > 0 && room > w { - // Spread over the CJK gaps and the word spaces alike. - let per = (room - w) / (cjk_gaps + spaces) as f64; - (per_space, per_cjk) = (per, per); - } else if spaces > 0 { - // Word spacing, letter spacing and glyph scaling within their ranges, then the - // word spaces alone; composed lines may shrink word spaces (never below zero). - let line: Vec = (i..body).map(line_flex).collect(); - let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); - fit = fit_line(&line, room - w, spaces, floor); - } else if room > w { - // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let line: Vec = (i..body).map(line_flex).collect(); - fit = fit_line(&line, room - w, 0, 0.0); - let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); - let rest = room - w - used; - let gaps = (i..body) - .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) - .count(); - if gaps > 0 && rest > EPS { - per_gap = rest / gaps as f64; - } + if glyph.is_space() { + advance += justification.per_space; + } else if justification.per_cjk != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk; } + } else if !line.held.contains(&glyph_index) + && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) + { + // Between glyphs, never inside a tate-chu-yoko block (one cell). + advance += justification.per_gap; } - let start_x = if justify { - ax0 - opt_l - } else if regions.is_none() { - match align { - 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, - _ => -ind_r + opt_r - w, - } - } else { - match align { - 0 => ax0 - opt_l, - 1 => ax0 - opt_l + (room - w) * 0.5, - _ => ax1 + opt_r - w, - } - }; - let li = cx.out.lines.len(); - let glyph_start = cx.out.glyphs.len(); - // Character Alignment lines smaller characters up with the line's largest em box. - let line_em = sg.get(i..end).map_or(0.0, |line| line.iter().map(glyph_em).fold(0.0, f64::max)); - let mut x_end = start_x; - // Tab stops are measured from the frame's left edge (point type: the origin). - let tab_origin = if regions.is_some() { x0 } else { 0.0 }; - // A line with tabs is measured as if it started at the left (see `set_width`), then - // aligned as a whole: its tabs stop where they were measured, however it is aligned. - let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; - // RTL's logical trailing spaces precede the visible content. Keep them outside - // the aligned content extent, just as LTR trailing spaces extend to its right. - let leading_space_width: f64 = - order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); - let mut x = start_x - leading_space_width; - // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. - let mut marker_glyphs: Option> = None; - let mut marker_tab = 0; - for (oi, &offset) in order.iter().enumerate() { - let j = i + offset; - let Some(g) = sg.get(j) else { continue }; - let mut adv = g.adv; - if j + 1 == trimmed { - adv -= end_trim; - } - // Glyph scaling a justified line adds to the glyph (1: none). - let mut scale = 1.0; - let mut leader = ""; - if g.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if g.len > 0 { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); - } - } else if j < body { - let f = line_flex(j); - if is_word_space(g) { - adv += fit.space(&f); - } else { - adv += fit.letter(&f) + fit.glyph(&f); - scale = fit.glyph_scale(&f); - } - if g.is_space() { - adv += per_space; - } else if per_cjk != 0.0 { - if cjk_gap(j) { - adv += per_cjk; - } - } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { - // Between glyphs, never inside a tate-chu-yoko block (one cell). - adv += per_gap; - } - } - // The kashida before a cluster in the text is drawn at the cluster's right, between - // it and the letter before it, and the cluster's last glyph advances over it: the - // caret, selection and hit testing take it as part of the cluster. - let kashida = kashidas - .binary_search_by_key(&g.byte, |k| k.0) - .ok() - .and_then(|at| kashidas.get(at)) - .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) - .map(|k| k.1); - adv += kashida.unwrap_or(0.0); - let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); - let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; - if j < marker { - let at = cx.out.glyphs.len(); - marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); - if j + 1 == marker { - marker_tab = at; - } - } - cx.emit(g, place, Point::new(x, y), 0.0, adv, li); - if let Some(out) = cx.out.glyphs.last_mut() { - out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; - } - if !leader.is_empty() { - // The leader belongs to the tab's glyph: the gap stays one character for the - // caret, selection and hit testing. - let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); - if let Some(tab) = cx.out.glyphs.last_mut() { - tab.outline.extend(dots); - } - } - if let Some(len) = kashida { - cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); - } - x += adv; - if j < trimmed { - x_end = x; - } + } + // The kashida before a cluster in the text is drawn at the cluster's right, between + // it and the letter before it, and the cluster's last glyph advances over it: the + // caret, selection and hit testing take it as part of the cluster. + let kashida = justification + .kashidas + .binary_search_by_key(&glyph.byte, |item| item.0) + .ok() + .and_then(|at| justification.kashidas.get(at)) + .filter(|_| order.get(order_index + 1).and_then(|&offset| sg.get(index + offset)).is_none_or(|next| next.byte != glyph.byte)) + .map(|item| item.1); + advance += kashida.unwrap_or(0.0); + let y = line.position.baseline - align_shift(glyph, cx.style_at(glyph.byte).char_align, line_em, cx.vertical); + let transform = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if glyph_index < paragraph.marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range| range.start.min(at)..range.end.max(at + 1))); + if glyph_index + 1 == paragraph.marker { + marker_tab = at; } - if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) { - cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + cx.emit(glyph, transform, Point::new(x, y), 0.0, advance, line_index); + if let Some(out) = cx.out.glyphs.last_mut() { + out.scale = paragraph.flex.get(glyph_index).map_or(1.0, |flex| flex.scale) * scale; + } + if !leader.is_empty() { + // The leader belongs to the tab's glyph: the gap stays one character for the + // caret, selection and hit testing. + let dots = leader_outline(cx.db, glyph, leader, tab_origin, x, x + advance, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); } - if let Some(h) = &hyphen { - // The hyphen follows the last non-space glyph. - let hx = x_end; - cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); - x_end = hx + h.adv; - } - cx.out.lines.push(LineInfo { - rtl: rtl && !cx.vertical, - baseline, - x0: start_x, - x1: x_end, - ascent: m.asc, - descent: m.desc, - first_baseline: if pen.model == LeadingModel::EmBoxTop { m.top.max(pen.fb_min) } else { m.first_baseline(pen.fb, pen.fb_min) }, - start: if i < n { sg[i].byte } else { pr.start }, - end: if last_of_para { pr.end } else { sg[end].byte }, - glyph_start, - glyph_end: cx.out.glyphs.len(), - avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, - region: if regions.is_some() { pen.ri } else { 0 }, - }); - pen.settled(baseline, m); - // Further spans of this line band share the settled baseline. - for q in &mut pen.queued { - q.0 = baseline; - } - li_para += 1; - i = end; - if i >= n { - break; - } + } + if let Some(length) = kashida { + cx.emit_kashida(glyph, para.kashida, length, Point::new(x + advance - length, y), line_index); + } + x += advance; + if glyph_index < trimmed { + x_end = x; } - pen.pending += para.space_after; + } + if let (Some(glyphs), Some(text)) = (marker_glyphs, paragraph.marker_text) { + cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + if let Some(hyphen) = &line.hyphen { + // The hyphen follows the last non-space glyph. + let x = x_end; + cx.emit(hyphen, Affine::translate((x, position.baseline)), Point::new(x, position.baseline), 0.0, hyphen.adv, line_index); + x_end = x + hyphen.adv; + } + cx.out.lines.push(LineInfo { + rtl: paragraph.rtl && !cx.vertical, + baseline: position.baseline, + x0: start_x, + x1: x_end, + ascent: position.m.asc, + descent: position.m.desc, + first_baseline: if pen.model == LeadingModel::EmBoxTop { + position.m.top.max(pen.fb_min) + } else { + position.m.first_baseline(pen.fb, pen.fb_min) + }, + start: if index < sg.len() { sg[index].byte } else { paragraph.pr.start }, + end: if line.last_of_para { paragraph.pr.end } else { sg[position.end].byte }, + glyph_start, + glyph_end: cx.out.glyphs.len(), + avail: if paragraph.regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, + region: if paragraph.regions.is_some() { pen.ri } else { 0 }, + }); + pen.settled(position.baseline, position.m); + // Further spans of this line band share the settled baseline. + for queued in &mut pen.queued { + queued.0 = position.baseline; } } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 7bdeba66d..ddf4e56f3 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -2,7 +2,10 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{Metrics, optical_hangs, optical_hangs_rtl, optical_share, roman_share}; use super::flow::list_markers; use super::region::in_range; -use super::*; +use super::{ + Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range, + SGlyph, Shape, TextObject, Vec2, style_metrics, +}; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index d3f953359..e0423e1dd 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(test)] +use super::PathData; +use super::{BezPath, LayoutOptions, PathEl, Point, Rect, Shape}; /// A flattened text wrap shape: (polygons, offset, invert). pub(super) type FlatWrap = (Vec>, f64, bool); diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index c09786bbc..4f2c76fe2 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -1,5 +1,5 @@ use super::driver::layout; -use super::*; +use super::{Affine, CharStyle, Ctx, EPS, Point, PositionedGlyph, RubyAlign, RubyPosition, Shape, TextKind, TextObject}; /// Ruby (furigana): lays each reading out over its base text in line space, grows the line's /// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. @@ -11,37 +11,54 @@ use super::*; /// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the /// base's width. Type on a path is left out (it has no above/below). pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { - use std::collections::BTreeMap; - - let db = cx.db; - let text_len = cx.text.len(); - let n_lines = cx.out.lines.len(); - if n_lines == 0 || cx.out.glyphs.is_empty() { + let line_count = cx.out.lines.len(); + if line_count == 0 || cx.out.glyphs.is_empty() { return; } - let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); - let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); - let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); - // One piece of base text on one line, with the part of the reading that belongs to it. - struct Piece { - line: usize, - x0: f64, - x1: f64, - run: usize, - byte: usize, - reading: String, - style: CharStyle, - position: RubyPosition, - align: RubyAlign, - base_size: f64, - overhang: bool, + let base_baselines = cx.out.lines.iter().map(|line| line.baseline).collect::>(); + let old_ascent = cx.out.lines.iter().map(|line| line.ascent).collect::>(); + let old_descent = cx.out.lines.iter().map(|line| line.descent).collect::>(); + let pieces = ruby_pieces(cx, t); + // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how + // far above/below the baseline its ink reaches. + let placed = place_ruby_pieces(cx, &pieces, &base_baselines, &old_descent); + if !placed.is_empty() { + grow_ruby_lines(cx, t, placed, &old_ascent, &old_descent); } - let mut pieces: Vec = Vec::new(); +} + +// One piece of base text on one line, with the part of the reading that belongs to it. +struct RubyPiece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, +} + +struct PlacedRuby { + line: usize, + glyphs: Vec, + ascent: f64, + descent: f64, +} + +fn ruby_pieces(cx: &Ctx<'_>, t: &TextObject) -> Vec { + use std::collections::BTreeMap; + + let text_len = cx.text.len(); // The glyphs by their byte, to find a span's without a scan of them all for each span. let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); by_byte.sort_by_key(|&i| byte_of(i)); let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); + let mut pieces = Vec::new(); for span in &t.ruby { let (start, end) = (span.start.min(text_len), span.end.min(text_len)); if start >= end || span.text.trim().is_empty() { @@ -56,225 +73,275 @@ pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { // In glyph order, as the scan found them. hits.sort_unstable(); for i in hits { - let Some(g) = cx.out.glyphs.get(i) else { continue }; - let b = g.byte.saturating_add(g.len.max(1)); - if g.byte < end && b > start && cx.out.marker_of(i).is_none() { - by_line.entry(g.line).or_default().push(i); + let Some(glyph) = cx.out.glyphs.get(i) else { continue }; + let glyph_end = glyph.byte.saturating_add(glyph.len.max(1)); + if glyph.byte < end && glyph_end > start && cx.out.marker_of(i).is_none() { + by_line.entry(glyph.line).or_default().push(i); } } let total: usize = by_line.values().map(Vec::len).sum(); if total == 0 { continue; } - let reading: Vec = span.text.trim().chars().collect(); - let ratio = span.safe_size_percent() / 100.0; - // Split the reading by each line's share of the base glyphs: cumulative fractions keep - // every character once and in order. - let mut taken = 0; - let mut done = 0; - let lines: Vec = by_line.keys().copied().collect(); - for line in &lines { - let count = by_line.get(line).map_or(0, Vec::len); - done += count; - let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; - let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); - taken = upto; - if part.is_empty() { - continue; - } - let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); - let mut x0 = f64::INFINITY; - let mut x1 = f64::NEG_INFINITY; - let mut base_size: f64 = 0.0; - let mut run = 0; - let mut byte = start; - for (j, &gi) in glyphs.iter().enumerate() { - let Some(g) = cx.out.glyphs.get(gi) else { continue }; - x0 = x0.min(g.origin.x); - x1 = x1.max(g.origin.x + g.advance); - base_size = base_size.max(cx.style_at(g.byte).size); - if j == 0 { - run = g.run; - byte = g.byte; - } - } - if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { - continue; - } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; - style.leading = None; - style.baseline_shift = 0.0; - style.tracking = 0.0; - style.kerning = None; - pieces.push(Piece { - line: *line, - x0, - x1, - run, - byte, - reading: part, - style, - position: span.position, - align: span.align, - base_size, - overhang: span.overhang, - }); + collect_ruby_pieces_for_span(cx, span, start, total, &by_line, &mut pieces); + } + pieces +} + +fn collect_ruby_pieces_for_span( + cx: &Ctx<'_>, + span: &vectorcraft_doc::text::RubySpan, + start: usize, + total: usize, + by_line: &std::collections::BTreeMap>, + pieces: &mut Vec, +) { + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + // Split the reading by each line's share of the base glyphs: cumulative fractions keep + // every character once and in order. + let mut taken = 0; + let mut done = 0; + for (&line, glyph_indices) in by_line { + done += glyph_indices.len(); + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + if let Some(piece) = ruby_piece_for_line(cx, line, start, &part, ratio, glyph_indices, span) { + pieces.push(piece); } } - // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how - // far above/below the baseline its ink reaches. - let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) - for piece in &pieces { - let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; - let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); - let lay = layout(db, &mini); - let Some(first_line) = lay.lines.first() else { continue }; - let mini_base = first_line.baseline; - let mini_asc = first_line.ascent; - let mini_desc = first_line.descent; - let base_w = (piece.x1 - piece.x0).max(0.0); - // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the - // base's em box bottom (the line's descent below the baseline). - let ruby_y = if piece.position == RubyPosition::Above { - baseline - piece.base_size - mini_desc - } else { - baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc - }; - let dest = |width: f64| match piece.align { - RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, - RubyAlign::Start => piece.x0, - RubyAlign::End => piece.x1 - width, - }; - let mut glyphs: Vec = Vec::new(); - if cx.vertical { - // Each reading character in its own cell along the column, upright once the lines turn. - let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); - let mut clusters: Vec> = Vec::new(); - for g in &lay.glyphs { - match clusters.last_mut() { - Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), - _ => clusters.push(vec![g]), - } - } - let mut ruby_w = clusters.len() as f64 * cell; - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - for (ci, cluster) in clusters.iter().enumerate() { - let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); - let mut box_: Option = None; - for g in cluster { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - box_ = Some(box_.map_or(b, |a| a.union(b))); - } - let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); - let mut m = - Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in cluster { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - } else { - // The reading set along the line: the line's extent, centred or aligned to the base. - let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in &lay.glyphs { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - // How far the reading reaches above and below the base line's baseline (its ink). - let mut ascent: f64 = 0.0; - let mut descent: f64 = 0.0; - for g in &glyphs { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - ascent = ascent.max(baseline - b.y0); - descent = descent.max(b.y1 - baseline); - } - if !glyphs.is_empty() { - placed.push((piece.line, glyphs, ascent, descent)); +} + +fn ruby_piece_for_line( + cx: &Ctx<'_>, + line: usize, + start: usize, + reading: &str, + ratio: f64, + glyph_indices: &[usize], + span: &vectorcraft_doc::text::RubySpan, +) -> Option { + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (index, &glyph_index) in glyph_indices.iter().enumerate() { + let Some(glyph) = cx.out.glyphs.get(glyph_index) else { continue }; + x0 = x0.min(glyph.origin.x); + x1 = x1.max(glyph.origin.x + glyph.advance); + base_size = base_size.max(cx.style_at(glyph.byte).size); + if index == 0 { + run = glyph.run; + byte = glyph.byte; } } - if placed.is_empty() { - return; + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + return None; } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + Some(RubyPiece { + line, + x0, + x1, + run, + byte, + reading: reading.to_string(), + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }) +} + +fn place_ruby_pieces(cx: &Ctx<'_>, pieces: &[RubyPiece], baselines: &[f64], old_descent: &[f64]) -> Vec { + pieces.iter().filter_map(|piece| place_ruby_piece(cx, piece, baselines, old_descent)).collect() +} + +fn place_ruby_piece(cx: &Ctx<'_>, piece: &RubyPiece, baselines: &[f64], old_descent: &[f64]) -> Option { + let baseline = baselines.get(piece.line).copied()?; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let layout = layout(cx.db, &mini); + let first_line = layout.lines.first()?; + let base_width = (piece.x1 - piece.x0).max(0.0); + let ruby_y = ruby_baseline(piece, baseline, first_line.ascent, first_line.descent, old_descent); + let glyphs = if cx.vertical { + place_vertical_ruby(piece, &layout.glyphs, first_line.ascent, first_line.descent, base_width, ruby_y) + } else { + place_horizontal_ruby(piece, &layout.glyphs, first_line, base_width, ruby_y) + }; + let (ascent, descent) = ruby_ink_extents(&glyphs, baseline); + (!glyphs.is_empty()).then_some(PlacedRuby { line: piece.line, glyphs, ascent, descent }) +} + +fn ruby_baseline(piece: &RubyPiece, baseline: f64, reading_ascent: f64, reading_descent: f64, old_descent: &[f64]) -> f64 { + // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the + // base's em box bottom (the line's descent below the baseline). + if piece.position == RubyPosition::Above { + baseline - piece.base_size - reading_descent + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + reading_ascent + } +} + +fn ruby_destination(piece: &RubyPiece, base_width: f64, reading_width: f64) -> f64 { + match piece.align { + RubyAlign::Center => piece.x0 + (base_width - reading_width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - reading_width, + } +} + +fn place_vertical_ruby(piece: &RubyPiece, reading: &[PositionedGlyph], asc: f64, desc: f64, base_width: f64, ruby_y: f64) -> Vec { + // Each reading character in its own cell along the column, upright once the lines turn. + let cell = (asc + desc).max(piece.style.size * 0.5); + let clusters = ruby_clusters(reading); + let (ruby_width, squeeze) = ruby_width(clusters.len() as f64 * cell, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let mut glyphs = Vec::new(); + for (index, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (index as f64 + 0.5) * cell, ruby_y); + let centre = ruby_cluster_center(cluster, anchor); + let transform = squeeze_transform( + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)), + x, + squeeze, + ); + for glyph in cluster { + glyphs.push(ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)); + } + } + glyphs +} + +fn ruby_clusters(glyphs: &[PositionedGlyph]) -> Vec> { + let mut clusters: Vec> = Vec::new(); + for glyph in glyphs { + match clusters.last_mut() { + Some(cluster) if cluster.last().is_some_and(|previous| previous.byte == glyph.byte && previous.len == glyph.len) => cluster.push(glyph), + _ => clusters.push(vec![glyph]), + } + } + clusters +} + +fn ruby_cluster_center(cluster: &[&PositionedGlyph], anchor: Point) -> Point { + let bounds = cluster + .iter() + .filter(|glyph| !glyph.outline.elements().is_empty()) + .map(|glyph| glyph.outline.bounding_box()) + .reduce(|left, right| left.union(right)); + bounds.map_or(anchor, |bounds| Point::new((bounds.x0 + bounds.x1) * 0.5, (bounds.y0 + bounds.y1) * 0.5)) +} + +fn place_horizontal_ruby( + piece: &RubyPiece, + reading: &[PositionedGlyph], + line: &super::LineInfo, + base_width: f64, + ruby_y: f64, +) -> Vec { + // The reading set along the line: the line's extent, centred or aligned to the base. + let reading_width = (line.x1 - line.x0).max(0.0); + let (ruby_width, squeeze) = ruby_width(reading_width, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let transform = squeeze_transform(Affine::translate((x - line.x0, ruby_y - line.baseline)), x, squeeze); + reading.iter().map(|glyph| ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)).collect() +} + +fn ruby_width(reading_width: f64, base_width: f64, overhang: bool) -> (f64, f64) { + if !overhang && reading_width > base_width + EPS && reading_width > EPS { (base_width, base_width / reading_width) } else { (reading_width, 1.0) } +} + +fn squeeze_transform(transform: Affine, x: f64, squeeze: f64) -> Affine { + if squeeze == 1.0 { + transform + } else { + Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * transform + } +} + +fn ruby_ink_extents(glyphs: &[PositionedGlyph], baseline: f64) -> (f64, f64) { + // How far the reading reaches above and below the base line's baseline (its ink). + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for glyph in glyphs.iter().filter(|glyph| !glyph.outline.elements().is_empty()) { + let bounds = glyph.outline.bounding_box(); + ascent = ascent.max(baseline - bounds.y0); + descent = descent.max(bounds.y1 - baseline); + } + (ascent, descent) +} + +fn grow_ruby_lines(cx: &mut Ctx<'_>, t: &TextObject, placed: Vec, old_ascent: &[f64], old_descent: &[f64]) { + let line_count = cx.out.lines.len(); // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they // need in the block direction (the first line too for area type, so the ruby stays inside the // frame; point type's first baseline is its anchor and stays). - let mut growth = vec![0.0_f64; n_lines]; - for (line, _, ascent, descent) in &placed { - if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { - let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); - if grown > 0.0 { - let g = growth.get_mut(*line); - if let Some(slot) = g { - *slot = slot.max(grown); - } - } + let mut growth = vec![0.0_f64; line_count]; + for ruby in &placed { + if let (Some(asc), Some(desc), Some(slot)) = (old_ascent.get(ruby.line), old_descent.get(ruby.line), growth.get_mut(ruby.line)) { + let grown = (asc.max(ruby.ascent) + desc.max(ruby.descent)) - (asc + desc); + *slot = slot.max(grown.max(0.0)); } } // A line's ruby grows above it: the line moves on in the block direction by its own growth, // so the line before it keeps its room (for area type the first line moves too, keeping the // ruby inside the frame; point type's first baseline is its anchor and stays). let area = matches!(t.kind, TextKind::Area { .. }); - let mut shift = vec![0.0_f64; n_lines]; - let mut cum = 0.0; - for i in 0..n_lines { - if area || i > 0 { - cum += growth.get(i).copied().unwrap_or(0.0); - if let Some(s) = shift.get_mut(i) { - *s = cum; - } + let mut shift = vec![0.0_f64; line_count]; + let mut cumulative = 0.0; + for (index, amount) in growth.iter().copied().enumerate() { + if area || index > 0 { + cumulative += amount; + shift[index] = cumulative; } } // Grow the lines' boxes to fit their ruby, then move them on in the block direction. - for (line, _, ascent, descent) in &placed { - if let Some(l) = cx.out.lines.get_mut(*line) { - l.ascent = l.ascent.max(*ascent); - l.descent = l.descent.max(*descent); + for ruby in &placed { + if let Some(line) = cx.out.lines.get_mut(ruby.line) { + line.ascent = line.ascent.max(ruby.ascent); + line.descent = line.descent.max(ruby.descent); } } - for (i, line) in cx.out.lines.iter_mut().enumerate() { - line.baseline += shift.get(i).copied().unwrap_or(0.0); + for (index, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(index).copied().unwrap_or(0.0); } - for g in &mut cx.out.glyphs { - let s = shift.get(g.line).copied().unwrap_or(0.0); - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - g.xf = Affine::translate((0.0, s)) * g.xf; + shift_base_glyphs(cx, &shift); + append_ruby_glyphs(cx, placed, &shift); +} + +fn shift_base_glyphs(cx: &mut Ctx<'_>, shift: &[f64]) { + for glyph in &mut cx.out.glyphs { + let amount = shift.get(glyph.line).copied().unwrap_or(0.0); + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); + glyph.xf = Affine::translate((0.0, amount)) * glyph.xf; } } - for (line, glyphs, _, _) in placed { - let s = shift.get(line).copied().unwrap_or(0.0); - for mut g in glyphs { - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); +} + +fn append_ruby_glyphs(cx: &mut Ctx<'_>, placed: Vec, shift: &[f64]) { + for ruby in placed { + let amount = shift.get(ruby.line).copied().unwrap_or(0.0); + for mut glyph in ruby.glyphs { + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); } - g.line = line; - cx.out.ruby.push(g); + glyph.line = ruby.line; + cx.out.ruby.push(glyph); } } } diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index a8ffad796..4ce9f03d8 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -1,6 +1,6 @@ use super::flow::flow; use super::region::Region; -use super::*; +use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Shape, Tcy, TextLayout, TextObject, VerticalAlign, is_cjk, punct}; /// System Layout on or Fractional Widths off: each character that takes space advances by a whole /// number of points, at least one, so small type does not collapse onto itself. A zero advance (a diff --git a/crates/three-d/src/caps.rs b/crates/three-d/src/caps.rs index 5cb17a366..ab604589d 100644 --- a/crates/three-d/src/caps.rs +++ b/crates/three-d/src/caps.rs @@ -1,8 +1,8 @@ //! Curved projected rings shared by a cap and its adjoining body faces. -use vectorcraft_geom::{CubicBez, Shape}; +use vectorcraft_geom::{CubicBez, ParamCurve, Shape}; -use super::*; +use super::{BezPath, Face, PathData, Point, Rect, Revolve, V3, Vec2, eye_distance, perspective_scale, project_face}; const CURVE_TOLERANCE: f64 = 0.001; const MAX_RING_CURVES: usize = 32_768; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c9527edaf..c78f2a38b 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,5 @@ use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area}; -use super::*; +use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index be5aec370..15da644a9 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,7 @@ use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; -use super::*; +use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 296b659df..02d258531 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,6 +1,6 @@ use super::build::segment_distance; use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; -use super::*; +use super::{FillRule, PathData, Point, Rect, Vec2}; /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is /// found among the buckets around it rather than among all the edges. diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 57335e4c6..b29493d96 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FillRule, ParamCurve, PathData, Point, Rect}; /// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel /// bands and distance tests. diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs index 27487e4c3..f34a29000 100644 --- a/crates/tools/src/builder/cache.rs +++ b/crates/tools/src/builder/cache.rs @@ -1,6 +1,6 @@ use super::lookup::{drawn_outlines, faces_of, shape_builder_shapes, shapes_for}; use super::map::{BuilderMap, sorted_roots}; -use super::*; +use super::{Arc, Document, NodeId, PathData, Point, Receiver, ToolContext, TryRecvError, po}; /// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids /// and whether filled open paths count as closed. diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 667a6e2e3..aef5bd087 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,7 +1,9 @@ use super::cache::RegionCache; use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at}; use super::map::{HIGHLIGHT_RED, sorted_roots}; -use super::*; +use super::{ + Action, Arc, BezPath, Cursor, Document, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Tool, ToolContext, Value, json, json_ids, +}; // ---------- Live Paint Bucket ---------- diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 298714b50..f3a0e3c19 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform}; /// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 7661f3d72..513f8ceb1 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Vec2}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Cursor { diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 77a96e8b2..d3cb7d3e8 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -487,66 +487,9 @@ impl Tool for DirectSelectionTool { // Anchors and handle ends are picked from a little further than segments and objects. let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (ev.kind, self.state.clone()) { - (PointerKind::Down, _) if self.group => { - if let Some(out) = self.guide.press(cx, ev) { - self.state = State::Idle; - return out; - } - let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else { - self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; - return vec![]; - }; - // A compound path's paths are picked one at a time, below the compound path (#2441). - if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { - h.ancestry.push(part); - h.leaf = part; - } - // Walk up from the leaf: select the first ancestor not yet selected (below the layer). - // Stop at the isolated object: the next group up is outside isolation mode. - let chain: Vec = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect(); - let target = if cx.selection.contains(h.leaf) { - chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) - } else { - h.leaf - }; - let mut deselect = None; - let mut out = vec![]; - if ev.mods.shift { - // Shift on a selected member deselects what was hit (a compound path's part), - // not the next ancestor a plain click would add (#2441). - if cx.selection.contains(h.leaf) { - deselect = Some(h.leaf); - } else { - out.push(Action::Exec("select.toggle".into(), json!({"id": target.0}))); - } - } else if !cx.selection.contains(target) { - if cx.selection.contains(h.leaf) { - out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]}))); - } else { - out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]}))); - } - } - self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect }; - out - } + (PointerKind::Down, _) if self.group => self.press_group(cx, ev, p, tol), (PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(), - (PointerKind::DoubleClick, _) if !self.group => { - self.state = State::Idle; - if let Some(a) = corners::double_click(cx, p, true) { - return vec![a]; - } - if let Some(a) = release_guide_at(cx, p, ev.mods) { - return vec![a]; - } - if crate::rulerguide::guide_at(cx, p).is_some() { - return vec![]; - } - // A double-click edits what is drawn: not a clipping path's outline. - if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { - return vec![Action::SwitchTool("type".into())]; - } - vec![] - } + (PointerKind::DoubleClick, _) if !self.group => self.double_click(cx, ev, p), (PointerKind::Move, State::Idle) => { self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) }; vec![] @@ -588,36 +531,7 @@ impl Tool for DirectSelectionTool { return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) { - if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) { - return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); - } - // Alt picks the whole path (and Alt-drag copies it), as on a segment. - if ev.mods.alt { - self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; - let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); - return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; - } - // A path with all its anchors selected is stored whole (no partial entry). - let already = cx.selection.contains(id) - && !cx.selection.segments.contains_key(&id) - && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); - // Paths all selected whole move as objects, held by this anchor, so Alt can turn - // the copy on and off during the drag as with the Selection tool. - self.state = if already && cx.selection.anchors.is_empty() { - State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } - } else { - State::MoveAnchors { start: p, grab, began: false } - }; - if ev.mods.shift { - return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; - } - // Pressed among other selected anchors: a click selects this one alone (the - // drag, which moves them all, keeps them). - self.click = already.then_some((id, si, ai)); - if !already { - return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]; - } - return vec![]; + return self.press_anchor(cx, ev, p, (id, si, ai, grab)); } if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) { return self.press_type_area(cx, id, anchors, p, ev.mods.shift); @@ -631,79 +545,13 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; return out; } - if let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) { - if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { - h.ancestry.push(part); - h.leaf = part; - } - // A segment pick keeps its endpoints available for movement without selecting - // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks - // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); - if !ev.mods.alt - && matches!(h.kind, HitKind::Stroke | HitKind::Outline) - && let Some(s) = segment_at(cx, h.leaf, p) - { - if whole && !ev.mods.shift { - self.click_segment = Some((h.leaf, s.si, s.seg)); - } else { - let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg))); - self.state = if s.curved && !ev.mods.shift { - State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } - } else { - // Held by the segment itself, so the segment is what lands on a - // ruler guide or lines up, not the pressed spot beside it (#3701). - State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } - }; - if ev.mods.shift { - return vec![Action::Exec( - "select.segments".into(), - json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}), - )]; - } - if selected { - return vec![]; - } - return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))]; - } - } - // Clicking the fill selects the whole leaf path (all anchors). - self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; - if ev.mods.shift { - return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; - } - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { - return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; - } - return vec![]; + if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { + return self.press_object(cx, ev, p, tol, h); } self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; vec![] } - (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => { - let mut out = vec![]; - if !began { - if p.distance(start) < cx.tol(3.0) { - return out; - } - out.push(Action::Begin("Move".into())); - self.state = State::MoveAnchors { start, grab, began: true }; - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - } - let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); - let unsnapped = d; - // The grabbed anchor snaps and the others follow it. Shift keeps the move at its - // angle, and the anchor slides along it onto what Smart Guides find there (#886). - self.guides.clear(); - if let Some(snap) = &self.anchor_snap { - let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); - let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); - (d, self.guides) = (q - grab, guides); - } - self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); - out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); - out - } + (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => self.drag_move_anchors(cx, ev, p, (start, grab, began)), (PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => { let mut out = vec![]; if !began { @@ -720,40 +568,7 @@ impl Tool for DirectSelectionTool { )); out } - (PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => { - let mut out = vec![]; - if !began { - if p.distance(start) < cx.tol(3.0) { - return out; - } - out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); - self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; - if grab.is_some() { - // Alt can go down or up at any time: gather both while nothing has moved. - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); - } else { - self.move_snap = Some(MoveSnap::new(cx)); - } - } - let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); - let unsnapped = d; - self.guides.clear(); - let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; - if let (Some(grab), Some(snap)) = (grab, snap) { - let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); - let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); - (d, self.guides) = (q - grab, guides); - } else if let Some(snap) = &self.move_snap { - (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; - } - self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); - out.push(Action::Preview( - "object.transform".into(), - json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), - )); - out - } + (PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => self.drag_move_object(cx, ev, p, (start, grab, hold, began)), (PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => { let mut out = vec![]; let on_path = cx.doc.node(id).is_some_and(is_path_type); @@ -846,71 +661,13 @@ impl Tool for DirectSelectionTool { | State::SpinePoint { began, .. } | State::SpineSegment { began, .. } | State::TypeArea { began, .. }, - ) => { - let deselect = match self.state { - State::MoveObject { deselect, .. } => deselect, - _ => None, - }; - self.state = State::Idle; - self.guides.clear(); - (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); - match (began, deselect, self.click.take(), self.click_segment.take()) { - (true, ..) => vec![Action::Commit], - (false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))], - (false, None, Some((id, si, ai)), _) => { - vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))] - } - (false, None, None, Some((id, si, seg))) => { - vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))] - } - (false, None, None, None) => vec![], - } - } + ) => self.release_move(began), (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { self.state = State::Idle; self.guides.clear(); vec![Action::Commit] } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Idle; - let r = Rect::from_points(start, p); - if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { - return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; - } - // With Group Selection's enclosed marquee, the tool selects the objects inside it - // (members of groups too), each whole, as a click selects one. - if enclosed { - let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); - let editable = cx.doc.editable(); - touched.retain(|id| editable(*id)); - let ids: Vec = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect(); - let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids })); - return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect(); - } - // Anchors inside the rect, on editable paths inside isolation mode. - let mut sel: Vec<(NodeId, Vec)> = vec![]; - walk_pickable(cx.doc, cx.isolation, |n| { - if let NodeKind::Path { path, .. } = &n.kind { - let v: Vec = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); - if !v.is_empty() { - sel.push((n.id, v)); - } - } - }); - // Objects without anchors to pick (type, images, symbols…) are selected whole when - // the marquee touches them, as with the Selection tool (#927). - for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) { - let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); - if whole && !sel.iter().any(|(s, _)| *s == id) { - sel.push((id, vec![])); - } - } - let editable = cx.doc.editable(); - sel.retain(|(id, _)| editable(*id)); - let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); - let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); - std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect() - } + (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => self.release_marquee(cx, p, (start, toggle, enclosed)), _ => vec![], } } @@ -1077,3 +834,260 @@ impl DirectSelectionTool { #[cfg(test)] mod tests; + +impl DirectSelectionTool { + fn press_group(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64) -> Vec { + if let Some(out) = self.guide.press(cx, ev) { + self.state = State::Idle; + return out; + } + let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else { + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; + return vec![]; + }; + // A compound path's paths are picked one at a time, below the compound path (#2441). + if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { + h.ancestry.push(part); + h.leaf = part; + } + // Walk up from the leaf: select the first ancestor not yet selected (below the layer). + // Stop at the isolated object: the next group up is outside isolation mode. + let chain: Vec = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect(); + let target = + if cx.selection.contains(h.leaf) { chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) } else { h.leaf }; + let mut deselect = None; + let mut out = vec![]; + if ev.mods.shift { + // Shift on a selected member deselects what was hit (a compound path's part), + // not the next ancestor a plain click would add (#2441). + if cx.selection.contains(h.leaf) { + deselect = Some(h.leaf); + } else { + out.push(Action::Exec("select.toggle".into(), json!({"id": target.0}))); + } + } else if !cx.selection.contains(target) { + if cx.selection.contains(h.leaf) { + out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]}))); + } else { + out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]}))); + } + } + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect }; + out + } + fn double_click(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Vec { + self.state = State::Idle; + if let Some(a) = corners::double_click(cx, p, true) { + return vec![a]; + } + if let Some(a) = release_guide_at(cx, p, ev.mods) { + return vec![a]; + } + if crate::rulerguide::guide_at(cx, p).is_some() { + return vec![]; + } + // A double-click edits what is drawn: not a clipping path's outline. + if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { + return vec![Action::SwitchTool("type".into())]; + } + vec![] + } + fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec { + if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) { + return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); + } + // Alt picks the whole path (and Alt-drag copies it), as on a segment. + if ev.mods.alt { + self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; + let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); + return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; + } + // A path with all its anchors selected is stored whole (no partial entry). + let already = + cx.selection.contains(id) && !cx.selection.segments.contains_key(&id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); + // Paths all selected whole move as objects, held by this anchor, so Alt can turn + // the copy on and off during the drag as with the Selection tool. + self.state = if already && cx.selection.anchors.is_empty() { + State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } + } else { + State::MoveAnchors { start: p, grab, began: false } + }; + if ev.mods.shift { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; + } + // Pressed among other selected anchors: a click selects this one alone (the + // drag, which moves them all, keeps them). + self.click = already.then_some((id, si, ai)); + if !already { + return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]; + } + vec![] + } + fn press_object(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64, mut h: vectorcraft_doc::hit::Hit) -> Vec { + if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { + h.ancestry.push(part); + h.leaf = part; + } + // A segment pick keeps its endpoints available for movement without selecting + // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks + // the whole path (and Alt-drag copies it), as with Group Selection. + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + if !ev.mods.alt + && matches!(h.kind, HitKind::Stroke | HitKind::Outline) + && let Some(s) = segment_at(cx, h.leaf, p) + { + if whole && !ev.mods.shift { + self.click_segment = Some((h.leaf, s.si, s.seg)); + } else { + let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg))); + self.state = if s.curved && !ev.mods.shift { + State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } + } else { + // Held by the segment itself, so the segment is what lands on a + // ruler guide or lines up, not the pressed spot beside it (#3701). + State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } + }; + if ev.mods.shift { + return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}))]; + } + if selected { + return vec![]; + } + return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))]; + } + } + // Clicking the fill selects the whole leaf path (all anchors). + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + if ev.mods.shift { + return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; + } + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; + } + vec![] + } + fn drag_move_anchors(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (start, grab, began): (Point, Point, bool)) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Move".into())); + self.state = State::MoveAnchors { start, grab, began: true }; + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + } + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); + let unsnapped = d; + // The grabbed anchor snaps and the others follow it. Shift keeps the move at its + // angle, and the anchor slides along it onto what Smart Guides find there (#886). + self.guides.clear(); + if let Some(snap) = &self.anchor_snap { + let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + (d, self.guides) = (q - grab, guides); + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); + out + } + fn drag_move_object( + &mut self, + cx: &ToolContext, + ev: &PointerEvent, + p: Point, + (start, grab, hold, began): (Point, Option, Point, bool), + ) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); + self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; + if grab.is_some() { + // Alt can go down or up at any time: gather both while nothing has moved. + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + } else { + self.move_snap = Some(MoveSnap::new(cx)); + } + } + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); + let unsnapped = d; + self.guides.clear(); + let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; + if let (Some(grab), Some(snap)) = (grab, snap) { + let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + (d, self.guides) = (q - grab, guides); + } else if let Some(snap) = &self.move_snap { + (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), + )); + out + } + fn release_move(&mut self, began: bool) -> Vec { + let deselect = match self.state { + State::MoveObject { deselect, .. } => deselect, + _ => None, + }; + self.state = State::Idle; + self.guides.clear(); + (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + match (began, deselect, self.click.take(), self.click_segment.take()) { + (true, ..) => vec![Action::Commit], + (false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))], + (false, None, Some((id, si, ai)), _) => { + vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))] + } + (false, None, None, Some((id, si, seg))) => { + vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))] + } + (false, None, None, None) => vec![], + } + } + fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec { + self.state = State::Idle; + let r = Rect::from_points(start, p); + if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { + return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + } + // With Group Selection's enclosed marquee, the tool selects the objects inside it + // (members of groups too), each whole, as a click selects one. + if enclosed { + let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); + let editable = cx.doc.editable(); + touched.retain(|id| editable(*id)); + let ids: Vec = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect(); + let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids })); + return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect(); + } + // Anchors inside the rect, on editable paths inside isolation mode. + let mut sel: Vec<(NodeId, Vec)> = vec![]; + walk_pickable(cx.doc, cx.isolation, |n| { + if let NodeKind::Path { path, .. } = &n.kind { + let v: Vec = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect(); + if !v.is_empty() { + sel.push((n.id, v)); + } + } + }); + // Objects without anchors to pick (type, images, symbols…) are selected whole when + // the marquee touches them, as with the Selection tool (#927). + for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) { + let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + if whole && !sel.iter().any(|(s, _)| *s == id) { + sel.push((id, vec![])); + } + } + let editable = cx.doc.editable(); + sel.retain(|(id, _)| editable(*id)); + let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); + let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); + std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect() + } +} diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 9e563f00c..07df302ff 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Action, Affine, BoxHandle, Cursor, Handle, Homography, Mods, NodeId, NodeKind, Overlay, PerspectiveGrid, Plane, PlaneDrag, Point, PointerEvent, + PointerKind, Press, Rect, Tool, ToolContext, ToolKey, Vec2, cell_steps, drag_handle, handles, json, scale_for_drag, +}; #[derive(Default)] pub struct PerspectiveGridTool { diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 673e9f078..c3401b010 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -1,5 +1,8 @@ use super::targets::{Targets, span}; -use super::*; +use super::{ + Affine, GuideStyle, Handle, HitOptions, Line, NodeId, OrientedBox, Overlay, ParamCurve, ParamCurveDeriv, ParamCurveNearest, PathSeg, Point, Rect, + ScreenFrame, ToolContext, Vec2, constrain_angle_from, hit_test, +}; /// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back /// settles where they meet (none when they don't nearby). diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 4d77ac652..5c5c061e9 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -1,5 +1,8 @@ use super::snap::{Leave, meeting, seg_direction, touch_params}; -use super::*; +use super::{ + Affine, Arc, BezPath, Document, FontDb, GuideStyle, Handle, HashSet, Line, Node, NodeId, NodeKind, OrientedBox, Overlay, ParamCurve, + ParamCurveNearest, PathSeg, Point, Rect, Ruler, Selection, Shape, SubPath, ToolContext, Vec2, layout_text, +}; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum Kind { diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index 804939840..f60ac96b5 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Point, Serialize, Value}; /// Modifier keys. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1ea9723b9..3dab80fa3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -628,51 +628,8 @@ impl Tool for SelectionTool { } // Mirror and grid repeat controls take precedence over art and ruler guides; the mirror // axis line and the grid's controls yield to the bounding-box handles they overlap. - if let State::Mirror(mut d) = self.state.clone() { - match ev.kind { - PointerKind::Drag | PointerKind::Up => { - let mut out = d.drag(cx, ev.pos, ev.mods); - if ev.kind == PointerKind::Up { - out.extend(d.finish()); - self.state = State::Idle; - } else { - self.state = State::Mirror(d); - } - return out; - } - _ => return vec![], - } - } - if let State::GridRepeat(mut d) = self.state.clone() { - match ev.kind { - PointerKind::Drag | PointerKind::Up => { - let mut out = d.drag(ev.pos); - if ev.kind == PointerKind::Up { - out.extend(d.finish()); - self.state = State::Idle; - } else { - self.state = State::GridRepeat(d); - } - return out; - } - _ => return vec![], - } - } - if matches!(self.state, State::Idle) - && ev.kind == PointerKind::Down - && let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods) - { - self.state = State::Mirror(d); - self.hover = None; - return vec![]; - } - if matches!(self.state, State::Idle) - && ev.kind == PointerKind::Down - && let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods) - { - self.state = State::GridRepeat(d); - self.hover = None; - return vec![]; + if let Some(out) = self.repeat_controls(cx, ev) { + return out; } if let Some(out) = self.guide.pointer(cx, ev) { return out; @@ -687,99 +644,10 @@ impl Tool for SelectionTool { self.hover = Self::hover_point(cx, p); } match (ev.kind, self.state.clone()) { - (PointerKind::DoubleClick, _) => { - self.state = State::Idle; - // A threaded port: the thread breaks there. Any other port takes the double-click - // too, so it neither edits the type nor isolates. - if let Some((id, port)) = crate::text::port_at(cx, p) { - let (input, output) = crate::text::port_states(cx.doc, id, false); - let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") }; - if state == crate::text::PortState::Threaded { - return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))]; - } - return vec![]; - } - if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p)) - { - return vec![a]; - } - // The Shaper Group's mode widget answers each click of a double-click on it. - if shaperwidget::over_widget(cx, p) { - return vec![]; - } - if let Some(a) = release_guide_at(cx, p, m) { - return vec![a]; - } - if crate::rulerguide::guide_at(cx, p).is_some() { - return vec![]; - } - // A double-click edits what is drawn: not a clipping path's outline. - if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { - let top = h.top_object(cx.isolation); - if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { - return vec![Action::SwitchTool("type".into())]; - } - // An object that [`isolates`], not the one already isolated. - if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { - return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; - } - } else if cx.isolation.is_some() { - return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; - } - vec![] - } + (PointerKind::DoubleClick, _) => self.double_click(cx, p, m), (PointerKind::Down, _) => { - // 0. A port of selected area type or type on a path: the pointer takes its text, - // to thread on from the out port or back from the in port. - if let Some((from, port)) = crate::text::port_at(cx, p) { - self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; - return vec![]; - } - // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the - // bounding-box handles of the current selection. - if let Some(c) = CornerDrag::hit(cx, ev, false) { - self.state = State::Corner(c); - return vec![]; - } - if let Some(d) = PieDrag::hit(cx, ev) { - self.state = State::Pie(d); - return vec![]; - } - if let Some(b) = BracketDrag::hit(cx, ev) { - self.state = State::Bracket(b); - return vec![]; - } - if let Some(a) = shaperwidget::click(cx, p) { - self.state = State::Idle; - return vec![a]; - } - // The type widget answers a double-click only: its clicks neither drag nor deselect. - if typewidget::over_widget(cx, p) { - self.state = State::Idle; - return vec![]; - } - if cx.show_bbox - && let Some(bx) = selection_box(cx) - { - match box_hit(cx, &bx, p) { - Some(BoxHit::Handle(handle)) => { - let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type); - let areas = cx.selection.objects.iter().any(is_area); - // Only area type: the step resizes type areas; anything else scales. - let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; - // What the handle moves snaps as a dragged point does: to pixels or the - // grid, else with Smart Guides to the other objects, else with Snap to - // Point to their anchors; the ruler guides with Snap to Point. - self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); - self.state = State::Scaling { handle, bx, areas }; - return vec![Action::Begin(label.into())]; - } - Some(BoxHit::Rotate) => { - self.state = State::Rotating { center: bx.center(), start: p }; - return vec![Action::Begin("Rotate".into())]; - } - None => {} - } + if let Some(out) = self.press_widgets(cx, ev, p) { + return out; } // 2. Ruler guides (over the art, as they are drawn). if let Some(out) = self.guide.press(cx, ev) { @@ -824,27 +692,7 @@ impl Tool for SelectionTool { } } } - (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => { - let mut out = vec![]; - if !began { - if p.distance(press) < Self::drag_threshold(cx) { - return out; - } - out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); - self.moving = Some(MoveSnap::new(cx)); - } - let mut d = move_delta(start, p, m.shift, cx.constrain_angle); - let unsnapped = d; - if let Some(snap) = &self.moving { - (d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) }; - } - // Shift has already constrained `d`: any change is a snap. - self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; - self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); - out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); - out - } + (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => self.drag_moving(cx, p, m, (start, press, hold, began)), (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { // Scale in the box's own frame: along the objects' axes when it is rotated. let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); @@ -1122,3 +970,176 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { #[cfg(test)] mod tests; + +impl SelectionTool { + fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + if let State::Mirror(mut d) = self.state.clone() { + match ev.kind { + PointerKind::Drag | PointerKind::Up => { + let mut out = d.drag(cx, ev.pos, ev.mods); + if ev.kind == PointerKind::Up { + out.extend(d.finish()); + self.state = State::Idle; + } else { + self.state = State::Mirror(d); + } + return Some(out); + } + _ => return Some(vec![]), + } + } + if let State::GridRepeat(mut d) = self.state.clone() { + match ev.kind { + PointerKind::Drag | PointerKind::Up => { + let mut out = d.drag(ev.pos); + if ev.kind == PointerKind::Up { + out.extend(d.finish()); + self.state = State::Idle; + } else { + self.state = State::GridRepeat(d); + } + return Some(out); + } + _ => return Some(vec![]), + } + } + if matches!(self.state, State::Idle) + && ev.kind == PointerKind::Down + && let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods) + { + self.state = State::Mirror(d); + self.hover = None; + return Some(vec![]); + } + if matches!(self.state, State::Idle) + && ev.kind == PointerKind::Down + && let Some(d) = crate::repeat_grid::GridDrag::hit(cx, ev.pos, ev.mods) + { + self.state = State::GridRepeat(d); + self.hover = None; + return Some(vec![]); + } + None + } + + fn double_click(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Vec { + self.state = State::Idle; + // A threaded port: the thread breaks there. Any other port takes the double-click + // too, so it neither edits the type nor isolates. + if let Some((id, port)) = crate::text::port_at(cx, p) { + let (input, output) = crate::text::port_states(cx.doc, id, false); + let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") }; + if state == crate::text::PortState::Threaded { + return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))]; + } + return vec![]; + } + if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p)) { + return vec![a]; + } + // The Shaper Group's mode widget answers each click of a double-click on it. + if shaperwidget::over_widget(cx, p) { + return vec![]; + } + if let Some(a) = release_guide_at(cx, p, m) { + return vec![a]; + } + if crate::rulerguide::guide_at(cx, p).is_some() { + return vec![]; + } + // A double-click edits what is drawn: not a clipping path's outline. + if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + let top = h.top_object(cx.isolation); + if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { + return vec![Action::SwitchTool("type".into())]; + } + // An object that [`isolates`], not the one already isolated. + if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } + } else if cx.isolation.is_some() { + return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; + } + vec![] + } + + fn press_widgets(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option> { + // 0. A port of selected area type or type on a path: the pointer takes its text, + // to thread on from the out port or back from the in port. + if let Some((from, port)) = crate::text::port_at(cx, p) { + self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; + return Some(vec![]); + } + // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the + // bounding-box handles of the current selection. + if let Some(c) = CornerDrag::hit(cx, ev, false) { + self.state = State::Corner(c); + return Some(vec![]); + } + if let Some(d) = PieDrag::hit(cx, ev) { + self.state = State::Pie(d); + return Some(vec![]); + } + if let Some(b) = BracketDrag::hit(cx, ev) { + self.state = State::Bracket(b); + return Some(vec![]); + } + if let Some(a) = shaperwidget::click(cx, p) { + self.state = State::Idle; + return Some(vec![a]); + } + // The type widget answers a double-click only: its clicks neither drag nor deselect. + if typewidget::over_widget(cx, p) { + self.state = State::Idle; + return Some(vec![]); + } + if cx.show_bbox + && let Some(bx) = selection_box(cx) + { + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(handle)) => { + let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type); + let areas = cx.selection.objects.iter().any(is_area); + // Only area type: the step resizes type areas; anything else scales. + let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; + // What the handle moves snaps as a dragged point does: to pixels or the + // grid, else with Smart Guides to the other objects, else with Snap to + // Point to their anchors; the ruler guides with Snap to Point. + self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); + self.state = State::Scaling { handle, bx, areas }; + return Some(vec![Action::Begin(label.into())]); + } + Some(BoxHit::Rotate) => { + self.state = State::Rotating { center: bx.center(), start: p }; + return Some(vec![Action::Begin("Rotate".into())]); + } + None => {} + } + } + None + } +} + +impl SelectionTool { + fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec { + let mut out = vec![]; + if !began { + if p.distance(press) < Self::drag_threshold(cx) { + return out; + } + out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); + self.moving = Some(MoveSnap::new(cx)); + } + let mut d = move_delta(start, p, m.shift, cx.constrain_angle); + let unsnapped = d; + if let Some(snap) = &self.moving { + (d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) }; + } + // Shift has already constrained `d`: any change is a snap. + self.snapped = (d - unsnapped).hypot() > 1e-9; + self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; + self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); + out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); + out + } +} diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs index 1b058a4b6..32db456c3 100644 --- a/crates/tools/src/text/plain.rs +++ b/crates/tools/src/text/plain.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ImeSurrounding, Range, ToolKey}; /// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). /// `None` when `r` runs past the end of `s` or backwards. diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs index ace85694c..d9c027fbc 100644 --- a/crates/tools/src/text/ports.rs +++ b/crates/tools/src/text/ports.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FontDb, NodeId, NodeKind, Overlay, Point, Rect, Shape, TextKind, TextObject, ToolContext}; /// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the /// out port above the bottom-right corner on the right side, the in port below the top-left corner diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index f5a454d09..53746bbf5 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -107,6 +107,37 @@ fn type_arrows_keep_devanagari_clusters_with_an_rtl_suffix() { } } +fn assert_unwrapped_hindi_glyphs( + db: &FontDb, + face: &vectorcraft_text::FontFace, + hindi_glyphs: &[&vectorcraft_text::PositionedGlyph], + hindi: &str, + text: &str, +) { + for glyph in hindi_glyphs { + let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face"); + assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes"); + assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index"); + assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present"); + assert!(!glyph.rtl, "setup: Hindi prefix is LTR"); + assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range"); + assert!( + text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len), + "setup: glyph source ranges are UTF-8 boundaries" + ); + } +} + +fn assert_wrapped_hindi_glyphs(db: &FontDb, face: &vectorcraft_text::FontFace, lay: &vectorcraft_text::TextLayout, hindi: &str) { + for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) { + let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face"); + assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes"); + assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index"); + assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present"); + assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR"); + } +} + #[test] fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { use vectorcraft_doc::{Composer, Justify}; @@ -146,18 +177,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { assert!(!natural.vertical, "setup: horizontal text"); let hindi_glyphs: Vec<_> = natural.glyphs.iter().filter(|g| g.byte < hindi.len()).collect(); assert!(!hindi_glyphs.is_empty(), "setup: actual Hindi glyphs exist"); - for glyph in &hindi_glyphs { - let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face"); - assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes"); - assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index"); - assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present"); - assert!(!glyph.rtl, "setup: Hindi prefix is LTR"); - assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range"); - assert!( - text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len), - "setup: glyph source ranges are UTF-8 boundaries" - ); - } + assert_unwrapped_hindi_glyphs(db, &face, &hindi_glyphs, hindi, text); let prefix_width: f64 = hindi_glyphs.iter().filter(|g| g.byte < split).map(|g| g.advance).sum(); let split_width: f64 = hindi_glyphs.iter().filter(|g| g.byte == split).map(|g| g.advance).sum(); assert!(prefix_width.is_finite() && prefix_width > 0.0, "setup: prefix has positive finite advance"); @@ -179,13 +199,7 @@ fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() { assert!(!lay.overflow, "setup: the full RTL suffix fits the tall frame"); assert!(!lay.vertical, "setup: horizontal area text"); assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "setup: actual RTL suffix selects Type's visual-arrow route"); - for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) { - let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face"); - assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes"); - assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index"); - assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present"); - assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR"); - } + assert_wrapped_hindi_glyphs(db, &face, &lay, hindi); assert_eq!(edit::next_cluster(text, second), hindi.len(), "setup: logical cluster end is 18"); assert_eq!(edit::prev_cluster(text, hindi.len()), second, "setup: logical cluster start is 6"); assert_eq!(edit::prev_cluster(text, split), second, "setup: raw line endpoint 12 is inside the grapheme"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 55f114a3d..daf754213 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -3,7 +3,10 @@ use super::plain::{ plain_len, step_key, }; use super::ports::{ported, ports, ports_shown}; -use super::*; +use super::{ + Action, BezPath, Cursor, ImeSurrounding, Mode, Mods, NodeId, Overlay, Point, PointerEvent, PointerKind, Preedit, Range, Rect, Shape, + SurroundingUnit, TextKind, TextRun, Tool, ToolContext, ToolKey, TypeTool, Value, edit, json, +}; impl Tool for TypeTool { fn id(&self) -> &'static str { diff --git a/crates/trace/src/centerline/graph.rs b/crates/trace/src/centerline/graph.rs index 81f37d9d5..a358918cd 100644 --- a/crates/trace/src/centerline/graph.rs +++ b/crates/trace/src/centerline/graph.rs @@ -68,18 +68,26 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi { return None; } + let degree: Vec = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect(); + let (node_of, mut graph) = build_nodes(g, dist, °ree, max_nodes)?; + let mut visited = vec![false; n]; + add_node_branches(g, dist, &node_of, &mut graph, &mut visited, max_branches)?; + add_ring_branches(g, dist, °ree, &node_of, &mut graph, &mut visited, max_branches)?; + Some(graph) +} + +fn build_nodes(g: &Grid, dist: &[f32], degree: &[u8], max_nodes: usize) -> Option<(Vec, Graph)> { let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); - let deg: Vec = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect(); - let mut node_of = vec![usize::MAX; n]; + let mut node_of = vec![usize::MAX; g.on.len()]; let mut graph = Graph::default(); // Ends on their own; touching junction pixels together, deepest pixel first on ties. for i in g.interior() { - let degree = *deg.get(i)?; - if g.on.get(i) != Some(&true) || degree == 2 || *node_of.get(i)? != usize::MAX { + let pixel_degree = *degree.get(i)?; + if g.on.get(i) != Some(&true) || pixel_degree == 2 || *node_of.get(i)? != usize::MAX { continue; } // Isolated pixels cannot form a stroke; leave them to the existing fill path. - if degree == 0 { + if pixel_degree == 0 { continue; } if graph.nodes.len() >= max_nodes.min(MAX_NODES) { @@ -88,40 +96,45 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi let id = graph.nodes.len(); *node_of.get_mut(i)? = id; let mut members = vec![i]; - if degree > 2 { - let mut k = 0; - while let Some(&p) = members.get(k) { - k += 1; - for q in g.neighbours(p) { - if *node_of.get(q)? == usize::MAX && *deg.get(q)? > 2 { - *node_of.get_mut(q)? = id; - members.push(q); + if pixel_degree > 2 { + let mut cursor = 0; + while let Some(&pixel) = members.get(cursor) { + cursor += 1; + for neighbour in g.neighbours(pixel) { + if *node_of.get(neighbour)? == usize::MAX && *degree.get(neighbour)? > 2 { + *node_of.get_mut(neighbour)? = id; + members.push(neighbour); } } } } let best = members.into_iter().max_by(|&a, &b| dval(a).total_cmp(&dval(b)).then(b.cmp(&a)))?; - graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: degree > 2 }); + graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: pixel_degree > 2 }); } + Some((node_of, graph)) +} + +fn add_node_branches(g: &Grid, dist: &[f32], node_of: &[usize], graph: &mut Graph, visited: &mut [bool], max_branches: usize) -> Option<()> { + let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); let mut seen_edge = BTreeSet::new(); - let mut visited = vec![false; n]; - for p in g.interior() { - let a = *node_of.get(p)?; - if a == usize::MAX { + let n = visited.len(); + for pixel in g.interior() { + let node = *node_of.get(pixel)?; + if node == usize::MAX { continue; } - for q in g.neighbours(p) { - let b = *node_of.get(q)?; - if b == a { + for neighbour in g.neighbours(pixel) { + let next_node = *node_of.get(neighbour)?; + if next_node == node { continue; } - if b != usize::MAX { - if seen_edge.insert((p.min(q), p.max(q))) { + if next_node != usize::MAX { + if seen_edge.insert((pixel.min(neighbour), pixel.max(neighbour))) { graph.push_branch( Branch { - pts: vec![graph.nodes.get(a)?.pos, graph.nodes.get(b)?.pos], - d: vec![dval(p), dval(q)], - ends: [Some(a), Some(b)], + pts: vec![graph.nodes.get(node)?.pos, graph.nodes.get(next_node)?.pos], + d: vec![dval(pixel), dval(neighbour)], + ends: [Some(node), Some(next_node)], closed: false, }, max_branches, @@ -129,66 +142,80 @@ fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usi } continue; } - if *visited.get(q)? { + if *visited.get(neighbour)? { continue; } - let mut px = vec![p]; - let (mut prev, mut cur) = (p, q); + let mut pixels = vec![pixel]; + let (mut previous, mut current) = (pixel, neighbour); let mut guard = n; - while *node_of.get(cur)? == usize::MAX && guard > 0 { + while *node_of.get(current)? == usize::MAX && guard > 0 { guard -= 1; - *visited.get_mut(cur)? = true; - px.push(cur); - let Some(next) = g.neighbours(cur).find(|&m| m != prev && (visited.get(m) == Some(&false) || node_of.get(m) != Some(&usize::MAX))) - else { + *visited.get_mut(current)? = true; + pixels.push(current); + let Some(next) = g.neighbours(current).find(|&candidate| { + candidate != previous && (visited.get(candidate) == Some(&false) || node_of.get(candidate) != Some(&usize::MAX)) + }) else { break; }; - (prev, cur) = (cur, next); + (previous, current) = (current, next); } if guard == 0 { return None; } - let end = (*node_of.get(cur)? != usize::MAX).then_some(*node_of.get(cur)?); + let end = (*node_of.get(current)? != usize::MAX).then_some(*node_of.get(current)?); if end.is_some() { - px.push(cur); + pixels.push(current); } - let mut pts: Vec = px.iter().map(|&i| g.centre(i)).collect(); - let d = px.into_iter().map(dval).collect(); - *pts.first_mut()? = graph.nodes.get(a)?.pos; - if let Some(e) = end { - *pts.last_mut()? = graph.nodes.get(e)?.pos; + let mut points: Vec = pixels.iter().map(|&i| g.centre(i)).collect(); + let distances = pixels.into_iter().map(dval).collect(); + *points.first_mut()? = graph.nodes.get(node)?.pos; + if let Some(end) = end { + *points.last_mut()? = graph.nodes.get(end)?.pos; } - graph.push_branch(Branch { pts, d, ends: [Some(a), end], closed: false }, max_branches)?; + graph.push_branch(Branch { pts: points, d: distances, ends: [Some(node), end], closed: false }, max_branches)?; } } - // Rings never reached from a node. - for s in g.interior() { - if g.on.get(s) != Some(&true) || deg.get(s) != Some(&2) || *visited.get(s)? || *node_of.get(s)? != usize::MAX { + Some(()) +} + +fn add_ring_branches( + g: &Grid, + dist: &[f32], + degree: &[u8], + node_of: &[usize], + graph: &mut Graph, + visited: &mut [bool], + max_branches: usize, +) -> Option<()> { + let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt(); + let n = visited.len(); + for start in g.interior() { + if g.on.get(start) != Some(&true) || degree.get(start) != Some(&2) || *visited.get(start)? || *node_of.get(start)? != usize::MAX { continue; } - let mut px = vec![]; - let (mut prev, mut cur) = (usize::MAX, s); + let mut pixels = vec![]; + let (mut previous, mut current) = (usize::MAX, start); let mut closed = false; let mut guard = n; while guard > 0 { guard -= 1; - *visited.get_mut(cur)? = true; - px.push(cur); - let Some(next) = g.neighbours(cur).find(|&m| m != prev && visited.get(m) == Some(&false)) else { - closed = g.neighbours(cur).any(|m| m == s) && px.len() > 2; + *visited.get_mut(current)? = true; + pixels.push(current); + let Some(next) = g.neighbours(current).find(|&candidate| candidate != previous && visited.get(candidate) == Some(&false)) else { + closed = g.neighbours(current).any(|neighbour| neighbour == start) && pixels.len() > 2; break; }; - (prev, cur) = (cur, next); + (previous, current) = (current, next); } if guard == 0 { return None; } graph.push_branch( - Branch { pts: px.iter().map(|&i| g.centre(i)).collect(), d: px.into_iter().map(dval).collect(), ends: [None, None], closed }, + Branch { pts: pixels.iter().map(|&i| g.centre(i)).collect(), d: pixels.into_iter().map(dval).collect(), ends: [None, None], closed }, max_branches, )?; } - Some(graph) + Some(()) } impl Graph { diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index bf5bfc402..3c9467d76 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -3,7 +3,12 @@ use super::params::{Method, Mode, TraceParams}; use super::pipeline::bw_tones; use super::pipeline::{TracedPath, is_ignored}; use super::raster::Raster; -use super::*; +use super::{ + AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, + trace_mask, +}; +#[cfg(test)] +use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 5503a84ff..6ce6ce241 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize, logo}; /// Tracing mode (Illustrator's Mode popup). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 6d3ea27f5..b857e73ae 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -1,7 +1,7 @@ use super::layers::{Layers, parallel_map_at_most}; use super::params::{Method, Mode, TraceParams}; use super::raster::{Raster, TraceError}; -use super::*; +use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, gradient, group, logo, quantize, smooth}; /// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) /// lines stroked with it. diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 2e47e64ca..5f0a30568 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::*; +use super::{Quantized, Raster, TRANSPARENT, luma, oklab}; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// diff --git a/crates/trace/src/quantize/denoise.rs b/crates/trace/src/quantize/denoise.rs index 4ca5c29e9..cb40ffa4f 100644 --- a/crates/trace/src/quantize/denoise.rs +++ b/crates/trace/src/quantize/denoise.rs @@ -1,5 +1,5 @@ +use super::TRANSPARENT; use super::rare::RARE_ISLAND; -use super::*; /// Merge whole 4-connected regions, smallest first, into their most common neighbouring label. /// The merge order does not depend on `min_area`: a higher threshold continues the same sequence diff --git a/crates/trace/src/quantize/palette.rs b/crates/trace/src/quantize/palette.rs index 43363b286..8f89a7c96 100644 --- a/crates/trace/src/quantize/palette.rs +++ b/crates/trace/src/quantize/palette.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Palette, Raster, TraceParams, oklab}; #[derive(Clone, Copy, Default)] pub(super) struct Bin { diff --git a/crates/trace/src/quantize/rare.rs b/crates/trace/src/quantize/rare.rs index 69069e48b..d835c5759 100644 --- a/crates/trace/src/quantize/rare.rs +++ b/crates/trace/src/quantize/rare.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, TracedAs, Used, labs, nearest_lab}; -use super::*; +use super::{Raster, oklab}; /// Automatic: the most rare colours added to a flat picture's count. const RARE_BUDGET: usize = 6; @@ -50,287 +50,390 @@ const CUT_EDGE_MIX_FROM: f64 = 0.45; /// salient colours that are rare by area follows the idea of salient-colour preservation in /// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are /// VectorCraft's own. +type ChosenRare = (usize, f64, [f64; 3], usize); + +struct RareGroups { + used: Vec<(usize, [f64; 3], f64)>, + groups: Vec>, + group_of: Vec, +} + +struct RasterGroups<'a> { + width: usize, + height: usize, + pixels: &'a [[u8; 4]], + own: &'a [bool], + choose: &'a [bool], + hk: &'a HistKey, + group_of: &'a [u16], +} + +struct RegionMetrics { + largest: Vec, + islands: Vec, + inner: Vec, + beside_left_out: Vec, +} + +struct BlendContext { + from: Vec, + with: Vec<[f64; 3]>, + pairs: Vec<(usize, usize)>, + near_labs: Vec<[f64; 3]>, + counts_as: Vec>, +} + pub(super) fn rare_colours(img: &Raster, own: &[bool], choose: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> (Vec<[f64; 3]>, Vec) { - let px = img.rgba.as_chunks::<4>().0; - if centers.is_empty() || own.len() != px.len() { + let pixels = img.rgba.as_chunks::<4>().0; + if centers.is_empty() || own.len() != pixels.len() { return (vec![], vec![]); } + let center_labs = labs(centers); + let groups = rare_groups(pixels, own, hk, ¢er_labs); + if groups.groups.is_empty() { + return (vec![], vec![]); + } + let (width, height) = (img.width as usize, img.height as usize); + let raster = RasterGroups { width, height, pixels, own, choose, hk, group_of: &groups.group_of }; + let metrics = region_metrics(&raster, groups.groups.len()); + let chosen = choose_rare_groups(&groups.groups, &groups.used, centers, &metrics); + if chosen.is_empty() { + return (vec![], vec![]); + } + let (onto, other_of) = trace_edge_blends(&raster, &chosen, centers, &metrics); + reindex_traced(chosen, onto, other_of, centers) +} + +fn rare_groups(pixels: &[[u8; 4]], own: &[bool], hk: &HistKey, center_labs: &[[f64; 3]]) -> RareGroups { // The picture's own (not blended) pixels by histogram bin. let mut bins = vec![Bin::default(); 1 << 15]; - for (p, _) in px.iter().zip(own).filter(|(p, f)| **f && p[3] >= 128) { - bins[hk.key(p[0], p[1], p[2])].add(p); + for (pixel, _) in pixels.iter().zip(own).filter(|(pixel, own)| **own && pixel[3] >= 128) { + bins[hk.key(pixel[0], pixel[1], pixel[2])].add(pixel); } - let used: Vec<(usize, [f64; 3], f64)> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); - let used_labs = labs(&used.iter().map(|u| u.1).collect::>()); - let center_labs = labs(centers); + let used: Vec<(usize, [f64; 3], f64)> = + bins.iter().enumerate().filter(|(_, bin)| bin.n > 0).map(|(i, bin)| (i, bin.mean(), bin.n as f64)).collect(); + let used_labs = labs(&used.iter().map(|entry| entry.1).collect::>()); // Each palette colour's main colour: its heaviest bin. - let mut peak: Vec> = vec![None; centers.len()]; - for ((_, _, n), lab) in used.iter().zip(&used_labs) { - let j = nearest_lab(¢er_labs, *lab); - if let Some(slot) = peak.get_mut(j) - && slot.is_none_or(|(m, _)| *n > m) + let mut peak: Vec> = vec![None; center_labs.len()]; + for ((_, _, count), lab) in used.iter().zip(&used_labs) { + let index = nearest_lab(center_labs, *lab); + if let Some(slot) = peak.get_mut(index) + && slot.is_none_or(|(largest, _)| *count > largest) { - *slot = Some((*n, *lab)); + *slot = Some((*count, *lab)); } } - let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt(); - let apart = |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, l)| l)).all(|c| dist(lab, *c) >= RARE_APART); - // The bins far from every colour, heaviest first, grouped: a bin joins the first group whose - // heaviest bin is nearer than RARE_APART. - let mut far: Vec = (0..used.len()).filter(|&u| used_labs.get(u).is_some_and(|l| apart(*l))).collect(); + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let far_from_palette = + |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, lab)| lab)).all(|center| distance(lab, *center) >= RARE_APART); + // Group far bins by their heaviest member, in descending weight order. + let mut far: Vec = (0..used.len()).filter(|&index| used_labs.get(index).is_some_and(|lab| far_from_palette(*lab))).collect(); far.sort_by(|&a, &b| used[b].2.total_cmp(&used[a].2).then(a.cmp(&b))); let mut group_of = vec![u16::MAX; 1 << 15]; let mut groups: Vec> = vec![]; - for u in far { - let (Some(lab), Some(&(bin, ..))) = (used_labs.get(u), used.get(u)) else { continue }; - let at = groups.iter().position(|g| g.first().and_then(|&f| used_labs.get(f)).is_some_and(|l| dist(*l, *lab) < RARE_APART)); - let g = match at { - Some(g) => g, + for index in far { + let (Some(lab), Some(&(bin, ..))) = (used_labs.get(index), used.get(index)) else { continue }; + let group = groups + .iter() + .position(|members| members.first().and_then(|&first| used_labs.get(first)).is_some_and(|first| distance(*first, *lab) < RARE_APART)); + let group = match group { + Some(group) => group, None if groups.len() < 64 => { groups.push(vec![]); groups.len() - 1 } None => continue, }; - if let Some(members) = groups.get_mut(g) { - members.push(u); + if let Some(members) = groups.get_mut(group) { + members.push(index); } - group_of[bin] = g as u16; + group_of[bin] = group as u16; } - if groups.is_empty() { - return (vec![], vec![]); - } - // The largest 4-connected region of each group's own pixels. - let (w, h) = (img.width as usize, img.height as usize); - let group_at = |i: usize| -> Option { - let p = px.get(i)?; - (own.get(i).copied().unwrap_or(false) && p[3] >= 128).then(|| group_of[hk.key(p[0], p[1], p[2])]).filter(|&g| g != u16::MAX) + RareGroups { used, groups, group_of } +} + +fn group_at(pixels: &[[u8; 4]], own: &[bool], hk: &HistKey, group_of: &[u16], index: usize) -> Option { + let pixel = pixels.get(index)?; + (own.get(index).copied().unwrap_or(false) && pixel[3] >= 128) + .then(|| group_of.get(hk.key(pixel[0], pixel[1], pixel[2])).copied().unwrap_or(u16::MAX)) + .filter(|&group| group != u16::MAX) +} + +fn region_metrics(raster: &RasterGroups<'_>, group_count: usize) -> RegionMetrics { + let RasterGroups { width, height, pixels, own, choose, hk, group_of } = *raster; + let mut metrics = RegionMetrics { + largest: vec![0; group_count], + islands: vec![0; group_count], + inner: vec![0; group_count], + beside_left_out: vec![false; group_count], }; - let mut largest = vec![0usize; groups.len()]; - let mut islands = vec![0usize; groups.len()]; - // Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel - // wide and has next to none. - let mut inner = vec![0usize; groups.len()]; - // Groups with a pixel beside a pixel that `choose` leaves out (see `choosing_pixels`): the blend - // test of `own` saw no neighbor on that side. - let mut beside_left_out = vec![false; groups.len()]; - let left_out = |j: usize| px.get(j).is_some_and(|p| p[3] >= 128) && !choose.get(j).copied().unwrap_or(true); - let mut seen = vec![false; w * h]; + let left_out = |index: usize| pixels.get(index).is_some_and(|pixel| pixel[3] >= 128) && !choose.get(index).copied().unwrap_or(true); + let mut seen = vec![false; width * height]; let mut stack = vec![]; - for start in 0..w * h { - let Some(g) = group_at(start).filter(|_| !seen[start]) else { continue }; + for start in 0..width * height { + let Some(group) = group_at(pixels, own, hk, group_of, start).filter(|_| !seen[start]) else { continue }; seen[start] = true; stack.push(start); let mut size = 0; - while let Some(i) = stack.pop() { + while let Some(index) = stack.pop() { size += 1; - let (x, y) = (i % w, i / w); + let (x, y) = (index % width, index / width); let mut alike = 0; - for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() { - if group_at(j) == Some(g) { + for neighbour in [ + (x > 0).then(|| index - 1), + (x + 1 < width).then(|| index + 1), + (y > 0).then(|| index - width), + (y + 1 < height).then(|| index + width), + ] + .into_iter() + .flatten() + { + if group_at(pixels, own, hk, group_of, neighbour) == Some(group) { alike += 1; - if !seen[j] { - seen[j] = true; - stack.push(j); + if !seen[neighbour] { + seen[neighbour] = true; + stack.push(neighbour); } } } if alike == 4 - && let Some(n) = inner.get_mut(g as usize) + && let Some(count) = metrics.inner.get_mut(group as usize) { - *n += 1; + *count += 1; } - if let Some(b) = beside_left_out.get_mut(g as usize).filter(|b| !**b) { - let (x0, y0, x1, y1) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(w - 1), (y + 1).min(h - 1)); - *b = (y0..=y1).any(|ny| (x0..=x1).any(|nx| left_out(ny * w + nx))); + if let Some(beside) = metrics.beside_left_out.get_mut(group as usize).filter(|beside| !**beside) { + let (left, top, right, bottom) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(width - 1), (y + 1).min(height - 1)); + *beside = (top..=bottom).any(|near_y| (left..=right).any(|near_x| left_out(near_y * width + near_x))); } } - if let Some(l) = largest.get_mut(g as usize) { - *l = (*l).max(size); + if let Some(largest) = metrics.largest.get_mut(group as usize) { + *largest = (*largest).max(size); } - if let Some(n) = islands.get_mut(g as usize).filter(|_| size >= RARE_ISLAND) { - *n += 1; + if let Some(islands) = metrics.islands.get_mut(group as usize).filter(|_| size >= RARE_ISLAND) { + *islands += 1; } } - // Coherent and flat groups, largest region first. - let mut chosen: Vec<(usize, f64, [f64; 3], usize)> = vec![]; - for (g, members) in groups.iter().enumerate() { - let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum(); - let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2); - let region = largest.get(g).copied().unwrap_or(0); - let coherent = region >= RARE_REGION || islands.get(g).is_some_and(|&n| n >= RARE_ISLANDS); + metrics +} + +fn choose_rare_groups(groups: &[Vec], used: &[(usize, [f64; 3], f64)], centers: &[[f64; 3]], metrics: &RegionMetrics) -> Vec { + let mut chosen = Vec::new(); + for (group, members) in groups.iter().enumerate() { + let weight: f64 = members.iter().filter_map(|&index| used.get(index)).map(|entry| entry.2).sum(); + let heaviest = members.first().and_then(|&index| used.get(index)).map_or(0.0, |entry| entry.2); + let region = metrics.largest.get(group).copied().unwrap_or(0); + let coherent = region >= RARE_REGION || metrics.islands.get(group).is_some_and(|&count| count >= RARE_ISLANDS); if !coherent || heaviest < 0.5 * weight || weight <= 0.0 { continue; } let mut mean = [0.0; 3]; - for u in members.iter().filter_map(|&u| used.get(u)) { - for (m, c) in mean.iter_mut().zip(u.1) { - *m += c * u.2 / weight; + for entry in members.iter().filter_map(|&index| used.get(index)) { + for (mean_channel, channel) in mean.iter_mut().zip(entry.1) { + *mean_channel += channel * entry.2 / weight; } } - // Not an edge's blend of two colours that the blend test let through (a thin ring of - // anti-aliasing between two bands is coherent too): a colour that lies between two others - // is kept only with an inside, pixels whose neighbours are all of it. - if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) { + // A color lying between two others needs a solid inside, not just a thin blended rim. + if is_mix(mean, centers) && metrics.inner.get(group).is_none_or(|&count| count < RARE_ISLAND) { continue; } - chosen.push((region, weight, mean, g)); + chosen.push((region, weight, mean, group)); } chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))); - // Beside the pixels that choose no color, the blend test of `own` does not see the - // anti-aliasing between a palette color and a rare color drawn along the cut edge. There, a - // color without an inside that lies between two colors, one of them another kept rare color, - // and at least CUT_EDGE_MIX_FROM of the way from the other to it, is that anti-aliasing when at - // least one in CUT_EDGE_MIX_TOUCH of its pixels has pixels of both colors around it: it is not - // kept, and it traces as that rare color (the nearer one when there are several). Art that - // reaches a rim drawn along the edge touches it in a few places only, and the rim keeps its - // color. The smallest regions are tested first, so that of two such colors, one stays, and a - // color found to trace as another counts as that color. - let tested = |g: usize| beside_left_out.get(g).copied().unwrap_or(false) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND); - // The pixels of each color tested, gathered in one pass over the image. - let mut pixels_of: Vec> = vec![vec![]; groups.len()]; - let wanted: Vec = (0..groups.len()).map(|g| tested(g) && chosen.iter().any(|c| c.3 == g)).collect(); + chosen +} + +fn trace_edge_blends( + raster: &RasterGroups<'_>, + chosen: &[ChosenRare], + centers: &[[f64; 3]], + metrics: &RegionMetrics, +) -> (Vec>, Vec>) { + let RasterGroups { width, height, pixels, own, choose, hk, group_of } = *raster; + let group_count = metrics.largest.len(); + let tested = |group: usize| { + metrics.beside_left_out.get(group).copied().unwrap_or(false) && metrics.inner.get(group).is_none_or(|&count| count < RARE_ISLAND) + }; + let wanted: Vec = (0..group_count).map(|group| tested(group) && chosen.iter().any(|candidate| candidate.3 == group)).collect(); + let mut pixels_of = vec![vec![]; group_count]; if wanted.contains(&true) { - for i in 0..w * h { - if let Some(g) = group_at(i).map(usize::from).filter(|&g| wanted.get(g).copied().unwrap_or(false)) - && let Some(p) = pixels_of.get_mut(g) + for index in 0..width * height { + if let Some(group) = + group_at(pixels, own, hk, group_of, index).map(usize::from).filter(|&group| wanted.get(group).copied().unwrap_or(false)) + && let Some(pixels) = pixels_of.get_mut(group) { - p.push(i); + pixels.push(index); } } } - let mut onto: Vec> = vec![None; chosen.len()]; - // For each color in `onto`, where the color on the other side of it comes from (see `from` - // below), or `None` when the color traces as the rare color alone. - let mut other_of: Vec> = vec![None; chosen.len()]; - for k in (0..chosen.len()).rev() { - let Some(&(_, _, c, g)) = chosen.get(k) else { continue }; - if !tested(g) { + let mut onto = vec![None; chosen.len()]; + let mut other_of = vec![None; chosen.len()]; + for index in (0..chosen.len()).rev() { + let Some(&(_, _, color, group)) = chosen.get(index) else { continue }; + if !tested(group) { continue; } - // The palette colors, then the other rare colors still kept, and where each comes from: a - // palette color's index, or the number of palette colors plus the index in `chosen`. - let from: Vec = (0..centers.len()) - .chain(chosen.iter().zip(&onto).enumerate().filter(|(o, (_, t))| *o != k && t.is_none()).map(|(o, _)| centers.len() + o)) - .collect(); - let with: Vec<[f64; 3]> = centers - .iter() - .copied() - .chain(from.iter().skip(centers.len()).filter_map(|&f| chosen.get(f.checked_sub(centers.len())?)).map(|x| x.2)) - .collect(); - let pairs: Vec<(usize, usize)> = (0..with.len()) - .flat_map(|a| (a + 1..with.len()).map(move |b| (a, b))) - .filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&x), Some(&y)) if is_mix(c, &[x, y]))) - .collect(); - if pairs.is_empty() { - continue; + let Some(context) = blend_context(index, color, chosen, &onto, centers) else { continue }; + let Some(group_pixels) = pixels_of.get(group) else { continue }; + let (both, pixel_count) = blend_neighbour_counts(group_pixels, width, height, pixels, choose, &context); + let best = best_blend_target(color, chosen, centers, metrics, &context, &both, pixel_count); + if let Some(target) = onto.get_mut(index) { + *target = best.map(|candidate| candidate.1); } - // Which of `with` the pixels in the 3 × 3 around the color's own pixels are: the nearest - // among `with`, the color itself and the colors that trace as one of `with`. A pixel that - // chooses no color counts only within RARE_APART of the one it is nearest to (beyond the - // cut edge, an outline over the edge shows its own color). - let mut near = with.clone(); - near.push(c); - let mut counts_as: Vec> = (0..with.len()).map(Some).chain([None]).collect(); - for (x, t) in chosen.iter().zip(&onto) { - if let Some(t) = t { - near.push(x.2); - counts_as.push(with.iter().position(|w| w == t)); - } + if let Some(other) = other_of.get_mut(index) { + *other = best.and_then(|candidate| candidate.2); } - let near_labs = labs(&near); - // For each pair, how many of the color's own pixels have pixels of both around them. - let mut both = vec![0usize; pairs.len()]; - let mut pixels = 0usize; - let mut around = vec![false; with.len()]; - for &i in pixels_of.get(g).map_or(&[][..], Vec::as_slice) { - let (x, y) = (i % w, i / w); - pixels += 1; - around.fill(false); - for ny in y.saturating_sub(1)..=(y + 1).min(h - 1) { - for nx in x.saturating_sub(1)..=(x + 1).min(w - 1) { - let j = ny * w + nx; - let Some(p) = px.get(j).filter(|p| p[3] >= 128) else { continue }; - let o = oklab([p[0], p[1], p[2]].map(f64::from)); - let n = nearest_lab(&near_labs, o); - if !choose.get(j).copied().unwrap_or(false) && near_labs.get(n).is_none_or(|l| dist(*l, o) >= RARE_APART) { - continue; - } - if let Some(&Some(a)) = counts_as.get(n) - && let Some(t) = around.get_mut(a) - { - *t = true; - } - } - } - for (n, &(a, b)) in pairs.iter().enumerate() { - if around.get(a).copied().unwrap_or(false) - && around.get(b).copied().unwrap_or(false) - && let Some(v) = both.get_mut(n) + } + (onto, other_of) +} + +fn blend_context(index: usize, color: [f64; 3], chosen: &[ChosenRare], onto: &[Option<[f64; 3]>], centers: &[[f64; 3]]) -> Option { + let from: Vec = (0..centers.len()) + .chain( + chosen + .iter() + .zip(onto) + .enumerate() + .filter(|(other, (_, target))| *other != index && target.is_none()) + .map(|(other, _)| centers.len() + other), + ) + .collect(); + let with: Vec<[f64; 3]> = centers + .iter() + .copied() + .chain(from.iter().skip(centers.len()).filter_map(|&source| chosen.get(source.checked_sub(centers.len())?)).map(|entry| entry.2)) + .collect(); + let pairs: Vec<(usize, usize)> = (0..with.len()) + .flat_map(|a| (a + 1..with.len()).map(move |b| (a, b))) + .filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&left), Some(&right)) if is_mix(color, &[left, right]))) + .collect(); + if pairs.is_empty() { + return None; + } + let mut near = with.clone(); + near.push(color); + let mut counts_as: Vec> = (0..with.len()).map(Some).chain([None]).collect(); + for (candidate, target) in chosen.iter().zip(onto) { + if let Some(target) = target { + near.push(candidate.2); + counts_as.push(with.iter().position(|other| other == target)); + } + } + Some(BlendContext { from, with, pairs, near_labs: labs(&near), counts_as }) +} + +fn blend_neighbour_counts( + group_pixels: &[usize], + width: usize, + height: usize, + pixels: &[[u8; 4]], + choose: &[bool], + context: &BlendContext, +) -> (Vec, usize) { + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let mut both = vec![0; context.pairs.len()]; + let mut pixel_count = 0; + let mut around = vec![false; context.with.len()]; + for &index in group_pixels { + let (x, y) = (index % width, index / width); + pixel_count += 1; + around.fill(false); + for near_y in y.saturating_sub(1)..=(y + 1).min(height - 1) { + for near_x in x.saturating_sub(1)..=(x + 1).min(width - 1) { + let neighbour = near_y * width + near_x; + let Some(pixel) = pixels.get(neighbour).filter(|pixel| pixel[3] >= 128) else { continue }; + let lab = oklab([pixel[0], pixel[1], pixel[2]].map(f64::from)); + let nearest = nearest_lab(&context.near_labs, lab); + if !choose.get(neighbour).copied().unwrap_or(false) + && context.near_labs.get(nearest).is_none_or(|candidate| distance(*candidate, lab) >= RARE_APART) { - *v += 1; - } - } - } - let lab = oklab(c); - // The nearest rare color it traces as, with where the other color of its pair comes from - // when its pixels split between the two (see below). - let mut best: Option<(f64, [f64; 3], Option)> = None; - for (n, &(a, b)) in pairs.iter().enumerate() { - if both.get(n).is_none_or(|&v| v == 0 || v * CUT_EDGE_MIX_TOUCH < pixels) { - continue; - } - for (e, f) in [(a, b), (b, a)].into_iter().filter(|&(e, _)| e >= centers.len()) { - let (Some(&x), Some(&y)) = (with.get(e), with.get(f)) else { continue }; - // How far `c` lies from the other color toward this one. - let d2: f64 = (0..3).map(|q| (x[q] - y[q]).powi(2)).sum(); - let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - y[q]) * (x[q] - y[q])).sum::() / d2 } else { 0.0 }; - if t < CUT_EDGE_MIX_FROM { continue; } - let d = dist(oklab(x), lab); - if best.is_none_or(|b| d < b.0) { - // A rare color along the cut edge with a region of RARE_REGION pixels is a line - // of its own there: the anti-aliasing beside it splits between it and the other - // color. Otherwise the anti-aliasing is most of a thin line, or the part of a - // line over the edge that lies inside it, and traces as the rare color alone. - let body = from - .get(e) - .and_then(|&m| chosen.get(m.checked_sub(centers.len())?)) - .is_some_and(|r| r.0 >= RARE_REGION && beside_left_out.get(r.3).copied().unwrap_or(false)); - best = Some((d, x, from.get(f).copied().filter(|_| body))); + if let Some(&Some(group)) = context.counts_as.get(nearest) + && let Some(is_near) = around.get_mut(group) + { + *is_near = true; } } } - if let Some(o) = onto.get_mut(k) { - *o = best.map(|b| b.1); - } - if let Some(o) = other_of.get_mut(k) { - *o = best.and_then(|b| b.2); + for (pair_index, &(a, b)) in context.pairs.iter().enumerate() { + if around.get(a).copied().unwrap_or(false) + && around.get(b).copied().unwrap_or(false) + && let Some(count) = both.get_mut(pair_index) + { + *count += 1; + } } } - let (out, traced_as) = kept_rare(chosen.iter().zip(&onto).zip(&other_of).map(|(((_, _, c, _), o), &from)| (*c, *o, from))); - // The index in `centers` followed by `out` of a palette color or a rare color, or of the kept - // rare color one that is not kept traces as. - let index = |from: usize| -> Option { - let Some(m) = from.checked_sub(centers.len()) else { return Some(from) }; - let (_, _, x, _) = chosen.get(m)?; - let x = onto.get(m).copied().flatten().unwrap_or(*x); - Some(centers.len() + out.iter().position(|o| *o == x)?) + (both, pixel_count) +} + +fn best_blend_target( + color: [f64; 3], + chosen: &[ChosenRare], + centers: &[[f64; 3]], + metrics: &RegionMetrics, + context: &BlendContext, + both: &[usize], + pixel_count: usize, +) -> Option<(f64, [f64; 3], Option)> { + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|channel| (a[channel] - b[channel]).powi(2)).sum::().sqrt(); + let lab = oklab(color); + let mut best = None; + for (pair_index, &(a, b)) in context.pairs.iter().enumerate() { + if both.get(pair_index).is_none_or(|&count| count == 0 || count * CUT_EDGE_MIX_TOUCH < pixel_count) { + continue; + } + for (edge, other) in [(a, b), (b, a)].into_iter().filter(|&(edge, _)| edge >= centers.len()) { + let (Some(&edge_color), Some(&other_color)) = (context.with.get(edge), context.with.get(other)) else { continue }; + let squared = (0..3).map(|channel| (edge_color[channel] - other_color[channel]).powi(2)).sum::(); + let along = if squared > 0.0 { + (0..3).map(|channel| (color[channel] - other_color[channel]) * (edge_color[channel] - other_color[channel])).sum::() / squared + } else { + 0.0 + }; + if along < CUT_EDGE_MIX_FROM { + continue; + } + let separation = distance(oklab(edge_color), lab); + if best.is_none_or(|candidate: (f64, [f64; 3], Option)| separation < candidate.0) { + // A substantial rare region along the edge keeps the identity of its other side. + let has_body = context + .from + .get(edge) + .and_then(|&source| chosen.get(source.checked_sub(centers.len())?)) + .is_some_and(|rare| rare.0 >= RARE_REGION && metrics.beside_left_out.get(rare.3).copied().unwrap_or(false)); + best = Some((separation, edge_color, context.from.get(other).copied().filter(|_| has_body))); + } + } + } + best +} + +fn reindex_traced( + chosen: Vec, + onto: Vec>, + other_of: Vec>, + centers: &[[f64; 3]], +) -> (Vec<[f64; 3]>, Vec) { + let (output, traced) = kept_rare(chosen.iter().zip(&onto).zip(&other_of).map(|(((_, _, color, _), target), &other)| (*color, *target, other))); + let output_index = |source: usize| -> Option { + let Some(index) = source.checked_sub(centers.len()) else { return Some(source) }; + let (_, _, color, _) = chosen.get(index)?; + let color = onto.get(index).copied().flatten().unwrap_or(*color); + Some(centers.len() + output.iter().position(|candidate| *candidate == color)?) }; - // A color whose rare color is not kept (past the budget) is left out. - let traced_as = traced_as + let traced = traced .into_iter() - .filter_map(|(c, o, from)| { - let rare = centers.len() + out.iter().position(|x| *x == o)?; - let other = match from { - Some(f) => index(f)?, + .filter_map(|(color, target, other)| { + let rare = centers.len() + output.iter().position(|candidate| *candidate == target)?; + let other = match other { + Some(source) => output_index(source)?, None => rare, }; - Some((c, rare, other)) + Some((color, rare, other)) }) .collect(); - (out, traced_as) + (output, traced) } /// Of `colors` (a rare color, the rare color it traces as, and where the other color of its pair diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 86b75c03d..a708a70c1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -8,7 +8,12 @@ use super::state::{ CaptureEpoch, GeometryStamp, InputGeneration, MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, SCREENSHOT_TIMEOUT_MS, SYSTEM_CLIPBOARD_POLL, take_frame_once, }; -use super::*; +use super::{ + Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome, + clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette, + panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme, + titlebar, toolbar, tools_float, unsaved, updates_ui, widgets, +}; mod clipboard; mod commands; diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index b6260b661..5efe2db30 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; impl VectorcraftApp { /// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b31f42d85..0f136e65e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,5 +1,5 @@ use super::fonts::MAX_RECENT_FONTS; -use super::*; +use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar}; impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 13eca92db..bbdf60bef 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS, + VectorcraftApp, file_access, json, now_ms, +}; use crate::control; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 03bcc240b..867494253 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, io, place}; impl VectorcraftApp { /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index 54fd9f86d..fd34eafc6 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, json}; /// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). pub const MAX_RECENT_FONTS: usize = 15; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 19374770f..5fd9dd8d0 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index 05222c39f..4a020d407 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; use crate::ime; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 8b9f96760..f8b42f406 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once}; +#[cfg(not(target_arch = "wasm32"))] +use super::{fileio, place}; impl VectorcraftApp { /// The first UI pass of a host iteration takes the host's input, once per context, viewport and diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index e4c6fc925..7c8c4abd6 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::dialogs; +use super::{VectorcraftApp, browsable_url}; impl VectorcraftApp { /// Open a link in the browser (Help → Discord, website, GitHub…). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 39b202330..24288594c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; impl VectorcraftApp { /// Was Preferences confirmed since the last call? The host then writes the preferences file, diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index a680f11b2..ce97fc747 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, CaptureEpoch, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, + VectorcraftApp, file_access, json, now_ms, +}; use crate::control; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 2306d1f45..6aac1150e 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Value, VectorcraftApp, View, ViewInfo, panels}; impl VectorcraftApp { /// Keep `views` aligned with the session's documents (a new one starts at its saved view). diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d5b2e6e1d..b32c64c4f 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{canvas, cursors, render_worker}; /// Cached canvas raster. pub struct CanvasCache { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d590c01f6..400a32d03 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -206,21 +206,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { scroll_bars(app, ui, rect, outer, &t); } let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); - // A press on the canvas while the context menu is open only closes it (a drag too, which egui - // alone would leave open); the tool doesn't get it. - if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { - // The artboard's 3D gizmo owns these gestures while its options are open. - // Do not let a ring drag also move or deselect the editable source path. - } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { - egui::Popup::close_all(ui.ctx()); - } else { - handle_input(app, ui, &resp, rect); - // `hover_doc` is the document point under the pointer in the view `handle_input` left. The - // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view - // there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); - app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p)); - } + canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. @@ -241,35 +227,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let doc = st.doc.clone(); let mask_view = st.shown_mask(); - // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the - // simulated paper colour; a white Background Contents hides the grid). - // An artboard with a background color draws its paper in that color. With Simulate Colored Paper - // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, - // rounded. The transparency grid, while it shows, covers it as it covers the others. - painter.rect_filled(rect, 0.0, t.pasteboard); - if app.ui.view.artboards && !app.ui.view.outline { - let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); - let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; - for ab in &doc.artboards { - let q = xf.quad(ab.rect); - // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). - let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); - painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); - painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); - match &grid { - Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell), - None => { - let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); - } - } - } - } else if app.ui.view.outline { - for ab in &doc.artboards { - painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); - } - } + paint_paper(app, ui, &painter, &xf, rect, &t); // The grid behind the art (Guides & Grid › Grids In Back), else over it below. let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs)); let grids_in_back = app.session.prefs.grids_in_back; @@ -290,9 +248,205 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; + let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images)); + // Placed documents' bitmaps are being made: draw again when they are ready. + if vectorcraft_render::placed_document::busy() { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + poll_worker(app, ui, key.doc); + // With `waits_for_still` set (the web build, which has no render worker), a heavy document is + // not rendered while only the view changes: the frame shown is drawn reprojected below, and the + // first frame after the view has been still for STILL_SECS renders. + let wait = if app.canvas.waits_for_still { + let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); + still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time)) + } else { + None + }; + if let Some(secs) = wait { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); + } + if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { + let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let opts = frame_options(app, &key, mask_view, smooth_images); + draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel }); + } + // The status bar reports each image that a renderer (this thread's or the worker's) could not + // decode, once per image in the process. + let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); + if undecoded > 0 { + app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes")); + } + reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation)); + if let Some(look) = grid_look.filter(|_| !grids_in_back) { + grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); + } + // The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from + // 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show. + if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid { + grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false }); + } + artboard_outlines(app, ui, &painter, &xf, &t); + if app.ui.view.guides { + guide_lines(app, &painter, &xf, rect, &t); + } + + if !app.session.slices_hidden() { + slice_overlay(app, &painter, &xf); + } + if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" { + print_tiling_overlay(app, &painter, &xf); + } + original_path_overlay(app, &painter, &xf); + + let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); + let view_info = app.view_info(); + let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, hover_still), revolve_edit, view_info); + draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); + if !revolve_edit { + shaper_widget_overlay(app, &painter, &xf); + } + ime_geometry(app, ui.ctx(), &xf); + crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); + // A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial + // at the canvas center, the point the view turns about. + if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::RotateView { .. })) { + rotate_view_dial(&painter, rect.center(), v.rotation, &t); + } + + if app.ui.view.rulers && app.ui.screen_mode < 3 { + ruler_bands(app, ui, &xf, (full, rect), origin_preview, &t); + } + // The task bar is not drawn while the Simplify slider shows. + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { + task_bar(app, ui, &xf); + } + if app.ui.screen_mode < 3 { + crate::free_transform::show(app, ui, xf.rect); + } + artboard_name_editor(app, ui, &xf); + if resp.hovered() { + canvas_cursor(app, ui, view_info, hover_still); + } +} + +/// The pointer on the canvas: the tool's input, unless the context menu or the 3D gizmo has the press. +fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { + // A press on the canvas while the context menu is open only closes it (a drag too, which egui + // alone would leave open); the tool doesn't get it. + if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { + // The artboard's 3D gizmo owns these gestures while its options are open. + // Do not let a ring drag also move or deselect the editable source path. + } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + egui::Popup::close_all(ui.ctx()); + } else { + handle_input(app, ui, resp, rect); + // `hover_doc` is the document point under the pointer in the view `handle_input` left. The + // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view + // there. + let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p)); + } +} + +/// The pointer over the canvas: the view tools' cursors, else the tool's own. +fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engine::ViewInfo, hover_still: bool) { + if opening::long_operation_busy(app) { + ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy)); + return; + } + let m = ui.input(|i| i.modifiers); + let space = ui.input(|i| i.key_down(egui::Key::Space)); + let drag = ui.data(|d| d.get_temp::(drag_id())); + let panning = matches!(drag, Some(Drag::Pan { .. })); + let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; + // A rotating drag keeps its cursor when Shift is let go. + let rotating = matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; + // The hand and the magnifier are drawn like the tool cursors below: Windows has no + // system cursor for them (winit shows the four-headed move cursor for Grab). + let view_cursor = if panning { + Some(Cursor::HandGrab) + } else if rotating { + Some(Cursor::Rotate) + } else if zooming { + Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) + } else if space || app.session.tool_id() == "hand" { + Some(Cursor::Hand) + } else { + None + }; + // In Presentation Mode the pointer is the default one, with no tool cursor. + let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); + let cur = if let Some(c) = tool_cursor.flatten() { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor"))); + // The loaded place cursor carries the file's thumbnail. + if app.session.tool_id() == "place" + && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) + { + crate::place::paint_cursor(app, ui.ctx(), &painter, hp); + } + // The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers + // into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs. + let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); + // User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling; + // off, they keep their size. + let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() }; + match ui.input(|i| i.pointer.hover_pos()) { + // An OS cursor: the system moves it at once, where a painted one trails the pointer + // (#444). The system cursor stands in for cursors without a glyph. Over a window + // without the focus, macOS shows its own pointer whichever cursor the window sets, + // and the glyph is not painted there. + Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => { + let ppp = ui.ctx().pixels_per_point() / ui_scale; + ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp)); + cursor_icon(c) + } + Some(hp) + if custom + && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) + && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => + { + // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). + if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } + } + _ => cursor_icon(c), + } + } else { + egui::CursorIcon::Default + }; + ui.ctx().set_cursor_icon(cur); +} + +/// The rulers, and the origin being dragged out of their corner. +fn ruler_bands(app: &VectorcraftApp, ui: &Ui, xf: &Xf, (full, rect): (egui::Rect, egui::Rect), origin_preview: Option, t: &Tokens) { + let painter = ui.painter_at(rect); + if let Some(origin) = origin_preview { + let at = xf.to_screen(origin); + painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); + painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); + } + // View › Rulers › Show Video Rulers counts the active artboard in video pixels. + let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); + let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) { + Some(ab) => RulerScale::video(ab), + None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())), + }; + rulers(ui, full, xf, app.hover_doc, scale, t); +} + +/// What the cached frame depends on. +fn cache_key( + app: &VectorcraftApp, + ui: &Ui, + st: &vectorcraft_engine::DocState, + t: &Tokens, + v: &View, + (w, h, ppp): (u32, u32, f32), + (pixel, smooth_images): (Option<[i64; 4]>, bool), +) -> CacheKey { // Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid). - let guide_look = LineLook::guides(&app.session.prefs, &t); - let key = CacheKey { + let guide_look = LineLook::guides(&app.session.prefs, t); + CacheKey { guide: ([guide_look.color.r(), guide_look.color.g(), guide_look.color.b()], guide_look.dots), doc: st.uid as usize, revision: st.revision, @@ -322,11 +476,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool), // The Image Trace panel's eye button, while it is held. trace_source: app.ui.trace_hold_source.clone(), - }; - // Placed documents' bitmaps are being made: draw again when they are ready. - if vectorcraft_render::placed_document::busy() { - ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); } +} + +/// Start the render worker, and upload the frame it finished. +fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) { if !app.canvas.worker_started { app.canvas.worker_started = true; if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() { @@ -335,7 +489,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Upload finished background renders. if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) - && done.key.doc == key.doc + && done.key.doc == key_doc { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); @@ -345,121 +499,164 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.moved = done.moved > 0; app.canvas.from_worker = true; } - // With `waits_for_still` set (the web build, which has no render worker), a heavy document is - // not rendered while only the view changes: the frame shown is drawn reprojected below, and the - // first frame after the view has been still for STILL_SECS renders. - let wait = if app.canvas.waits_for_still { - let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); - still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time)) - } else { - None - }; - if let Some(secs) = wait { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); +} + +/// The size and view matrix of the frame to render: the pixel region in Pixel Preview, else the whole canvas. +fn frame_view(pixel: Option<[i64; 4]>, (w, h): (u32, u32), v: &View, ppp: f32) -> (u32, u32, Affine) { + match pixel { + Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), + None => ( + w, + h, + Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) + * Affine::rotate(v.rotation.to_radians()) + * Affine::scale(v.zoom * ppp as f64) + * Affine::translate(-v.center.to_vec2()), + ), } - if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { - let (w, h, view) = match pixel { - Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), - None => ( - w, - h, - Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) - * Affine::rotate(v.rotation.to_radians()) - * Affine::scale(v.zoom * ppp as f64) - * Affine::translate(-v.center.to_vec2()), - ), - }; - let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; - let opts = vectorcraft_render::RenderOptions { - proof: vectorcraft_render::proof::active_proof(), - overprint_preview: vectorcraft_render::proof::overprint_preview_on(), - trim: app.ui.view.trim_view, - hide_templates: !app.ui.view.templates, - hide_guides: !app.ui.view.guides, - tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { - let [r, g, b, _] = c.to_rgba8(1.0); - [r, g, b] - }), - guide: key.guide, - mask_view, - highlight_substitutions: true, - // Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions. - highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, - // Type › Highlight Substituted Fonts: pink behind text in a substitute font. - highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, - // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and - // pattern tiles stay smooth), as in Illustrator. - anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, - progressive_placed: true, - trace_views: true, - // Image Trace › the eye button held: the source image of the selected objects. - trace_source: key.trace_source.clone(), - smooth_images, - // Isolation mode: the art around the isolated group or layer is dimmed (#833). - isolated: key.isolated.map(vectorcraft_doc::NodeId), - // Images far smaller on screen than they are sample their mip levels (#1244). - image_mips: true, - // An image whose pixels can't be decoded shows as a crossed box, and the status message - // below reports it. - mark_undecoded_images: true, - // During a drag or a dialog's preview, objects that only moved draw their raster - // effects from the cached rasters of an earlier frame (#1257). - interacting: key.interacting, - // During a tool's drag, raster effects without a cached raster to draw render at a - // lower resolution (#2558); the frame after the drag renders them as a still frame does. - dragging: key.dragging, - rich_black: key.rich_black, - ..opts - }; - // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. - // A document switch renders synchronously so another document's frame is never shown. - // The first frame after an interaction, when the frame shown drew moved rasters, also goes - // to the worker. An interaction's frames stay on the worker once it drew the frame shown: - // its renderer keeps the rasters of the art's effects that make those frames fast, and this - // thread's renderer has none, so a frame fast on the worker would be slow here. - let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); - let settling = app.canvas.moved && !key.interacting; - let staying = key.interacting && app.canvas.from_worker; - let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); - match (&mut app.canvas.worker, heavy, changed) { - // Only the document changed since the frame shown, in a part of it: that part is - // rendered again, here, into the texture. `last_ms` stays the whole frame's. - (_, _, Some(region)) => { - let t0 = now_ms(); - if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { - let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); - let color = color_image(&part); - let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; - tex.set_partial([x0 as usize, y0 as usize], color, options); - app.canvas.moved |= app.canvas.renderer.stats.moved > 0; - } - app.canvas.key = Some(key.clone()); - app.canvas.shown = Some((doc.clone(), opts.clone())); - app.perf.render_ms = now_ms() - t0; - } - (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), - _ => { - let t0 = now_ms(); - let img = app.canvas.renderer.render(&doc, w, h, view, &opts); - upload(app, ui.ctx(), &img, pixel.is_some()); - app.canvas.key = Some(key.clone()); - app.canvas.shown = Some((doc.clone(), opts.clone())); - app.perf.render_ms = now_ms() - t0; - app.canvas.last_ms = app.perf.render_ms; - app.canvas.moved = app.canvas.renderer.stats.moved > 0; - app.canvas.from_worker = false; +} + +/// The options the canvas renders the document with. +fn frame_options( + app: &VectorcraftApp, + key: &CacheKey, + mask_view: Option, + smooth_images: bool, +) -> vectorcraft_render::RenderOptions { + let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; + vectorcraft_render::RenderOptions { + proof: vectorcraft_render::proof::active_proof(), + overprint_preview: vectorcraft_render::proof::overprint_preview_on(), + trim: app.ui.view.trim_view, + hide_templates: !app.ui.view.templates, + hide_guides: !app.ui.view.guides, + tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + guide: key.guide, + mask_view, + highlight_substitutions: true, + // Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions. + highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, + // Type › Highlight Substituted Fonts: pink behind text in a substitute font. + highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts, + // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and + // pattern tiles stay smooth), as in Illustrator. + anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, + progressive_placed: true, + trace_views: true, + // Image Trace › the eye button held: the source image of the selected objects. + trace_source: key.trace_source.clone(), + smooth_images, + // Isolation mode: the art around the isolated group or layer is dimmed (#833). + isolated: key.isolated.map(vectorcraft_doc::NodeId), + // Images far smaller on screen than they are sample their mip levels (#1244). + image_mips: true, + // An image whose pixels can't be decoded shows as a crossed box, and the status message + // below reports it. + mark_undecoded_images: true, + // During a drag or a dialog's preview, objects that only moved draw their raster + // effects from the cached rasters of an earlier frame (#1257). + interacting: key.interacting, + // During a tool's drag, raster effects without a cached raster to draw render at a + // lower resolution (#2558); the frame after the drag renders them as a still frame does. + dragging: key.dragging, + rich_black: key.rich_black, + ..opts + } +} + +/// A frame to render: here, or on the worker. +struct Frame<'a> { + key: &'a CacheKey, + doc: &'a std::sync::Arc, + size: (u32, u32), + view: Affine, + opts: vectorcraft_render::RenderOptions, + pixel: Option<[i64; 4]>, +} + +fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { + let Frame { key, doc, size: (w, h), view, opts, pixel } = frame; + // to the worker. An interaction's frames stay on the worker once it drew the frame shown: + // its renderer keeps the rasters of the art's effects that make those frames fast, and this + // thread's renderer has none, so a frame fast on the worker would be slow here. + let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); + let settling = app.canvas.moved && !key.interacting; + let staying = key.interacting && app.canvas.from_worker; + let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; + let changed = changed_pixels(app, key, doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + _ => { + let t0 = now_ms(); + let img = app.canvas.renderer.render(doc, w, h, view, &opts); + upload(app, ui.ctx(), &img, pixel.is_some()); + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + app.canvas.last_ms = app.perf.render_ms; + app.canvas.moved = app.canvas.renderer.stats.moved > 0; + app.canvas.from_worker = false; } } - // The status bar reports each image that a renderer (this thread's or the worker's) could not - // decode, once per image in the process. - let undecoded = vectorcraft_render::newly_undecodable(doc.images.values()); - if undecoded > 0 { - app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes")); +} + +/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards. +fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; + // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the + // simulated paper colour; a white Background Contents hides the grid). + // An artboard with a background color draws its paper in that color. With Simulate Colored Paper + // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, + // rounded. The transparency grid, while it shows, covers it as it covers the others. + painter.rect_filled(rect, 0.0, t.pasteboard); + if app.ui.view.artboards && !app.ui.view.outline { + let setup = &doc.setup; + let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); + let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; + for ab in &doc.artboards { + let q = xf.quad(ab.rect); + // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). + let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); + painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); + painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); + match &grid { + Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), + None => { + let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); + painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); + } + } + } + } else if app.ui.view.outline { + for ab in &doc.artboards { + painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); + } } +} + +/// The last rendered frame, placed through the current view while a new one is on its way. +fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) { if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) - && k.doc == key.doc + && k.doc == key_doc { // The frame being shown covers the canvas rect it was rendered for. While a panel is // dragged that rect is a different size, so reprojecting the frame through the *current* @@ -472,9 +669,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { match k.pixel { // Document pixels: placed over the document rect they cover, turning with the view. Some([x0, y0, x1, y1]) => { - textured_quad(&painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0)); + textured_quad(painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0)); } - None if (k.rot - v.rotation).abs() < 1e-9 => { + None if (k.rot - rotation).abs() < 1e-9 => { // Reproject the last frame if it was rendered for a different view. let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot }; let a = xf.to_screen(old.to_doc(old_rect.min)); @@ -488,18 +685,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() }; let q = [old_rect.left_top(), old_rect.right_top(), old_rect.right_bottom(), old_rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c))); - textured_quad(&painter, tex.id(), &q, (1.0, 1.0)); + textured_quad(painter, tex.id(), &q, (1.0, 1.0)); } } } - if let Some(look) = grid_look.filter(|_| !grids_in_back) { - grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); - } - // The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from - // 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show. - if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid { - grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false }); - } +} + +/// The artboards' edges, bleeds, marks and names. +fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; // Artboard edges and names. The Artboard tool labels its active artboard itself (following // a move or resize): that one isn't named twice. let active_ab = crate::panels::artboards::selected(app, doc.artboards.len()); @@ -515,53 +710,56 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the // canvas draws them, as it does the edges: exports and print leave them out. - artboard_marks(&painter, &xf, ab, t.artboard_mark); + artboard_marks(painter, xf, ab, t.artboard_mark); if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() { // At the artboard's own top-left corner, which a rotated view turns away from its // bounding box's (#1095). let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); - artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim); + artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim); } } - if app.ui.view.guides { - let look = LineLook::guides(&app.session.prefs, &t); - // Selected guides in the selection colour. - let picked = LineLook { color: t.selection, ..look }; - let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); - let key = app.session.active().and_then(|st| st.selection.key_guide); - let view = view_bounds(&xf); - // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { - let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); - // An artboard guide runs across its artboard, a canvas guide across the window (the part - // of its document line that the canvas shows, turned with the view). - let (a, b) = match doc.guide_span(g) { - Some((from, to)) => (at(from), at(to)), - None => { - let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) }; - let Some(ends) = clip_segment(at(from), at(to), rect) else { continue }; - ends - } - }; - if selected.contains(&i) { picked } else { look }.guide(&painter, a, b); - // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. - if key == Some(i) { - painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); +} + +/// The ruler guides. +fn guide_lines(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let doc = &st.doc; + let look = LineLook::guides(&app.session.prefs, t); + // Selected guides in the selection colour. + let picked = LineLook { color: t.selection, ..look }; + let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); + let key = app.session.active().and_then(|st| st.selection.key_guide); + let view = view_bounds(xf); + // Guides on a hidden layer hide with it. + for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); + // An artboard guide runs across its artboard, a canvas guide across the window (the part + // of its document line that the canvas shows, turned with the view). + let (a, b) = match doc.guide_span(g) { + Some((from, to)) => (at(from), at(to)), + None => { + let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) }; + let Some(ends) = clip_segment(at(from), at(to), rect) else { continue }; + ends } + }; + if selected.contains(&i) { picked } else { look }.guide(painter, a, b); + // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. + if key == Some(i) { + painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); } } +} - if !app.session.slices_hidden() { - slice_overlay(app, &painter, &xf); - } - if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" { - print_tiling_overlay(app, &painter, &xf); - } - original_path_overlay(app, &painter, &xf); - - // Selection visuals and tool overlays. - let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); - let view_info = app.view_info(); +/// The selection's visuals and the tools' overlays, to be drawn. +fn selection_visuals( + app: &mut VectorcraftApp, + painter: &egui::Painter, + xf: &Xf, + (hidden_chars, hover_still): (HiddenChars, bool), + revolve_edit: bool, + view_info: vectorcraft_engine::ViewInfo, +) -> Vec { // View → Hide Gradient Annotator hides the Gradient tool's annotator. let mut overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { app.session.overlays(view_info) @@ -572,11 +770,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { overlays.extend(crate::dialogs::live_paint_gaps::overlays(app)); if app.ui.view.edges && !revolve_edit { if hover_still { - hover_highlight(app, &painter, &xf); + hover_highlight(app, painter, xf); } - selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); - mirror_axis_overlay(app, &painter, &xf); - grid_repeat_overlay(app, &painter, &xf); + selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); + mirror_axis_overlay(app, painter, xf); + grid_repeat_overlay(app, painter, xf); if app.ui.view.text_threads { // Under the tools' overlays: a selected frame's own ports are drawn over its thread's. let mut threads = thread_overlays(app, xf.zoom); @@ -588,111 +786,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if app.ui.view.hidden_chars { app.canvas.hidden_chars = hidden_chars; - hidden_chars_overlay(app, &painter, &xf); + hidden_chars_overlay(app, painter, xf); } } - draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); - if !revolve_edit { - shaper_widget_overlay(app, &painter, &xf); - } - ime_geometry(app, ui.ctx(), &xf); - crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); - // A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial - // at the canvas center, the point the view turns about. - if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::RotateView { .. })) { - rotate_view_dial(&painter, rect.center(), v.rotation, &t); - } - - if app.ui.view.rulers && app.ui.screen_mode < 3 { - if let Some(origin) = origin_preview { - let at = xf.to_screen(origin); - painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); - painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); - } - // View › Rulers › Show Video Rulers counts the active artboard in video pixels. - let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); - let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) { - Some(ab) => RulerScale::video(ab), - None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())), - }; - rulers(ui, full, &xf, app.hover_doc, scale, &t); - } - // The task bar is not drawn while the Simplify slider shows. - if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { - task_bar(app, ui, &xf); - } - if app.ui.screen_mode < 3 { - crate::free_transform::show(app, ui, xf.rect); - } - artboard_name_editor(app, ui, &xf); - // Cursor. - if resp.hovered() { - if opening::long_operation_busy(app) { - ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy)); - return; - } - let m = ui.input(|i| i.modifiers); - let space = ui.input(|i| i.key_down(egui::Key::Space)); - let drag = ui.data(|d| d.get_temp::(drag_id())); - let panning = matches!(drag, Some(Drag::Pan { .. })); - let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; - // A rotating drag keeps its cursor when Shift is let go. - let rotating = - matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; - // The hand and the magnifier are drawn like the tool cursors below: Windows has no - // system cursor for them (winit shows the four-headed move cursor for Grab). - let view_cursor = if panning { - Some(Cursor::HandGrab) - } else if rotating { - Some(Cursor::Rotate) - } else if zooming { - Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) - } else if space || app.session.tool_id() == "hand" { - Some(Cursor::Hand) - } else { - None - }; - // In Presentation Mode the pointer is the default one, with no tool cursor. - let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); - let cur = if let Some(c) = tool_cursor.flatten() { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor"))); - // The loaded place cursor carries the file's thumbnail. - if app.session.tool_id() == "place" - && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) - { - crate::place::paint_cursor(app, ui.ctx(), &painter, hp); - } - // The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers - // into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs. - let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); - // User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling; - // off, they keep their size. - let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() }; - match ui.input(|i| i.pointer.hover_pos()) { - // An OS cursor: the system moves it at once, where a painted one trails the pointer - // (#444). The system cursor stands in for cursors without a glyph. Over a window - // without the focus, macOS shows its own pointer whichever cursor the window sets, - // and the glyph is not painted there. - Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => { - let ppp = ui.ctx().pixels_per_point() / ui_scale; - ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp)); - cursor_icon(c) - } - Some(hp) - if custom - && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) - && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => - { - // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). - if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } - } - _ => cursor_icon(c), - } - } else { - egui::CursorIcon::Default - }; - ui.ctx().set_cursor_icon(cur); - } + overlays } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/artboards.rs b/crates/ui-egui/src/canvas/artboards.rs index 986be51aa..4f803ef0c 100644 --- a/crates/ui-egui/src/canvas/artboards.rs +++ b/crates/ui-egui/src/canvas/artboards.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Point, Pos2, Rect, Shape, Stroke, Ui, VectorcraftApp, Xf, json, vec2}; /// The width an artboard needs on screen for its name to show (px): narrower, the name is left /// out rather than spilling over its neighbours. diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 534321242..8db01d422 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -1,5 +1,5 @@ use super::outline::{hit_at, hit_with, selection_hit_at}; -use super::*; +use super::{Ui, VectorcraftApp, Xf, json, widgets}; /// Right-click: the object under the pointer is selected first unless it already is, then the /// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index b67276df4..abd4e0cf8 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CacheKey, Cursor, Mods, Point, VectorcraftApp}; /// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still` /// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index e17690dd3..bfc3a070f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Drag, Mods, Point, PointerEvent, PointerKind, Pos2, StylusFeed, Ui, VectorcraftApp, Xf, widgets}; /// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the /// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it. diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index a36380df0..15576b9ae 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -1,5 +1,7 @@ use super::drag::mods; -use super::*; +use super::{ + Color32, Point, PointerEvent, PointerKind, Pos2, RULER, Rect, Sense, Shape, Stroke, Tokens, Ui, Unit, VectorcraftApp, Xf, json, pos2, vec2, +}; /// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per /// look, and its cell size in screen points. diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs index 1d23d9882..2987a03c5 100644 --- a/crates/ui-egui/src/canvas/home.rs +++ b/crates/ui-egui/src/canvas/home.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CornerRadius, Tokens, Ui, VectorcraftApp, json, theme, vec2, widgets}; /// The Home screen (`app.home`; with no document open, unless its preference is off). pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 319e9c84b..e07ba12dc 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -7,43 +7,11 @@ use super::view::{ HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, wheel, whole_pixels, zoom_about, }; -use super::*; +use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json}; /// Dispatch canvas pointer, keyboard and drop events through the active document view. pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let turn_id = egui::Id::unique("canvas-wheel-turn"); - let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); - let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn))); - if turn != turn_before { - ui.data_mut(|d| d.insert_temp(turn_id, turn)); - } - if turn.glide != 0.0 { - ui.ctx().request_repaint(); - } - let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); - // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also - // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The - // tool's events below take their document points from the view this moves to. - if resp.hovered() { - if (factor - 1.0).abs() > 1e-6 - && let (Some(p), Some(vm)) = (hover, app.view_mut()) - { - zoom_about(vm, rect, p, vm.zoom * factor); - } - if scroll != egui::Vec2::ZERO { - scroll_view(app, ui, scroll); - } - if let Some(p) = hover - && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) - && let Some(vm) = app.view_mut() - { - rotate_about(vm, rect, p, deg); - } - } + let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), @@ -51,6 +19,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // double-taps) where it was seen last (#491). let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); let view = app.view_info(); + let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view }; touch_gestures(app, ui, rect, view); // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the // tool's press is dropped, as a right-click presses nothing. @@ -97,24 +66,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) }); - // In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, - // drag or hover, and a drag does not pan the view. A drag that began before the mode was turned - // on ends as usual. if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) { - let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); - // `press` is this frame's new press, or else the press held so far. A quick tap can be - // pressed and released in one frame. - let press = if pressed { Some(Drag::Present) } else { drag }; - if press.is_some() && !pointer.primary_down() { - ui.data_mut(|dd| dd.remove::(drag_id())); - // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release - // does not move the view to the next artboard. - if press == Some(Drag::Present) && resp.clicked() { - crate::shortcuts::presentation_step(app, true); - } - } else if pressed { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); - } + present_step(app, ui, &f, drag, at); return; } @@ -125,12 +78,6 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { crate::stylus::sync_eraser_tool(app); } - let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and - // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. - let zoom_mode = if space { m.command } else { tool == "zoom" }; - let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; - let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a // press on the canvas itself reaches the tools, else it would land at the canvas's centre. @@ -146,35 +93,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo if let Some(i) = artboard_name_at(app, ui, &xf, p) { ui.data_mut(|dd| dd.insert_temp(name_press_id(), i)); } else { - let d = if crate::touch::finger_navigates_only() { - // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. - Drag::Gesture - } else if zoom_mode { - if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } - } else if pan_mode { - Drag::Pan { start: p, center: v.center, middle: false } - } else if rotate_mode { - let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } - } else { - // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; - dispatch(app, &ev, view); - crate::document_drag::begin(app, ui.ctx(), ev.pos); - Drag::Tool - }; - // Panning, zooming or turning the view takes the pointer from the tool's hover. - if !tool_holds(d) { - app.session.tool_hover_ended(view); - } - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), d); - dd.insert_temp(drag_pos_id(), p); - dd.insert_temp(drag_layer_id(), resp.layer_id); - // A new press has not dragged yet, whatever an interrupted one left behind. - dd.remove::(tool_drag_doc_id()); - dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); - }); + begin_drag(app, ui, &f, p); } } else if drag.is_none() && resp.hovered() @@ -189,157 +108,9 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center()); let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() }; if held { - ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); - match d { - Drag::Pan { start, center, .. } => { - let ppp = ui.ctx().pixels_per_point(); - if let Some(vm) = app.view_mut() { - vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); - } - } - Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } - if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); - } - } - Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), - Drag::ZoomScrub { start, held, moved } => { - let dt = ui.input(|i| i.unstable_dt).min(0.25); - let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); - // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). - let exponent = if moved { - f64::from(pointer.delta().x) * SCRUB_ZOOM - } else if held > HOLD_DELAY { - f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } - } else { - 0.0 - }; - if exponent != 0.0 - && let Some(vm) = app.view_mut() - { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); - } - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); - } - // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). - Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { - // Back on the canvas: the move goes on from where it left, with the pointer. - egui::DragAndDrop::clear_payload(ui.ctx()); - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), Drag::Tool); - dd.insert_temp(drag_pos_id(), p); - }); - } - Drag::Art | Drag::Gesture | Drag::Present => {} - Drag::Tool if drag_art_out(app, ui, resp, p) => { - ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); - } - Drag::Tool => { - // Every position the pen or mouse passed this frame, not just the last, for a - // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a - // mouse reports far more often than frames come). Touch events (a pen on Windows - // or the web, a finger) and a macOS pen's moves carry their own pressure; a - // mouse's, a pen's on X11 among them, take the host's latest. Other tools get - // the last position only, so a costly drag preview runs once a frame. - let moves = tool_frame_moves(ui, app); - // A frame whose pointer ends where the last one did still moved in between. - if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() { - for (at, force) in moves { - let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); - } - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts - // a quarter second at most. - if app.session.tool_wants_ticks() { - let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); - let r = app.session.tool_tick(dt, view); - apply_requests(app, r); - } - } - } + drag_held(app, ui, &f, d, p); } else { - // The last position the tool's drag got: none if the press never dragged. - let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); - ui.data_mut(|dd| { - dd.remove::(drag_id()); - dd.remove::(drag_pos_id()); - dd.remove::(tool_drag_doc_id()); - dd.remove::(tool_press_doc_id()); - }); - match d { - Drag::ZoomBox { start } => { - let r = egui::Rect::from_two_pos(start, p); - if let Some(vm) = app.view_mut() { - if r.width() > 8.0 && r.height() > 8.0 { - // The marquee's center and its size on screen set the view, at any rotation. - vm.center = xf.to_doc(r.center()); - let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); - } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - } - // A click (not held, not dragged) steps the zoom as with the marquee. - Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { - if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); - } - } - Drag::ZoomScrub { .. } => {} - Drag::Tool => { - // The second click of a double-click: don't fit another artboard on this release. - // A double-click on an object does not open Artboard Options. Empty artboard - // space still does. - if resp.double_clicked() && app.session.tool_id() == "artboard" { - app.session.set_tool_option("suppressFitClick", &json!(true)); - } - // A move that came in the release's own frame shapes the preview first: the - // tools commit the last preview, which should be where the pointer let go - // (#834). Only once the press has dragged, so a click's jitter stays a click - // (the Pen's corner anchor, no nudge with Direct Selection). - if let Some(last) = last_drag - && last != xf.to_doc(p) - { - let ev = - PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; - dispatch(app, &ev, view); - } - Drag::Art if on_canvas(ui, resp, p) => { - // Came back to the canvas and released in one frame: finish the move here. - egui::DragAndDrop::clear_payload(ui.ctx()); - let pressure = pen_pressure(ui, app, false); - let pos = xf.to_doc(p); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); - } - Drag::Art => { - // Released over a panel: the move goes back. (The app ends it before the panels - // see the release, so a panel can take the art; this is the headless path.) - // With no document open there is nothing to roll back; the tool still gets its up. - let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - } - Drag::Gesture | Drag::Present => {} - Drag::Pan { .. } | Drag::RotateView { .. } => {} - } + drag_released(app, ui, &f, d, p); } } else if let Some(p) = hover_tool && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) @@ -370,27 +141,306 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo if resp.double_clicked() && let Some(p) = at { - if let Some(i) = artboard_name_at(app, ui, &xf, p) { - begin_artboard_name_edit(ui, app, i); - } else { - let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - let before = app.session.tool_id().to_string(); - dispatch(app, &ev, view); - // A double-click on type switched a selection tool to the Type tool: the caret goes where - // the type was clicked, so typing edits it at once. - if before != "type" && app.session.tool_id() == "type" { - app.session.set_tool_option("prevTool", &json!(before)); - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(app, &PointerEvent { kind, ..ev }, view); - } - } - } + double_click(app, ui, &f, p); } if drag.is_some() || pointer.is_moving() || mods_changed { ui.ctx().request_repaint(); } } +/// What the pointer handlers share for one frame. +struct Pointing<'a> { + resp: &'a egui::Response, + rect: egui::Rect, + pointer: &'a egui::PointerState, + m: egui::Modifiers, + space: bool, + xf: Xf, + v: View, + view: vectorcraft_engine::ViewInfo, +} + +/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the +/// modifiers, whether Space is held and where the pointer hovers over the canvas. +fn wheel_view( + app: &mut VectorcraftApp, + ui: &Ui, + resp: &egui::Response, + rect: egui::Rect, +) -> (egui::PointerState, egui::Modifiers, bool, Option) { + let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; + let turn_id = egui::Id::unique("canvas-wheel-turn"); + let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); + let mut turn = turn_before; + let (pointer, m, space, (factor, scroll)) = + ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn))); + if turn != turn_before { + ui.data_mut(|d| d.insert_temp(turn_id, turn)); + } + if turn.glide != 0.0 { + ui.ctx().request_repaint(); + } + let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); + // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also + // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The + // tool's events below take their document points from the view this moves to. + if resp.hovered() { + if (factor - 1.0).abs() > 1e-6 + && let (Some(p), Some(vm)) = (hover, app.view_mut()) + { + zoom_about(vm, rect, p, vm.zoom * factor); + } + if scroll != egui::Vec2::ZERO { + scroll_view(app, ui, scroll); + } + if let Some(p) = hover + && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) + && let Some(vm) = app.view_mut() + { + rotate_about(vm, rect, p, deg); + } + } + (pointer, m, space, hover) +} + +/// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, drag or hover, and a +/// drag does not pan the view. A drag that began before the mode was turned on ends as usual. +fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option, at: Option) { + let (resp, pointer) = (f.resp, f.pointer); + let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some(); + // `press` is this frame's new press, or else the press held so far. A quick tap can be + // pressed and released in one frame. + let press = if pressed { Some(Drag::Present) } else { drag }; + if press.is_some() && !pointer.primary_down() { + ui.data_mut(|dd| dd.remove::(drag_id())); + // A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release + // does not move the view to the next artboard. + if press == Some(Drag::Present) && resp.clicked() { + crate::shortcuts::presentation_step(app, true); + } + } else if pressed { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present)); + } +} + +/// A press on the canvas at `p` starts a gesture: a zoom, pan or turn of the view, or the tool's drag. +fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { resp, rect, m, space, xf, v, view, .. } = *f; + let tool = app.session.tool_id(); + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. + let zoom_mode = if space { m.command } else { tool == "zoom" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { + if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } + } else if pan_mode { + Drag::Pan { start: p, center: v.center, middle: false } + } else if rotate_mode { + let c = rect.center(); + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } + } else { + // Cmd with another tool drags with the selection tool used last (the session lends it). + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; + dispatch(app, &ev, view); + crate::document_drag::begin(app, ui.ctx(), ev.pos); + Drag::Tool + }; + // Panning, zooming or turning the view takes the pointer from the tool's hover. + if !tool_holds(d) { + app.session.tool_hover_ended(view); + } + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), d); + dd.insert_temp(drag_pos_id(), p); + dd.insert_temp(drag_layer_id(), resp.layer_id); + // A new press has not dragged yet, whatever an interrupted one left behind. + dd.remove::(tool_drag_doc_id()); + dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); + }); +} + +/// A gesture held at `p`: the view follows, or the tool gets drag events. +fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) { + let Pointing { resp, rect, pointer, m, space, xf, view, .. } = *f; + ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + match d { + Drag::Pan { start, center, .. } => { + let ppp = ui.ctx().pixels_per_point(); + if let Some(vm) = app.view_mut() { + vm.center = center; + pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + } + } + Drag::RotateView { start_angle, start_rot, snaps } => { + let c = rect.center(); + let a = (p.y - c.y).atan2(p.x - c.x) as f64; + let mut deg = start_rot + (a - start_angle).to_degrees(); + if snaps && m.shift { + deg = (deg / 15.0).round() * 15.0; + } + if let Some(vm) = app.view_mut() { + vm.rotation = vectorcraft_geom::normalize_deg(deg); + } + } + Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), + Drag::ZoomScrub { start, held, moved } => { + let dt = ui.input(|i| i.unstable_dt).min(0.25); + let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); + // Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out). + let exponent = if moved { + f64::from(pointer.delta().x) * SCRUB_ZOOM + } else if held > HOLD_DELAY { + f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 } + } else { + 0.0 + }; + if exponent != 0.0 + && let Some(vm) = app.view_mut() + { + zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + } + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); + } + // Without a pointer it is over a floating panel's own OS window (`drag_art_out`). + Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { + // Back on the canvas: the move goes on from where it left, with the pointer. + egui::DragAndDrop::clear_payload(ui.ctx()); + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), Drag::Tool); + dd.insert_temp(drag_pos_id(), p); + }); + } + Drag::Art | Drag::Gesture | Drag::Present => {} + Drag::Tool if drag_art_out(app, ui, resp, p) => { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); + } + Drag::Tool => { + // Every position the pen or mouse passed this frame, not just the last, for a + // tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a + // mouse reports far more often than frames come). Touch events (a pen on Windows + // or the web, a finger) and a macOS pen's moves carry their own pressure; a + // mouse's, a pen's on X11 among them, take the host's latest. Other tools get + // the last position only, so a costly drag preview runs once a frame. + let moves = tool_frame_moves(ui, app); + // A frame whose pointer ends where the last one did still moved in between. + if tool_drag_moved(ui, &xf, p, pointer) || !moves.is_empty() { + for (at, force) in moves { + let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); + } + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts + // a quarter second at most. + if app.session.tool_wants_ticks() { + let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); + let r = app.session.tool_tick(dt, view); + apply_requests(app, r); + } + } + } +} + +/// A gesture released at `p`: the view settles, or the tool gets its up. +fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) { + let Pointing { resp, rect, m, space, xf, view, .. } = *f; + // The last position the tool's drag got: none if the press never dragged. + let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); + ui.data_mut(|dd| { + dd.remove::(drag_id()); + dd.remove::(drag_pos_id()); + dd.remove::(tool_drag_doc_id()); + dd.remove::(tool_press_doc_id()); + }); + match d { + Drag::ZoomBox { start } => { + let r = egui::Rect::from_two_pos(start, p); + if let Some(vm) = app.view_mut() { + if r.width() > 8.0 && r.height() > 8.0 { + // The marquee's center and its size on screen set the view, at any rotation. + vm.center = xf.to_doc(r.center()); + let fill = (rect.width() / r.width()).min(rect.height() / r.height()); + vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + } else { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + } + // A click (not held, not dragged) steps the zoom as with the marquee. + Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { + if let Some(vm) = app.view_mut() { + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + } + } + Drag::ZoomScrub { .. } => {} + Drag::Tool => { + // The second click of a double-click: don't fit another artboard on this release. + // A double-click on an object does not open Artboard Options. Empty artboard + // space still does. + if resp.double_clicked() && app.session.tool_id() == "artboard" { + app.session.set_tool_option("suppressFitClick", &json!(true)); + } + // A move that came in the release's own frame shapes the preview first: the + // tools commit the last preview, which should be where the pointer let go + // (#834). Only once the press has dragged, so a click's jitter stays a click + // (the Pen's corner anchor, no nudge with Direct Selection). + if let Some(last) = last_drag + && last != xf.to_doc(p) + { + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + Drag::Art if on_canvas(ui, resp, p) => { + // Came back to the canvas and released in one frame: finish the move here. + egui::DragAndDrop::clear_payload(ui.ctx()); + let pressure = pen_pressure(ui, app, false); + let pos = xf.to_doc(p); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); + } + Drag::Art => { + // Released over a panel: the move goes back. (The app ends it before the panels + // see the release, so a panel can take the art; this is the headless path.) + // With no document open there is nothing to roll back; the tool still gets its up. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + Drag::Gesture | Drag::Present => {} + Drag::Pan { .. } | Drag::RotateView { .. } => {} + } +} + +/// A double-click at `p`: an artboard's name edits, else the tool gets it. +fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { m, space, xf, view, .. } = *f; + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + begin_artboard_name_edit(ui, app, i); + } else { + let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let before = app.session.tool_id().to_string(); + dispatch(app, &ev, view); + // A double-click on type switched a selection tool to the Type tool: the caret goes where + // the type was clicked, so typing edits it at once. + if before != "type" && app.session.tool_id() == "type" { + app.session.set_tool_option("prevTool", &json!(before)); + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(app, &PointerEvent { kind, ..ev }, view); + } + } + } +} + /// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white /// artboard and on the grey pasteboard alike (#725). fn marquee(p: &egui::Painter, r: egui::Rect) { diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 907f33612..b5d14e151 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BezPath, Color32, Node, NodeKind, PathEl, Point, Pos2, Shape, Stroke, SymbolBoxes, VectorcraftApp, Xf, pos2}; pub(super) fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec> { // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6722c4761..704745f8a 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,7 +1,7 @@ use super::artboards::names_an_artboard; use super::input::marquee_through; use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn}; -use super::*; +use super::{Color32, CornerRadius, NodeKind, Overlay, Point, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index e2fc0f165..321ed2acf 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, CacheKey, VectorcraftApp, Xf}; /// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied /// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen. diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 273fce5e6..d7cc1c4df 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Color32, CornerRadius, NodeKind, Pos2, Rect, Sense, Stroke, StrokeKind, TextKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, json, pos2, vec2, widgets, +}; /// The Contextual Task Bar's area. fn task_bar_id() -> egui::Id { diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index c3f1b8007..bbb8bf5f0 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -1,6 +1,6 @@ use super::grid::{clip_segment, pref_color}; use super::outline::{OVERLAY_MAX_ANCHORS, c32, stroke_path}; -use super::*; +use super::{Color32, CornerRadius, Node, NodeKind, Overlay, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, pos2, vec2}; /// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story. /// The marks of each text object are kept from one frame to the next ([`HiddenChars`]). diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index f4ca00008..d1b3cff27 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,5 +1,5 @@ use super::drag::{drag_id, drop_press}; -use super::*; +use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; /// Fit the view (View → Fit Artboard / Fit All / Actual Size). pub fn fit(app: &mut VectorcraftApp, how: &str) { diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index a0d063b07..2f4f8c7b9 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{NodeKind, Sense, Stroke, Tokens, Ui, VectorcraftApp, json, stroke_panel, theme, vec2, widgets}; /// An anchor button: icon, tooltip, command and the convert command's `to`. type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>); diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 947f0dcf2..7fc2e749b 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,5 +1,5 @@ use super::status_bar::ANIMATED_ZOOM_HINT; -use super::*; +use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { Some(match tool { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 92d718fc9..9a1ffbfbb 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -1,5 +1,5 @@ use super::tabs::artboard_list; -use super::*; +use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, ZOOM_STOPS, json, theme, vec2, widgets, zoom_label}; /// Draw the status message, bounded readouts and artboard navigation controls. pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 649030172..a53062be8 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label}; /// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited /// "Name* @ 66.67 % (/Opacity Mask)". diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 8a340d9f5..362854a5f 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -707,81 +707,103 @@ fn artboard_navigation_properties_frame(app: &mut crate::VectorcraftApp, ctx: &e .collect() } +/// Delete the shown artboard with `key`, or with Properties' Delete Artboard button. +fn trigger_artboard_delete(app: &mut crate::VectorcraftApp, ctx: &egui::Context, key: Option, before: &vectorcraft_doc::Document) { + if let Some(key) = key { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::status_bar(app, ui); + }); + out.textures_delta.clear(); + } else { + artboard_navigation_properties_frame(app, ctx, vec![]); + let texts = artboard_navigation_properties_frame(app, ctx, vec![]); + assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); + let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); + assert!(!app.canvas_rect.unwrap().contains(at)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(at), button(true), button(false)] { + artboard_navigation_properties_frame(app, ctx, vec![event]); + } + } +} + /// None clicks Properties' Delete Artboard; Some routes the real key through shortcuts. fn artboard_navigation_delete(key: Option) { for multiple in [false, true] { - let (mut app, id) = artboard_navigation_app("artboard"); - let selected = if multiple { json!([0, 2]) } else { json!([0]) }; - app.session.set_tool_option("selected", &selected); - assert_eq!(app.view().unwrap().artboard, 0); - assert_eq!(app.session.tool_options()["active"], json!(0)); - assert_eq!(app.session.tool_options()["selected"], selected); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let undo = st.history.undo.len(); - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - - artboard_navigation_next(&mut app, &ctx); - - assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); - assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); - assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "navigation only changes view and tool state"); - assert_eq!(st.selection, selection, "ordinary artwork stays selected"); - assert_eq!(st.history.undo.len(), undo); - assert!(st.history.redo.is_empty()); - assert!(!st.is_dirty()); - - if let Some(key) = key { - let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| { - crate::shortcuts::handle(&mut app, ui.ctx()); - super::status_bar(&mut app, ui); - }); - out.textures_delta.clear(); - } else { - artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board"); - let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button"); - assert!(!app.canvas_rect.unwrap().contains(at)); - let button = - |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - } - } - - let st = app.session.active().unwrap(); - assert_eq!( - st.doc.artboards, - vec![before.artboards[0].clone(), before.artboards[2].clone()], - "first and third original board records survive" - ); - assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); - assert_eq!(st.selection, selection); - assert_eq!(st.history.undo.len(), undo + 1); - assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); - assert!(st.history.redo.is_empty()); - assert!(st.is_dirty()); - let deleted = st.doc.clone(); - app.run("edit.undo", json!({})).unwrap(); - let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "one Undo restores every board and the artwork"); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); - assert!(!st.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.active().unwrap().doc, deleted); + artboard_navigation_delete_case(key, multiple); } } +fn artboard_navigation_delete_case(key: Option, multiple: bool) { + let (mut app, id) = artboard_navigation_app("artboard"); + let selected = if multiple { json!([0, 2]) } else { json!([0]) }; + app.session.set_tool_option("selected", &selected); + assert_eq!(app.view().unwrap().artboard, 0); + assert_eq!(app.session.tool_options()["active"], json!(0)); + assert_eq!(app.session.tool_options()["selected"], selected); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let undo = st.history.undo.len(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + navigate_to_next_board(&mut app, &ctx, &before, &selection, undo); + delete_navigated_board(&mut app, &ctx, key, id, &before, &selection, undo); +} + +fn navigate_to_next_board( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + before: &vectorcraft_doc::Document, + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + artboard_navigation_next(app, ctx); + assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2"); + assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion"); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection"); + assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.as_ref(), before, "navigation only changes view and tool state"); + assert_eq!(&st.selection, selection, "ordinary artwork stays selected"); + assert_eq!(st.history.undo.len(), undo); + assert!(st.history.redo.is_empty()); + assert!(!st.is_dirty()); +} + +fn delete_navigated_board( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + key: Option, + id: vectorcraft_doc::NodeId, + before: &vectorcraft_doc::Document, + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + trigger_artboard_delete(app, ctx, key, before); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.artboards, vec![before.artboards[0].clone(), before.artboards[2].clone()], "first and third original board records survive"); + assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged"); + assert_eq!(&st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert!(st.history.redo.is_empty()); + assert!(st.is_dirty()); + let deleted = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.as_ref(), before, "one Undo restores every board and the artwork"); + assert_eq!(&st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); + assert!(!st.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, deleted); +} + #[test] fn artboard_navigation_next_then_delete_uses_shown_board() { artboard_navigation_delete(Some(egui::Key::Delete)); @@ -1201,59 +1223,54 @@ fn artboard_document_tab_roundtrip_preserves_other_tools() { } } -#[test] -fn artboard_close_active_tab_properties_delete_uses_shown_board() { - let (mut app, ctx) = artboard_document_app(); - let history = |st: &vectorcraft_engine::DocState| { - let entries = |items: &[vectorcraft_engine::HistoryEntry]| { - items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>() - }; - (entries(&st.history.undo), entries(&st.history.redo)) - }; - let outside_canvas = |app: &crate::VectorcraftApp| { - let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); - assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); - }; - app.select_tool("artboard"); - app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); - app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); - artboard_navigation_next(&mut app, &ctx); - outside_canvas(&app); - assert_eq!(app.view().unwrap().artboard, 1); - assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); - app.session.active_mut().unwrap().mark_saved(); - let st = app.session.active().unwrap(); - let (uid, revision) = (st.uid, st.revision); - let before = st.doc.clone(); - let selection = st.selection.clone(); - let history_before = history(st); - let history_limits = (st.history.limit, st.history.byte_budget); - assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); - assert!(!selection.objects.is_empty()); - assert!(history_before.1.is_empty()); - assert!(!st.is_dirty()); - let mut expected = before.as_ref().clone(); - expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; +/// One history entry: its label, document and selection. +type HistoryRow = (String, Arc, Arc); +/// The undo and redo entries of a document. +type HistoryDump = (Vec, Vec); + +fn history_of(st: &vectorcraft_engine::DocState) -> HistoryDump { + let entries = + |items: &[vectorcraft_engine::HistoryEntry]| items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::>(); + (entries(&st.history.undo), entries(&st.history.redo)) +} + +fn assert_outside_canvas(app: &crate::VectorcraftApp, ctx: &egui::Context) { + let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position"); + assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}"); +} + +/// Document A as it was saved, before its second tab closed and its shown board was deleted. +struct Baseline { + uid: u64, + revision: u64, + before: Arc, + selection: vectorcraft_doc::Selection, + history_before: HistoryDump, + history_limits: (usize, usize), + expected: vectorcraft_doc::Document, +} + +/// Close document B's tab with its close box, leaving A as it was. +fn close_b_tab(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) { + let Baseline { uid, revision, before, selection, history_before, history_limits, expected: _ } = base; // Route from the status bar along the panel edge to the tab strip, never through canvas. for at in [egui::pos2(1000.0, 280.0), egui::pos2(1000.0, 20.0)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + assert_outside_canvas(app, ctx); } - artboard_document_click_tab(&mut app, &ctx, 1); - outside_canvas(&app); + artboard_document_click_tab(app, ctx, 1); + assert_outside_canvas(app, ctx); app.session.active_mut().unwrap().mark_saved(); let other_uid = app.session.active().unwrap().uid; - assert_ne!(other_uid, uid); + assert_ne!(other_uid, *uid); assert!(app.session.documents().iter().all(|d| !d.is_dirty()), "both ordinary documents are in saved state"); assert!(app.ui.dialog.is_none()); eprintln!("ARTBOARD_CLOSE_TARGET_SETUP_READY"); // The actual B tab x, using the existing close-box test's title-relative hit position. - let titles = artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + let titles = artboard_document_tabs_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); assert_eq!(titles.len(), 2); let title = titles[1]; let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; @@ -1261,35 +1278,39 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { assert!(!app.canvas_rect.unwrap().contains(at)); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; for event in [egui::Event::PointerMoved(at), button(true), button(false)] { - artboard_document_tabs_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![event]); + assert_outside_canvas(app, ctx); } assert_eq!(app.session.documents().len(), 1, "B tab x must really close B"); assert_eq!(app.session.active_index(), Some(0)); - assert_eq!(app.session.active().unwrap().uid, uid, "the survivor is the original A, not a replacement document"); + assert_eq!(app.session.active().unwrap().uid, *uid, "the survivor is the original A, not a replacement document"); assert!(app.session.documents().iter().all(|d| d.uid != other_uid)); assert!(app.ui.dialog.is_none(), "a clean close must not leave a save/discard modal"); for _ in 0..2 { - artboard_document_tabs_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + artboard_document_tabs_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); } let st = app.session.active().unwrap(); - assert_eq!(st.doc, before, "closing B must not edit A"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_before, "closing B preserves all A Undo/Redo entries"); - assert_eq!(st.revision, revision); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert_eq!(st.doc.as_ref(), before.as_ref(), "closing B must not edit A"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st), *history_before, "closing B preserves all A Undo/Redo entries"); + assert_eq!(st.revision, *revision); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); assert!(!st.is_dirty()); assert_eq!(app.view().unwrap().artboard, 1); eprintln!("ARTBOARD_CLOSE_TARGET_CLOSED_B"); +} +/// Delete the board Properties shows, then Undo and Redo it. +fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) { + let Baseline { uid, revision, before, selection, history_before, history_limits, expected } = base; // Cross the tab strip to the Properties panel before moving down to its Delete control. app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); - outside_canvas(&app); + artboard_navigation_properties_frame(app, ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]); + assert_outside_canvas(app, ctx); app.sync_views(); - let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]); - outside_canvas(&app); + let texts = artboard_navigation_properties_frame(app, ctx, vec![]); + assert_outside_canvas(app, ctx); assert!(texts.iter().any(|(label, _)| label == "Shown"), "actual Properties must display Shown before deletion"); assert_eq!(app.view().unwrap().artboard, 1); let tool_before_delete = app.session.tool_options(); @@ -1300,14 +1321,15 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let commands = app.run_count; for event in [egui::Event::PointerMoved(at), button(true), button(false)] { app.sync_views(); - artboard_navigation_properties_frame(&mut app, &ctx, vec![event]); - outside_canvas(&app); + artboard_navigation_properties_frame(app, ctx, vec![event]); + assert_outside_canvas(app, ctx); } let st = app.session.active().unwrap(); assert_eq!(app.run_count, commands.wrapping_add(1), "the actual Properties click executes one command"); assert_eq!(st.doc.artboards.len(), 1, "the real Delete action removed one board"); assert_eq!(st.history.undo.len(), history_before.0.len() + 1); assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard"); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); eprintln!("ARTBOARD_CLOSE_TARGET_DELETE_CLICKED tool_before={tool_before_delete}"); // Do not assert tool alignment before this destructive-action oracle: it must expose the wrong deletion. assert_eq!( @@ -1315,40 +1337,77 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { vec!["Keep"], "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); - assert_eq!(st.doc.as_ref(), &expected, "only Shown and its attached guide may be removed from the full document"); + assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); - assert_eq!(st.selection, selection); - assert_eq!(st.uid, uid); + assert_eq!(&st.selection, selection); + assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); - let history_after = history(st); + undo_and_redo_the_deletion(app, base); +} + +/// Undo and Redo of the deletion that [`delete_shown_board`] made. +fn undo_and_redo_the_deletion(app: &mut crate::VectorcraftApp, base: &Baseline) { + let Baseline { uid, before, selection, history_before, history_limits, expected, revision: _ } = base; + let st = app.session.active().unwrap(); + let history_after = history_of(st); assert_eq!(&history_after.0[..history_before.0.len()], history_before.0.as_slice()); let entry = history_after.0.last().unwrap(); - assert_eq!(entry.1, before); - assert_eq!(entry.2.as_ref(), &selection); + assert_eq!(entry.1.as_ref(), before.as_ref()); + assert_eq!(entry.2.as_ref(), selection); assert!(history_after.1.is_empty()); assert!(st.is_dirty()); let deleted = st.doc.clone(); app.run("edit.undo", json!({})).unwrap(); let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc, before, "one Undo restores the full saved A including both boards, guides and artwork"); - assert_eq!(st.selection, selection); - assert_eq!(history(st).0, history_before.0); + assert_eq!(st.uid, *uid); + assert_eq!(st.doc.as_ref(), before.as_ref(), "one Undo restores the full saved A including both boards, guides and artwork"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st).0, history_before.0); assert_eq!(st.history.redo.len(), 1); assert_eq!(st.history.redo[0].label, "Delete Artboard"); - assert_eq!(st.history.redo[0].doc, deleted); - assert_eq!(st.history.redo[0].selection.as_ref(), &selection); + assert_eq!(st.history.redo[0].doc.as_ref(), deleted.as_ref()); + assert_eq!(st.history.redo[0].selection.as_ref(), selection); assert!(!st.is_dirty()); app.run("edit.redo", json!({})).unwrap(); let st = app.session.active().unwrap(); - assert_eq!(st.uid, uid); - assert_eq!(st.doc.as_ref(), &expected, "one Redo repeats only the correct deletion"); - assert_eq!(st.selection, selection); - assert_eq!(history(st), history_after); - assert_eq!((st.history.limit, st.history.byte_budget), history_limits); + assert_eq!(st.uid, *uid); + assert_eq!(st.doc.as_ref(), expected, "one Redo repeats only the correct deletion"); + assert_eq!(&st.selection, selection); + assert_eq!(history_of(st), history_after); + assert_eq!((st.history.limit, st.history.byte_budget), *history_limits); assert!(st.is_dirty()); assert_eq!(app.session.documents().len(), 1, "Undo/Redo do not reopen B"); eprintln!("ARTBOARD_CLOSE_TARGET_MODEL_AND_HISTORY_PRESERVED"); } + +#[test] +fn artboard_close_active_tab_properties_delete_uses_shown_board() { + let (mut app, ctx) = artboard_document_app(); + app.select_tool("artboard"); + app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap(); + app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap(); + artboard_navigation_next(&mut app, &ctx); + assert_outside_canvas(&app, &ctx); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown"); + app.session.active_mut().unwrap().mark_saved(); + let st = app.session.active().unwrap(); + let (uid, revision) = (st.uid, st.revision); + let before = st.doc.clone(); + let selection = st.selection.clone(); + let history_before = history_of(st); + let history_limits = (st.history.limit, st.history.byte_budget); + assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); + assert_eq!(before.guides.len(), 2); + assert!(!selection.objects.is_empty()); + assert!(history_before.1.is_empty()); + assert!(!st.is_dirty()); + let mut expected = before.as_ref().clone(); + expected.artboards = vec![before.artboards[0].clone()]; + expected.guides = vec![before.guides[0].clone()]; + let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; + close_b_tab(&mut app, &ctx, &base); + delete_shown_board(&mut app, &ctx, &base); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cd6efdf7c..14d6fe831 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -259,41 +259,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()), - "ui.pointer" => { - let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; - let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); - let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); - for e in events { - let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { - "down" => PointerKind::Down, - "drag" => PointerKind::Drag, - "up" => PointerKind::Up, - "move" => PointerKind::Move, - "doubleclick" | "dblclick" => PointerKind::DoubleClick, - other => return err(format!("unknown pointer kind `{other}`")), - }; - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); - let pos = if e.get("space").and_then(Value::as_str) == Some("screen") { - match xf { - Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), - None => return err("no canvas yet"), - } - } else { - vectorcraft_geom::Point::new(x, y) - }; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); - let hold = PointerEvent::json_hold(e); - if hold > 0.0 { - let r = app.session.tool_tick(hold, view); - crate::canvas::apply_requests(app, r); - } - } - ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) - } + "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { @@ -318,51 +284,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ctx.request_repaint(); ok(Value::Null) } - "ui.click" | "ui.drag" => { - // Validate the entire expansion before appending even the initial move/press. - let steps = if req.method == "ui.drag" { - match input_count(p, "steps", 8, MAX_DRAG_STEPS) { - Ok(n) => n, - Err(e) => return err(e), - } - } else { - 0 - }; - let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) { - Ok(n) => n, - Err(e) => return err(e), - }; - let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { - return err("synthetic input event count is out of range"); - }; - if let Err(e) = reserve_synthetic(app, events) { - return err(e); - } - // Screen-space (egui points) pointer input through egui itself: reaches every widget. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; - let button = match s("button") { - Some("right") | Some("secondary") => egui::PointerButton::Secondary, - _ => egui::PointerButton::Primary, - }; - let modifiers = modifiers(p); - let a = egui::pos2(f("x"), f("y")); - let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); - if req.method == "ui.drag" { - for i in 1..=steps { - let t = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); - } - } - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - for _ in 1..count { - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - } - ctx.request_repaint(); - ok(Value::Null) - } + "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { if let Err(e) = reserve_synthetic(app, 2) { return err(e); @@ -392,48 +314,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ctx.request_repaint(); ok(Value::Null) } - "ui.set" => { - let mut r = Ok(Value::Null); - if let Some(b) = s("brightness") { - r = app.run("window.brightness", json!({"brightness": b})); - } - if let Some(pn) = s("panel") { - r = app.run("window.panel", json!({"panel": pn})); - } - if let Some(st) = s("status") { - app.ui.status = st.to_string(); - } - for (k, flag) in [ - ("rulers", "view.rulers"), - ("outline", "view.outline"), - ("grid", "view.grid"), - ("smartGuides", "view.smartGuides"), - ("boundingBox", "view.boundingBox"), - ] { - if let Some(want) = p.get(k).and_then(Value::as_bool) { - let cur = match k { - "rulers" => app.ui.view.rulers, - "outline" => app.ui.view.outline, - "grid" => app.ui.view.grid, - "smartGuides" => app.ui.view.smart_guides, - _ => app.ui.view.bounding_box, - }; - if cur != want { - r = app.run(flag, json!({})); - } - } - } - if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { - app.ui.control_bar = v; - } - if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) { - r = app.run("imageTrace.preview", json!({"on": v})); - } - if p.get("closePanel").is_some() { - app.ui.open_panel = None; - } - wrap(r) - } + "ui.set" => set_ui(app, req), "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let fields = match dialog_fields(p) { @@ -619,3 +500,136 @@ pub(crate) fn save_screenshot_with_limit( #[cfg(test)] mod tests; + +/// `ui.pointer`: pointer events through the tools. +fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let view = app.view_info(); + let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + for e in events { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return err(format!("unknown pointer kind `{other}`")), + }; + let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let pos = if e.get("space").and_then(Value::as_str) == Some("screen") { + match xf { + Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), + None => return err("no canvas yet"), + } + } else { + vectorcraft_geom::Point::new(x, y) + }; + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let r = app.session.tool_tick(hold, view); + crate::canvas::apply_requests(app, r); + } + } + ctx.request_repaint(); + wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) +} + +/// `ui.click` and `ui.drag`: pointer input through egui itself. +fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + // Validate the entire expansion before appending even the initial move/press. + let steps = if req.method == "ui.drag" { + match input_count(p, "steps", 8, MAX_DRAG_STEPS) { + Ok(n) => n, + Err(e) => return err(e), + } + } else { + 0 + }; + let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) { + Ok(n) => n, + Err(e) => return err(e), + }; + let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { + return err("synthetic input event count is out of range"); + }; + if let Err(e) = reserve_synthetic(app, events) { + return err(e); + } + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let button = match s("button") { + Some("right") | Some("secondary") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let modifiers = modifiers(p); + let a = egui::pos2(f("x"), f("y")); + let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + if req.method == "ui.drag" { + for i in 1..=steps { + let t = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + } + } + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + for _ in 1..count { + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + } + ctx.request_repaint(); + ok(Value::Null) +} + +/// `ui.set`: view flags, panels and status. +fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + let mut r = Ok(Value::Null); + if let Some(b) = s("brightness") { + r = app.run("window.brightness", json!({"brightness": b})); + } + if let Some(pn) = s("panel") { + r = app.run("window.panel", json!({"panel": pn})); + } + if let Some(st) = s("status") { + app.ui.status = st.to_string(); + } + for (k, flag) in [ + ("rulers", "view.rulers"), + ("outline", "view.outline"), + ("grid", "view.grid"), + ("smartGuides", "view.smartGuides"), + ("boundingBox", "view.boundingBox"), + ] { + if let Some(want) = p.get(k).and_then(Value::as_bool) { + let cur = match k { + "rulers" => app.ui.view.rulers, + "outline" => app.ui.view.outline, + "grid" => app.ui.view.grid, + "smartGuides" => app.ui.view.smart_guides, + _ => app.ui.view.bounding_box, + }; + if cur != want { + r = app.run(flag, json!({})); + } + } + } + if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { + app.ui.control_bar = v; + } + if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) { + r = app.run("imageTrace.preview", json!({"on": v})); + } + if p.get("closePanel").is_some() { + app.ui.open_panel = None; + } + wrap(r) +} diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index 8c6ffa66d..e7cbfcc5b 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -380,6 +380,50 @@ fn warp_options_slider_drag_previews_exact_parameter_and_cancel_restores() { assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); } +/// The edit the Warp dialog test makes: the Bend field, the style, or none. +fn make_warp_edit(app: &mut VectorcraftApp, ctx: &egui::Context, edit: &str) { + match edit { + "bend" => { + click_number(app, ctx, "Bend:"); + frame(app, ctx, replace_number("25%")); + // Blur via the existing orientation choice; it stays Horizontal. + click_text(app, ctx, "Horizontal"); + } + "style" => { + click_text(app, ctx, "Arc"); + click_text(app, ctx, "Arc Lower"); + } + _ => {} + } +} + +/// OK or Cancel was clicked: the document, the history and Redo as each leaves them. +fn finish_warp_dialog( + app: &mut VectorcraftApp, + accept: bool, + (before, expected): (&std::sync::Arc, &std::sync::Arc), + (undo, redo): (usize, usize), +) { + let state = app.session.doc().unwrap(); + if accept { + // Whole-document equality also checks both targets, sibling effects and + // the first visible / second hidden Warp. Existing OK history stays one step. + assert_eq!(state.doc.as_ref(), expected.as_ref()); + assert_eq!(state.history.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.as_ref(), before.as_ref()); + assert!(!app.session.doc().unwrap().is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.as_ref(), expected.as_ref()); + } else { + assert_eq!(state.doc.as_ref(), before.as_ref()); + assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo)); + assert!(!state.is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_ne!(app.session.doc().unwrap().doc.as_ref(), before.as_ref(), "Cancel keeps the original Redo usable"); + } +} + #[test] fn warp_options_mixed_styles_change_only_after_explicit_style_choice() { for item in [Value::Null, json!(1)] { @@ -423,19 +467,7 @@ fn warp_options_mixed_styles_change_only_after_explicit_style_choice() { click_text(&mut app, &ctx, "Edit"); settle(&mut app, &ctx); assert_eq!(app.session.doc().unwrap().doc, before, "opening must not broadcast the first target's style"); - match edit { - "bend" => { - click_number(&mut app, &ctx, "Bend:"); - frame(&mut app, &ctx, replace_number("25%")); - // Blur via the existing orientation choice; it stays Horizontal. - click_text(&mut app, &ctx, "Horizontal"); - } - "style" => { - click_text(&mut app, &ctx, "Arc"); - click_text(&mut app, &ctx, "Arc Lower"); - } - _ => {} - } + make_warp_edit(&mut app, &ctx, edit); assert_eq!(app.session.doc().unwrap().doc, expected, "edit={edit}, item={item}"); assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); click_text(&mut app, &ctx, "Preview"); @@ -449,23 +481,7 @@ fn warp_options_mixed_styles_change_only_after_explicit_style_choice() { let state = app.session.doc().unwrap(); assert_eq!(state.selection, selection); assert_eq!(app.last_effect, last_effect); - if accept { - // Whole-document equality also checks both targets, sibling effects and - // the first visible / second hidden Warp. Existing OK history stays one step. - assert_eq!(state.doc, expected); - assert_eq!(state.history.undo.len(), undo + 1); - app.run("edit.undo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, before); - assert!(!app.session.doc().unwrap().is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_eq!(app.session.doc().unwrap().doc, expected); - } else { - assert_eq!(state.doc, before); - assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo)); - assert!(!state.is_dirty()); - app.run("edit.redo", json!({})).unwrap(); - assert_ne!(app.session.doc().unwrap().doc, before, "Cancel keeps the original Redo usable"); - } + finish_warp_dialog(&mut app, accept, (&before, &expected), (undo, redo)); } } } @@ -525,6 +541,18 @@ fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &st panic!("Tab traversal did not focus {label}"); } +/// With Preview on the Style choice shows on the art in an interaction; off, the document is as it was. +fn check_style_preview(app: &VectorcraftApp, preview: bool, id: NodeId, before: &std::sync::Arc) { + let state = app.session.doc().unwrap(); + if preview { + assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower"); + assert!(app.session.in_interaction()); + } else { + assert_eq!(state.doc.as_ref(), before.as_ref()); + assert!(!state.is_dirty() && !app.session.in_interaction()); + } +} + #[test] fn warp_options_style_keyboard_enter_does_not_accept_dialog() { for preview in [true, false] { @@ -575,13 +603,7 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() { assert_eq!(entries(&state.history.undo), undo); assert_eq!(entries(&state.history.redo), redo); assert_eq!(app.last_effect, last_effect); - if preview { - assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower"); - assert!(app.session.in_interaction()); - } else { - assert_eq!(state.doc, before); - assert!(!state.is_dirty() && !app.session.in_interaction()); - } + check_style_preview(&app, preview, id, &before); click_text(&mut app, &ctx, "Cancel"); let state = app.session.doc().unwrap(); assert_eq!(state.doc, before); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index c23035794..93ae778e5 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -452,6 +452,34 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & Listed { st, rows, current: current.to_string(), enter, keyed, opening } } +/// The disclosure triangle of a family with several styles; whether the pointer is on it. +fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool { + p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim); + resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos)) +} + +/// The star at the end of a family's row, shown when it is a favourite, hovered or highlighted; whether the pointer is +/// on it. +fn star_cell(p: &egui::Painter, resp: &egui::Response, star: egui::Rect, (on, highlighted): (bool, bool), t: &Tokens) -> bool { + let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos)); + if on || hot || highlighted { + let color = if on { t.accent } else { t.text_dim }; + p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color); + } + resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos)) +} + +/// A heading row (Recent Fonts, Fonts), which cannot preview or apply a font. +fn heading_row(ui: &mut Ui, row_h: f32, label: &str, t: &Tokens) { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover()); + // Existing translated menu labels; the heading cannot preview or apply a font. + let label = match label { + "Recent Fonts" => tl!("Recent Fonts"), + _ => tl!("Fonts"), + }; + ui.painter_at(rect).text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.0), t.text_dim); +} + /// The rows of the open menu in `range`, the ones in view: the menu's one scrolling part, under /// the search field and filters (#555). fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option { @@ -490,19 +518,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio for i in range { let Some(row) = rows.get(i) else { continue }; if let Row::Heading(label) = row { - let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover()); - // Existing translated menu labels; the heading cannot preview or apply a font. - let label = match *label { - "Recent Fonts" => tl!("Recent Fonts"), - _ => tl!("Fonts"), - }; - ui.painter_at(rect).text( - rect.left_center() + vec2(4.0, 0.0), - egui::Align2::LEFT_CENTER, - label, - egui::FontId::proportional(11.0), - t.text_dim, - ); + heading_row(ui, row_h, label, &t); continue; } let Some(family) = row.family() else { continue }; @@ -523,8 +539,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio && db.styles(f).len() > 1 { let open = st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)); - p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim); - on_triangle = resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos)); + on_triangle = disclosure(&p, &resp, tri, open, &t); if resp.clicked() && on_triangle { toggled = Some(f.to_lowercase()); } @@ -534,12 +549,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio let mut on_star = false; if let Row::Family(f) = row { let on = favorite(f); - let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos)); - if on || hot || st.highlight == Some(i) { - let color = if on { t.accent } else { t.text_dim }; - p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color); - } - on_star = resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos)); + on_star = star_cell(&p, &resp, star, (on, st.highlight == Some(i)), &t); if resp.clicked() && on_star { starred = Some(f.clone()); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0ad8e2ace..a34d6184b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -23,6 +23,27 @@ fn tags_map_to_languages() { for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] { assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}"); } + other_tags_map_to_languages(); + // Traditional Chinese: by region, by script, and with a region after the script. + assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH())); + assert_eq!(lang_from_tag("zh-TW"), Some(ZH())); + assert_eq!(lang_from_tag("zh-HK"), Some(ZH())); + assert_eq!(lang_from_tag("zh_MO"), Some(ZH())); + assert_eq!(lang_from_tag("zh-Hant"), Some(ZH())); + assert_eq!(lang_from_tag("zh-Hant-TW"), Some(ZH())); + assert_eq!(lang_from_tag("zh-Hant-HK"), Some(ZH())); + assert_eq!(lang_from_tag("zh_TW.UTF-8@radical"), Some(ZH())); + // Simplified Chinese locales never pick up the Traditional catalog (they resolve to a + // `zh-hans` catalog once one is registered, and to English until then). + for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] { + assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}"); + } + assert_eq!(lang_from_tag(""), None); + assert_eq!(lang_from_tag("_"), None); +} + +/// The Georgian, Ukrainian, Hungarian, Korean, Vietnamese and Swedish tags of [`tags_map_to_languages`]. +fn other_tags_map_to_languages() { for tag in ["ka", "ka-GE", "ka_GE.UTF-8", "KA-ge"] { assert_eq!(lang_from_tag(tag), Some(ka()), "{tag}"); } @@ -41,22 +62,6 @@ fn tags_map_to_languages() { for tag in ["sv", "sv-SE", "sv-FI", "sv_SE.UTF-8", "SV-se"] { assert_eq!(lang_from_tag(tag), Lang::from_code("sv"), "{tag}"); } - // Traditional Chinese: by region, by script, and with a region after the script. - assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH())); - assert_eq!(lang_from_tag("zh-TW"), Some(ZH())); - assert_eq!(lang_from_tag("zh-HK"), Some(ZH())); - assert_eq!(lang_from_tag("zh_MO"), Some(ZH())); - assert_eq!(lang_from_tag("zh-Hant"), Some(ZH())); - assert_eq!(lang_from_tag("zh-Hant-TW"), Some(ZH())); - assert_eq!(lang_from_tag("zh-Hant-HK"), Some(ZH())); - assert_eq!(lang_from_tag("zh_TW.UTF-8@radical"), Some(ZH())); - // Simplified Chinese locales never pick up the Traditional catalog (they resolve to a - // `zh-hans` catalog once one is registered, and to English until then). - for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] { - assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}"); - } - assert_eq!(lang_from_tag(""), None); - assert_eq!(lang_from_tag("_"), None); } /// Italian, Brazilian Portuguese, Finnish, Turkish and Polish resolve from their locale tags and diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 05df48941..d7bb70977 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -2,7 +2,7 @@ use super::save::{ ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting, }; -use super::*; +use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; /// The status once an export was cancelled. pub const EXPORT_CANCELLED: &str = "Export cancelled"; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 35da8b1b3..c8261ea85 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,5 +1,5 @@ use super::save::suggested; -use super::*; +use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; /// Template extensions New from Template's open dialog lists first. const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index b5489b33d..b9e2ddeed 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -1,6 +1,9 @@ use super::export::file_url; use super::open::{note_recent, open_options}; -use super::*; +use super::{ + EngineError, FilePick, Format, SAVE_FORMATS, SaveMode, SavePlan, SavedView, Services, TEMPLATE_FORMATS, Value, VectorcraftApp, Writer, + background, dialogs, fileio, json, svg_options, +}; /// A save or an export that didn't write `path`: say so in a dialog that names the file and the /// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which diff --git a/crates/ui-egui/src/layout_migration/tests.rs b/crates/ui-egui/src/layout_migration/tests.rs index a717783bd..d97567eb1 100644 --- a/crates/ui-egui/src/layout_migration/tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -75,6 +75,20 @@ fn a_layout_saved_before_the_canvas_joined_keeps_its_arrangement() { assert_eq!(groups(docked), [vec!["pathfinder"], vec!["transform"]], "set docked as a column"); assert!(!docked.iconic); + floating_windows_keep_their_places(layout); + + // Symbols is in the icon column, and the floating panels came from it: none keeps a + // place in the dock to go back to. + assert!(ui.docking_hidden.is_empty(), "{:?}", ui.docking_hidden); + + let workspace = ui.custom_workspaces.iter().find(|w| w.name == "Migration Test").expect("custom workspace"); + assert_eq!(workspace.docking, ui.docking); + assert_eq!(workspace.docking_hidden, ui.docking_hidden); + assert!(workspace.floating_panels.is_empty() && workspace.docking_icons.is_empty()); +} + +/// The three floating windows of the migrated layout. +fn floating_windows_keep_their_places(layout: &Layout) { assert_eq!(layout.floating.len(), 3); let color = &layout.floating[0]; assert_eq!(window_groups(color), [vec!["color", "stroke"]]); @@ -88,15 +102,6 @@ fn a_layout_saved_before_the_canvas_joined_keeps_its_arrangement() { assert_eq!(window_groups(align), [vec!["align"]]); assert!(align.minimized, "collapsed floating group"); assert_eq!(&align.rect[..2], &[300.0, 560.0]); - - // Symbols is in the icon column, and the floating panels came from it: none keeps a - // place in the dock to go back to. - assert!(ui.docking_hidden.is_empty(), "{:?}", ui.docking_hidden); - - let workspace = ui.custom_workspaces.iter().find(|w| w.name == "Migration Test").expect("custom workspace"); - assert_eq!(workspace.docking, ui.docking); - assert_eq!(workspace.docking_hidden, ui.docking_hidden); - assert!(workspace.floating_panels.is_empty() && workspace.docking_icons.is_empty()); } #[test] diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index b00d500d9..8ad27053a 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -1,7 +1,7 @@ use super::entries::{Row, entry, pretty_shortcut}; use super::invoke::{field_event, focused_text_field, invoke, to_field}; use super::tree::menu_tree_named; -use super::*; +use super::{Entry, Item, Tokens, Value, VectorcraftApp, widgets}; /// Render the menu bar. /// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index 8a6bfbf44..9a4284bb1 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -1,7 +1,7 @@ use super::bar::render_items; use super::state::enabled; use super::tree::{arrange_items, transform_items}; -use super::*; +use super::{Item, Sep, Value, VectorcraftApp, c, cp, json, sub, widgets}; /// The canvas context menu (right-click): what applies to the selection, or to the view when /// nothing is selected. Commands that can't run now are left out rather than greyed out. diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index fab26507c..fa78fef80 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -3,7 +3,7 @@ use super::invoke::click_target; use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label}; use super::state::{shortcut_of, shown_state}; use super::tree::menu_tree_named; -use super::*; +use super::{Entry, Item, Serialize, Value, VectorcraftApp}; /// A menu item as it shows now ([`entry`]). pub struct Row<'a> { diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 9b4dd262f..a0e7acae6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -1,5 +1,5 @@ use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime}; -use super::*; +use super::{Value, VectorcraftApp, json}; /// What a menu click runs: items whose label ends with "…" and that carry default params open a /// generic parameter dialog (fields = the defaults) instead of running immediately. @@ -119,144 +119,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } - // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. - if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::envelope::open(app, id) { - app.status(e); - } - return; - } - // Variables (data merge): a name and a kind, a name and a row of values. - if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::variables::open(app, id, None); - return; - } - // Pathfinder Options: a dialog with the current options. - if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { - crate::dialogs::pathfinder_options::open(app); - return; - } - // Repeat Options: a dialog with the selected repeat's current values. - if id == "object.repeat.options" - && p.as_object().is_none_or(|o| o.is_empty()) - && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) - { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); - return; - } - // Area Type Options and Type on a Path Options: dialogs with the selected type's current - // values (the command's query), less those the dialog leaves alone. - if let Some((label, hidden)) = queried_dialog(id) - && p.as_object().is_none_or(|o| o.is_empty()) - { - match app.session.execute(id, &json!({})) { - Ok(mut fields) => { - if let Some(o) = fields.as_object_mut() { - o.retain(|k, _| !hidden.contains(&k.as_str())); - } - let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. - if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = graph_dialog(app, id) { - app.status(e); - } - return; - } - // Object → Graph → Design… / Column… / Marker…: the document's graph designs. - if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = match id { - "graph.design" => crate::dialogs::graph_design::open_design(app), - "graph.column" => crate::dialogs::graph_design::open_column(app), - _ => crate::dialogs::graph_design::open_marker(app), - }; - if let Err(e) = r { - app.status(e); - } - return; - } - // New View…: a name dialog, starting from the first free "View N". - if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); - return; - } - // Edit Views…: the saved views in a list, to rename or delete. - if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_views::open(app) { - app.status(e); - } - return; - } - // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with - // the view's angle filled in. - if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.view_mut().map(|v| v.rotation) { - Some(angle) => { - app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); - } - None => app.status("no document"), - } - return; - } - // Save Selection…: a name dialog, starting from the first free "Selection N". - if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { - let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); - return; - } - // Edit Selection…: the saved selections in a list, to rename or delete. - if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::edit_selection::open(app) { - app.status(e); - } - return; - } - // Document Raster Effects Settings and File Info: their dialogs. - if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { - let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; - if let Err(e) = r { - app.status(e); - } - return; - } - // Rasterize…: its options, starting from the document's raster effects settings. - if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute("document.rasterEffectsSettings", &json!({})) { - Ok(v) => { - let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Text Wrap Options: a dialog with the selected wrap object's current values. - if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute(id, &json!({})) { - Ok(fields) => { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); - } - Err(e) => app.status(e.to_string()), - } - return; - } - // Blend Options…: its dialog, on the selected blend's options. - if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::blend_options::open(app) { - app.status(e); - } - return; - } - // Expand…: its dialog. - if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { - if let Err(e) = crate::dialogs::expand::open(app) { - app.status(e); - } + if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) { return; } // Help links: the command returns the URL; a menu click opens it (agents just get the URL). @@ -277,3 +140,155 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.open_panel = Some("patternOptions".into()); } } + +/// The dialogs the Envelope, Variables, Pathfinder, Repeat, type, graph and View commands open when called without +/// params; whether `id` was one of them. +fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { + // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. + if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::envelope::open(app, id) { + app.status(e); + } + return true; + } + // Variables (data merge): a name and a kind, a name and a row of values. + if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::variables::open(app, id, None); + return true; + } + // Pathfinder Options: a dialog with the current options. + if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::pathfinder_options::open(app); + return true; + } + // Repeat Options: a dialog with the selected repeat's current values. + if id == "object.repeat.options" + && p.as_object().is_none_or(|o| o.is_empty()) + && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) + { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); + return true; + } + // Area Type Options and Type on a Path Options: dialogs with the selected type's current + // values (the command's query), less those the dialog leaves alone. + if let Some((label, hidden)) = queried_dialog(id) + && p.as_object().is_none_or(|o| o.is_empty()) + { + match app.session.execute(id, &json!({})) { + Ok(mut fields) => { + if let Some(o) = fields.as_object_mut() { + o.retain(|k, _| !hidden.contains(&k.as_str())); + } + let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return true; + } + // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. + if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = graph_dialog(app, id) { + app.status(e); + } + return true; + } + // Object → Graph → Design… / Column… / Marker…: the document's graph designs. + if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = match id { + "graph.design" => crate::dialogs::graph_design::open_design(app), + "graph.column" => crate::dialogs::graph_design::open_column(app), + _ => crate::dialogs::graph_design::open_marker(app), + }; + if let Err(e) = r { + app.status(e); + } + return true; + } + // New View…: a name dialog, starting from the first free "View N". + if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); + return true; + } + // Edit Views…: the saved views in a list, to rename or delete. + if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_views::open(app) { + app.status(e); + } + return true; + } + // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with + // the view's angle filled in. + if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.view_mut().map(|v| v.rotation) { + Some(angle) => { + app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); + } + None => app.status("no document"), + } + return true; + } + false +} + +/// The dialogs the selection, raster, wrap, blend and Expand commands open when called without params; whether `id` +/// was one of them. +fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { + // Save Selection…: a name dialog, starting from the first free "Selection N". + if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); + return true; + } + // Edit Selection…: the saved selections in a list, to rename or delete. + if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_selection::open(app) { + app.status(e); + } + return true; + } + // Document Raster Effects Settings and File Info: their dialogs. + if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; + if let Err(e) = r { + app.status(e); + } + return true; + } + // Rasterize…: its options, starting from the document's raster effects settings. + if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute("document.rasterEffectsSettings", &json!({})) { + Ok(v) => { + let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); + } + Err(e) => app.status(e.to_string()), + } + return true; + } + // Text Wrap Options: a dialog with the selected wrap object's current values. + if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(fields) => { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return true; + } + // Blend Options…: its dialog, on the selected blend's options. + if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::blend_options::open(app) { + app.status(e); + } + return true; + } + // Expand…: its dialog. + if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::expand::open(app) { + app.status(e); + } + return true; + } + false +} diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 12afaf058..c95a17c46 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -2,12 +2,21 @@ use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_se use super::state::{hidden_when_disabled, shortcut_of}; use super::tree::menu_tree; use super::ui_commands::UI_COMMANDS; -use super::*; +use super::{Item, Value, VectorcraftApp}; /// Label for toggles whose text flips (Outline/Preview, Hide/Show …). pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { + view_toggle_label(app, id) + .or_else(|| window_toggle_label(app, id)) + .or_else(|| slot_label(app, id)) + .or_else(|| command_label(app, id, label)) + .unwrap_or_else(|| label.into()) +} + +/// The View menu items that flip between Hide and Show (edges, template, threads, …). +fn view_toggle_label(app: &VectorcraftApp, id: &str) -> Option { let v = &app.ui.view; - match id { + let text: String = match id { "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), "window.workspace.reset" => format!("Reset {}", app.ui.workspace), "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), @@ -17,16 +26,15 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), - "effect.last" => match &app.last_effect { - Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), - None => label.into(), - }, - id if id.starts_with("type.recentFont") => { - let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) - } - id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), - id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), + _ => return None, + }; + Some(text) +} + +/// The rulers, guides, grid and print tiling items that flip between Hide and Show. +fn window_toggle_label(app: &VectorcraftApp, id: &str) -> Option { + let v = &app.ui.view; + let text: String = match id { "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), @@ -45,9 +53,50 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), "view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(), + _ => return None, + }; + Some(text) +} + +/// The menu slots that show a name: recent fonts, saved views and selections, recent files, libraries and presets. +fn slot_label(app: &VectorcraftApp, id: &str) -> Option { + let text: String = match id { + id if id.starts_with("type.recentFont") => { + let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); + n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) + } + id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()), + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), id if id.starts_with("file.openRecent") => recent_slot(app, id) .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) .unwrap_or_else(|| "—".into()), + id if id.starts_with(crate::panels::swatches::USER_SLOT) => { + crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { + crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => { + crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::panels::brushes::USER_SLOT) => { + crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) + } + _ => return None, + }; + Some(text) +} + +/// The labels that follow the last effect, the envelope being edited, the history and the perspective grid. +fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option { + let text: String = match id { + "effect.last" => match &app.last_effect { + Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), + None => label.into(), + }, // Edit Contents reads Edit Envelope while an envelope's contents are being edited. "object.envelope.editContents" => { let editing = app.session.active().is_some_and(|st| { @@ -64,26 +113,12 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), - id if id.starts_with(crate::panels::swatches::USER_SLOT) => { - crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { - crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::symbols::USER_SLOT) => { - crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::panels::brushes::USER_SLOT) => { - crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) - } - id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { - crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) - } "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), - _ => label.into(), - } + _ => return None, + }; + Some(text) } /// Does the menu item `id` show a name that is user, file or system data rather than an interface diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 57137bee5..8e2e7a93a 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -3,7 +3,7 @@ use super::invoke::menu_dialog; use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; use super::state::{checked, selected_image}; use super::ui_commands::UI_COMMANDS; -use super::*; +use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom}; /// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, /// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id @@ -74,11 +74,24 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Option> { let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); - let flag = |b: &mut bool| { - *b = !*b; - Ok(json!(*b)) - }; let r = match id { "file.newDialog" => { crate::dialogs::open_new_document(app); @@ -116,6 +129,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// UI commands, part 2. +fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { "ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")), "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), "file.clearRecent" => { @@ -156,6 +182,18 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.outline), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 3. +fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), "view.trimView" => flag(&mut app.ui.view.trim_view), "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), @@ -203,6 +241,18 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::paragraph::apply_bullets(app, p), "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 4. +fn run_ui_part4(app: &mut VectorcraftApp, id: &str, _p: &Value) -> Option> { + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), @@ -259,6 +309,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.smart_guides), "view.grid" => flag(&mut app.ui.view.grid), "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 5. +fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), "view.zoomIn" | "view.zoomOut" => { let up = id == "view.zoomIn"; @@ -370,6 +433,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// UI commands, part 6. +fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { "window.taskBar" => flag(&mut app.ui.task_bar), "window.taskBar.pin" => { let place = &mut app.ui.task_bar_place; @@ -462,6 +538,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_save_pdf(app, &json!({})), "file.export.pdf" => io::export_pdf(app, p.clone()), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 7. +fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "help.about" => { app.ui.about = true; Ok(Value::Null) @@ -506,6 +591,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::new_brush::open(app, p.get("ids").cloned()), "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 8. +fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "ui.colorGuideOptions" => { crate::dialogs::color_guide_options::open(app); Ok(Value::Null) @@ -543,6 +637,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// UI commands, part 9. +fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "ui.flattenerPresetsDialog" => { crate::dialogs::flattener_presets::open(app, s("selected").as_deref()); Ok(Value::Null) @@ -580,6 +683,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::brushes::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), }, + _ => return None, + }; + Some(r) +} + +/// UI commands, part 10. +fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "ui.expandDialog" => crate::dialogs::expand::open(app), "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), @@ -608,6 +720,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, s("format").as_deref(), s("path"), p), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 11. +fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), @@ -637,6 +758,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::shortcut_import::command(app, p), + _ => return None, + }; + Some(r) +} + +/// UI commands, part 12. +fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "file.showPackage" => crate::dialogs::package::show_package(app, p), "docInfo.save" => crate::panels::doc_info::save_report(app, p), // Slice Options… and Divide Slices…: their dialogs, on the selected slices. @@ -673,6 +803,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("printing isn't available here".into()), } } + _ => return None, + }; + Some(r) +} + +/// UI commands, part 13. +fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let r = match id { "ui.printPresetsDialog" => { crate::dialogs::print_presets::open(app, s("selected").as_deref()); Ok(Value::Null) @@ -696,6 +835,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::blend_options::open(app), "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), + _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs index 8ecc79b9f..2dee19dcf 100644 --- a/crates/ui-egui/src/menus/slots.rs +++ b/crates/ui-egui/src/menus/slots.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{VectorcraftApp, io}; /// The active document's perspective grid (View → Perspective Grid toggles). pub(super) fn perspective_grid(app: &VectorcraftApp) -> Option { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 3ca86c0a3..0c6bb8206 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -1,6 +1,6 @@ use super::run::normalize_panel; use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot}; -use super::*; +use super::{Brightness, Value, VectorcraftApp}; /// Checked state for toggle items. pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 56544260c..00637705e 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Item, Sep, c, cp, json, sub, todo}; /// Type → Size presets. pub(super) const TYPE_SIZES: [(&str, u32); 14] = [ diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index d806eac4a..2d3a81725 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -1,6 +1,6 @@ use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; -use super::*; +use super::{Item, Sep, c, cp, json, sub, todo, todos}; /// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see /// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 08336673d..813700105 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -568,12 +568,8 @@ fn open_popover(app: &mut VectorcraftApp, i: usize, at: Pos2) { app.ui.dialog = Some(Dialog::new("gradientStop", json!({ "index": i, "screen": [at.x, at.y], "tab": "color" }))); } -/// The gradient slider: ramp, stops below, midpoint diamonds above. -fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); - let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0)); +/// The ramp's colours over a checkerboard, which shows the opacity. +fn paint_ramp(ui: &Ui, g: &Gradient, bar: Rect) { // Checkerboard under the ramp (opacity). let cell = 6.0; let mut y = bar.top(); @@ -599,6 +595,42 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height())); ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a)); } +} + +/// A midpoint diamond centred at `c`. +fn paint_diamond(ui: &Ui, t: &Tokens, c: Pos2, lit: bool) { + let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; + ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); +} + +/// A house-shaped stop marker at `(x, top)`, with a chip of the stop's colour. +fn paint_stop(ui: &Ui, t: &Tokens, (x, top): (f32, f32), (selected, outline): (bool, Color32), color: &vectorcraft_color::Color) { + let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)]; + ui.painter().add(egui::Shape::convex_polygon(body, if selected { t.text_strong } else { t.icon }, Stroke::new(1.0, outline))); + let chip = Rect::from_min_size(pos2(x - 4.0, top + 6.0), vec2(8.0, 7.0)); + ui.painter().rect_filled(chip, 0.0, super::c32(color)); +} + +/// The stops after a colour swatch was dropped on the ramp `bar`, and the stop to select. +fn dropped_swatch(app: &VectorcraftApp, ui: &Ui, g: &Gradient, area_resp: &egui::Response, bar: Rect) -> Option<(Vec, usize)> { + let marker = |o: f32| Rect::from_min_size(pos2(bar.left() + o * bar.width() - 6.0, bar.bottom() + 2.0), vec2(12.0, 16.0)); + let d = area_resp.dnd_release_payload::()?; + let c = d.color()?; + let p = ui.input(|i| i.pointer.latest_pos())?; + let link = match &*d { + PanelDrag::Paint { params, .. } => dropped_link(app, params), + _ => None, + }; + Some(drop_color(g, g.stops.iter().position(|s| marker(s.offset).contains(p)), x_to_offset(p.x, bar.left(), bar.width()), c, link)) +} + +/// The gradient slider: ramp, stops below, midpoint diamonds above. +fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); + let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0)); + paint_ramp(ui, g, bar); // A colour swatch held over the ramp: outline it as a drop target. let droppable = is_grad && area_resp.dnd_hover_payload::().is_some_and(|d| d.color().is_some()); let edge = if droppable { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; @@ -616,7 +648,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let origin = if origin.is_empty() { stops.clone() } else { origin }; // The stop of `origin` other than `i` whose marker is under `p`. let stop_under = |p: Pos2, i: usize| origin.iter().enumerate().position(|(j, s)| j != i && marker(s.offset).contains(p)); - let mut changed: Option<(Vec, Live)> = None; + let mut changed: Option = None; // The stop to select once `changed` is applied. let mut select: Option = None; let mut pick_mid: Option = None; @@ -645,8 +677,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let rect = Rect::from_center_size(c, vec2(10.0, 10.0)); let resp = ui.interact(rect, ui.scope_id().with(("grad-mid", i)), Sense::click_and_drag()); let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered(); - let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; - ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); + paint_diamond(ui, &t, c, lit); if !is_grad { continue; } @@ -683,11 +714,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let off_ramp = |p: Pos2| !copy && p.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS; let off = dragging_this && resp.interact_pointer_pos().is_some_and(off_ramp); let outline = if sel == Some(i) { t.accent } else { t.border }; - let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)]; if !off { - ui.painter().add(egui::Shape::convex_polygon(body, if sel == Some(i) { t.text_strong } else { t.icon }, Stroke::new(1.0, outline))); - let chip = Rect::from_min_size(pos2(x - 4.0, top + 6.0), vec2(8.0, 7.0)); - ui.painter().rect_filled(chip, 0.0, super::c32(&s.color)); + paint_stop(ui, &t, (x, top), (sel == Some(i), outline), &s.color); } if !is_grad { continue; @@ -727,20 +755,26 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { } } // A colour swatch dropped on the ramp. - if is_grad - && let Some(d) = area_resp.dnd_release_payload::() - && let Some(c) = d.color() - && let Some(p) = ui.input(|i| i.pointer.latest_pos()) - { - let link = match &*d { - PanelDrag::Paint { params, .. } => dropped_link(app, params), - _ => None, - }; - let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c, link); + if is_grad && let Some((v, i)) = dropped_swatch(app, ui, g, &area_resp, bar) { select = Some(i); changed = Some((v, Live::Released)); } set_pstate(ui.ctx(), "grad-drag", drag); + apply_ramp_edit(app, ui, sel, (pick_mid, select), changed, popover); +} + +/// New stops and how live the edit is. +type StopEdit = (Vec, Live); + +/// What the ramp's gestures this frame asked for: the midpoint or stop to select, the new stops, the stop popover. +fn apply_ramp_edit( + app: &mut VectorcraftApp, + ui: &Ui, + sel: Option, + (pick_mid, select): (Option, Option), + changed: Option, + popover: Option<(usize, Pos2)>, +) { if let Some(i) = pick_mid { set_pstate(ui.ctx(), MID_SELECTED, Some((i, first_id(app)))); if sel.is_some() { diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index f46124c8b..e19b2591b 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -319,16 +319,9 @@ fn slider(ui: &mut Ui, label: &str, v: &mut f64, range: std::ops::RangeInclusive out } -pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { - let all = presets(app); - let mut st: TraceUi = pstate(ui.ctx(), "image-trace"); - if st.params.is_null() { - st.preset = "Default".into(); - st.params = all.first().map(|p| p.1.clone()).unwrap_or_default(); - } - let (is_trace, is_image, stored) = target(app); - let any_trace = any_trace(app); - let object = target_key(app); +/// Bring the panel's settings `st` in line with the selected object `object`, whose trace settings are `stored`; +/// whether the object came back with the settings that waited for it. +fn sync_with_object(st: &mut TraceUi, stored: Option, object: Option<(u64, NodeId)>) -> bool { let same = |a: &Value, b: &Value| a["preset"] == b["preset"] && a["params"] == b["params"]; let mut restored = false; // Another object: the settings that wait for the one left are kept until it is selected again, @@ -366,6 +359,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { st.synced = t; st.object = object; } + restored +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let all = presets(app); + let mut st: TraceUi = pstate(ui.ctx(), "image-trace"); + if st.params.is_null() { + st.preset = "Default".into(); + st.params = all.first().map(|p| p.1.clone()).unwrap_or_default(); + } + let (is_trace, is_image, stored) = target(app); + let any_trace = any_trace(app); + let object = target_key(app); + let restored = sync_with_object(&mut st, stored, object); let mut retrace = false; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index a8b0b3abe..f911c66ad 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -1,5 +1,8 @@ use super::model::{ObjectKind, RowState, names, parse_names, toggled}; -use super::*; +use super::{ + Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, StrokeKind, Ui, Value, VectorcraftApp, View, json, + last_selected, see_selection, vec2, widgets, +}; /// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under /// By Object and the row states under By Layer, each ticked or not. A tick, or Clear All, runs diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index 642aec542..722391911 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -1,5 +1,5 @@ use super::rows::key; -use super::*; +use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; /// Where a dragged row goes relative to the row it is dropped on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 7c92566aa..5ed4c44ba 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -1,7 +1,10 @@ use super::controls::collect_for_export_menu; use super::model::{Place, Row, opens, painted_name}; use super::thumbs::{node_arc, real_thumb, thumb}; -use super::*; +use super::{ + Arc, COLUMN, Color32, ColumnDrag, Document, INDENT, LayersDrag, Node, NodeId, NodeKind, Out, PanelDrag, SQUARE_X, Sense, Stroke, StrokeKind, + TARGET_X, Tokens, Ui, Value, View, icons, json, vec2, widgets, +}; /// Stable id for the layers panel's per-name UI state. pub(super) fn key(name: &str) -> egui::Id { @@ -162,6 +165,42 @@ fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Re } } +/// The row's fill (selected, current or hovered), its bottom edge and the current layer's marker. +fn row_background(ui: &Ui, view: &View, n: &Node, r: egui::Rect, resp: &egui::Response) { + let t = &view.t; + // Highlighted rows (else the current layer's). + let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; + if highlighted { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + if n.is_layer() && Some(n.id) == view.current { + // Current-layer marker: a small triangle in the top-right corner. + let c = r.right_top(); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE)); + } +} + +/// The disclosure triangle of a row at `(r, x)`; Alt opens or closes everything inside too. +fn disclosure(ui: &Ui, view: &View, n: &Node, (r, x): (egui::Rect, f32), open: bool, out: &mut Out) { + let t = &view.t; + let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); + let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click()); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); + if dresp.clicked() { + out.toggle.push((n.id, !open)); + if ui.input(|i| i.modifiers.alt) { + n.walk(&mut |c| { + if c.id != n.id && opens(c, &view.opts) { + out.toggle.push((c.id, !open)); + } + }); + } + } +} + /// The widgets of row `item`. pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { if let Some(i) = item.guide { @@ -183,39 +222,14 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); any }); - // Highlighted rows (else the current layer's). - let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; - if highlighted { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); - } - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - if n.is_layer() && Some(n.id) == view.current { - // Current-layer marker: a small triangle in the top-right corner. - let c = r.right_top(); - ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE)); - } + row_background(ui, view, n, r, &resp); eye_and_lock(ui, view, n, r, out); // Layer colour bar (every row shows the colour of its layer). let mut x = r.left() + 2.0 * COLUMN + 2.0; colour_bar(ui, n, r, x, color); x += 6.0 + depth as f32 * INDENT; - // Disclosure triangle; Alt opens or closes everything inside too. if has_children { - let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); - let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click()); - icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); - if dresp.clicked() { - out.toggle.push((n.id, !open)); - if ui.input(|i| i.modifiers.alt) { - n.walk(&mut |c| { - if c.id != n.id && opens(c, &view.opts) { - out.toggle.push((c.id, !open)); - } - }); - } - } + disclosure(ui, view, n, (r, x), open, out); } x += 16.0; // Thumbnail. diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index cd9850212..adcadd6a2 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Color32, Document, HashMap, Node, NodeId, Settle, ThumbWorker, Ui, VectorcraftApp, Weak, vec2}; /// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in /// progress (the thumbnails shown then stay until it ends). diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index f82c9db91..1dc860dd8 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -828,6 +828,20 @@ fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui } } +/// Duplicate the artboards, or add a new one, the way the test's history step needs. +fn add_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, duplicate: bool) { + if duplicate { + let commands = app.run_count; + artboard_deletion_click(app, ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE); + assert_eq!(app.run_count, commands.wrapping_add(1)); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3); + assert_eq!(app.session.tool_options()["active"], json!(2)); + assert_eq!(app.view().unwrap().artboard, 2); + } else { + artboard_history_new(app, ctx); + } +} + fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { let mut app = artboard_deletion_app(2); app.session.set_tool_option("moveArt", &json!(false)); @@ -839,16 +853,7 @@ fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough"); let undo = st.history.undo.len(); let ctx = egui::Context::default(); - if duplicate { - let commands = app.run_count; - artboard_deletion_click(&mut app, &ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE); - assert_eq!(app.run_count, commands.wrapping_add(1)); - assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3); - assert_eq!(app.session.tool_options()["active"], json!(2)); - assert_eq!(app.view().unwrap().artboard, 2); - } else { - artboard_history_new(&mut app, &ctx); - } + add_board(&mut app, &ctx, duplicate); assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); let st = app.session.doc().unwrap(); @@ -856,20 +861,32 @@ fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { assert_eq!(st.selection, selection); assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); assert!(!st.is_dirty()); + delete_again_after_undo(&mut app, &ctx, key, &before, &selection, undo); +} + +/// Delete the normalized shown board after Undo, then verify its own Undo and Redo. +fn delete_again_after_undo( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + key: Option, + before: &std::sync::Arc, + selection: &vectorcraft_doc::Selection, + undo: usize, +) { // Capture the state before another panel or pointer can repair it. Still exercise deletion // on RED: keyboard deletion used to do nothing; Properties already clamped its own target. let after_undo = (app.view().unwrap().artboard, app.session.tool_options()); if let Some(key) = key { - artboard_history_key(&mut app, &ctx, key, egui::Modifiers::NONE); + artboard_history_key(app, ctx, key, egui::Modifiers::NONE); } else { - let texts = panel_frame(&mut app, &ctx, vec![], show); + let texts = panel_frame(app, ctx, vec![], show); assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name)); - artboard_deletion_click(&mut app, &ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE); + artboard_deletion_click(app, ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE); } let st = app.session.doc().unwrap(); assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board"); assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes"); - assert_eq!(st.selection, selection, "deletion never falls through to Clear"); + assert_eq!(&st.selection, selection, "deletion never falls through to Clear"); assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture"); assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true})); assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); @@ -879,18 +896,18 @@ fn artboard_history_delete_after_undo(key: Option, duplicate: bool) { assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true})); let deleted = st.doc.clone(); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND); let st = app.session.doc().unwrap(); - assert_eq!(st.doc, before); - assert_eq!(st.selection, selection); + assert_eq!(st.doc.as_ref(), before.as_ref()); + assert_eq!(&st.selection, selection); assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1)); assert!(!st.is_dirty()); // Valid current selection stays put; history does not restore an earlier artboard selection. assert_eq!(app.session.tool_options()["selected"], json!([0])); - artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); let st = app.session.doc().unwrap(); - assert_eq!(st.doc, deleted); - assert_eq!(st.selection, selection); + assert_eq!(st.doc.as_ref(), deleted.as_ref()); + assert_eq!(&st.selection, selection); assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); assert!(st.is_dirty()); } diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index 33a4078cd..6e9a56fd9 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -1,6 +1,6 @@ use super::dnd::{group_of, selection}; use super::libraries::SwatchLibraries; -use super::*; +use super::{Kind, LibraryKind, Ui, Value, VectorcraftApp, View, active_paint, json, library_panel, menu_item, pstate, set_pstate, widgets}; /// The selected swatches and groups that can be deleted (all but None and Registration). pub(super) fn deletable(sel: &[String]) -> Vec { diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 259a48809..7f9c5d296 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -3,7 +3,7 @@ use super::dnd::{TileEvents, apply_drop, click_selection, highlighted, selection use super::tiles::{ draw_folder, draw_registration, folder_tile, global_mark, list_icons, list_row, list_row_name, pattern_thumb, tile_grid, tile_id, }; -use super::*; +use super::{Entry, Kind, Paint, Sense, Ui, VectorcraftApp, View, active_paint, apply, entries, json, pstate, set_pstate, swatch_tile, widgets}; /// The id of the find field's text (shown with the panel menu's Show Find Field). pub(super) fn find_id() -> egui::Id { diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index e7916fe8b..22b542a3f 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -1,5 +1,8 @@ use super::tiles::{describe, draw_registration, pattern_thumb}; -use super::*; +use super::{ + Document, Entry, Paint, PanelDrag, REGISTRATION, Rect, Response, Sense, Stroke, StrokeKind, SwatchRows, Tokens, Ui, Value, VectorcraftApp, json, + pos2, pstate, set_pstate, swatch_params, swatch_tile, vec2, +}; /// The swatches and colour groups selected in the panel, in click order, without names that no /// longer exist (deleted, renamed or undone). diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs index 3f0200398..02f149987 100644 --- a/crates/ui-egui/src/panels/swatches/libraries.rs +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -1,7 +1,10 @@ use super::actions::deletable; use super::dnd::selection; use super::tiles::{describe, global_mark, list_icons}; -use super::*; +use super::{ + Arc, BuiltinLibrary, GRADIENT_LIBRARIES, Item, LibraryKind, LibraryRef, Rect, Row, SWATCH_LIBRARIES, Swatch, SwatchLibrary, Ui, Value, + VectorcraftApp, json, library_panel, menu_item, swatch_tile, swatchlib, +}; fn library_refs(libs: Vec) -> Vec { libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() diff --git a/crates/ui-egui/src/panels/swatches/tiles.rs b/crates/ui-egui/src/panels/swatches/tiles.rs index d2d36efa9..99d813620 100644 --- a/crates/ui-egui/src/panels/swatches/tiles.rs +++ b/crates/ui-egui/src/panels/swatches/tiles.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Color, Color32, Entry, GradientKind, Paint, Rect, Response, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, icons, pos2, vec2, +}; /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) { diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 46007dcbc..57c5d03dd 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -319,24 +319,79 @@ fn click_new_style_footer_range(ctx: &egui::Context, app: &mut VectorcraftApp, i assert_eq!(text_editing(app), Some((id, start, end)), "footer capture preserves the Type range"); } -fn capture_during_live_typing(redefine: bool, footer: bool) { - let (mut app, id, ctx) = capture_app(); +fn create_redefined_texts(app: &mut VectorcraftApp, ctx: &egui::Context, id: NodeId) -> (Option, Option) { let mut follower = None; let mut local_override = None; - if redefine { - app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); - click_style_ui(&ctx, &mut app, id, None, false, "Body"); - for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { - let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); - app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); - app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); - *slot = Some(user); - } - app.run("select.set", json!({"ids": [id.0]})).unwrap(); - // This override is already the captured size, so Redefine does not alter the edited - // runs and accidentally make TextTool::replace restart the typing interaction. - app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap(); + + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + click_style_ui(ctx, app, id, None, false, "Body"); + for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { + let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); + app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); + *slot = Some(user); } + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + // This override is already the captured size, so Redefine does not alter the edited + // runs and accidentally make TextTool::replace restart the typing interaction. + app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap(); + (follower, local_override) +} + +/// Undo and Redo each commit one distinct typing or style-capture history entry. +fn assert_typing_undo_redo( + app: &mut VectorcraftApp, + undo_states: [&std::sync::Arc; 3], + redo_states: [&std::sync::Arc; 3], + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + assert_typing_undo(app, undo_states, selection, undo); + assert_typing_redo(app, redo_states, selection, undo); +} + +fn assert_typing_undo( + app: &mut VectorcraftApp, + states: [&std::sync::Arc; 3], + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + for (step, expected) in states.into_iter().enumerate() { + let result = app.run("edit.undo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["undone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.as_ref(), expected.as_ref()); + assert_eq!(&st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1)); + assert_eq!(st.is_dirty(), step != 2); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(false)); + } +} + +fn assert_typing_redo( + app: &mut VectorcraftApp, + states: [&std::sync::Arc; 3], + selection: &vectorcraft_doc::Selection, + undo: usize, +) { + for (step, expected) in states.into_iter().enumerate() { + let result = app.run("edit.redo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["redone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.as_ref(), expected.as_ref()); + assert_eq!(&st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step)); + assert!(st.is_dirty()); + } +} +fn capture_during_live_typing(redefine: bool, footer: bool) { + let (mut app, id, ctx) = capture_app(); + let (follower, local_override) = if redefine { create_redefined_texts(&mut app, &ctx, id) } else { (None, None) }; let link = redefine.then(|| "Body".to_string()); app.session.doc_mut().unwrap().mark_saved(); let before = app.session.doc().unwrap().doc.clone(); @@ -366,12 +421,7 @@ fn capture_during_live_typing(redefine: bool, footer: bool) { assert!(app.session.in_interaction()); assert_eq!(app.session.tool_options()["typing"], json!(true)); assert_eq!(app.session.doc().unwrap().selection, selection); - let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; - if footer { - click_new_style_footer(&ctx, &mut app, id); - } else { - click_style_ui(&ctx, &mut app, id, Some((0, 5)), true, label); - } + capture_style_from_typing(&mut app, &ctx, id, redefine, footer); let captured = app.session.doc().unwrap().doc.clone(); let capture_history = (app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()); let capture_settled = !app.session.in_interaction() && app.session.tool_options()["typing"] == json!(false); @@ -386,12 +436,7 @@ fn capture_during_live_typing(redefine: bool, footer: bool) { assert_eq!(captured.node(node.id), Some(node)); } }); - if let Some(user) = follower { - assert_eq!(text_runs(&app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]); - } - if let Some(user) = local_override { - assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); - } + assert_related_text_styles(&app, follower, local_override); // Check persistence before the history assertions: RED must expose the lost definition, // not merely report that typing was still live after capture. @@ -414,29 +459,24 @@ fn capture_during_live_typing(redefine: bool, footer: bool) { // Undo itself commits the new live typing interaction. Three separate snapshots prove // that typing, capture and further typing can each be undone/redone independently. - for (step, expected) in [&captured, &typed, &before].into_iter().enumerate() { - let result = app.run("edit.undo", json!({})).unwrap(); - if step != 1 { - assert_eq!(result["undone"], json!("Typing")); - } - let st = app.session.doc().unwrap(); - assert_eq!(&st.doc, expected); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1)); - assert_eq!(st.is_dirty(), step != 2); - assert!(!app.session.in_interaction()); - assert_eq!(app.session.tool_options()["typing"], json!(false)); + assert_typing_undo_redo(&mut app, [&captured, &typed, &before], [&typed, &captured, &after], &selection, undo); +} + +fn capture_style_from_typing(app: &mut VectorcraftApp, ctx: &egui::Context, id: NodeId, redefine: bool, footer: bool) { + let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; + if footer { + click_new_style_footer(ctx, app, id); + } else { + click_style_ui(ctx, app, id, Some((0, 5)), true, label); } - for (step, expected) in [&typed, &captured, &after].into_iter().enumerate() { - let result = app.run("edit.redo", json!({})).unwrap(); - if step != 1 { - assert_eq!(result["redone"], json!("Typing")); - } - let st = app.session.doc().unwrap(); - assert_eq!(&st.doc, expected); - assert_eq!(st.selection, selection); - assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step)); - assert!(st.is_dirty()); +} + +fn assert_related_text_styles(app: &VectorcraftApp, follower: Option, local_override: Option) { + if let Some(user) = follower { + assert_eq!(text_runs(app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]); + } + if let Some(user) = local_override { + assert_eq!(text_runs(app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); } } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 1b0488754..45f363eda 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, ClipboardProbeFactory, Mutex, SystemClipboard, background, native_menu, picks, place, print, screen_color, stylus}; /// A file type a dialog filters by: its label, which shows translated, and its extensions without /// the dot. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 251617931..2af7dd845 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -269,49 +269,166 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.session.tool_wants_text() { ctx.input_mut(drop_typing_presses); } - // Type tool editing: text, IME composition and editing keys go to the tool. if app.session.tool_wants_text() { - // Route-level loss (no current target or caret geometry) retires a live guard before any - // marker in this pass can be considered. The finalizer may re-arm on a later activation. - crate::ime::suspend_if_route_lost(app, ctx); - let old_composition_retired = type_text(app, ctx); - // Keys of a frame that began composing are the IME's too (a Backspace that empties the - // marked text must not delete the committed character before it). - if composing && !old_composition_retired || app.session.tool_composing() { - // The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts. - ctx.input_mut(|i| { - i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))) - }); - return; - } - // A deferred pointer/navigation notice has not established its destination yet. Its - // clipboard suffix must not cut or paste at the old caret; only the notice's own actual - // handler (kept in the raw vector) may run. - if crate::ime::deferred(app, ctx) { - ctx.input_mut(|input| { - input.events.retain(|event| match event { - egui::Event::Cut | egui::Event::Paste(_) => false, - egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. } - if modifiers.command && !modifiers.shift && !modifiers.alt => - { - false - } - _ => true, - }) - }); - } - // Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text). - crate::panels::character::route_type_input(app, ctx); - // Enter was already delivered above as ToolKey::Enter (newline). Only chords with the - // command key run commands: the rest type or edit. - let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd); - for c in hits { - run_hit(app, ctx, &c); - } - settings_on_a_mac(app, ctx, mac(ctx)); + type_tool_input(app, ctx, composing); return; } crate::ime::controls_without_editor(app, ctx); + handle_clipboard(app, ctx, textless_paste); + if busy { + busy_tool_keys(app, ctx, view); + return; + } + if claimed_tool_keys(app, ctx, view) { + return; + } + // Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are + // handled below as text (tool shortcuts, X, D, /, `,` and `.`). + let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key))); + let ran = !hits.is_empty(); + for c in hits { + run_hit(app, ctx, &c); + } + if settings_on_a_mac(app, ctx, mac(ctx)) || ran { + return; + } + if alt_type_arrows(app, ctx) { + return; + } + nudge_and_clear(app, ctx); + single_key_shortcuts(app, ctx); +} + +/// Dispatch unmodified text keys to command bindings or tools. +fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { + // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, + // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). + let events: Vec<(String, Modifiers)> = ctx.input(|i| { + let mut physical = None; + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { physical_key, pressed: true, .. } => { + physical = *physical_key; + None + } + egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)), + _ => None, + }) + .collect() + }); + let keymap = crate::shortcut_editor::current(); + for (text, m) in events { + if m.command || m.alt || m.ctrl { + continue; + } + let upper = text.to_uppercase(); + let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() }; + // A shifted punctuation character can also be written with its Shift (the Curvature tool's + // Shift+~ arrives as `~`). + let shifted = m.shift.then(|| format!("Shift+{text}")); + let hits: Vec = [Some(key), Some(text), shifted] + .into_iter() + .flatten() + .filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c))) + .collect(); + // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before + // tools. + let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; + match hit.id.strip_prefix("tool:") { + Some(tool) => app.select_tool(tool), + None if hit.params.is_null() => { + let _ = app.run(&hit.id, json!({})); + } + None => crate::menus::invoke(app, &hit.id, hit.params.clone()), + } + } +} + +/// Route Alt+arrows to spacing controls when text objects are selected. +fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let m = ctx.input(|i| i.modifiers); + if !m.alt + || !app.session.active().is_some_and(|st| { + st.selection.guides.is_empty() + && !st.selection.objects.is_empty() + && st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_)))) + }) + { + return false; + } + let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; + for (key, tool_key) in keys { + if ctx.input_mut(|i| i.consume_key(m, key)) { + if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) { + let by = if m.command { by * 5.0 } else { by }; + let _ = app.run("type.step", json!({"attribute": attribute, "by": by})); + } + return true; + } + } + false +} + +/// Route text-tool input and command chords while preserving IME composition ownership. +fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: bool) { + // Route-level loss (no current target or caret geometry) retires a live guard before any + // marker in this pass can be considered. The finalizer may re-arm on a later activation. + crate::ime::suspend_if_route_lost(app, ctx); + let old_composition_retired = type_text(app, ctx); + // Keys of a frame that began composing are the IME's too (a Backspace that empties the + // marked text must not delete the committed character before it). + if composing && !old_composition_retired || app.session.tool_composing() { + // The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts. + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)))); + return; + } + // A deferred pointer/navigation notice has not established its destination yet. Its + // clipboard suffix must not cut or paste at the old caret; only the notice's own actual + // handler (kept in the raw vector) may run. + if crate::ime::deferred(app, ctx) { + ctx.input_mut(|input| { + input.events.retain(|event| match event { + egui::Event::Cut | egui::Event::Paste(_) => false, + egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. } + if modifiers.command && !modifiers.shift && !modifiers.alt => + { + false + } + _ => true, + }) + }); + } + // Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text). + crate::panels::character::route_type_input(app, ctx); + // Enter was already delivered above as ToolKey::Enter (newline). Only chords with the + // command key run commands: the rest type or edit. + let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd); + for c in hits { + run_hit(app, ctx, &c); + } + settings_on_a_mac(app, ctx, mac(ctx)); +} + +/// Apply arrow-key movement and clear selected art when no text editor owns input. +fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context) { + // Arrow nudges (Shift = ×10, Alt = copy). + let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; + for (k, dx, dy) in arrows { + let m = ctx.input(|i| i.modifiers); + if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) { + let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt})); + } + } + // Delete / Backspace clear the selection. + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) + { + let _ = app.run("edit.clear", json!({})); + } +} + +/// Route clipboard events to the system clipboard or the app's rich-format paste path. +fn handle_clipboard(app: &mut VectorcraftApp, ctx: &egui::Context, textless_paste: Option) { // A clipboard image (eframe's `clipboard` feature, 0.37): the host's text paste path never // carries it. With the app's rich-format `SystemClipboard`, it is only the signal that the // clipboard holds no text — the next tick invokes Paste, whose `adopt_system_clipboard` reads @@ -387,53 +504,53 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { app.clipboard_in = text.map(|t| Flavour { mime: TEXT, data: t.into_bytes() }); crate::menus::invoke(app, id, json!({})); } - if busy { - // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… - let arrows = - [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; - for (k, tk) in arrows { - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); - } +} + +/// Route arrows, digits and letters to a tool while a gesture is active. +fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) { + // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… + let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; + for (k, tk) in arrows { + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { + let _ = app.session.tool_key(tk, Mods::default(), view); } - // Digits (5 while dragging with the Perspective Selection tool), once per press. - let digits: Vec = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key), - _ => None, - }) - .collect() - }); - for d in digits { - let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view); - } - // The tool gets each letter it takes (E while dragging a selection marquee) once per press, - // without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin - // letters is the key at that position on a US layout. - let letters: Vec = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } - if !(modifiers.command || modifiers.alt || modifiers.ctrl) => - { - letter_of(*key) - } - _ => None, - }) - .collect() - }); - for c in letters { - if app.session.tool_claims_key(ToolKey::Letter(c), view) { - let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view); - } - } - return; } - // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: - // Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files). + // Digits (5 while dragging with the Perspective Selection tool), once per press. + let digits: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key), + _ => None, + }) + .collect() + }); + for d in digits { + let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view); + } + // The tool gets each letter it takes (E while dragging a selection marquee) once per press, + // without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin + // letters is the key at that position on a US layout. + let letters: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !(modifiers.command || modifiers.alt || modifiers.ctrl) => { + letter_of(*key) + } + _ => None, + }) + .collect() + }); + for c in letters { + if app.session.tool_claims_key(ToolKey::Letter(c), view) { + let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view); + } + } +} + +/// Give keys claimed by the active tool precedence over their command shortcuts. +fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) -> bool { let m = ctx.input(|i| i.modifiers); for (k, tk) in [ (Key::Delete, ToolKey::Delete), @@ -456,7 +573,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { v.artboard = i; } crate::canvas::apply_requests(app, r); - return; + return true; } } // Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows). @@ -477,7 +594,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text))); let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view); crate::canvas::apply_requests(app, r); - return; + return true; } } } @@ -510,95 +627,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } if taken { - return; - } - } - // Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are - // handled below as text (tool shortcuts, X, D, /, `,` and `.`). - let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key))); - let ran = !hits.is_empty(); - for c in hits { - run_hit(app, ctx, &c); - } - if settings_on_a_mac(app, ctx, mac(ctx)) || ran { - return; - } - // Alt+arrows on selected type objects step their spacing as the Type tool does (Cmd/Ctrl: - // five steps); Alt+arrow copies and nudges every other selection. - let m = ctx.input(|i| i.modifiers); - if m.alt - && app.session.active().is_some_and(|st| { - st.selection.guides.is_empty() - && !st.selection.objects.is_empty() - && st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_)))) - }) - { - let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; - for (key, tool_key) in keys { - if ctx.input_mut(|i| i.consume_key(m, key)) { - if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) { - let by = if m.command { by * 5.0 } else { by }; - let _ = app.run("type.step", json!({"attribute": attribute, "by": by})); - } - return; - } - } - } - // Arrow nudges (Shift = ×10, Alt = copy). - let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; - for (k, dx, dy) in arrows { - let m = ctx.input(|i| i.modifiers); - if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) { - let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt})); - } - } - // Delete / Backspace clear the selection. - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) - { - let _ = app.run("edit.clear", json!({})); - } - // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, - // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - let events: Vec<(String, Modifiers)> = ctx.input(|i| { - let mut physical = None; - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Key { physical_key, pressed: true, .. } => { - physical = *physical_key; - None - } - egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)), - _ => None, - }) - .collect() - }); - let keymap = crate::shortcut_editor::current(); - for (text, m) in events { - if m.command || m.alt || m.ctrl { - continue; - } - let upper = text.to_uppercase(); - let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() }; - // A shifted punctuation character can also be written with its Shift (the Curvature tool's - // Shift+~ arrives as `~`). - let shifted = m.shift.then(|| format!("Shift+{text}")); - let hits: Vec = [Some(key), Some(text), shifted] - .into_iter() - .flatten() - .filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c))) - .collect(); - // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before - // tools. - let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; - match hit.id.strip_prefix("tool:") { - Some(tool) => app.select_tool(tool), - None if hit.params.is_null() => { - let _ = app.run(&hit.id, json!({})); - } - None => crate::menus::invoke(app, &hit.id, hit.params.clone()), + return true; } } + false } /// Runs this frame's key presses against the keymap in effect; `accept` declines a hit, leaving diff --git a/crates/ui-egui/src/shortcuts/paste.rs b/crates/ui-egui/src/shortcuts/paste.rs index 9f43e76c5..f13cf0244 100644 --- a/crates/ui-egui/src/shortcuts/paste.rs +++ b/crates/ui-egui/src/shortcuts/paste.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Key, Modifiers, all_shortcuts}; /// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index 691922ab7..c93ca762e 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Key, VectorcraftApp, is_modifier, json}; /// The second press of Escape pressed twice comes less than this many seconds after the first. const ESCAPE_PAIR_SECONDS: f64 = 1.0; diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index c42e5d57d..4ed15a6b3 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineResult, Key, Mods, SurroundingUnit, ToolKey, UiRequest, VectorcraftApp, ViewInfo}; /// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox /// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e357a340..f4e90f6d8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -66,7 +66,7 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [ ("-90°", -90.0), ]; -use super::*; +use super::{ControlResponse, Sender}; use std::sync::atomic::{AtomicBool, Ordering}; /// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs index 32e2712ae..5fdf065b1 100644 --- a/crates/ui-egui/src/widgets/blend.rs +++ b/crates/ui-egui/src/widgets/blend.rs @@ -2,7 +2,7 @@ use super::color::{Live, pointer_phase}; use super::dropdowns::combo; use super::labels::dim_label; use super::numeric::{range_field, slider}; -use super::*; +use super::{BlendMode, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, id_with, vec2}; /// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a /// [`BlendMode::group`] starts. diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index d759dcd29..44ee3c477 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Vec2, icons, pos2, theme, vec2}; /// Square icon button; `selected` draws the pressed well. pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 068bd4e7d..a215b3ee8 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -1,5 +1,5 @@ use super::drag::{PanelDrag, drag_source}; -use super::*; +use super::{Color32, Paint, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, vec2}; /// Draw a paint preview (swatch chip) into `rect`. pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 4ffdaf6a9..80c43e2e8 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,7 +1,7 @@ use super::labels::{label_field, name_field, take_previous_label}; use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus}; -use super::*; +use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; /// Drag phase of a live slider/handle: previews while dragging, commits on release. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 4a73214b9..177e830e5 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Paint, Response, Stroke, StrokeKind, Tokens, Ui, Vec2}; /// What panels drag onto art and onto each other. One payload type, so the canvas has one drop /// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index db391edec..70e6920c0 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -1,6 +1,6 @@ use super::drag::PanelDrag; use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; -use super::*; +use super::{CornerRadius, HashOnly, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, vec2}; /// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { diff --git a/crates/ui-egui/src/widgets/labels.rs b/crates/ui-egui/src/widgets/labels.rs index 76c69053e..a71a0ec6a 100644 --- a/crates/ui-egui/src/widgets/labels.rs +++ b/crates/ui-egui/src/widgets/labels.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Pos2, Rect, Response, Sense, Stroke, Tokens, Ui, icons, pos2, scrub, theme, vec2}; /// Bold section header (Properties panel). pub fn section_header(ui: &mut Ui, text: &str) { diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 655f3f978..f2a9cc2ec 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -1,7 +1,7 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::panel_chrome::chevron_cell; use super::text::{after_shift_enter, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_dialog_focus}; -use super::*; +use super::{CornerRadius, RangeInclusive, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Unit, id_with, pos2, scrub, vec2}; /// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it /// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs index f92114448..0dcad26fe 100644 --- a/crates/ui-egui/src/widgets/panel_chrome.rs +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, theme, vec2}; /// A bordered list box (Swatches tiles, Appearance rows, Artboards list). pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index f3f922dfa..b4e531a52 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -1,7 +1,7 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::numeric::mixed_field_named; use super::panel_chrome::chevron_cell; -use super::*; +use super::{Rect, Response, Stroke, StrokeKind, Tokens, Ui, id_with, vec2}; /// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the /// first enabled field drawn takes it ([`take_dialog_focus`]). diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 26ee2e125..8dac694ec 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, pos2, vec2}; /// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), /// draws the text and returns the box, the response and the box's border colour. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa877..6d935ae8f 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,5 +5,8 @@ edition.workspace = true license.workspace = true publish = false +[lints] +workspace = true + [dependencies] serde_json = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da3835674..0516321ab 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -82,7 +82,9 @@ fn main() -> ExitCode { /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() + // The repository root, as a clean path (`join("..")` would print as `…/xtask/..`). + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest.parent().unwrap_or(manifest).to_path_buf() } /// Tracked and untracked (not ignored) files that exist, relative to `root`.