Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type

Fourteen more Preferences options that were stored but read by nothing now
act, for the mouse and for agents (prefs.set, then pointer_gesture,
press_key or the commands):

- Selection & Anchor Display: Snap to Point and its distance (with Smart
  Guides off, View > Snap to Point lands a dragged selection's grabbed
  point, drawn points and reference points on anchors and ruler guides);
  Move Locked and Hidden Artwork with Artboard (artboard.move, the Artboard
  tool and Rearrange; off, hidden art now stays too); Enable Rubber Band for
  the Pen and Curvature tools; Handles (solid, hollow, large);
  Show handles when multiple anchors are selected (canvas and Direct
  Selection's handle picking); Hide Corner Widget for angles greater than.
- General: Transform Pattern Tiles is the default of a new `patterns`
  param on object.transform/move/rotate/scale/reflect/shear/transformEach
  (journalled when the art uses a pattern), and the transform dialogs get a
  Transform Patterns checkbox; Select Same Tint % (off, Select > Same
  Fill/Stroke Color takes every tint of a global or spot swatch).
- Type: new type.step {attribute, by} steps size, leading, tracking, kerning
  and baseline shift by the Size/Leading, Tracking and Baseline Shift
  increments; Increase/Decrease Font Size (Cmd+Shift+. and Cmd+Shift+,);
  the Type tool's Alt+arrows (kerning at a caret or tracking, leading,
  Shift for baseline shift, Cmd/Ctrl for five steps). Enable Missing Glyph
  Protection keeps characters a new font lacks in the font that had them
  (text.setStyle, text.setRangeStyle). Fill New Type Objects With
  Placeholder Text: type the Type tools place starts with placeholder text,
  selected (text.create / text.createInPath take `placeholder`).

Units > General, Stroke and Type were already the single source of truth
(units.rs). Auto Size New Area Type, Show Font Names in English and
Highlight Alternate Glyphs need subsystems the app doesn't have yet and
stay open with the rest of #394.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 09:43:12 +07:00
1 parent 3fbf14a3bf
commit 8e3dbd5369
34 files changed
+984 -115

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414) and the first Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -103,7 +103,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. They reach agents too (`prefs.set`, then `pointer_gesture`; `ui.wheel` on the control channel). About 55 stored options still do nothing yet (the rest of #394).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
@@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 55 Preferences options stored but not yet acted on (#394: smart guide display options, type increments and options, units, Show Pixel Grid, Snap to Point, rubber band, handle style, performance, devices…) | 18–28 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 45 Preferences options stored but not yet acted on (#394: smart guide display options, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Show Pixel Grid, performance, devices…) | 16–26 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
+20 -15
View File
@@ -914,16 +914,15 @@ impl Document {
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
/// The art that moves with an artboard at `rect`: unlocked top-level objects (children of
/// unlocked layers and sublayers) lying entirely inside it.
pub fn art_on_artboard(&self, rect: Rect) -> Vec<NodeId> {
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.locked {
continue;
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() {
collect(c, rect, out);
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
@@ -933,8 +932,8 @@ impl Document {
}
}
let mut art = vec![];
for l in self.layers.iter().filter(|l| !l.locked) {
collect(l, rect, &mut art);
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
collect(l, rect, locked_and_hidden, &mut art);
}
art
}
@@ -1088,12 +1087,18 @@ mod tests {
#[test]
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
let (mut d, a, b) = doc_with_rects();
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0)), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0)), vec![a], "b only half inside");
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
d.node_mut(a).unwrap().locked = true;
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)), vec![b]);
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
d.node_mut(b).unwrap().visible = false;
assert!(d.art_on_artboard(wide, false).is_empty());
assert_eq!(d.art_on_artboard(wide, true), vec![a, b]);
Arc::make_mut(&mut d.layers[0]).locked = true;
assert!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)).is_empty());
assert!(d.art_on_artboard(wide, false).is_empty());
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
}
#[test]
+13 -1
View File
@@ -252,6 +252,9 @@ pub struct Scaling {
pub keep_type_strokes: bool,
/// Live corner radii keep their size.
pub keep_corners: bool,
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
pub patterns: bool,
}
impl Scaling {
@@ -526,6 +529,12 @@ impl Node {
_ => None,
}
}
/// Does this object or layer move with its artboard? Locked and hidden ones only with
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool {
locked_and_hidden || (!self.locked && self.visible)
}
pub fn is_layer(&self) -> bool {
matches!(self.kind, NodeKind::Layer { .. })
}
@@ -721,6 +730,9 @@ impl Node {
}
}
self.appearance.transform_gradients(a);
if sc.patterns {
crate::pattern::transform_pattern_paints(self, a);
}
match &mut self.kind {
NodeKind::Path { path, live, .. } => {
path.transform(a);
@@ -1318,7 +1330,7 @@ mod tests {
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
#[test]
fn uneven_scales_keep_live_corners_circular() {
+6 -3
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText)} → {id}",
has_doc,
text_create
),
@@ -414,8 +414,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let h = f64_or(a, "height", 100.0);
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(lay.bounds);
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
super::typecmd::refresh_bounds(&mut t);
}
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
}
+1 -1
View File
@@ -163,7 +163,7 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
fn typing_in_progress(s: &Session) -> bool {
pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
}
+7 -3
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform Each…",
["Object", "Transform"],
Some("Cmd+Alt+Shift+D"),
"{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences)} transform every selected object about its own reference point → {ids}",
"{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}",
has_selection,
transform_each
),
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rearrange All Artboards…",
["Object", "Artboards"],
None,
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true} lay artboards out in a grid",
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard)} lay artboards out in a grid",
has_doc,
rearrange_artboards
),
@@ -288,7 +288,8 @@ fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("object.transformEach", "scale must be non-zero"));
}
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
let sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
let ids = s.edit("Transform Each", |d, sel| {
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
for id in &targets {
@@ -742,6 +743,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let by_col = bool_or(p, "byColumn", false);
let move_art = bool_or(p, "moveArtwork", true);
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let scale_strokes = false;
s.edit("Rearrange Artboards", |d, _| {
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
@@ -753,7 +755,9 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let tops: Vec<(NodeId, Point)> = d
.layers
.iter()
.filter(|l| l.rides_with_artboard(locked_and_hidden))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.rides_with_artboard(locked_and_hidden))
.filter_map(|n| Some((n.id, n.geometric_bounds()?.center())))
.collect();
for (id, c) in tops {
+41 -9
View File
@@ -21,17 +21,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform",
[],
None,
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
has_doc,
transform
),
cmd!("object.move", "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?}", has_selection, move_cmd),
cmd!(
"object.move",
"Move…",
["Object", "Transform"],
Some("Cmd+Shift+M"),
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
move_cmd
),
cmd!(
"object.rotate",
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}",
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}",
has_selection,
rotate
),
@@ -40,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Scale…",
["Object", "Transform"],
None,
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale)} (strokes/corners default to the preferences)",
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences)",
has_selection,
scale
),
@@ -49,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reflect…",
["Object", "Transform"],
None,
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?}",
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
reflect
),
@@ -58,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shear…",
["Object", "Transform"],
None,
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?}",
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_selection,
shear
),
@@ -208,15 +216,39 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
let corners = bool_or(p, "corners", s.prefs.scale_corners);
s.note_journal("strokes", json!(strokes));
s.note_journal("corners", json!(corners));
Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners }
Scaling {
strokes,
effects: strokes.then_some(vectorcraft_render::effects::scale_effect),
keep_type_strokes: !strokes,
keep_corners: !corners,
..Scaling::default()
}
}
/// Transform Patterns: do the pattern fills and strokes of `ids` transform with them (`patterns`
/// param, else General › Transform Pattern Tiles)? Noted in the journal when they use a pattern,
/// so a replay moves their tiles alike whatever the preference is then.
pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<bool> {
let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles);
let d = &s.doc()?.doc;
let mut uses = false;
for n in ids.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None));
}
if uses {
s.note_journal("patterns", json!(on));
}
Ok(on)
}
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Records
/// Transform Again.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
let copy = bool_or(p, "copy", false);
let areas = bool_or(p, "typeAreas", false);
let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
sc.patterns = transform_patterns(s, p, &ids)?;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
+47 -13
View File
@@ -3,6 +3,7 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::*;
@@ -30,26 +31,50 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many),
cmd!("select.same.fillColor", "Fill Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
cmd!(
"select.same.fillColor",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint()
)),
cmd!("select.same.strokeColor", "Stroke Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"Fill Color",
["Select", "Same"],
None,
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)",
has_selection,
|s, _| {
let tint = s.prefs.select_same_tint_percent;
same(s, "select.same.fillColor", |a, b| same_paint(&a.appearance.fill_paint(), &b.appearance.fill_paint(), tint))
}
),
cmd!(
"select.same.strokeColor",
|a, b| a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
"Stroke Color",
["Select", "Same"],
None,
"{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)",
has_selection,
|s, _| {
let tint = s.prefs.select_same_tint_percent;
same(s, "select.same.strokeColor", |a, b| same_paint(&a.appearance.stroke_paint(), &b.appearance.stroke_paint(), tint))
}
),
cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.strokeWeight",
|a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9
)),
cmd!("select.same.fillAndStroke", "Fill & Stroke", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
cmd!(
"select.same.fillAndStroke",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint() && a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
"Fill & Stroke",
["Select", "Same"],
None,
"{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)",
has_selection,
|s, _| {
let tint = s.prefs.select_same_tint_percent;
same(s, "select.same.fillAndStroke", |a, b| {
let (x, y) = (&a.appearance, &b.appearance);
same_paint(&x.fill_paint(), &y.fill_paint(), tint) && same_paint(&x.stroke_paint(), &y.stroke_paint(), tint)
})
}
),
cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a
.opacity
- b.opacity)
@@ -255,7 +280,16 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
/// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot
/// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too.
fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
match (a, b) {
(Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4,
_ => a == b,
}
}
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn))
+166 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -58,6 +58,33 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
fit_headline
),
cmd!(
"type.step",
"Step Type",
[],
None,
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
has_doc,
type_step
),
cmd!(
"type.size.increase",
"Increase Font Size",
[],
Some("Cmd+Shift+."),
"{by?: steps (1)} type.step {attribute: size}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": f64_or(p, "by", 1.0)}))
),
cmd!(
"type.size.decrease",
"Decrease Font Size",
[],
Some("Cmd+Shift+,"),
"{by?: steps (1)} type.step {attribute: size, by: -by}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
),
cmd!(
"text.discardEmpty",
"Discard Empty Type",
@@ -332,6 +359,45 @@ impl CharChange {
}
}
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
/// font has no glyph for, but their font `before` the change had, keep that font.
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
runs.iter()
.scan(0, |at, r| {
let start = *at;
*at += r.text.len();
Some(r.text.char_indices().map(move |(i, c)| (start + i, c, &r.style)))
})
.flatten()
}
let db = vectorcraft_text::FontDb::global();
let mut faces = std::collections::HashMap::new();
let mut covers = |st: &CharStyle, c: char| {
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
if !changed || c.is_whitespace() || c.is_control() || covers(new, c) != Some(false) || covers(old, c) != Some(true) {
continue;
}
let end = at + c.len_utf8();
match keep.last_mut() {
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
_ => keep.push((at, end, old)),
}
}
for (a, b, old) in keep {
edit::style_range(runs, a, b, |st| {
st.font_family.clone_from(&old.font_family);
st.font_style.clone_from(&old.font_style);
});
}
}
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setRangeStyle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
@@ -339,6 +405,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
@@ -347,7 +414,11 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
return Ok(t.runs.len());
}
let before = protect.then(|| t.runs.clone());
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
if let Some(before) = before {
protect_missing_glyphs(&before, &mut t.runs);
}
refresh_bounds(t);
Ok(t.runs.len())
})?;
@@ -397,7 +468,11 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
wrap: Vec::new(),
cached_bounds: None,
};
refresh_bounds(&mut t);
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
refresh_bounds(&mut t);
}
let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| {
let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?;
d.remove(pid)?;
@@ -413,6 +488,95 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0}))
}
/// What `type.step` changes.
#[derive(Clone, Copy, PartialEq)]
enum Step {
Size,
Leading,
Tracking,
Kerning,
BaselineShift,
}
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
if super::edit::typing_in_progress(s) {
s.set_tool_option("commitTyping", &Value::Bool(true));
s.commit_interaction()?;
}
let at = |k: &str| byte_param(&o, k).unwrap_or(0);
Ok(Some((id, at("start"), at("end"))))
}
fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.step";
let step = match str_param(p, "attribute") {
Some("size") => Step::Size,
Some("leading") => Step::Leading,
Some("tracking") => Step::Tracking,
Some("kerning") => Step::Kerning,
Some("baselineShift") => Step::BaselineShift,
_ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")),
};
let by = f64_or(p, "by", 1.0);
if !by.is_finite() {
return Err(bad(C, "by must be a number"));
}
let prefs = &s.prefs;
let inc = by.clamp(-1000.0, 1000.0)
* match step {
Step::Size | Step::Leading => prefs.type_size_increment,
Step::Tracking | Step::Kerning => prefs.tracking_increment,
Step::BaselineShift => prefs.baseline_shift_increment,
};
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs))))],
None => match editing_range(s)? {
Some((id, a, b)) => vec![(id, Some((a.min(b), a.max(b))))],
None => super::typecmd::text_targets(s, p, C)?.into_iter().map(|id| (id, None)).collect(),
},
};
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
// the paragraph's; the rest wait for a selection.
let mut ranges = vec![];
for (id, range) in targets {
let text = text_ref(s, id)?.plain_text();
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
let (a, b) = match step {
_ if a < b => (a, b),
Step::Kerning => (edit::prev_char(&text, a), a),
Step::Leading => {
let para = edit::paragraph_at(&text, a);
(para.start, para.end)
}
_ => continue,
};
if a < b {
ranges.push((id, a, b));
}
}
if !ranges.is_empty() {
s.edit("Character", |d, _| {
for &(id, a, b) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| match step {
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
});
refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
/// on one line at all.
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
+5
View File
@@ -238,9 +238,11 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "nothing to change"));
}
let ids = text_targets(s, p, C)?;
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let before = protect.then(|| t.runs.clone());
for r in &mut t.runs {
let st = &mut r.style;
if let Some(f) = &font {
@@ -265,6 +267,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.features = f.clone();
}
}
if let Some(before) = before {
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
}
if let Some(j) = justify {
t.para.justify = j;
}
+33 -25
View File
@@ -3,7 +3,7 @@
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextRun};
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::typecmd::refresh_bounds;
@@ -388,36 +388,44 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.fillPlaceholder")?;
s.edit("Fill with Placeholder Text", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let style = t.first_style();
let text = match &t.kind {
TextKind::Area { frame } => {
let b = frame.bounds().unwrap_or_default();
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
let lines = (b.height() / style.effective_leading()).max(1.0);
let want = (chars_per_line * lines) as usize;
let mut out = String::new();
let words: Vec<&str> = PLACEHOLDER.split(' ').collect();
let mut i = 0;
while out.len() < want && i < 10_000 {
if !out.is_empty() {
out.push(' ');
}
out.push_str(words[i % words.len()]);
i += 1;
}
out
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
refresh_bounds(t);
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
fill_with_placeholder(t);
}
}
Ok(())
})?;
Ok(ids_json(&ids))
}
/// Replace `t`'s text with placeholder text in its first style: area type is filled to its frame,
/// other type gets a sentence (Type › Fill with Placeholder Text, and new type with Preferences ›
/// Type › Fill New Type Objects With Placeholder Text).
pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
let style = t.first_style();
let text = match &t.kind {
TextKind::Area { frame } => {
let b = frame.bounds().unwrap_or_default();
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
let lines = (b.height() / style.effective_leading()).max(1.0);
let want = (chars_per_line * lines) as usize;
let mut out = String::new();
for (i, w) in PLACEHOLDER.split(' ').cycle().enumerate() {
if out.len() >= want || i >= 10_000 {
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(w);
}
out
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
refresh_bounds(t);
}
pub(crate) fn special_char(name: &str) -> Option<&'static str> {
Some(match name {
"bullet" => "•",
+2 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the unlocked art fully inside it); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@@ -374,7 +374,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let move_art = bool_or(p, "moveArt", false);
let st = s.doc()?;
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let art = if move_art { st.doc.art_on_artboard(rect) } else { vec![] };
let art = if move_art { st.doc.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] };
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
+2
View File
@@ -8,7 +8,9 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
// Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off).
let mut s = Session::new();
s.prefs.placeholder_text = false;
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
+259 -1
View File
@@ -1,6 +1,6 @@
//! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`.
use serde_json::json;
use serde_json::{Value, json};
use super::*;
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
@@ -227,3 +227,261 @@ fn use_precise_cursors_makes_drawing_cursors_crosshairs() {
assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair);
assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow);
}
// ---------- #394, second batch ----------
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn square(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap())
}
fn set_pref(s: &mut Session, key: &str, value: Value) {
s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
fn gesture(s: &mut Session, v: ViewInfo, events: &[(vectorcraft_tools::PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&vectorcraft_tools::PointerEvent::new(*k, *x, *y), v).unwrap();
}
}
fn top_left(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(b.x0, b.y0)
}
/// View › Snap to Point with Selection & Anchor Display › Snap to Point's distance (#394): with
/// Smart Guides off, the point a selection is dragged by lands on an anchor within 2 px.
#[test]
fn snap_to_point_lands_a_dragged_selection_on_anchors() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let a = square(&mut s, 100.0, 100.0);
square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("selection", v).unwrap();
// Grabbed by its centre and dropped 1.5, 1 px from the other square's top-left anchor.
let drag = |s: &mut Session, v: ViewInfo| {
gesture(s, v, &[(Down, 125.0, 125.0), (Drag, 200.0, 110.0), (Drag, 301.5, 101.0), (Up, 301.5, 101.0)]);
let at = top_left(s, a);
s.execute("edit.undo", &json!({})).unwrap();
at
};
assert_eq!(drag(&mut s, v), (275.0, 75.0), "the centre lands on the anchor");
set_pref(&mut s, "snapToPointTolerance", json!(1));
assert_eq!(drag(&mut s, v), (276.5, 76.0), "1.8 px is beyond 1 px");
set_pref(&mut s, "snapToPointTolerance", json!(2));
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
}
/// Enable Rubber Band for Pen Tool / Curvature Tool (#394): off, no segment follows the pointer.
#[test]
fn rubber_band_preferences_hide_the_preview_to_the_pointer() {
use vectorcraft_tools::Overlay;
use vectorcraft_tools::PointerKind::{Down, Move, Up};
let v = ViewInfo::default();
for (tool, key) in [("pen", "penRubberBand"), ("curvature", "curvatureRubberBand")] {
let mut s = new_doc();
s.select_tool(tool, v).unwrap();
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0), (Down, 150.0, 50.0), (Up, 150.0, 50.0), (Move, 200.0, 150.0)]);
let bands = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, Overlay::Path { .. })).count();
assert_eq!(bands(&mut s), 1, "{tool}: the rubber band");
set_pref(&mut s, key, json!(false));
assert_eq!(bands(&mut s), 0, "{tool}: off");
}
}
/// Move Locked and Hidden Artwork with Artboard (#394): off, an artboard moved with its art
/// leaves locked and hidden objects where they are; on, they move too.
#[test]
fn move_locked_and_hidden_artwork_with_artboard() {
let mut s = new_doc();
let (free, locked, hidden) = (square(&mut s, 10.0, 10.0), square(&mut s, 100.0, 10.0), square(&mut s, 200.0, 10.0));
for (id, cmd) in [(locked, "object.lock"), (hidden, "object.hide")] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute(cmd, &json!({})).unwrap();
}
let moved = |s: &mut Session| {
s.execute("artboard.move", &json!({"index": 0, "dx": 30, "dy": 0, "moveArt": true})).unwrap();
let xs = [free, locked, hidden].map(|id| top_left(s, id).0);
s.execute("edit.undo", &json!({})).unwrap();
xs
};
assert_eq!(moved(&mut s), [40.0, 100.0, 200.0]);
set_pref(&mut s, "moveLockedWithArtboard", json!(true));
assert_eq!(moved(&mut s), [40.0, 130.0, 230.0]);
}
/// General › Transform Pattern Tiles (#394) is what transforms do with pattern fills unless their
/// `patterns` param says otherwise (the dialogs' Transform Patterns).
#[test]
fn transform_pattern_tiles_is_the_transforms_default() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let tile = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [tile.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let big = square(&mut s, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [big.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(big).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "off: the tiles stay");
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "on: they move with the art");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "the journal keeps the choice");
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0], "patterns": false})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "the param wins");
s.execute("object.scale", &json!({"sx": 50, "origin": [0, 0]})).unwrap();
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
fn select_same_tint_percent() {
let mut s = new_doc();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
// Before the others: new art takes the last fill applied.
square(&mut s, 300.0, 10.0);
let ids: Vec<NodeId> = [(10.0, 40), (100.0, 40), (200.0, 80)]
.into_iter()
.map(|(x, tint)| {
let id = square(&mut s, x, 10.0);
s.execute("paint.setFill", &json!({"ids": [id.0], "swatch": "Ink", "tint": tint})).unwrap();
id
})
.collect();
let same = |s: &mut Session| {
s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("select.same.fillColor", &json!({})).unwrap();
s.doc().unwrap().selection.objects.len()
};
assert_eq!(same(&mut s), 3, "every tint of Ink");
set_pref(&mut s, "selectSameTintPercent", json!(true));
assert_eq!(same(&mut s), 2, "only Ink 40%");
}
fn style_at(s: &Session, id: NodeId, byte: usize) -> vectorcraft_doc::CharStyle {
let Some(vectorcraft_doc::NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
let mut at = 0;
for r in &t.runs {
if byte < at + r.text.len() {
return r.style.clone();
}
at += r.text.len();
}
panic!("byte {byte} past the text")
}
/// Type › Size/Leading, Tracking and Baseline Shift (#394): the increments `type.step` and the
/// font size shortcuts step by, on selected type objects and on the Type tool's selected text
/// (Alt+arrows; Cmd/Ctrl too: five steps).
#[test]
fn type_increments_step_the_type() {
use vectorcraft_tools::PointerKind::{Down, Up};
use vectorcraft_tools::{Mods, ToolKey};
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.size.increase", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 22.0, "Size/Leading is 2 pt");
set_pref(&mut s, "typeSizeIncrement", json!(3));
s.execute("type.size.decrease", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 19.0);
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
assert!((style_at(&s, id, 0).leading.unwrap() - (19.0 * 1.2 + 3.0)).abs() < 1e-9, "from Auto");
set_pref(&mut s, "baselineShiftIncrement", json!(1.5));
s.execute("type.step", &json!({"attribute": "baselineShift", "by": -2})).unwrap();
assert_eq!(style_at(&s, id, 10).baseline_shift, -3.0);
// The Type tool: Alt+→ tracks the selected text by Tracking (20/1000 em), Cmd-Alt five times.
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
let alt = Mods { alt: true, ..Mods::default() };
let select = |s: &mut Session, a: usize, b: usize| s.set_tool_option("select", &json!({"start": a, "end": b}));
select(&mut s, 0, 5);
s.tool_key(ToolKey::Right, alt, v).unwrap();
assert_eq!((style_at(&s, id, 0).tracking, style_at(&s, id, 6).tracking), (20.0, 0.0));
s.tool_key(ToolKey::Left, Mods { cmd: true, ..alt }, v).unwrap();
assert_eq!(style_at(&s, id, 4).tracking, -80.0);
// At a caret: Alt+← kerns the character before it, Alt+↓ opens up the paragraph's leading.
select(&mut s, 3, 3);
s.tool_key(ToolKey::Left, alt, v).unwrap();
assert_eq!((style_at(&s, id, 2).kerning, style_at(&s, id, 3).kerning), (Some(-20.0), None));
let leading = style_at(&s, id, 0).leading.unwrap();
s.tool_key(ToolKey::Down, alt, v).unwrap();
assert_eq!(style_at(&s, id, 8).leading, Some(leading + 3.0));
// Alt+Shift+↑ raises the selected text's baseline.
select(&mut s, 6, 11);
s.tool_key(ToolKey::Up, Mods { shift: true, ..alt }, v).unwrap();
assert_eq!((style_at(&s, id, 0).baseline_shift, style_at(&s, id, 6).baseline_shift), (-3.0, -1.5));
// The font size shortcut steps the selected text while the tool edits.
s.execute("type.size.increase", &json!({})).unwrap();
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (19.0, 22.0));
}
/// Type › Fill New Type Objects With Placeholder Text (#394, on by default): type the Type tool
/// places starts with placeholder text, selected, so typing replaces it; off, it starts empty.
#[test]
fn new_type_starts_with_placeholder_text() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let v = ViewInfo::default();
let mut s = new_doc();
s.select_tool("type", v).unwrap();
let text = |s: &Session| {
let st = s.doc().unwrap();
match &st.doc.node(st.selection.objects[0]).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
};
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0)]);
let placeholder = text(&s);
assert!(placeholder.len() > 10, "{placeholder}");
assert_eq!((s.tool_options()["start"].clone(), s.tool_options()["end"].clone()), (json!(0), json!(placeholder.len())), "selected");
s.tool_text("Hi", v).unwrap();
assert_eq!(text(&s), "Hi");
// Area type is filled to its frame.
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
assert!(text(&s).len() > placeholder.len());
set_pref(&mut s, "placeholderText", json!(false));
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
assert_eq!(text(&s), "");
}
/// Type › Enable Missing Glyph Protection (#394, on by default): characters a new font has no
/// glyph for keep the font that has one; off, they take the new font.
#[test]
fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
let (from, to, c) = ("Source Sans 3", "JetBrains Mono", '\u{180}');
let db = vectorcraft_text::FontDb::global();
let covers = |f: &str| db.face(f, "Regular").is_some_and(|face| face.covers(c));
if !covers(from) || covers(to) {
return; // Installed fonts of these names cover other characters.
}
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": from})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b).font_family);
s.execute("text.setStyle", &json!({"font": to})).unwrap();
assert_eq!(fonts(&s), [to, from, to], "the character keeps its font");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 4, "font": to})).unwrap();
assert_eq!(fonts(&s), [from, from, to]);
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "missingGlyphProtection", json!(false));
s.execute("text.setStyle", &json!({"font": to})).unwrap();
assert_eq!(fonts(&s), [to, to, to]);
}
+2
View File
@@ -9,7 +9,9 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
// Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off).
let mut s = Session::new();
s.prefs.placeholder_text = false;
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
+7
View File
@@ -161,6 +161,13 @@ impl Session {
double_click_isolate: self.prefs.double_click_to_isolate,
select_behind: self.prefs.ctrl_click_selects_behind,
highlight_anchors: self.prefs.highlight_anchors_on_hover,
snap_tolerance: self.prefs.snap_to_point_tolerance,
handles_multiple: self.prefs.show_handles_multiple_anchors,
corner_widget_max_angle: self.prefs.hide_corner_widget_above,
move_locked_with_artboard: self.prefs.move_locked_with_artboard,
pen_rubber_band: self.prefs.pen_rubber_band,
curvature_rubber_band: self.prefs.curvature_rubber_band,
placeholder_text: self.prefs.placeholder_text,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
+26
View File
@@ -538,3 +538,29 @@ fn selection_preferences_apply_to_pointer_gestures() {
assert_eq!(click(&mut s, 6, 225.0, json!({})), json!([]), "path only: the fill doesn't select");
assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does");
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
fn type_preferences_apply_to_agents() {
let mut s = server();
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
let style = |s: &mut Server, n: u64| {
let r = call(s, n, "run_command", json!({"command": "text.getRange", "params": {"id": id}}));
let v = serde_json::from_str::<Value>(&text_of(&r)).unwrap();
assert!(v["text"].as_str().unwrap().len() > 10, "placeholder text: {v}");
let st = &v["runs"][0]["style"];
(st["size"].as_f64().unwrap(), st["tracking"].as_f64().unwrap())
};
let (size, tracking) = style(&mut s, 2);
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.set", "params": {"values": {"typeSizeIncrement": 4, "trackingIncrement": 50}}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "press_key", json!({"key": "Right", "mods": {"alt": true}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 5, "press_key", json!({"key": ".", "mods": {"cmd": true, "shift": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
}
+37 -1
View File
@@ -64,12 +64,27 @@ impl CornerWidgets {
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
}
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
/// corners wider than `max` degrees (a skewed rectangle's obtuse ones) hide; none if all do.
pub fn within_angle(mut self, max: f64) -> Option<Self> {
// The angle between the shape's sides where they meet at the top-left corner; the
// top-right and bottom-left corners are its supplement.
let c = self.xf.as_coeffs();
let (u, v) = (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]));
let a = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
for (k, shown) in self.shown.iter_mut().enumerate() {
let angle = if k % 2 == 0 { a } else { 180.0 - a };
*shown &= angle <= max + 1e-9;
}
self.shown.contains(&true).then_some(self)
}
/// The widgets the active tool can drag (View → Show Corner Widget on).
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
if !cx.corner_widgets {
return None;
}
Self::of(cx.doc, cx.selection, cx.zoom)
Self::of(cx.doc, cx.selection, cx.zoom)?.within_angle(cx.corner_widget_max_angle)
}
/// The centres of the widgets that show.
@@ -318,6 +333,27 @@ mod tests {
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below
/// 90°; sheared, only its acute corners keep their widgets.
#[test]
fn corners_wider_than_the_preference_hide_their_widgets() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
assert_eq!(w.within_angle(177.0).map(|w| w.shown), Some([true; 4]));
assert_eq!(w.within_angle(90.0).map(|w| w.shown), Some([true; 4]));
assert!(w.within_angle(89.0).is_none());
// Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others.
let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]);
let (d, id) = live_rect(0.0, shear);
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
assert_eq!(w.within_angle(100.0).map(|w| w.shown), Some([true, false, true, false]));
let s = selected(id);
let p = paint();
let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) };
assert!(over_widget(&c, w.points[0]));
assert!(!over_widget(&c, w.points[1]), "the 120° corner's widget is hidden");
}
#[test]
fn widgets_off_or_elsewhere_keep_the_tools_behaviour() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
+9
View File
@@ -189,8 +189,17 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
})
}
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, handles show
/// (and drag) only while a single anchor is selected.
pub fn handles_shown(selection: &vectorcraft_doc::Selection, multiple: bool) -> bool {
multiple || selection.anchors.values().map(|set| set.len()).sum::<usize>() <= 1
}
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
if !handles_shown(cx.selection, cx.handles_multiple) {
return None;
}
for (id, set) in &cx.selection.anchors {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for &(si, ai) in set {
+3 -2
View File
@@ -3,7 +3,7 @@
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
//! A rubber band shows the curve to the cursor.
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool).
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
@@ -167,7 +167,8 @@ impl Tool for CurvatureTool {
return vec![];
}
let mut o = vec![];
if !self.closed
if cx.curvature_rubber_band
&& !self.closed
&& self.drag.is_none()
&& let Some(h) = self.hover
{
+32 -5
View File
@@ -17,6 +17,7 @@ enum Kind {
Edge,
Artboard,
Bleed,
Guide,
}
impl Kind {
@@ -27,6 +28,7 @@ impl Kind {
Kind::Edge => "path",
Kind::Artboard => "artboard",
Kind::Bleed => "bleed",
Kind::Guide => "guide",
}
}
}
@@ -95,6 +97,27 @@ impl Targets {
t
}
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
/// unlocked, as the selection tools pick them) pull the pointer into line.
fn for_snap_to_point(mut self, cx: &ToolContext) -> Self {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
if cx.guides {
for g in &cx.doc.guides {
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
lines.push((g.pos, at, Kind::Guide));
}
}
self
}
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
(cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx))
}
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
/// reach into `near`.
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
@@ -309,7 +332,8 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
}
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`.
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap
/// to Point) to their anchors and the ruler guides within the Snap to Point distance.
fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec<Overlay>) {
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
@@ -317,10 +341,13 @@ fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
}
if !cx.smart_guides {
return (p, vec![]);
if cx.smart_guides {
return targets().snap_point(p, cx.tol(5.0));
}
targets().snap_point(p, cx.tol(5.0))
if cx.snap_to_point {
return targets().for_snap_to_point(cx).snap_point(p, cx.tol(cx.snap_tolerance));
}
(p, vec![])
}
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
@@ -350,7 +377,7 @@ pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
if !(cx.snap_to_point || cx.smart_guides) {
return (p, vec![]);
}
let tol = cx.tol(5.0);
let tol = cx.tol(if cx.smart_guides { 5.0 } else { cx.snap_tolerance });
let mut roots = cx.selection.objects.clone();
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
+29 -1
View File
@@ -228,7 +228,9 @@ pub struct ToolContext<'a> {
pub show_bbox: bool,
/// View → Snap to Pixel.
pub snap_to_pixel: bool,
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
/// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked
/// points (a transform's reference point) land on anchors and ruler guides within
/// [`Self::snap_tolerance`].
pub snap_to_point: bool,
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
pub corner_widgets: bool,
@@ -279,6 +281,25 @@ pub struct ToolContext<'a> {
/// Selection & Anchor Display → Highlight anchors on mouse over: Direct Selection marks the
/// anchor under the pointer.
pub highlight_anchors: bool,
/// Selection & Anchor Display → Snap to Point (screen pixels): how near an anchor or a ruler
/// guide pulls the pointer while View → Snap to Point is on.
pub snap_tolerance: f64,
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, Direct
/// Selection shows and drags handles only while a single anchor is selected.
pub handles_multiple: bool,
/// Selection & Anchor Display → Hide Corner Widget for angles greater than (degrees): corners
/// wider than this show no Live Corners widget.
pub corner_widget_max_angle: f64,
/// Selection & Anchor Display → Move Locked and Hidden Artwork with Artboard.
pub move_locked_with_artboard: bool,
/// Selection & Anchor Display → Enable Rubber Band for Pen Tool: the Pen previews the next
/// segment to the pointer.
pub pen_rubber_band: bool,
/// Selection & Anchor Display → Enable Rubber Band for Curvature Tool.
pub curvature_rubber_band: bool,
/// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with
/// placeholder text, selected.
pub placeholder_text: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
@@ -585,6 +606,13 @@ pub(crate) mod testutil {
double_click_isolate: true,
select_behind: true,
highlight_anchors: true,
snap_tolerance: 2.0,
handles_multiple: true,
corner_widget_max_angle: 177.0,
move_locked_with_artboard: false,
pen_rubber_band: true,
curvature_rubber_band: true,
placeholder_text: false,
screen: None,
plane_widget: Some(Default::default()),
}
+2 -2
View File
@@ -6,7 +6,7 @@
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment. Auto Add/Delete: between paths, a click on a
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
@@ -201,7 +201,7 @@ impl Tool for PenTool {
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.drawing || self.drag.is_some() || self.handle.is_some() {
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
return vec![];
}
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
+13 -1
View File
@@ -1,4 +1,5 @@
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains; Smart
//! Guides, or with them off View › Snap to Point, snap it),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
@@ -55,6 +56,9 @@ pub struct SelectionTool {
measure: Option<(Point, String)>,
guides: Vec<Overlay>,
targets: Option<crate::guides::Targets>,
/// View → Snap to Point (Smart Guides off): the anchors and ruler guides the grabbed point
/// lands on while moving.
points: Option<crate::guides::Targets>,
start_bounds: Option<Rect>,
guide: GuideEdit,
}
@@ -212,8 +216,15 @@ impl Tool for SelectionTool {
if !began {
self.start_bounds = selection_bounds(cx);
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.points = crate::guides::Targets::snap_to_point(cx, &cx.selection.objects);
}
self.guides.clear();
// Snap to Point: the point the selection was grabbed by lands on an anchor or guide.
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
self.guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
d += adj;
@@ -269,6 +280,7 @@ impl Tool for SelectionTool {
self.measure = None;
self.guides.clear();
self.targets = None;
self.points = None;
match (began, deselect) {
(true, _) => vec![Action::Commit],
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
+31 -3
View File
@@ -11,7 +11,9 @@
//! undo step per typing session: the tool keeps the styled runs locally and previews a single
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
//! selection from [`Tool::options`]).
//! selection from [`Tool::options`]); Alt+arrows step it by the Preferences › Type increments
//! (`type.step`). New type starts with placeholder text, selected, when Fill New Type Objects With
//! Placeholder Text is on.
//!
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
@@ -263,7 +265,7 @@ impl TypeTool {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical, "placeholder": cx.placeholder_text}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
@@ -275,6 +277,7 @@ impl TypeTool {
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
params["placeholder"] = json!(cx.placeholder_text);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
@@ -303,6 +306,18 @@ impl TypeTool {
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
/// `None` when `r` runs past the end of `s` or backwards.
/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking;
/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift.
fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> {
let sign = |up: bool| if up { 1.0 } else { -1.0 };
Some(match (key, shift) {
(ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)),
(ToolKey::Up | ToolKey::Down, false) => ("leading", sign(key == ToolKey::Down)),
(ToolKey::Up | ToolKey::Down, true) => ("baselineShift", sign(key == ToolKey::Up)),
_ => return None,
})
}
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
let (a, b) = (byte(r.start)?, byte(r.end)?);
@@ -454,6 +469,16 @@ impl Tool for TypeTool {
self.caret = self.caret.min(len);
self.anchor = self.anchor.min(len);
let (a, b) = self.sel();
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
if mods.alt
&& let Some(id) = self.editing
&& let Some((attribute, by)) = step_key(key, mods.shift, a == b)
{
let mut out = self.commit();
let by = if mods.cmd { by * 5.0 } else { by };
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
return out;
}
let word = mods.cmd || mods.alt;
let lay = self.layout(&t);
let key = if lay.vertical {
@@ -623,7 +648,10 @@ impl Tool for TypeTool {
&& let Some(id) = cx.selection.objects.first().copied()
{
self.start_editing(id, 0);
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
let t = Self::text(cx, id);
self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
// Placeholder text comes selected: typing replaces it.
self.caret = t.map_or(0, |t| edit::runs_len(&t.runs));
}
}
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
+1 -1
View File
@@ -79,7 +79,7 @@ impl ArtboardTool {
self.guides.clear();
self.targets = cx.smart_guides.then(|| {
let art = match cx.doc.artboards.get(index) {
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect),
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard),
_ => vec![],
};
Targets::for_artboard(cx.doc, index, &art)
+97 -13
View File
@@ -352,7 +352,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator {
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
}
ime_output(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
@@ -1118,6 +1118,31 @@ fn selected_anchor(c: Color32) -> Color32 {
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
}
/// How anchors and handles look: Selection & Anchor Display › Size and Handles.
#[derive(Clone, Copy)]
struct HandleLook<'a> {
/// Points bigger (or smaller) than the default size 3, per step of Size (1–7).
grow: f32,
/// `solid` (the default), `hollow` or `large`.
style: &'a str,
}
impl<'a> HandleLook<'a> {
fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self {
Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style }
}
/// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one.
fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
let r = 2.75 + self.grow / 2.0;
let hollow = self.style == "hollow";
p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
if hollow {
p.circle_stroke(c, r, Stroke::new(1.0, color));
}
}
}
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
let r = egui::Rect::from_center_size(c, vec2(size, size));
if filled {
@@ -1279,8 +1304,12 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
// box's handles a point bigger or smaller per step.
let grow = app.session.prefs.anchor_size.clamp(1, 7) as f32 - 3.0;
let look = HandleLook::of(&app.session.prefs);
let grow = look.grow;
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
// anchors are selected off shows them for a single one only).
let (mut handles, mut with_handles) = (vec![], 0);
for id in &st.selection.objects {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
@@ -1303,13 +1332,8 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
};
let sp = xf.to_screen(a.p);
if sel && (direct || partial.is_some()) {
for h in [a.h_in, a.h_out] {
if h.distance(a.p) > 1e-6 {
let hp = xf.to_screen(h);
p.line_segment([sp, hp], Stroke::new(1.0, color));
p.circle_filled(hp, 2.75 + grow / 2.0, color);
}
}
with_handles += 1;
handles.extend([a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6).map(|h| (sp, xf.to_screen(h), color)));
}
anchor_square(p, sp, if sel && partial.is_some() { selected_anchor(color) } else { color }, sel, anchor(partial.is_some() || direct));
}
@@ -1331,6 +1355,12 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
p.circle_filled(o, 2.5, color);
}
}
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
for (sp, hp, color) in handles {
p.line_segment([sp, hp], Stroke::new(1.0, color));
look.draw(p, hp, color);
}
}
// The spine of each selected blend (or of the blend a selected key object belongs to).
let mut spines = vec![];
for id in &st.selection.objects {
@@ -1349,6 +1379,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
if matches!(tool, "selection" | "directSelection")
&& app.ui.view.corner_widgets
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
.and_then(|w| w.within_angle(app.session.prefs.hide_corner_widget_above))
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.visible().map(|q| xf.to_screen(q)) {
@@ -1398,7 +1429,7 @@ fn names_an_artboard(text: &str) -> bool {
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
for o in overlays {
match o {
Overlay::Marquee(r) => {
@@ -1428,9 +1459,7 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
}
}
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
Overlay::Handle { p: pt, color } => {
p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color));
}
Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)),
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(
@@ -1881,6 +1910,59 @@ mod tests {
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
}
/// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected
/// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected
/// anchor hides the handles.
#[test]
fn handle_style_and_handles_of_multiple_anchors() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
app.session.select_tool("directSelection", app.view_info()).unwrap();
let ctx = egui::Context::default();
let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| {
app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap();
};
// The handle ends: (radius, filled white).
let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> {
shapes(app, &ctx)
.iter()
.filter_map(|s| match s {
Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)),
_ => None,
})
.collect()
};
select(&mut app, json!([[0, 0]]));
assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default");
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap();
assert_eq!(dots(&mut app), [(2.75, true); 2]);
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap();
assert_eq!(dots(&mut app), [(4.25, false); 2]);
select(&mut app, json!([[0, 0], [0, 1]]));
assert_eq!(dots(&mut app).len(), 4, "both anchors' handles");
app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap();
assert!(dots(&mut app).is_empty(), "off: none for two anchors");
select(&mut app, json!([[0, 1]]));
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their
/// widgets below 90°.
#[test]
fn corner_widgets_hide_above_the_preference_angle() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let ctx = egui::Context::default();
let widgets = |app: &mut VectorcraftApp| {
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
};
assert_eq!(widgets(&mut app), 4);
app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap();
assert_eq!(widgets(&mut app), 0);
}
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways.
@@ -2274,6 +2356,8 @@ mod tests {
#[test]
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
// The type placed starts empty (Fill New Type Objects With Placeholder Text off).
app.session.prefs.placeholder_text = false;
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
// Not editing: the IME stays off (single-key tool shortcuts keep working).
+42 -8
View File
@@ -1,5 +1,5 @@
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy; Scale also has Uniform,
//! Scale Corners and Scale Strokes & Effects).
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns and Copy;
//! Scale also has Uniform, Scale Corners and Scale Strokes & Effects).
use serde_json::{Value, json};
@@ -26,10 +26,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, "uniform", tl!("Uniform"));
scale_options(app, ui, d);
}
d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d)));
form::check(ui, d, "patterns", tl!("Transform Patterns"));
form::check(ui, d, "copy", tl!("Copy (make a transformed copy)"));
false
}
/// Transform Patterns: the dialog's field, else General › Transform Pattern Tiles.
fn transform_patterns(app: &VectorcraftApp, d: &Dialog) -> bool {
d.fields.get("patterns").and_then(Value::as_bool).unwrap_or(app.session.prefs.transform_pattern_tiles)
}
/// The scale options: (dialog field, preference, label).
const SCALE_OPTIONS: [(&str, &str, &str); 2] = [("corners", "scaleCorners", "Scale Corners"), ("strokes", "scaleStrokes", "Scale Strokes & Effects")];
@@ -74,17 +81,21 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value {
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let copy = d.bool("copy");
let (copy, patterns) = (d.bool("copy"), transform_patterns(app, d));
let (id, params) = match d.kind.as_str() {
"move" => return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
"move" => {
return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy, "patterns": patterns}));
}
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy, "patterns": patterns})),
"scale" => {
let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy}))?)
("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy, "patterns": patterns}))?)
}
"reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})),
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
"reflect" => {
("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy, "patterns": patterns}))
}
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy, "patterns": patterns})),
};
run_and_close(app, id, origin_params(d, params))
}
@@ -121,4 +132,27 @@ mod tests {
let (cmd, p) = app.session.journal.last().unwrap();
assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.scale", &json!(false), &json!(true)));
}
/// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the
/// command.
#[test]
fn transform_patterns_starts_from_the_preference() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
for on in [false, true] {
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": on})).unwrap();
crate::menus::invoke(&mut app, "object.move", json!({}));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields["patterns"], json!(on));
d.fields.insert("dx".into(), json!(10));
crate::dialogs::confirm(&mut app).unwrap();
let (cmd, p) = app.session.journal.last().unwrap();
assert_eq!((cmd.as_str(), &p["patterns"]), ("object.move", &json!(on)));
}
}
}
+3
View File
@@ -922,6 +922,9 @@
Fit Artboard in Window Encajar mesa de trabajo en la ventana
Fit dashes to corners and ends Ajustar guiones a esquinas y extremos
Fit Headline Ajustar titular
Decrease Font Size Reducir tamaño de fuente
Increase Font Size Aumentar tamaño de fuente
Step Type Ajustar texto por incrementos
Fit in View Encajar en la vista
Fit in Window Encajar en la ventana
Fit on Screen Encajar en pantalla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -290,6 +290,9 @@
Release Selection 選択部分をリリース
Remove Threading スレッドを解除
Fit Headline ヘッドラインを合わせる
Decrease Font Size フォントサイズを小さく
Increase Font Size フォントサイズを大きく
Step Type 文字を段階的に調整
Find Font… フォントの検索と置換…
Change Case 大文字と小文字の変更
UPPERCASE すべて大文字
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -872,6 +872,9 @@
Fit All in Window Ajustar Tudo na Janela
Fit Artboard in Window Ajustar Mesa de Trabalho na Janela
Fit Headline Ajustar Manchete
Decrease Font Size Diminuir Tamanho da Fonte
Increase Font Size Aumentar Tamanho da Fonte
Step Type Ajustar Texto em Incrementos
Fit dashes to corners and ends Ajustar tracejados aos cantos e extremidades
Fit in Window Ajustar na Janela
Fit on Screen Ajustar na Tela
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -916,6 +916,9 @@
Fit Artboard in Window 使画板适合窗口
Fit dashes to corners and ends 让虚线贴合转角与端点
Fit Headline 适合标题
Decrease Font Size 减小字体大小
Increase Font Size 增大字体大小
Step Type 按增量调整文字
Fit in View 适合视图
Fit in Window 适合窗口
Fit on Screen 显示全页
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -938,6 +938,9 @@
Fit Artboard in Window 使工作區域符合視窗
Fit dashes to corners and ends 讓虛線貼合轉角與端點
Fit Headline 符合標題
Decrease Font Size 縮小字體大小
Increase Font Size 放大字體大小
Step Type 依增量調整文字
Fit in View 符合檢視
Fit in Window 符合視窗
Fit on Screen 顯示全頁
Can't render this file because it contains an unexpected character in line 6 and column 47.
+33
View File
@@ -1117,6 +1117,22 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
topmost again. Cmd held to borrow the selection tool from another tool clicks as usual.
- `doubleClickToIsolate` (on by default): off, a `doubleclick` on a group with the Selection tool no longer isolates it.
- `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair.
- `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler
guide that near.
- `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and
hidden art with the artboard too; off (the default) it stays where it is.
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
the pointer.
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection shows and drags direction handles only while a
single anchor is selected. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
- `hideCornerWidgetAbove` (177° by default): corners wider than this show no Live Corners widget (a rectangle's right
angles hide below 90°).
- `transformPatternTiles` (off by default): the default of the transforms' `patterns` param (`object.transform`,
`object.move`, `object.rotate`, `object.scale`, `object.reflect`, `object.shear`, `object.transformEach`, the
Selection and transform tools, the dialogs' Transform Patterns): pattern fills and strokes move with the art.
- `selectSameTintPercent` (off by default): `select.same.fillColor`, `strokeColor` and `fillAndStroke` take every tint
of a global or spot swatch; on, only the same tint.
```json
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"objectSelectionByPathOnly","value":true}}}
@@ -1128,6 +1144,23 @@ wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel s
`ui.wheel` turns the wheel), `zoomToSelection` (on: Zoom In and Zoom Out centre the selection), `showToolTips`,
`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle` and `recentFontsCount`.
## Type preferences
- `placeholderText` (on by default): type the Type tools place (`pointer_gesture` with `type`, `areaType`…) starts with
placeholder text, selected, so `type_text` replaces it; `text.create` and `text.createInPath` take `placeholder: true`.
- `typeSizeIncrement`, `trackingIncrement`, `baselineShiftIncrement`: what `type.step {attribute: "size" | "leading" |
"tracking" | "kerning" | "baselineShift", by?}` steps by, on the text range given, the Type tool's selected text or the
selected type objects. `type.size.increase` / `type.size.decrease` (Cmd+Shift+. and Cmd+Shift+,) step the size; the
Type tool's Alt+arrows (`press_key {key: "Right", mods: {alt: true}}`) step kerning at a caret or tracking of the
selection (←/→), leading (↑/↓) and baseline shift (Shift+↑/↓), five steps with Cmd/Ctrl too.
- `missingGlyphProtection` (on by default): `text.setStyle` and `text.setRangeStyle` changing the font leave the
characters the new font has no glyph for in the font that had one.
```json
{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}}
{"name":"run_command","arguments":{"command":"type.step","params":{"attribute":"tracking","by":-2}}}
```
## Width points
`stroke.widthPoint.set {id, t, left, right, index?, adjustAdjoining?}` adds a width point (side widths in points) or,