Merge main into fix/plugin-grant-app-approval

This commit is contained in:
dotCooCoo
2026-11-08 11:28:38 -08:00
79 changed files with 2183 additions and 238 deletions
+1 -1
View File
@@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
let field = |key: &str| {
device_id.split(['\\', '&']).find_map(|part| {
let (name, hex) = part.split_at_checked(key.len())?;
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
})
};
Some((field("VEN_")?, field("DEV_")?))
+7
View File
@@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
}
/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id.
#[test]
fn pci_ids_reject_a_sign_in_the_hex_digits() {
assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None);
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None);
}
+152 -39
View File
@@ -13,6 +13,13 @@
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
//! so they don't run a shortcut bound to Alt and a digit.
//!
//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right,
//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the
//! separate navigation keys by the extended-key flag, which only the separate keys set
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
//! so with Alt held they count as digits and are taken out of the frame too, rather than moving
//! the caret before the character arrives.
//!
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
//! platforms, whose systems have no such convention, can't tell them apart either.
@@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102;
const WM_SYSKEYDOWN: u32 = 0x0104;
const WM_SYSKEYUP: u32 = 0x0105;
const WM_SYSCHAR: u32 = 0x0106;
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
/// are Insert, End, … and type no code.
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on.
const VK_NUMPAD0: usize = 0x60;
const VK_NUMPAD9: usize = 0x69;
/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`,
/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`.
const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21];
/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it
/// comes as the top-row digit key, `Num5`).
const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] =
[Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp];
const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the
/// keypad (the keypad's own keys clear it).
const EXTENDED_KEY: isize = 1 << 24;
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
@@ -51,23 +68,42 @@ struct AltCodes {
high: Option<u16>,
/// Characters the codes typed, for the next frame.
typed: String,
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
/// as Alt and a digit: taken out of the frame.
keypad_events: usize,
/// The digits of the keypad presses and releases with Alt held since the last frame, which
/// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken
/// out of the frame.
keypad_events: Vec<usize>,
}
/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and
/// `lparam` the message's flags.
fn keypad_digit(wparam: usize, lparam: isize) -> Option<usize> {
if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) {
return Some(wparam - VK_NUMPAD0);
}
if lparam & EXTENDED_KEY != 0 {
return None;
}
NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam)
}
impl AltCodes {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
fn message(&mut self, message: u32, wparam: usize) {
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
match message {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit)
/// and `lparam` (its flags).
fn message(&mut self, message: u32, wparam: usize, lparam: isize) {
let keypad = match message {
WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam),
_ => None,
};
if let Some(d) = keypad {
// Windows sends a key pressed with Alt held as a system key.
WM_SYSKEYDOWN if keypad => {
if message == WM_SYSKEYDOWN {
self.digits += 1;
self.keypad_events += 1;
self.armed = false;
}
WM_SYSKEYUP if keypad => self.keypad_events += 1,
self.keypad_events.push(d);
return;
}
match message {
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
// Any other key ends the sequence and keeps its own meaning.
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
@@ -99,14 +135,23 @@ impl AltCodes {
/// out the keypad digits typed with Alt held.
fn drain(&mut self, events: &mut Vec<Event>) {
let mut keypad = std::mem::take(&mut self.keypad_events);
if keypad > 0 {
if !keypad.is_empty() {
events.retain(|e| {
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
if digit && keypad > 0 {
keypad -= 1;
return false;
let Event::Key { key, modifiers, .. } = e else {
return true;
};
if !modifiers.alt {
return true;
}
// The first keypad key still to take out that egui reports as `key`.
let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key);
match keypad.iter().position(stands_for) {
Some(i) => {
keypad.remove(i);
false
}
None => true,
}
true
});
}
if !self.typed.is_empty() {
@@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) {
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.message(msg.message, msg.wParam.0);
c.message(msg.message, msg.wParam.0, msg.lParam.0);
}
});
}
@@ -152,14 +197,14 @@ mod tests {
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, ALT, 0);
for d in code.bytes() {
let vk = VK_NUMPAD0 + usize::from(d - b'0');
c.message(WM_SYSKEYDOWN, vk);
c.message(WM_SYSKEYUP, vk);
c.message(WM_SYSKEYDOWN, vk, 0);
c.message(WM_SYSKEYUP, vk, 0);
}
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(posted));
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(posted), 0);
}
fn key(key: Key, alt: bool, pressed: bool) -> Event {
@@ -186,11 +231,11 @@ mod tests {
fn a_character_without_a_code_before_it_is_left_to_winit() {
let mut c = AltCodes::default();
// A plain key press's character (winit types it), and Alt pressed and released alone.
c.message(WM_KEYDOWN, 0x41);
c.message(WM_CHAR, usize::from(b'a'));
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(b'b'));
c.message(WM_KEYDOWN, 0x41, 0);
c.message(WM_CHAR, usize::from(b'a'), 0);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(b'b'), 0);
let mut events = vec![];
c.drain(&mut events);
assert!(events.is_empty(), "{events:?}");
@@ -199,12 +244,12 @@ mod tests {
#[test]
fn another_key_while_alt_is_held_cancels_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
// Alt+F: a shortcut, not a code.
c.message(WM_SYSKEYDOWN, 0x46);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0x41);
c.message(WM_SYSKEYDOWN, 0x46, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0x41, 0);
let mut events = vec![];
c.drain(&mut events);
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
@@ -213,13 +258,81 @@ mod tests {
#[test]
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0xD83D);
c.message(WM_CHAR, 0xDE00);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0xD83D, 0);
c.message(WM_CHAR, 0xDE00, 0);
let mut events = vec![];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("😀".into())]);
}
/// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the
/// extended-key bit), Alt released, and the character Windows then posts.
fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT, 0);
for d in code.bytes() {
let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')];
c.message(WM_SYSKEYDOWN, vk, 0);
c.message(WM_SYSKEYUP, vk, 0);
}
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(posted), 0);
}
#[test]
fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() {
let mut c = AltCodes::default();
alt_code_numlock_off(&mut c, "0176", 0xB0);
// What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a
// release each, then a plain Home typed after Alt came up.
let mut events = vec![];
for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] {
events.push(key(k, true, true));
events.push(key(k, true, false));
}
events.push(key(Key::Home, false, true));
c.drain(&mut events);
assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]);
}
#[test]
fn with_numlock_off_keypad_five_counts_too() {
let mut c = AltCodes::default();
// Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear;
// 0234 uses Insert, Down, Page Down and Left.
alt_code_numlock_off(&mut c, "0151", 0x2014);
alt_code_numlock_off(&mut c, "0234", 0xEA);
let mut events =
vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("—ê".into())]);
}
#[test]
fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0);
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0);
// Alt+Home on the separate Home key (extended): a shortcut, not a digit.
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0x41, 0);
let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)];
c.drain(&mut events);
assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]);
}
#[test]
fn numlock_off_keys_without_alt_are_left_alone() {
let mut c = AltCodes::default();
c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0);
c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0);
let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)];
c.drain(&mut events);
assert_eq!(events.len(), 2);
}
}
+30 -1
View File
@@ -34,7 +34,16 @@ impl Document {
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
let n = self
.assets
.iter()
.filter_map(|a| {
let digits = a.name.strip_prefix(ASSET)?;
// `parse` alone would accept a leading `+`.
digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::<u64>().ok()).flatten()
})
.max()
.unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
@@ -61,3 +70,23 @@ impl Document {
v.into_iter().map(|(_, id)| id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name_after(names: &[&str]) -> String {
let mut doc = Document::new(100.0, 100.0);
for (i, n) in names.iter().enumerate() {
doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None });
}
doc.next_asset_name()
}
#[test]
fn next_asset_name_ignores_non_digit_suffixes() {
assert_eq!(name_after(&["Asset +5"]), "Asset 1");
assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1");
assert_eq!(name_after(&["Asset 5"]), "Asset 6");
}
}
+103 -19
View File
@@ -13,6 +13,8 @@
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
//! ([`gradient_meshes`]).
use std::collections::VecDeque;
use kurbo::{BezPath, Point, Shape, Vec2};
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
@@ -78,14 +80,26 @@ struct Lay<'a> {
st: &'a StrokeLayer,
/// Half the stroked width ([`aligned_width`]).
hw: f64,
/// How far ribs reach either side of the path where nothing wider is near.
cover: f64,
/// How far ribs reach past the stroke's edge.
margin: f64,
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
ext: (f64, f64),
}
impl Lay<'_> {
/// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider
/// side, just before or just after a step there.
fn half(&self, t: f64) -> f64 {
let Some(p) = &self.st.profile else { return self.hw };
let ((bl, br), (al, ar)) = p.around(t);
self.hw * bl.max(br).max(al).max(ar).max(0.0)
}
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
fn cover(&self, t: f64) -> f64 {
self.half(t) + self.margin
}
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
/// left; `None`: both, a centred stroke).
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
@@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
if !(hw > 0.0 && hw.is_finite()) {
return vec![];
}
let full = hw * st.profile_max();
let heads = (st.head_reach(false), st.head_reach(true));
let cap = cap_extent(st.cap, 2.0 * full);
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
// Caps reach as far past the ends as the stroke is wide there.
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin);
let subs = subpaths(bp);
let count = subs.len();
let mut out = vec![];
@@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
// A subpath of no length: a dot (round and projecting caps paint it).
1 => {
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
let r = lay.cover(0.0);
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
}
_ => {
let total = length(&pts, closed);
@@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
// Ribs near an arrowhead reach across it.
let reach = |s: f64| {
let mut r = lay.cover;
// How far each point is from the next, and along the subpath.
let seg: Vec<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
let along: Vec<f64> = seg
.iter()
.scan(0.0, |s, l| {
let at = *s;
*s += l;
Some(at)
})
.collect();
// How far the ribs at point `i` reach: past the stroke there and on its way to the points
// either side, where it is no wider than at one of the two (each side of a width profile
// stays between its widths at the points of a span, and its points have points here). The
// caps and dots of a dashed stroke reach along the path as far as it is wide where they sit,
// so there the ribs cover the widest the stroke gets that far either way. Not as far as the
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
// subpath is also its end. Ribs near an arrowhead reach across it.
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
let window = match lay.st.dash {
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
_ => 0.0,
};
let ahead = widest_ahead(&cover, &seg, closed, window);
// Behind a point is ahead of it on the subpath run backwards.
let back_seg: Vec<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
let behind: Vec<f64> = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect();
let reach = |i: usize| {
let mut r = cover[i].max(ahead[i]).max(behind[i]);
let s = along[i];
if heads.0 > 0.0 && s <= heads.0 {
r = r.max(heads.0 + lay.margin);
}
@@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
r
};
let mut out = Vec::with_capacity(n + 4);
let mut s = 0.0;
if !closed {
let (d, b) = (dir[0], lay.band(0.0, side));
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
s = pts[0].distance(pts[1]);
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0), 0.0, b));
}
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
for i in first..last {
let v = i % n;
let t = s / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
let t = along[v] / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
// A closed subpath starts after the fan of its first corner and ends with it.
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
out.extend(ribs.into_iter().skip(skip));
s += pts[v].distance(pts[(v + 1) % n]);
}
if closed {
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side)));
} else {
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b));
}
out
}
/// For each of the points of a subpath, how far the ribs of the points ahead of it reach
/// (`cover`), out to the first point more than `window` along (or the end of an open subpath,
/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last,
/// the first when `closed`). Both ends of the window only move forward, so a queue of the
/// points that reach farther than every point after them gives each maximum, and a subpath
/// costs time in proportion to its points however many a window spans.
pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec<f64> {
let n = cover.len().min(seg.len());
if n == 0 {
return vec![];
}
// A closed subpath's points ahead are numbered on round it once more.
let last = if closed { 2 * n - 1 } else { n - 1 };
let mut pos = Vec::with_capacity(last + 1);
let mut s = 0.0;
for k in 0..=last {
pos.push(s);
s += seg[k % n];
}
let mut out = Vec::with_capacity(n);
let mut queue: VecDeque<usize> = VecDeque::new();
let mut next = 1;
for i in 0..n {
next = next.max(i + 1);
let stop = if closed { i + n } else { last };
let mut past = next > i + 1 && pos[next - 1] - pos[i] > window;
while !past && next <= stop {
let r = cover[next % n];
while queue.back().is_some_and(|&k| cover[k % n] <= r) {
queue.pop_back();
}
queue.push_back(next);
past = pos[next] - pos[i] > window;
next += 1;
}
while queue.front().is_some_and(|&k| k <= i) {
queue.pop_front();
}
out.push(queue.front().map_or(0.0, |&k| cover[k % n]));
}
out
}
+110
View File
@@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
}
}
}
/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of
/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece
/// runs the way the path does; across, square to it.
fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option<bool> {
slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| {
let d = s.to - s.from;
(d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along)
})
}
/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one
/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as
/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it.
#[test]
fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() {
// Right along y = 50, round, then down through the first leg at x = 50, near the narrow end.
let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false);
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let mut st = stroke(10.0, mode);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&crossing, &st);
// In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg,
// which is less than 1 pt either side of itself there.
let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode);
assert_eq!(level, Some(true), "{mode:?}: painted by the second leg");
// Where the two overlap, the second leg is on top.
let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode);
assert_eq!(level, Some(false), "{mode:?}: painted by the first leg");
assert_covered(&crossing, &st);
}
}
/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge.
#[test]
fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
// Right along y = 0, then back along y = 7: the way back is the narrow end of a taper.
let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&hairpin, &st);
// Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the
// way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8.
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
assert_covered(&hairpin, &st);
}
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
/// 256 do.
#[test]
fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() {
// The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt.
let mut pts = vec![(0.0, 0.0), (100.0, 0.0)];
pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0)));
let hairpin = poly(&pts, false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
st.cap = LineCap::Round;
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
let sl = slices(&hairpin, &st);
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
}
/// The widest the ribs ahead reach is the widest over the points out to the first one more than
/// the window along, as a walk along the subpath finds it, open and closed.
#[test]
fn widest_ahead_is_the_widest_within_the_window() {
let mut seed = 0x9e37_79b9_u64;
let mut rnd = move || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for _ in 0..200 {
let n = 1 + (rnd() * 40.0) as usize;
let cover: Vec<f64> = (0..n).map(|_| rnd() * 10.0).collect();
// Some points on top of each other.
let seg: Vec<f64> = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect();
let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize];
for closed in [false, true] {
let got = super::gradient::widest_ahead(&cover, &seg, closed, window);
assert_eq!(got.len(), n);
for (i, &got) in got.iter().enumerate() {
let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64);
for _ in 0..n {
let k = if closed {
(j + 1) % n
} else if j + 1 < n {
j + 1
} else {
break;
};
want = want.max(cover[k]);
d += seg[j];
j = k;
if d > window {
break;
}
}
assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}");
}
}
}
}
+4 -1
View File
@@ -930,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
let st = s.doc()?;
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
// the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123).
let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection);
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
+3 -1
View File
@@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched};
pub(crate) use access::{may_read, may_write};
pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
pub(crate) use export_names::{
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
};
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
+29 -2
View File
@@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json};
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
unique_file_names(artboard_names(doc, boards))
}
/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens,
/// which puts every stem through `safe_file_name` after this (trailing periods, device names).
pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names_keeping_dots(artboard_names(doc, boards))
}
/// Each of `boards`' name, with the file name to use when it has none.
fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator<Item = (&'a str, String)> {
boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))
}
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() { fallback } else { base }
}))
}
/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as
/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the
/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs
/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names.
pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect();
if base.trim_matches('.').is_empty() {
return fallback;
}
// A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before.
let rest = base.trim_start_matches('.');
format!("{}{rest}", "-".repeat(base.len() - rest.len()))
}))
}
/// `names` with `-2`, `-3`… after a name already used (compared without case).
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
distinct_names_by(names, str::to_lowercase)
+4 -4
View File
@@ -21,8 +21,8 @@ use super::encode::single_artboard;
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
unique_file_names_keeping_dots, with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -1184,7 +1184,7 @@ fn items(
// the item folders are (`fold`).
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -1261,7 +1261,7 @@ fn items(
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = safe(artboard_file_names(doc, &boards));
let stems = safe(artboard_file_names_keeping_dots(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
@@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
assert_eq!(r["name"], "nul-.zip");
}
/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or
/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no
/// hidden `.draft.png`), and `/` and `:` are dashes.
#[test]
fn an_artboard_name_keeps_its_dots() {
let mut s = session(7);
for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let r = export(&mut s, json!({"formats": [{"format": "png"}]}));
assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]);
}
/// `first_file` names the file the export writes first, for the same params: the item's file name,
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
+147 -2
View File
@@ -10,8 +10,9 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::Rect;
use super::select::on_artboard;
use super::typecmd::refresh_bounds;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Text by Font",
[],
None,
"{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
has_doc,
select_font
),
@@ -713,6 +714,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
let style = str_param(p, "style").map(str::to_string);
let ids = scope(s, false, None)?;
let d = &s.doc()?.doc;
let within = FontScope::read(d, p)?;
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
// or layer, or on a locked artboard.
let editable = d.editable();
@@ -758,11 +760,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
});
}
drop(editable);
let symbols = d.symbol_boxes();
hits.retain(|id| within.holds(d, &symbols, *id));
drop(symbols);
let n = hits.len();
s.select(|_, sel| sel.set(hits))?;
Ok(json!({ "count": n }))
}
/// Where Select Text by Font looks: its `scope`.
enum FontScope {
Document,
/// The artboards' rectangles.
Artboards(Vec<Rect>),
/// The layers and sublayers, with what they hold.
Layers(BTreeSet<NodeId>),
}
impl FontScope {
fn read(d: &Document, p: &Value) -> Result<Self> {
const C: &str = "select.font";
let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}");
let scope = match p.get("scope") {
None | Some(Value::Null) => return Ok(Self::Document),
Some(Value::String(v)) if v == "document" => return Ok(Self::Document),
Some(Value::Object(m)) if m.len() == 1 => m,
Some(_) => return Err(expected()),
};
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
match scope.get(key) {
None => Ok(None),
Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)),
Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))),
}
};
if let Some(a) = list("artboards")? {
let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect);
return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::<Result<_>>().map(Self::Artboards);
}
if let Some(a) = list("layers")? {
let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer));
return a
.iter()
.map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer"))))
.collect::<Result<_>>()
.map(Self::Layers);
}
Err(expected())
}
/// Is object `id` in the scope.
fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool {
match self {
Self::Document => true,
Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))),
Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))),
}
}
}
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
match str_param(p, "family") {
@@ -911,6 +967,95 @@ mod tests {
assert!(s.doc().unwrap().selection.objects.is_empty());
}
/// Select Text by Font with an artboard scope takes the text on the artboards, as All on
/// Active Artboard does: overlapping one, or centered on it, a straddling one included.
#[test]
fn select_font_in_artboards() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap();
let text = |s: &mut Session, x: i32| {
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
};
let first = text(&mut s, 10);
let straddling = text(&mut s, 180);
let second = text(&mut s, 350);
let third = text(&mut s, 650);
let _off = text(&mut s, 1000);
let select = |s: &mut Session, scope: Value| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap();
s.doc().unwrap().selection.objects.clone()
};
assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]);
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]);
assert_eq!(select(&mut s, json!("document")).len(), 5);
// The same objects All on Active Artboard takes.
s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]);
// Hidden text stays out.
s.execute("select.set", &json!({"ids": [third.0]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]);
for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] {
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}");
}
}
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
/// included, and still leaves out hidden and locked text.
#[test]
fn select_font_in_layers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let text = |s: &mut Session, y: i32| {
NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
};
let first_layer = s.doc().unwrap().doc.layers[0].id;
let a = text(&mut s, 20);
let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let b = text(&mut s, 40);
let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
let in_sub = text(&mut s, 60);
let locked = text(&mut s, 80);
s.execute("select.set", &json!({"ids": [locked.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
let select = |s: &mut Session, layers: Value| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap();
let mut v = s.doc().unwrap().selection.objects.clone();
v.sort();
v
};
let d = &s.doc().unwrap().doc;
assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer");
assert_eq!(select(&mut s, json!([first_layer.0])), [a]);
assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]);
assert_eq!(select(&mut s, json!([sub])), [in_sub]);
assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]);
// Not a layer: an error, the selection as it was.
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err());
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err());
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
}
/// A symbol instance whose art uses the font is selected when the instance is in scope.
#[test]
fn select_font_scope_takes_symbol_instances_in_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap();
let instance = s.doc().unwrap().selection.objects[0];
let select = |s: &mut Session, i: u64| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone()
};
assert_eq!(select(&mut s, 0), 1);
assert_eq!(s.doc().unwrap().selection.objects, [instance]);
assert_eq!(select(&mut s, 1), 0);
}
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
#[test]
+19 -1
View File
@@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option<String> {
let def = lib.pattern(name)?;
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::<u32>().is_ok());
let copy = |n: &str| {
n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()))
};
let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect();
found.sort_by_key(|p| p.name != name);
found.first().map(|p| p.name.clone())
@@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) {
add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]);
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plus_signed_suffix_is_not_a_copy_number() {
let mut lib = Document::new(100.0, 100.0);
lib.patterns.push(PatternDef::new("Dots", vec![]));
for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] {
let mut d = Document::new(100.0, 100.0);
d.patterns.push(PatternDef::new(other, vec![]));
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
}
}
}
+8
View File
@@ -62,6 +62,14 @@ pub struct Session {
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
/// gesture found, so that one that leaves it as it was picks nothing.
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
/// The selection a lent tool's gesture changed and left as it gave `lender` back, in the
/// document (its uid) it was made in: while it is still that document's selection, the anchors
/// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or
/// Curvature tool back too (#6123).
pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>,
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
/// lends the other tools. None until one is chosen.
pub(crate) last_selection_tool: Option<&'static str>,
+2
View File
@@ -42,6 +42,8 @@ impl Session {
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
selection_at_lend: None,
lent_selection: None,
last_selection_tool: None,
last_view: ViewInfo::default(),
preview_memo: Default::default(),
+81
View File
@@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
assert_eq!(s.tool_id(), "rectangle");
}
/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are
/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected
/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks
/// nothing.
#[test]
fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
for x in [100.0, 200.0, 300.0, 400.0] {
gesture(&mut s, &[(x, 300.0)], none);
}
let id = s.doc().unwrap().selection.objects[0];
let pasted = |s: &mut Session| {
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
let ids: Vec<NodeId> = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::<Vec<_>>()
};
// A Cmd marquee around the middle two anchors: Copy takes them and the segment between.
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
assert_eq!(s.tool_id(), "pen");
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
// Cut takes the segment between them out of the path, as with Direct Selection chosen.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::<Vec<_>>();
assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2]));
// Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
gesture(&mut s, &[(500.0, 300.0)], none);
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [5]);
// A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own.
s.execute("edit.undo", &json!({})).unwrap();
let shift_cmd = Mods { shift: true, ..cmd };
gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd);
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [5]);
// The tool started afresh in the same document (another view of it) keeps a pick picked.
s.execute("edit.undo", &json!({})).unwrap();
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
s.reset_tool();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
}
/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection
/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct
/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole.
#[test]
fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() {
let cmd = Mods { cmd: true, ..Mods::default() };
let v = view();
let copied = |tool: &str, last_selection: Option<&str>| {
let mut s = session();
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]);
s.execute("path.create", &json!({"anchors": anchors})).unwrap();
s.execute("select.none", &json!({})).unwrap();
if let Some(t) = last_selection {
s.select_tool(t, v).unwrap();
}
s.select_tool(tool, v).unwrap();
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
assert_eq!(s.tool_id(), tool);
s.execute("edit.copy", &json!({})).unwrap();
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
let id = NodeId(ids[0].as_u64().unwrap());
path(&s, id).subpaths[0].anchors.len()
};
assert_eq!(copied("rectangle", Some("directSelection")), 2);
assert_eq!(copied("ellipse", Some("directSelection")), 2);
assert_eq!(copied("curvature", None), 2);
assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole");
}
/// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn
/// moves on its own and the Pen goes on drawing that path from it; another path's straight segment
/// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the
+7
View File
@@ -219,6 +219,7 @@ impl Session {
// The Selection and Group Selection tools act on the instance whole.
self.select_whole_instance(id);
let lent = self.make_tool(id);
self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone()));
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
}
@@ -232,6 +233,12 @@ impl Session {
let r = self.apply_actions(acts);
self.keep_tool_settings();
self.tool = lender;
// A gesture that left the selection as it found it (a Shift marquee over nothing) picked
// nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick.
let left = self.active().map(|d| (d.uid, d.selection.clone()));
if left != self.selection_at_lend.take() {
self.lent_selection = left;
}
self.after_command(None);
r.map(drop)
}
+4
View File
@@ -230,6 +230,10 @@ fn structured_junk() {
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
("select.add", json!({"ids": [[1], {"a": 1}]})),
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [0, u64::MAX, -1, "x", null, 1.5]}})),
("select.font", json!({"family": "Source Sans 3", "scope": {"layers": [0, u64::MAX, -1, [1], {"a": 1}]}})),
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [], "layers": [1]}})),
("select.font", json!({"family": "Source Sans 3", "scope": 7})),
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})),
+56 -5
View File
@@ -67,20 +67,29 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
"xs" | "xz" => registration(vals),
"xs" | "xz" => registration(op, vals),
_ => None,
}
}
fn is_registration(name: &str) -> bool {
matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]")
}
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
/// `rgb` forces RGB or CMYK.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)))?;
let name = vals.get(at)?.text()?;
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
if is_registration(&name) {
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
let paint = Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint };
return Some((paint, None));
}
let n = before.len();
let kind = after.get(1).copied();
let color = if rgb.unwrap_or(kind == Some(1.0)) {
@@ -102,9 +111,20 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
/// `… (name) tint Xs` / `… (name) tint type Xz`: the built-in Registration swatch at the tint (its
/// own colour, whatever values the operands give).
fn registration(vals: &[V]) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let tint = 1.0 - vals.get(at + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
fn registration(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)));
let tint = if let Some(i) = at {
1.0 - vals.get(i + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32
} else {
let mut nums = vals.iter().rev().filter_map(V::num);
let t = if op.eq_ignore_ascii_case("xz") {
let _type = nums.next();
nums.next().unwrap_or(0.0)
} else {
nums.next().unwrap_or(0.0)
};
1.0 - t.clamp(0.0, 1.0) as f32
};
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
Some((Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }, None))
}
@@ -512,6 +532,29 @@ mod tests {
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.0)]);
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 1.0));
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(1.0)]);
assert!(
matches!(color_op("Xs", &v), Some((Paint::Solid { tint, color, .. }, None)) if tint == 0.0 && color == Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0))
);
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Name("All".into()), V::Num(1.0)]);
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 0.0));
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.2), V::Num(0.0)]);
let (p, named) = color_op("Xx", &v).unwrap();
assert!(named.is_none());
assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.8).abs() < 1e-6));
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Name("All".into()), V::Num(0.5), V::Num(0.0)]);
let (p, named) = color_op("Xx", &v).unwrap();
assert!(named.is_none());
assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.5).abs() < 1e-6));
// Name-free Xs and Xz operands
let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.3]);
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.7).abs() < 1e-6));
let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.2, 1.0]);
assert!(matches!(color_op("Xz", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.8).abs() < 1e-6));
}
#[test]
@@ -528,6 +571,14 @@ mod tests {
assert_eq!(named.name, "MySpot");
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
let mut v_name = nums(&[0.0, 0.5, 1.0, 0.0]);
v_name.push(V::Name("MySpot".into()));
v_name.push(V::Num(0.4));
let (p, named) = color_op("x", &v_name).unwrap();
let named = named.unwrap();
assert_eq!(named.name, "MySpot");
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
assert!(color_op("k", &nums(&[1.0])).is_none());
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
+17 -3
View File
@@ -43,12 +43,26 @@ impl<'a> Reader<'a> {
}
}
/// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+`
/// ([`Self::end_mask`]).
fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> {
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") {
// `X=` also introduces legacy text frame art, read by `text_frames`.
return Ok(());
}
self.end_path_quietly();
// The art goes on from the paint before the dictionaries it is in, which `begin_obj` set
// aside: the writer leaves out paint that doesn't change, so a mask rectangle with no color
// of its own takes the white of the art before it, not a fresh state's black, which hides
// what it masks. Lock, visibility and transparency start afresh, as a dictionary's art does.
if let Some(f) = self.frames.iter().rev().take_while(|f| matches!(f.kind, Kind::Obj(_))).last() {
let before = &f.gs;
let gs = &mut self.gs;
(gs.fill, gs.stroke, gs.dash) = (before.fill.clone(), before.stroke.clone(), before.dash.clone());
(gs.width, gs.cap, gs.join, gs.miter, gs.rule) = (before.width, before.cap, before.join, before.miter, before.rule);
(gs.overprint_fill, gs.overprint_stroke) = (before.overprint_fill, before.overprint_stroke);
(self.pending_unread_fill, self.pending_unread_stroke) = (f.pending_unread_fill, f.pending_unread_stroke);
}
self.open(Kind::Mask, hidden)
}
@@ -509,7 +523,7 @@ impl<'a> Reader<'a> {
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ");
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ") || p.is_registration();
match p.color().filter(|_| !registration) {
Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1,
Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1,
@@ -1189,9 +1203,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
/// A style's stroke as an appearance item (`None` for a fill).
fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option<AppearanceItem> {
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None };
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, align, brush } = item else { return None };
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile, layer.align) = (*cap, *join, *miter, dash.clone(), profile.clone(), *align);
layer.brush = brush.as_ref().map(|(name, _)| name.clone());
Some(AppearanceItem::Stroke(layer))
}
+59 -24
View File
@@ -36,8 +36,15 @@
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
//! the object as entries (see [`super::brush`]).
//!
//! A stroke aligned inside or outside its path (Stroke panel › Align Stroke,
//! https://helpx.adobe.com/illustrator/desktop/draw-and-paint/use-strokes/stroke-panel-options.html)
//! is its own basic filter too, `(Adobe Stroke Offset)`, with its stroke's style and
//! `StrokeOffsetInside` (1: inside, 0: outside, as its `DisplayString` names it) as entries.
//! Without that entry, or with it as anything but a `/Bool` 0 or 1, which side it paints isn't
//! known and the style isn't plain (its objects keep their drawn look).
//!
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own
//! transparency and effects, and the effects of
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
@@ -47,12 +54,12 @@ use std::collections::HashMap;
use serde_json::json;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, StrokeAlign, WidthProfile};
use vectorcraft_effects::is_pathfinder;
use super::brush::PathBrush;
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::obj::{self, V};
use super::paint;
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
@@ -78,6 +85,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
/// A stroke aligned inside or outside its path (Stroke panel › Align Stroke).
const STROKE_OFFSET: &str = "Adobe Stroke Offset"; // brand-ok: the filter's name in the files
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
@@ -211,6 +220,8 @@ pub(super) enum StyleItem {
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
/// Which side of the path it paints (centred, or inside or outside a closed path).
align: StrokeAlign,
/// A brush: its name, and its settings for this object.
brush: Option<(String, PathBrush)>,
},
@@ -421,6 +432,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
b"FillStyle" => style_entry = Some(Kind::Fill),
b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other),
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
b"All" | b"[Registration]" | b"Registration" => operands.push(V::Name(obj::text(n))),
b"CompoundFilter" => pending = Some(true),
b"BasicFilter" => pending = Some(false),
b"Part" => in_basic = false,
@@ -594,7 +606,8 @@ fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option<Vec<u8>> {
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
let last = vals.iter().rev().find_map(V::num);
match op {
"Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => {
"Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs"
| "xz" => {
let Some((p, _)) = paint::color_op(op, vals) else { return };
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
f.fill = Some(p);
@@ -1211,29 +1224,49 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
(None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))),
(None, None, None) => Err(()),
},
STROKE if !f.paints.0 => Ok(None),
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()),
STROKE => match &f.stroke {
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
brush: None,
})),
_ => Err(()),
},
STROKE => stroke_item(f, StrokeAlign::Center),
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
STROKE_OFFSET => offset_stroke(f),
ART_BRUSH => art_stroke(f).map(Some).ok_or(()),
_ => Err(()),
}
}
/// A stroke filter `f`'s stroke, painting on side `align` of its path: `Ok(None)` when it paints
/// nothing, `Err` when it paints in a way this doesn't read.
fn stroke_item(f: &Filter, align: StrokeAlign) -> Result<Option<StyleItem>, ()> {
if !f.paints.0 {
return Ok(None);
}
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) {
return Err(());
}
match &f.stroke {
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
align,
brush: None,
})),
_ => Err(()),
}
}
/// A Stroke Offset filter `f`: a stroke whose own style is in its dictionary, aligned inside its
/// path when `StrokeOffsetInside` is 1 and outside when it is 0 (its `DisplayString` says which).
/// `Err` when that switch is missing or not a switch, or for a stroke [`paint_item`] doesn't read.
fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
let inside = switch(f, "StrokeOffsetInside", Kind::Bool)?.ok_or(())?;
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
}
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
/// relative to the path isn't read.
@@ -1261,6 +1294,7 @@ fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))),
})
}
@@ -1339,6 +1373,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Art)),
})
}
@@ -1383,7 +1418,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => {}
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
_ => {}
@@ -1397,7 +1432,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => self.plain = false,
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => {
paints += 1;
look.visible &= c.visible;
match paint_item(c) {
@@ -1518,7 +1553,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
let f = &filters[id];
match f.name.as_str() {
STACK if f.compound => b.stack(id, &mut st),
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
if let Some((item, look)) = b.item(id) {
st.items.push(item);
st.looks.push(look);
+50
View File
@@ -1,4 +1,5 @@
use super::*;
use vectorcraft_color::Color;
#[test]
fn a_variable_width_stroke_keeps_its_profile() {
@@ -1393,3 +1394,52 @@ fn a_style_is_read_as_a_writer_checks_it() {
let _ = read(junk, Some(&own));
}
}
#[test]
fn art_styles_read_registration_and_named_colors() {
let reg_0 = fill("1 1 1 1 ([Registration]) 1 Xs");
let reg_100 = fill("1 1 1 1 ([Registration]) 0 Xs");
let reg_name = fill("1 1 1 1 /All 1 Xs");
let named_spot = fill("0 0.5 1 0 (Shemen) 0 0 Xx");
let stroke_reg = basic(
"Stroke Style Filter",
"/StrokeStyle : 0 R\n1 1 1 1 ([Registration]) 1 XS\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n",
);
let data = format!(
"/KnownStyle :\n(Reg0) /Name ,\n/ActiveStyle :\n{}\n\
/KnownStyle :\n(Reg100) /Name ,\n/ActiveStyle :\n{}\n\
/KnownStyle :\n(RegName) /Name ,\n/ActiveStyle :\n{}\n\
/KnownStyle :\n(Shemen) /Name ,\n/ActiveStyle :\n{}\n\
/KnownStyle :\n(StrokeReg) /Name ,\n/ActiveStyle :\n{}\n",
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_0])]),
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_100])]),
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_name])]),
compound("Chain Style Filter", &[compound("Stack Style Filter", &[named_spot])]),
compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke_reg])]),
);
let styles = art_styles(data.as_bytes());
let st0 = &styles["Reg0"];
assert!(st0.plain);
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st0.items.as_slice() else { panic!("{st0:?}") };
assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)));
let st100 = &styles["Reg100"];
assert!(st100.plain);
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st100.items.as_slice() else { panic!("{st100:?}") };
assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 1.0, Color::cmyk(1.0, 1.0, 1.0, 1.0)));
let st_name = &styles["RegName"];
assert!(st_name.plain);
let [StyleItem::Fill(Paint::Solid { swatch, tint, .. })] = st_name.items.as_slice() else { panic!("{st_name:?}") };
assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0));
let st_spot = &styles["Shemen"];
assert!(st_spot.plain);
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st_spot.items.as_slice() else { panic!("{st_spot:?}") };
assert_eq!((swatch.as_deref(), *tint, *color), (Some("Shemen"), 1.0, Color::cmyk(0.0, 0.5, 1.0, 0.0)));
let st_stroke = &styles["StrokeReg"];
assert!(st_stroke.plain);
let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") };
assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0));
}
+176
View File
@@ -5778,6 +5778,111 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// An art style named `name`: a blue fill under a red 2 pt stroke written as a Stroke Offset filter
/// whose `StrokeOffsetInside` entry is `inside` (`None`: left out).
fn offset_stroke_style(name: &str, inside: Option<u8>) -> String {
let switch = inside.map(|v| format!("{v} /Bool (StrokeOffsetInside) ,\n")).unwrap_or_default();
offset_stroke_style_with(name, &switch, OFFSET_STROKE)
}
/// The Stroke Offset stroke of [`offset_stroke_style`]: red, 2 pt, butt caps, miter joins, solid.
const OFFSET_STROKE: &str = "0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
/// An art style named `name`: a blue fill under a Stroke Offset filter whose dictionary holds
/// `entries` and the stroke style `stroke` (what follows `/StrokeStyle :`).
fn offset_stroke_style_with(name: &str, entries: &str, stroke: &str) -> String {
format!(
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Stroke Offset) 1 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n{entries}(Side) /String (DisplayString) ,\n\
/StrokeStyle : {stroke}\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n"
)
}
#[test]
fn a_stroke_offset_filter_is_a_stroke_aligned_inside_or_outside() {
// GUARDS: a style's stroke aligned inside or outside (Stroke panel › Align Stroke) left the object as its drawn look.
use vectorcraft_doc::StrokeAlign;
for (inside, align) in [(Some(1), Some(StrokeAlign::Inside)), (Some(0), Some(StrokeAlign::Outside)), (None, None), (Some(2), None)] {
let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{}", offset_stroke_style("Edge", inside), layer(&styled_square("Edge"))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let stroke = a[0].appearance.stroke().map(|s| (s.align, s.width, s.paint.color()));
match align {
Some(align) => {
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert_eq!(stroke, Some((align, 2.0, Some(Color::rgb(1.0, 0.0, 0.0)))));
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
// Without a 0/1 switch, which side it paints isn't known: the drawn look stays.
None => assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{inside:?}: {:?}", r.warnings),
}
}
}
/// A Stroke Offset stroke keeps the caps, joins, miter limit, dashes and width profile of its
/// style, as a Stroke Style Filter's does.
#[test]
fn a_stroke_offset_filter_keeps_its_strokes_style() {
use vectorcraft_doc::StrokeAlign;
let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let entries = format!("0 /Bool (StrokeOffsetInside) ,\n{profile}");
let stroke = "0 R\n0 1 1 0 1 0 0 XA\n1 J 2 j 3 w 4 M [5 10 ]0 d\n0 XR\n1 0 Xd";
let style = offset_stroke_style_with("Edge", &entries, stroke);
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
let s = a[0].appearance.stroke().expect("a stroke");
assert_eq!((s.align, s.width, s.cap, s.join, s.miter_limit), (StrokeAlign::Outside, 3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(s.dash.as_ref().map(|d| d.pattern.clone()), Some(vec![5.0, 10.0]));
assert_eq!(s.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
}
/// A Stroke Offset stroke this doesn't read as a stroke keeps the object's drawn look: a switch
/// written as another kind, dashes aligned to corners and path ends, a gradient.
#[test]
fn a_stroke_offset_filter_with_a_stroke_this_does_not_read_keeps_the_drawn_look() {
let outside = "0 /Bool (StrokeOffsetInside) ,\n";
let gradient = "0 R\nBb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
let cases = [
("a switch written as a number", offset_stroke_style_with("Edge", "1 /Int (StrokeOffsetInside) ,\n", OFFSET_STROKE)),
(
"dashes aligned to corners",
offset_stroke_style_with(
"Edge",
&format!("{outside}/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"),
&OFFSET_STROKE.replace("[]0 d", "[5 10 ]0 d"),
),
),
("a gradient", offset_stroke_style_with("Edge", outside, gradient)),
];
for (why, style) in cases {
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
}
/// A listed `[Default]` style whose stroke is aligned outside comes in as the default graphic
/// style with that stroke.
#[test]
fn a_default_style_with_a_stroke_aligned_outside_comes_in() {
use vectorcraft_doc::StrokeAlign;
let r =
read_ok(&format!("{}{}", styles_and_list(&[offset_stroke_style("[Default]", Some(0))], &["[Default]"]), layer(&styled_square("[Default]"))));
assert_eq!(style_names(&r.doc), [("[Default]".to_string(), 1)]);
let stroke = r.doc.graphic_styles[0].appearance.stroke().map(|s| (s.align, s.width));
assert_eq!(stroke, Some((StrokeAlign::Outside, 2.0)));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert_eq!(a[0].extras().graphic_style, Some(1));
}
#[test]
fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() {
// GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes
@@ -5964,6 +6069,30 @@ fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() {
assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity");
}
#[test]
fn ai_import_registration_style_does_not_carry_over_previous_color_6205() {
// Regression test for #6205:
// A background shape sets a spot colour ("Shemen").
// A following object has an art style with [Registration] at 0% tint.
// The object must resolve the Registration style ([Registration] 0% / #ffffff)
// rather than falling back to the prior spot color.
let defs = [fill_style("RegWhite", "1 1 1 1 ([Registration]) 1 Xs\n")];
let background = format!("0 0.67 0.73 0.26 (Shemen) 0 0 Xx\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0));
let styled_obj = styled_square("RegWhite");
let content = format!("{background}{styled_obj}");
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["RegWhite"]), layer(&content)));
let a = art(&r.doc);
assert_eq!(a.len(), 2, "{a:#?}");
// First object is background in Shemen spot
let Paint::Solid { swatch: s0, .. } = fill(&a[0]) else { panic!("{:#?}", a[0]) };
assert_eq!(s0.as_deref(), Some("Shemen"));
// Second object is styled in RegWhite: [Registration] at 0% tint (#ffffff)
let Paint::Solid { swatch, tint, color } = fill(&a[1]) else { panic!("{:#?}", a[1]) };
assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION));
assert_eq!(tint, 0.0);
assert_eq!(color, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0));
}
#[test]
fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() {
let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
@@ -7519,6 +7648,53 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
}
}
/// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer
/// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle
/// with no colour, which came in black and hid them.
/// The unread pattern flags it also takes aren't covered here: an owner painted with a pattern the
/// file doesn't have is unread itself, which hides whether its mask art is.
#[test]
fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() {
let paint = "0 0 0 0 k\n1 O\n0 1 0 0 K\n1 R\n2 w\n1 J\n1 j\n4 M\n[3 1] 0 d\n1 XR\n";
let owner = format!("{paint}{}B\n", rect_path(10.0, 20.0, 50.0, 60.0));
let hidden_rect = rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::<String>();
let mask = |own_paint: &str| {
format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{own_paint}{hidden_rect}%_B\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n")
};
let fill_layer = |n: &Node| n.appearance.fill().cloned();
let stroke_layer = |n: &Node| n.appearance.stroke().cloned();
let rule = |n: &Node| match &n.kind {
NodeKind::Path { rule, .. } => Some(*rule),
_ => None,
};
let r = read_ok(&layer(&format!("{owner}{}", mask(""))));
let a = art(&r.doc);
let m = a[0].extras().mask.as_ref().unwrap();
// The owner has the paint set, none of it the default, so equal layers mean it was taken.
let (f, s) = (fill_layer(&a[0]).unwrap(), stroke_layer(&a[0]).unwrap());
assert!(f.overprint && s.overprint);
assert_eq!((s.width, s.cap, s.join, s.miter_limit), (2.0, LineCap::Round, LineJoin::Round, 4.0));
assert_eq!(s.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), Some((vec![3.0, 1.0], 0.0)));
assert_eq!(rule(&a[0]), Some(vectorcraft_geom::FillRule::EvenOdd));
assert_eq!(fill_layer(&m.art), Some(f));
assert_eq!(stroke_layer(&m.art), Some(s));
assert_eq!(rule(&m.art), rule(&a[0]));
// Paint the mask's art sets for itself stays in the mask: an object after the dictionary that
// sets none paints with the owner's.
let own = "%_1 g\n%_0 O\n%_0 G\n%_0 R\n%_5 w\n%_0 J\n%_0 j\n%_10 M\n%_[] 0 d\n%_0 XR\n";
let following = format!("{}B\n", rect_path(70.0, 20.0, 80.0, 30.0));
let r = read_ok(&layer(&format!("{owner}{}{following}", mask(own))));
let a = art(&r.doc);
assert_eq!(a.len(), 2);
let m = a[0].extras().mask.as_ref().unwrap();
assert_ne!(stroke_layer(&m.art), stroke_layer(&a[0]), "the mask's art has its own paint");
assert_eq!(fill_layer(&a[1]), fill_layer(&a[0]));
assert_eq!(stroke_layer(&a[1]), stroke_layer(&a[0]));
assert_eq!(rule(&a[1]), rule(&a[0]));
}
/// An opacity mask's art of one shown object is that object; other art is read into a group.
#[test]
fn opacity_mask_art_of_one_object_is_that_object() {
+10 -1
View File
@@ -222,7 +222,16 @@ impl Record {
extra.push(ascii.len() as u8);
extra.extend(ascii);
extra.resize(extra.len().next_multiple_of(4), 0);
let units: Vec<u16> = self.name.encode_utf16().take(255).collect();
// Cut before the character that would pass 255 units, never inside a surrogate pair.
let mut units: Vec<u16> = vec![];
for c in self.name.chars() {
let mut buf = [0; 2];
let encoded = c.encode_utf16(&mut buf);
if units.len() + encoded.len() > 255 {
break;
}
units.extend_from_slice(encoded);
}
let mut luni = (units.len() as u32).to_be_bytes().to_vec();
luni.extend(units.iter().flat_map(|u| u.to_be_bytes()));
block(&mut extra, b"luni", &luni)?;
+9
View File
@@ -321,3 +321,12 @@ fn the_profile_and_the_size_limit() {
let err = Renderer::new().export_region(&doc, doc.artboards[0].rect, RasterFormat::Psd, &o).unwrap_err();
assert!(err.contains(&MAX_SIDE.to_string()), "{err}");
}
#[test]
fn long_unicode_name_is_cut_on_a_character_boundary() {
let mut b = DocBuilder::new(100.0, 60.0);
b.layer(&("a".repeat(254) + "😀"));
b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {});
let file = export(&b.build(), &opts(PsdOptions::default()));
assert_eq!(parse(&file).records.last().unwrap().name, "a".repeat(254));
}
+10 -4
View File
@@ -18,7 +18,8 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
split_decls(s)
let s = strip_comments(s);
split_decls(&s)
.into_iter()
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
@@ -354,9 +355,8 @@ fn top_level_commas(list: &str) -> impl Iterator<Item = &str> {
})
}
/// Rules from every `<style>` element, in source order.
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
// Drop comments outside strings.
/// `css` without its `/* … */` comments (those inside quoted strings stay).
fn strip_comments(css: &str) -> String {
let mut text = String::with_capacity(css.len());
let mut chars = css.chars().peekable();
let mut quote = None;
@@ -387,6 +387,12 @@ fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
text.push(c);
}
}
text
}
/// Rules from every `<style>` element, in source order.
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
let text = strip_comments(css);
let mut s = text.as_str();
loop {
s = s.trim_start();
+8
View File
@@ -333,3 +333,11 @@ fn not_selectors() {
},
);
}
#[test]
fn decl_comments_are_stripped() {
let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false);
assert_eq!(parse_decls("fill:red /* note */; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
assert_eq!(parse_decls("fill:red /* a;b */ ; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
assert_eq!(parse_decls("font-family:'A /* x */ B'"), [d("font-family", "'A /* x */ B'")]);
}
+8 -2
View File
@@ -257,7 +257,7 @@ pub struct CornerDrag {
}
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
/// (Shift: toggles), as a click on the corner's anchor does.
/// (Shift: toggles, unless every corner is selected), as a click on the corner's anchor does.
#[derive(Clone, Debug)]
struct CornerClick {
id: NodeId,
@@ -282,7 +282,13 @@ impl CornerDrag {
let path = widgets.paths.get(widget.0)?;
let corner = path.widgets.get(widget.1)?.corner.index;
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
// While every corner is selected (the path selected whole), a Shift-click picks
// this corner alone, as the first click of "click one widget, then Shift-click the
// others" does: toggling it would deselect it and leave the rest (#5188). Selected
// anchors that are no corner show every widget too, but select no corner: there a
// Shift-click adds the corner.
let every = cx.selection.partial(path.id).is_none_or(|anchors| lc.all().is_subset(&lc.corners_of(anchors)));
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every })
})
.flatten();
let outlines = widgets
+67 -5
View File
@@ -196,7 +196,9 @@ fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
/// toggles the corner. The Selection tool's click still does nothing.
/// toggles the corner, except while every corner is selected (#5188, see
/// `a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`). The Selection tool's click
/// still does nothing.
#[test]
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
@@ -211,20 +213,80 @@ fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
let shift = Mods { shift: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
// With that corner selected, its widget alone shows, and a drag rounds it alone
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
let mut part = selected(id);
part.anchors.insert(id, anchors.iter().copied().collect());
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
// A Shift-click on it then deselects it.
let shift = Mods { shift: true, ..Mods::default() };
let one = crate::testutil::cx(&d, &part, &p);
t.pointer(&one, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
let a = t.pointer(&one, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
}
/// #5188: while every corner of a path is selected (the path selected whole), a Shift-click on a
/// widget picks that corner alone, as the first click of "click one widget, then Shift-click the
/// others" does, rather than deselecting it and leaving the others. Once some corners are picked,
/// Shift-clicks toggle.
#[test]
fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let p = paint();
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
let anchors = |k: usize| -> Vec<(usize, usize)> { lc.anchors_of(&[k].into()).into_iter().collect() };
let shift = Mods { shift: true, ..Mods::default() };
let click = |s: &Selection, at: Point| {
let cx = crate::testutil::cx(&d, s, &p);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift))
};
let pick = |k: usize, mode: &str| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors(k), "mode": mode}))];
// Selected whole: every widget shows; a Shift-click on one picks its corner alone.
let whole = selected(id);
let w = pts(&CornerWidgets::of(&d, &whole, 1.0, true).unwrap());
assert_eq!(w.len(), 4);
assert_eq!(click(&whole, w[2]), pick(2, "set"));
// Every anchor direct-selected is every corner selected too.
let mut every = selected(id);
every.anchors.insert(id, (0..4).flat_map(anchors).collect());
assert_eq!(click(&every, w[1]), pick(1, "set"));
// Two corners picked: a Shift-click on either widget deselects that corner.
let mut two = selected(id);
two.anchors.insert(id, [0, 2].into_iter().flat_map(anchors).collect());
let shown = pts(&CornerWidgets::of(&d, &two, 1.0, true).unwrap());
assert_eq!(shown, [w[0], w[2]]);
assert_eq!(click(&two, w[0]), pick(0, "toggle"));
}
/// #5188: an anchor selected that is no corner (here half way along a side) shows every widget
/// but selects no corner, so a Shift-click on a widget adds its corner to that selection.
#[test]
fn a_shift_click_on_a_widget_adds_to_anchors_that_are_no_corner() {
let corners = [(100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| Point::new(x, y));
let (d, id) = doc_with(PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)), None);
let p = paint();
let mut s = selected(id);
s.anchors.insert(id, [(0, 1)].into());
let cx = cx(&d, &s, &p);
let widgets = CornerWidgets::for_tool(&cx, true).unwrap();
assert_eq!(widgets.corners().len(), 4, "no corner selected: every widget shows");
let at = pts(&widgets)[0];
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
let corner = *lc.all().iter().next().unwrap();
let anchors: Vec<(usize, usize)> = lc.anchors_of(&[corner].into()).into_iter().collect();
let shift = Mods { shift: true, ..Mods::default() };
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
}
#[test]
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
+1
View File
@@ -89,6 +89,7 @@ impl VectorcraftApp {
view_moved: None,
hover_cursor: None,
presented: None,
opening_fit: None,
panes: Default::default(),
windows: crate::doc_windows::Windows::new(known),
},
+4
View File
@@ -65,6 +65,10 @@ pub struct CanvasCache {
/// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted
/// again when the rect changes (the window going full screen).
pub presented: Option<egui::Rect>,
/// The canvas a document's opening view was fitted to, with the view the fit gave. The window's
/// layout can still move the canvas's edges over the next frames, so until the view is moved or
/// the user acts, the opening view is fitted again when the canvas changes ([`canvas::show`]).
pub opening_fit: Option<canvas::ViewKey>,
/// Window › Arrange › Tile: the renders the panes of the other documents draw.
pub panes: canvas::Panes,
/// Window › Arrange › Float in Window: what the floating document windows keep between frames.
+37 -1
View File
@@ -186,6 +186,36 @@ fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect,
(full, rect.round_to_pixels(ppp))
}
/// Whether the user pressed a button or a key, typed, scrolled or zoomed in this frame: the
/// opening fit stops following the canvas then ([`crate::caches::CanvasCache::opening_fit`]).
fn acted(i: &egui::InputState) -> bool {
i.pointer.any_pressed()
|| i.events.iter().any(|e| {
matches!(
e,
egui::Event::Key { pressed: true, .. }
| egui::Event::Text(_)
| egui::Event::MouseWheel { .. }
| egui::Event::Zoom(_)
| egui::Event::Touch { .. }
)
})
}
/// The view `v` shows in the canvas `rect`, as the opening fit records it.
fn view_key(v: &View, rect: egui::Rect) -> ViewKey {
ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }
}
/// Whether the opening fit still follows the canvas `rect` (the view is unfitted, or unmoved since
/// the fit and the user hasn't acted), and whether the canvas moved under it this frame, so the view
/// is fitted again.
fn opening_fit_follows(app: &VectorcraftApp, ui: &Ui, rect: egui::Rect, fitted: bool) -> (bool, bool) {
let following = !fitted || (!ui.input(acted) && app.canvas.opening_fit.zip(app.view()).is_some_and(|(was, v)| view_key(v, was.rect) == was));
let settling = fitted && following && app.canvas.opening_fit.is_some_and(|was| was.rect != rect);
(following, settling)
}
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -225,13 +255,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point());
app.canvas_rect = Some(rect);
let fitted = app.view().is_some_and(|v| v.fitted);
// A document's opening view is fitted to the canvas of the first frame that shows it, and the
// window's layout can still move the canvas's edges over the next frames. Until the view is
// moved or the user acts, the opening fit follows the canvas, so that the document opens at the
// zoom of the settled window.
let (following, settling) = opening_fit_follows(app, ui, rect, fitted);
// Presentation Mode shows the active artboard on the whole screen and nothing but the art:
// no selection, guides, grids or artboard edges (#4365).
let presenting = app.ui.screen_mode == 3;
if !fitted || (presenting && app.canvas.presented != Some(rect)) {
if !fitted || settling || (presenting && app.canvas.presented != Some(rect)) {
fit(app, "view.fitArtboard");
}
app.canvas.presented = presenting.then_some(rect);
app.canvas.opening_fit = following.then(|| app.view().map(|v| view_key(v, rect))).flatten();
// Before the canvas's own interaction, so the view the canvas draws is the one they moved to.
if bars {
scroll_bars(app, ui, rect, outer, &t);
+54
View File
@@ -87,6 +87,60 @@ fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping");
}
/// A document's opening view is fitted to the canvas of the first frame that shows it, and the
/// window's layout can still move the canvas's edges over the next frames. Two starts of the app
/// then showed one file at zooms a hair apart, on canvases alike once the window had settled. So
/// until the view is moved or the user presses, types or scrolls, the opening fit follows the
/// canvas.
#[test]
fn the_opening_fit_follows_the_canvas_until_the_view_moves_or_the_user_acts() {
// A frame that shows the canvas `width` points wide, which limits the fit of the 800 × 600
// artboard, with `events`: the view after it.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec<egui::Event>) -> View {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))),
max_texture_side: Some(8192),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
show(app, &mut child);
});
out.textures_delta.clear();
*app.view().unwrap()
}
fn opened() -> (VectorcraftApp, egui::Context) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap();
(app, egui::Context::default())
}
let first_shown_at = |width: f32| {
let (mut app, ctx) = opened();
frame(&mut app, &ctx, width, vec![]).zoom
};
let (narrow, wide) = (first_shown_at(700.0), first_shown_at(702.0));
assert!(narrow < wide, "the canvas's width limits the fit: {narrow} and {wide}");
let escape = || egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
// The first frame lays the canvas out 2 points narrower than the next: the document opens at
// the zoom of the settled canvas, as it does when the first frame has it already.
let (mut app, ctx) = opened();
frame(&mut app, &ctx, 700.0, vec![]);
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, wide);
// Once the user has pressed a key, the canvas changing keeps the view, as resizing the window
// does.
assert_eq!(frame(&mut app, &ctx, 702.0, vec![escape()]).zoom, wide);
assert_eq!(frame(&mut app, &ctx, 700.0, vec![]).zoom, wide);
// A view moved before the canvas changes stays where it was moved to.
let (mut app, ctx) = opened();
frame(&mut app, &ctx, 700.0, vec![]);
app.view_mut().unwrap().zoom = 0.5;
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, 0.5);
}
/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent
/// edges and all; one whose bytes don't match its size becomes an empty image of that size.
#[test]
+322 -29
View File
@@ -101,7 +101,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
"section",
]
},
min_width: 650.0,
min_width: 720.0,
max_width: Some(720.0),
..DialogSpec::FORM
};
@@ -885,40 +886,81 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own
/// id, so the stored value is found on every frame.
fn footer_id(ui: &egui::Ui) -> egui::Id {
ui.layer_id().id.with("char-style-footer")
}
/// The dialog's body: the section list beside the selected section's content, which scrolls when it
/// is taller than the window (General lists every attribute of the style and OpenType every
/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's
/// error. The content's height is the space the footer leaves, measured last frame, so the buttons
/// stay on screen at any interface scale; the section list is outside the scroll area and stays put
/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes
/// from the button row in `dialogs::show`).
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// The content scrolls inside a shorter window: General lists every attribute of the style and
// OpenType every feature, so the window stays within the screen and its buttons stay visible.
// The footer was measured last frame, so the room left for the content keeps it on screen at any
// interface scale; the section list stays put while the content scrolls.
let footer = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)).unwrap_or(120.0));
let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0);
ui.horizontal(|ui| {
section_list(ui, d);
ui.separator();
ui.vertical(|ui| {
if d.str("section") == "basic" {
basic_body(ui, d);
} else if d.str("section") == "advanced" {
advanced_body(ui, d);
} else if d.str("section") == "color" {
color_body(ui, d);
} else if d.str("section") == "opentype" {
opentype_body(ui, d);
} else {
ui.horizontal(|ui| {
ui.label(tl!("Style Name:"));
form::text(ui, d, "name", 190.0);
});
ui.separator();
if let Ok(attrs) = edited_attrs(d) {
for (key, value) in attrs {
ui.label(format!("{}: {}", super::humanize(&key), value));
// The section list handles a click before this frame's content is laid out, so the section
// read here is the one just picked: its own scroll offset is used from the first frame.
let section = d.str("section");
let content_w = ui.available_width();
// Long sections scroll both ways: at a large interface scale the window has fewer points, and
// a wide row (a character-format grid) must stay inside it rather than widen the window.
egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show(
ui,
|ui| {
ui.vertical(|ui| {
if d.str("section") == "basic" {
basic_body(ui, d);
} else if d.str("section") == "advanced" {
advanced_body(ui, d);
} else if d.str("section") == "color" {
color_body(ui, d);
} else if d.str("section") == "opentype" {
opentype_body(ui, d);
} else {
ui.horizontal(|ui| {
ui.label(tl!("Style Name:"));
form::text(ui, d, "name", 190.0);
});
ui.separator();
if let Ok(attrs) = edited_attrs(d) {
for (key, value) in attrs {
ui.label(format!("{}: {}", super::humanize(&key), value));
}
}
}
}
}
});
});
},
);
});
reset_button(ui, d);
match edited_attrs(d) {
Ok(attrs) => {
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
}
Err(why) => attrs_error(ui, &why),
let footer_h = ui
.scope(|ui| {
reset_button(ui, d);
match edited_attrs(d) {
Ok(attrs) => {
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
}
Err(why) => attrs_error(ui, &why),
}
})
.response
.rect
.height();
let was = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)));
ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h));
if was != Some(footer_h) {
ui.ctx().request_repaint();
}
false
}
@@ -952,6 +994,257 @@ mod tests {
out.textures_delta.clear();
}
/// The texts a frame drew, with their screen rects.
fn texts_of(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> {
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut texts = vec![];
for c in &out.shapes {
collect(&c.shape, &mut texts);
}
texts
}
/// The texts of a few headless frames in `ctx` at a screen `w` by `h`, with their screen rects:
/// the dialog lays its scroll area out and measures its footer over the first frames.
fn frame_texts_in(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h));
let mut texts = vec![];
for _ in 0..5 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
texts = texts_of(&out);
}
texts
}
/// The texts of a few headless frames at a screen `w` by `h`, and the context they were drawn in.
fn frame_texts_at(app: &mut VectorcraftApp, w: f32, h: f32) -> (egui::Context, Vec<(String, egui::Rect)>) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame_texts_in(&ctx, app, w, h);
(ctx, texts)
}
/// The General section lists every attribute of the style, so the sections scroll inside a
/// window shorter than that list: the buttons stay on the screen, the window keeps its width in
/// every section, and the content is clipped by the scroll area's viewport.
#[test]
fn the_sections_scroll_in_a_window_shorter_than_the_general_list() {
let mut app = app();
app.run(
"charStyle.new",
json!({"name": "Body", "attrs": {
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
}}),
)
.unwrap();
open(&mut app, "Body").unwrap();
let (ctx, texts) = frame_texts_at(&mut app, 900.0, 560.0);
let in_screen = |label: &str| {
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1;
r.top() >= 0.0 && r.bottom() <= 560.0 + 1.0
};
assert!(in_screen("OK") && in_screen("Cancel"), "the buttons stay on a 560-point screen");
let dialog = egui::Id::unique(("dialog", "charStyleOptions"));
let width = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
assert!((700.0..=820.0).contains(&width), "the dialog keeps its 720-point width: {width}");
let reset = texts.iter().find(|(text, _)| text == "Reset Panel").expect("the footer is drawn").1.top();
assert!(
texts.iter().any(|(text, rect)| text.contains(':') && rect.bottom() > reset + 1.0),
"the General list is taller than the scroll area and runs under the footer: {texts:?}"
);
// A short section uses the same width.
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic"));
let _ = frame_texts_in(&ctx, &mut app, 900.0, 560.0);
let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}");
}
/// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every
/// section the section list, OK and Cancel are on the screen.
#[test]
fn every_section_and_the_buttons_fit_the_smallest_window() {
let mut app = app();
app.run("charStyle.new", json!({"name": "Body"})).unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
const W: f32 = 800.0;
const H: f32 = 500.0;
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
for section in ["general", "basic", "advanced", "color", "opentype"] {
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
let texts = frame_texts_in(&ctx, &mut app, W, H);
for label in labels {
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays on an 800 by 500 screen in {section}: {r:?}");
}
}
}
/// At 2x UI scale the smallest window (800 by 500 pixels) holds only 400 by 250 points: in
/// every section the section list and both buttons stay on the screen.
#[test]
fn every_section_and_the_buttons_fit_the_smallest_window_at_twice_the_scale() {
let mut app = app();
app.run(
"charStyle.new",
json!({"name": "Body", "attrs": {
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
}}),
)
.unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.set_zoom_factor(2.0);
const W: f32 = 400.0;
const H: f32 = 250.0;
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
for section in ["general", "basic", "advanced", "color", "opentype"] {
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
let texts = frame_texts_in(&ctx, &mut app, W, H);
for label in labels {
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
assert!(
r.top() >= 0.0 && r.bottom() <= H + 1.0 && r.left() >= 0.0 && r.right() <= W + 1.0,
"{label} stays on a 400 by 250 point screen at 2x in {section}: {r:?}"
);
}
// A wheel over the content scrolls it; the section list and both buttons stay put.
let at = egui::Pos2::new(300.0, 60.0);
let mut after = texts.clone();
for _ in 0..8 {
let events = vec![
egui::Event::PointerMoved(at),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-40.0, -40.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
},
];
let mut out = ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))), events, ..Default::default() },
|ui| super::super::show(&mut app, ui.ctx()),
);
out.textures_delta.clear();
after = texts_of(&out);
}
for label in labels {
let r = after.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays after scrolling in {section}: {r:?}");
}
}
}
/// Switching from a scrolled General section to a shorter one and back keeps General's own
/// scroll offset: the section is read after the list handles the click, so the click frame
/// stores no offset for the section that is leaving.
#[test]
fn a_scrolled_section_keeps_its_offset_after_switching_away_and_back() {
let mut app = app();
app.run(
"charStyle.new",
json!({"name": "Body", "attrs": {
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
}}),
)
.unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 560.0));
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out =
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
texts_of(&out)
};
let mut texts = vec![];
for _ in 0..5 {
texts = frame(&mut app, vec![]);
}
let y = |texts: &[(String, egui::Rect)], label: &str| {
texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.top()
};
eprintln!("DEBUG initial texts: {texts:?}");
let general_top = y(&texts, "Style Name:");
let before = texts.clone();
// Scroll General down with the wheel.
let at = egui::Pos2::new(600.0, 200.0);
for _ in 0..8 {
let events = vec![
egui::Event::PointerMoved(at),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -40.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
},
];
texts = frame(&mut app, events);
}
// Let the smooth wheel scroll finish before changing sections.
for _ in 0..15 {
texts = frame(&mut app, vec![]);
}
let scrolled = texts.clone();
assert!(
before.iter().any(|(label, _)| !scrolled.iter().any(|(other, _)| other == label))
&& scrolled.iter().any(|(label, _)| !before.iter().any(|(other, _)| other == label)),
"General scrolls to other attributes"
);
// Click Basic Character Formats, then General.
let click = |app: &mut VectorcraftApp, label: &str| {
let texts = frame(app, vec![]);
let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
let _ = frame(app, vec![e]);
}
let _ = frame(app, vec![]);
};
click(&mut app, "Basic Character Formats");
let basic = frame(&mut app, vec![]);
assert!(y(&basic, "Font Family:") < general_top + 40.0, "a short section opens at its top");
click(&mut app, "General");
let back = frame(&mut app, vec![]);
let mut same = 0;
for (label, old) in &scrolled {
if let Some((_, new)) = back.iter().find(|(text, _)| text == label) {
assert!((new.top() - old.top()).abs() < 1.0, "General keeps its offset at {label}: {old:?} -> {new:?}");
same += 1;
}
}
assert!(same > 0, "General draws its content again: {back:?}");
}
#[test]
fn unchanged_basic_fields_preserve_original_keys() {
for attrs in [
+8 -6
View File
@@ -114,17 +114,19 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>,
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
/// ask (the caller goes on), including a file that carries its VectorCraft document when opening.
/// ask (the caller goes on), including, when opening, a file that carries its VectorCraft document
/// and a `.ai`/`.ait` with its editing data.
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
let Some(format) = fileio::detect(name, bytes).map(|f| f.id).filter(|id| matches!(*id, "pdf" | "ai" | "ait")) else {
return false;
}
};
// A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing)
// opens as that document, every artboard and layer: there are no pages to pick. Placing still
// asks, since a place takes one page.
// opens as that document, every artboard and layer: there are no pages to pick. So does a .ai
// or .ait with its editing data, which holds them all (#6135); a PDF with editing data still
// asks, as its pages are what it is. Placing still asks, since a place takes one page.
// The file is read once for both checks.
let opened = vectorcraft_pdf::open_pdf(bytes, None);
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some()) {
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some() || (format != "pdf" && o.has_illustrator_data())) {
return false;
}
let (pages, locked) = match opened.map(|o| o.page_count()) {
+71 -1
View File
@@ -3,7 +3,8 @@
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use vectorcraft_testkit::ai;
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with};
use super::*;
use crate::io;
@@ -197,3 +198,72 @@ fn placing_a_saved_ai_still_asks_for_the_page() {
assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({}))));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
}
/// Two pages, 100 and 200 pt wide; with `editing_data` the first page has the keys of an editor's
/// copy of the art.
fn two_pages(editing_data: bool, password: Option<&str>) -> Vec<u8> {
let mut first = PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f");
if editing_data {
first.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
}
pdf(&[first, PdfPage::new(200.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], password)
}
/// Two 100 pt pages and, on the first, editing data with two artboards to match and a layer of its
/// own, which draws the square the first page draws.
fn two_pages_with_a_layer_in_the_editing_data(layer: &str) -> Vec<u8> {
let point = |x: i32, y: i32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
let board = |top: i32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(0, top, 1), point(100, top - 100, 2));
let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(100), board(-50));
let art = format!("0 0 0 1 k\n{}f\n", ai::rect(10.0, 10.0, 30.0, 30.0));
let data = ai::editing_data(100.0, 100.0, &format!("{boards}{}", ai::layer(layer, &art)));
let mut first = PdfPage::new(100.0, 100.0, "0 0 0 1 k 10 10 20 20 re f");
first.entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2));
let stream = format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len());
pdf_with(&[first, PdfPage::new(100.0, 100.0, "")], &[&stream], None)
}
/// #6135: the editing data of a `.ai` holds every artboard, so opening one asks nothing, opens
/// them all and reads the art from that data: the layer is in nothing else.
#[test]
fn opening_an_ai_with_editing_data_reads_it_without_asking() {
let mut app = app();
let bytes = two_pages_with_a_layer_in_the_editing_data("Only in the editing data");
io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap();
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
assert_eq!(app.session.documents().len(), 2);
assert_eq!(artboard_widths(&app), [100.0, 100.0]);
let layers = &app.session.active().unwrap().doc.layers;
assert!(layers.iter().any(|l| l.name.as_deref() == Some("Only in the editing data")), "read from the editing data");
assert!(!import_pdf::offer(&mut app, "two.ait", &bytes, None, None), "a template too");
assert!(app.ui.dialog.is_none());
}
/// A `.ai` whose editing data can't be read asks nothing either, and every page opens from the PDF
/// part. Asking at that point is #6134.
#[test]
fn opening_an_ai_with_unreadable_editing_data_opens_every_page_without_asking() {
let mut app = app();
io::open_bytes(&mut app, "two.ai", &two_pages(true, None), Some("/tmp/two.ai".into())).unwrap();
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
assert_eq!(artboard_widths(&app), [100.0, 200.0]);
}
/// #6135: the dialog stays for what still has something to ask: a PDF, even one with editing data
/// keys, a `.ai` that is only a PDF, a password, and Place.
#[test]
fn the_import_dialog_stays_where_there_is_something_to_ask() {
for (name, bytes) in [("two.pdf", two_pages(true, None)), ("two.ai", two_pages(false, None))] {
let mut app = app();
assert!(import_pdf::offer(&mut app, name, &bytes, None, None), "{name}");
assert_eq!((app.ui.dialog.as_ref().unwrap().str("mode"), field(&app, "pageCount")), ("open".into(), json!(2)), "{name}");
}
let mut app = app();
assert!(import_pdf::offer(&mut app, "locked.ai", &two_pages(true, Some("pw")), None, None));
assert_eq!(field(&app, "__locked"), true);
let mut app = self::app();
assert!(import_pdf::offer(&mut app, "two.ai", &two_pages(true, None), None, Some(&json!({}))));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
}
+94 -10
View File
@@ -1,7 +1,8 @@
//! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text
//! using a font), Change / Change All to another font from the list Replace With Font From shows
//! (the document's fonts, the recent fonts or every installed font), and Find in Folder… (the
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`).
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). Find In limits Find
//! to the active artboard or the selected layers.
use egui::Ui;
use serde_json::{Value, json};
@@ -23,9 +24,10 @@ pub fn open_on_missing(app: &mut VectorcraftApp) {
open_at(app, first.unwrap_or(0));
}
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and
/// System's font ([`system_family`]) to change to.
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen,
/// System's font ([`system_family`]) to change to, and the last Find In choice (Document at first).
fn open_at(app: &mut VectorcraftApp, selected: usize) {
let find_in = FIND_IN.iter().find(|f| **f == app.ui.find_font_in).copied().unwrap_or("document");
let used = fonts(app);
let source = "system";
let rows = replacement_rows(app, source, &used);
@@ -33,7 +35,7 @@ fn open_at(app: &mut VectorcraftApp, selected: usize) {
let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default();
app.ui.dialog = Some(Dialog::new(
"findFont",
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }),
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows), "findIn": find_in }),
));
}
@@ -210,6 +212,42 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]
d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default()));
}
/// Find In's choices (the `findIn` field): the whole document, the active artboard, or the layers
/// highlighted in the Layers panel (else the current layer).
const FIND_IN: [&str; 3] = ["document", "artboard", "layers"];
const FIND_IN_LABELS: [&str; 3] = ["Document", "Artboard", "Layers"];
/// The `scope` Find passes `select.font` for Find In choice `find_in`. A document without
/// artboards, or without a layer to look in, is looked through whole.
fn find_scope(app: &VectorcraftApp, find_in: &str) -> Value {
let Some(st) = app.session.active() else { return json!("document") };
let n = st.doc.artboards.len();
match find_in {
"artboard" if n > 0 => json!({ "artboards": [crate::panels::artboards::selected(app, n)] }),
"layers" => {
let is_layer = |id: &vectorcraft_doc::NodeId| st.doc.node(*id).is_some_and(vectorcraft_doc::Node::is_layer);
let mut layers: Vec<u64> = st.highlighted_rows().into_iter().filter(is_layer).map(|id| id.0).collect();
if layers.is_empty() {
layers.extend(st.current_layer().map(|id| id.0));
}
if layers.is_empty() { json!("document") } else { json!({ "layers": layers }) }
}
_ => json!("document"),
}
}
/// Run the dialog's button `button` (find, change or changeAll) on the document font `from`.
fn run_button(app: &mut VectorcraftApp, d: &Dialog, button: &str, from: &Value) -> Result<Value, String> {
match button {
"find" => {
let scope = find_scope(app, &d.str("findIn"));
app.run("select.font", json!({ "family": from["family"], "style": from["style"], "scope": scope }))
}
"change" => change(app, d, from, false),
_ => change(app, d, from, true),
}
}
fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
}
@@ -342,6 +380,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
ui.add_space(14.0);
ui.horizontal(|ui| {
widgets::field_label(ui, tl!("Find In"));
let at = FIND_IN.iter().position(|s| *s == d.str("findIn")).unwrap_or(0);
if let Some(i) = widgets::dropdown_named(ui, "ff-find-in", Some(tl!("Find In")), FIND_IN_LABELS[at], &FIND_IN_LABELS, 120.0) {
d.fields.insert("findIn".into(), json!(FIND_IN[i]));
}
});
ui.add_space(6.0);
// An empty list (Recent before any font was applied) leaves nothing to change to.
let replace = sel < list.len() && can_replace(&d, families.as_deref());
ui.horizontal(|ui| {
@@ -362,14 +408,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
});
});
});
app.ui.find_font_in = d.str("findIn");
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
if let (Some(a), Some(from)) = (act, list.get(sel)) {
let r = match a {
"find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })),
"change" => change(app, &d, from, false),
_ => change(app, &d, from, true),
};
match r {
match run_button(app, &d, a, from) {
// The rows are a snapshot: the document's fonts and the recent fonts have moved on.
Ok(_) if a != "find" => {
let used = fonts(app);
@@ -433,6 +475,48 @@ mod tests {
assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3");
}
/// Find In passes Find the active artboard, or the selected layers (else the current
/// layer), as `select.font`'s scope.
#[test]
fn find_in_limits_find_to_the_artboard_or_the_layers() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("artboard.new", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap();
let text = |app: &mut VectorcraftApp, x: i32| {
app.session.execute("text.create", &json!({"x": x, "y": 40, "text": "Hi", "font": "Inter"})).unwrap()["id"].as_u64().unwrap()
};
let first = text(&mut app, 10);
let layer = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let second = text(&mut app, 210);
let third = text(&mut app, 20);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// Choose Find In on the open dialog, draw a frame, then press Find.
let find = |app: &mut VectorcraftApp, find_in: &str| {
open(app);
app.ui.dialog.as_mut().unwrap().fields.insert("findIn".into(), json!(find_in));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.clone().unwrap();
let from = fonts(app).into_iter().find(|f| f["family"] == "Inter").unwrap();
run_button(app, &d, "find", &from).unwrap();
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect();
ids.sort_unstable();
ids
};
open(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "document");
assert_eq!(find(&mut app, "document"), [first, second, third]);
// Without a view, the active artboard is the first.
assert_eq!(find(&mut app, "artboard"), [first, third]);
assert_eq!(find_scope(&app, "layers"), json!({ "layers": [layer] }));
assert_eq!(find(&mut app, "layers"), [second, third]);
// Closing and opening the dialog again keeps the last choice.
app.ui.dialog = None;
open(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "layers");
}
/// Change All leaves locked text in its font, and the status bar says so.
#[test]
fn change_all_clears_the_skip_warning_after_unlocking_text() {
+1
View File
@@ -2699,6 +2699,7 @@ digits Malayalam Малаялам
Reset Transformation Нулиране на трансформацията
Replace With Font Замяна с шрифт
Replace With Font From Замяна с шрифт от
Find In Търсене в
System Система
Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име.
Replace… Замяна…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4000,6 +4000,7 @@ effect Regular Obično
Find in Folder… Pronađi u folderu…
Replace With Font Zamijeni fontom
Replace With Font From Zamijeni fontom iz
Find In Traži u
System Sistem
— substituted by {style} — zamijenjeno stilom {style}
— {n} characters from another font — {n} znakova iz drugog fonta
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3406,6 +3406,7 @@ digits Malayalam Malaiàlam
Reset Transformation Restableix la transformació
Replace With Font Reemplaça per un tipus de lletra
Replace With Font From Reemplaça per un tipus de lletra de
Find In Cerca a
System Sistema
Replace “{name}”, the file this document was opened from? Voleu reemplaçar “{name}”, el fitxer amb què es va obrir aquest document?
Replaces the saved preset of that name. Reemplaça el preajust desat amb aquest nom.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4050,6 +4050,7 @@ effect Regular Pravidelný
Find in Folder… Najít ve složce…
Replace With Font Nahradit písmem
Replace With Font From Nahradit písmem z
Find In Hledat v
System Systém
— substituted by {style} — nahrazeno řezem {style}
— {n} characters from another font — znaky z jiného písma: {n}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3766,6 +3766,7 @@ effect Regular Regular
Find in Folder… Find i mappe…
Replace With Font Erstat med skrifttype
Replace With Font From Erstat med skrifttype fra
Find In Søg i
System System
— substituted by {style} — erstattet af {style}
— {n} characters from another font — {n} tegn fra en anden skrifttype
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2731,6 +2731,7 @@ digits Malayalam Malayalam
Reset Transformation Transformation zurücksetzen
Replace With Font Durch Schrift ersetzen
Replace With Font From Ersetzende Schrift aus
Find In Suchen in
System System
Replaces the saved preset of that name. Ersetzt die gespeicherte Vorgabe dieses Namens.
Replace… Ersetzen…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3341,6 +3341,7 @@ digits Malayalam Μαλαγιάλαμ
Reset Transformation Επαναφορά μετασχηματισμού
Replace With Font Αντικατάσταση με γραμματοσειρά
Replace With Font From Αντικατάσταση με γραμματοσειρά από
Find In Αναζήτηση σε
System Σύστημα
Replaces the saved preset of that name. Αντικαθιστά την αποθηκευμένη προεπιλογή με αυτό το όνομα.
Require a password to open the document Απαίτηση κωδικού για το άνοιγμα εγγράφου
Can't render this file because it contains an unexpected character in line 168 and column 5.
+1
View File
@@ -2774,6 +2774,7 @@ digits Malayalam Malayálam
Reset Transformation Restablecer transformación
Replace With Font Reemplazar con fuente
Replace With Font From Reemplazar con fuente de
Find In Buscar en
System Sistema
Replaces the saved preset of that name. Reemplaza el preajuste guardado con ese nombre.
Replace… Reemplazar…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3969,6 +3969,7 @@ effect Regular Normaali
Find in Folder… Etsi kansiosta…
Replace With Font Korvaa fontilla
Replace With Font From Korvaavan fontin lähde
Find In Etsi kohteesta
System Järjestelmä
— substituted by {style} — korvattu tyylillä {style}
— {n} characters from another font — {n} merkkiä toisesta fontista
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2766,6 +2766,7 @@ digits Malayalam Malayalam
Reset Transformation Réinitialiser la transformation
Replace With Font Remplacer par la police
Replace With Font From Remplacer par une police de
Find In Rechercher dans
System Système
Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom.
Replace… Remplacer…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3306,6 +3306,7 @@ mark-style Japanese Japán
Reset Transformation Átalakítás visszaállítása
Replace With Font Helyettesítő betűtípus
Replace With Font From Helyettesítő betűtípus forrása
Find In Keresés helye
System Rendszer
Replace “{name}”, the file this document was opened from? Felülírod ezt a fájlt, amelyből a dokumentumot megnyitottad: „{name}”?
Replaces the saved preset of that name. Felülírja az azonos nevű mentett előbeállítást.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3766,6 +3766,7 @@ effect Regular Reguler
Find in Folder… Temukan di folder…
Replace With Font Ganti dengan font
Replace With Font From Ganti dengan font dari
Find In Cari di
System Sistem
— substituted by {style} — digantikan oleh {style}
— {n} characters from another font — {n} karakter dari font lain
Can't render this file because it contains an unexpected character in line 2270 and column 5.
+1
View File
@@ -2807,6 +2807,7 @@ digits Malayalam Malayalam
Reset Transformation Reimposta trasformazione
Replace With Font Sostituisci con font
Replace With Font From Sostituisci con font da
Find In Cerca in
System Sistema
Replaces the saved preset of that name. Sostituisce il preset salvato con quel nome.
Replace… Sostituisci…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2793,6 +2793,7 @@ mark-style Japanese 日本式
Reset Transformation 変形をリセット
Replace With Font 置換するフォント
Replace With Font From 置換するフォントの一覧
Find In 検索範囲
System システム
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 別のファイルに置き換えるか、無視してください。無視した場合、画像にはドキュメントに保存されたプレビューが表示され続けます。
Replaces the saved preset of that name. 同じ名前の保存済みプリセットを置き換えます。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1330,6 +1330,7 @@ lighting Light სინათლე
Arabic არაბული
Hindi ჰინდი
Farsi სპარსული
digits Hindi ჰინდი
digits Devanagari დევანაგარი
digits Bengali ბენგალური
digits Gurmukhi გურმუხი
@@ -3559,6 +3560,7 @@ effect Regular ჩვეულებრივი
Find in Folder… საქაღალდეში ძებნა…
Replace With Font ჩანაცვლება შრიფტით
Replace With Font From ჩანაცვლების შრიფტის წყარო
Find In ძებნის არე
System სისტემა
— substituted by {style} — ჩანაცვლებულია: {style}
— {n} characters from another font — სხვა შრიფტის სიმბოლოები: {n}
Can't render this file because it is too large.
+1
View File
@@ -2782,6 +2782,7 @@ mark-style Japanese 일본식
Reset Transformation 변형 재설정
Replace With Font 바꿀 글꼴
Replace With Font From 바꿀 글꼴 목록
Find In 찾을 위치
System 시스템
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 다른 파일로 바꾸거나 무시하세요. 무시하면 해당 이미지는 문서에 저장된 미리 보기로 계속 표시됩니다.
Replaces the saved preset of that name. 같은 이름으로 저장된 사전 설정을 대체합니다.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3780,6 +3780,7 @@ effect Regular Regelmatig
Find in Folder… Zoeken in map…
Replace With Font Vervangen door lettertype
Replace With Font From Vervangen door lettertype uit
Find In Zoeken in
System Systeem
— substituted by {style} — vervangen door {style}
— {n} characters from another font — {n} tekens van een ander lettertype
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3889,6 +3889,7 @@ effect Regular Normalny
Find in Folder… Znajdź w folderze…
Replace With Font Zastąp czcionką
Replace With Font From Zastąp czcionką z
Find In Szukaj w
System System
— substituted by {style} — zastąpiona przez {style}
— {n} characters from another font — znaki z innej czcionki: {n}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2314,6 +2314,7 @@ digits Malayalam Malaiala
Reset Transformation Redefinir Transformação
Replace With Font Substituir Com Fonte
Replace With Font From Substituir Com Fonte De
Find In Localizar em
System Sistema
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Substitua por outro arquivo, ou ignore-o: suas imagens continuam mostrando a pré-visualização salva com o documento.
Replaces the saved preset of that name. Substitui o predefinido salvo com esse nome.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2802,6 +2802,7 @@ digits Malayalam Малаялам
Reset Transformation Сбросить трансформацию
Replace With Font Заменить шрифтом
Replace With Font From Заменить шрифтом из
Find In Искать в
System Система
Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем.
Replace… Заменить…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2710,6 +2710,7 @@ digits Malayalam Malayalam
Reset Transformation Återställ transformering
Replace With Font Ersätt med typsnitt
Replace With Font From Ersätt med typsnitt från
Find In Sök i
System System
Replaces the saved preset of that name. Ersätter den sparade förinställningen med samma namn.
Replace… Ersätt…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2579,6 +2579,7 @@ digits Malayalam Malayalam
Reset Transformation Dönüşümü Sıfırla
Replace With Font Yazı Tipiyle Değiştir
Replace With Font From Değiştirilecek Yazı Tipi Kaynağı
Find In Arama yeri
System Sistem
Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir.
Replace… Değiştir…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2799,6 +2799,7 @@ digits Malayalam Малаялам
Reset Transformation Скинути трансформацію
Replace With Font Замінити шрифтом
Replace With Font From Замінити шрифтом з
Find In Шукати в
System Система
Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям.
Replace… Замінити…
Can't render this file because it contains an unexpected character in line 182 and column 5.
+1
View File
@@ -3074,6 +3074,7 @@ mark-style Japanese Kiểu Nhật
Reset Transformation Đặt lại biến đổi
Replace With Font Thay thế bằng phông chữ
Replace With Font From Thay thế bằng phông chữ từ
Find In Tìm trong
System Hệ thống
Replace “{name}”, the file this document was opened from? Thay thế “{name}”, tệp mà tài liệu này được mở từ đó?
Replaces the saved preset of that name. Thay thế thiết lập sẵn đã lưu có tên đó.
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -2677,6 +2677,7 @@ digits Malayalam 马拉雅拉姆数字
Reset Transformation 重置变换
Replace With Font 替换为字体
Replace With Font From 替换字体来源
Find In 查找范围
System 系统
Replaces the saved preset of that name. 替换同名已存预设。
Replace… 替换…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3680,6 +3680,7 @@ digits Malayalam 馬來亞拉姆數字
Reset Transformation 重設變形
Replace With Font 取代為字體
Replace With Font From 取代字體來源
Find In 尋找範圍
System 系統
Replaces the saved preset of that name. 取代同名的已儲存預設集。
Replace… 取代…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+27 -22
View File
@@ -106,13 +106,14 @@ fn app(fill: Value) -> VectorcraftApp {
struct Panel {
ctx: egui::Context,
time: f64,
scope: egui::Id,
}
impl Panel {
fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Self { ctx, time: 0.0 }
Self { ctx, time: 0.0, scope: egui::Id::NULL }
}
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
@@ -144,23 +145,27 @@ impl Panel {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| draw(app, ui));
let mut out = self.ctx.run_ui(input, |ui| {
self.scope = ui.scope_id();
draw(app, ui);
});
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is
/// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with;
/// debug builds remember the `with(key)` that made each id, so match that.
fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect {
let suffix = format!(".with({key:?})");
self.ctx
.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect)
})
.expect("widget drawn")
/// Match actual IDs in the panel or its immediate horizontal rows. `parent_id` is an
/// unstable auto ID, and the human-readable `Id` debug source is absent in release builds.
fn widget(&self, key: impl egui::AsIdSalt + Copy) -> Rect {
// Match Ui::new_child: its default child salt is an IdSalt, not the raw string.
let row_scope = self.scope.with(egui::IdSalt::new("child"));
let ids = [self.scope.with(key), row_scope.with(key)];
// Outside run_ui, read_response can prefer the older this_pass geometry after
// end_pass swaps the passes. Use the completed frame, including after Hide Options.
let matches: Vec<_> = self.ctx.viewport(|vp| ids.into_iter().filter_map(|id| vp.prev_pass.widgets.get(id).map(|w| w.rect)).collect());
assert_eq!(matches.len(), 1, "expected one widget for {key:?}");
matches[0]
}
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
@@ -252,7 +257,7 @@ fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() {
let fill = p.widget("fill-proxy");
p.click(&mut app, fill.left_top() + vec2(2.0, 2.0));
assert!(app.session.fill_active, "the proxy works with the options hidden");
p.widget(("grad-stop", 1));
p.widget(("grad-stop", 1_usize));
}
#[test]
@@ -331,7 +336,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}");
let diamond = p.widget(("grad-mid", 0));
let diamond = p.widget(("grad-mid", 0_usize));
let texts = p.click(&mut app, diamond.center());
assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection");
assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}");
@@ -343,7 +348,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops);
// Clicking a stop selects it again.
let stop = p.widget(("grad-stop", 0));
let stop = p.widget(("grad-stop", 0_usize));
let texts = p.click(&mut app, stop.center());
assert_eq!(app.session.selected_stop(), Some(0));
assert!(texts.iter().any(|(t, _)| t == "0%"));
@@ -474,7 +479,7 @@ fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
let mid = pos2((a.x + b.x) / 2.0, a.y);
let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::<Vec<_>>();
// A plain drag moves the stop.
@@ -499,7 +504,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| {
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE);
@@ -509,7 +514,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0));
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
p.frame(&mut app, vec![], Modifiers::NONE);
let last = p.widget(("grad-stop", 2)).center();
let last = p.widget(("grad-stop", 2_usize)).center();
drop(&mut p, &mut app, last);
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
// A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop.
@@ -538,7 +543,7 @@ fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() {
app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap();
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
let at = pos2((a.x + b.x) / 2.0, a.y - 20.0);
let red = Color::rgb(1.0, 0.0, 0.0);
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red)));
@@ -554,7 +559,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let stop = p.widget(("grad-stop", 1));
let stop = p.widget(("grad-stop", 1_usize));
let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE);
let d = app.ui.dialog.clone().expect("the popover opened");
@@ -755,7 +760,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
let mut app = app(json!({"gradient": {"stops": stops}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let mid = p.widget(("grad-stop", 1)).center();
let mid = p.widget(("grad-stop", 1_usize)).center();
// Dragged down off the ramp, the middle stop goes.
p.drag(&mut app, mid, mid + vec2(0.0, 60.0), Modifiers::NONE);
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
@@ -773,7 +778,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
app.run("edit.redo", json!({})).unwrap();
// Two stops stay two: dragged off, the stop comes back, and the button is off.
let first = p.widget(("grad-stop", 0)).center();
let first = p.widget(("grad-stop", 0_usize)).center();
p.drag(&mut app, first, first + vec2(0.0, 60.0), Modifiers::NONE);
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
+16 -3
View File
@@ -176,6 +176,9 @@ struct Out {
column: Option<Option<ColumnDrag>>,
/// Rows to open or close, and whether to open them.
toggle: Vec<(NodeId, bool)>,
/// The highlighted rows after this frame's row click, when one happened: the click's action
/// runs after `delete_key`, so `view.rows` is still the state before it.
rows_after_click: Option<Vec<NodeId>>,
}
/// The panel state that says the Layers panel's rows have the keyboard ([`super::has_row_keys`]).
@@ -361,12 +364,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
// clicked last, not the canvas).
// The panel keeps it while it is drawn ([`super::has_row_keys`]); the app's shortcuts leave
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]).
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]). With no highlighted rows
// left (the last one was deleted) the panel gives the keys back, so Delete clears the selected
// art again instead of doing nothing.
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside;
// A click's highlight action runs after this block, so read the rows it will leave, not the
// snapshot `view.rows` took before it.
let rows = out.rows_after_click.as_deref().unwrap_or(&view.rows);
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside && !rows.is_empty();
super::set_row_keys(ui.ctx(), ROW_KEYS, focus);
if focus
&& !view.rows.is_empty()
&& !rows.is_empty()
&& ui.ctx().memory(|m| m.focused().is_none())
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
{
@@ -376,6 +384,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
for (c, p) in out.actions {
app.run(&c, p).ok();
}
// A deletion that took the last highlighted row leaves the panel nothing to delete. Release the
// keys now, not at the next draw, so the very next frame's Delete reaches Edit › Clear.
if app.session.active().is_some_and(|st| st.highlighted_rows().is_empty()) {
super::set_row_keys(ui.ctx(), ROW_KEYS, false);
}
see_selection(app, ui, selected);
}
+3
View File
@@ -91,6 +91,9 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
if click.moves_anchor() {
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
}
let mut rows = view.rows.clone();
widgets::apply_row_click(&mut rows, &click);
out.rows_after_click = Some(rows);
let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::<Vec<_>>();
out.actions.push(match click {
widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})),
+54 -2
View File
@@ -1348,10 +1348,15 @@ fn app_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, f:
}
}
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
/// `key` pressed and released in one app-like frame of `f`, with no idle frame after it.
fn app_key_now(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
app_frame(ctx, app, vec![ev(true), ev(false)], f);
}
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
app_key_now(ctx, app, key, f);
app_frame(ctx, app, vec![], f);
}
@@ -1481,6 +1486,53 @@ fn after_a_row_click_delete_reaches_the_layers_panel_not_edit_clear() {
assert!(d.node(rect).is_some(), "the rectangle on Layer 1 stays");
}
/// After Delete removed the last highlighted Layers row, the panel has nothing left to delete: the
/// next Delete must go back to the app and clear the selected object, not be swallowed by the
/// panel's row keys.
#[test]
fn delete_after_the_last_highlighted_layer_row_is_deleted_clears_the_selection() {
let ctx = drop_ctx();
let mut app = app_with_rect();
run(&mut app, "layer.new", json!({}));
let rect = first_selected(&app).unwrap().id;
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
let row = text_rect(&texts, "Layer 2").center();
app_click(&ctx, &mut app, row, boxed_layers);
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Layer 2 is deleted");
assert!(app.session.active().unwrap().selection.contains(rect), "the rectangle stays selected");
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
assert!(app.session.active().unwrap().doc.node(rect).is_none(), "Delete clears the selected object once the panel has no rows");
}
/// Clicking a row right after the last highlighted row was deleted restores the row keys: Delete
/// then deletes that row, not the selected art.
#[test]
fn clicking_a_row_after_the_last_one_was_deleted_keeps_delete_with_the_layers_panel() {
let ctx = drop_ctx();
let mut app = app_with_rect();
run(&mut app, "layer.new", json!({}));
run(&mut app, "layer.new", json!({}));
let rect = first_selected(&app).unwrap().id;
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
let layer2 = text_rect(&texts, "Layer 2").center();
app_click(&ctx, &mut app, layer2, boxed_layers);
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
assert_eq!(app.session.active().unwrap().doc.layers.len(), 2, "Layer 2 is deleted");
// Click Layer 3 and press Delete in the very next frame: the click's highlight action runs at
// the end of its frame, so the panel must read the rows the click will leave.
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
let layer3 = text_rect(&texts, "Layer 3").center();
let button =
|pressed| egui::Event::PointerButton { pos: layer3, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
for e in [egui::Event::PointerMoved(layer3), button(true), button(false)] {
app_frame(&ctx, &mut app, vec![e], boxed_layers);
}
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Delete removes the clicked row");
assert!(app.session.active().unwrap().doc.node(rect).is_some(), "the rectangle on Layer 1 stays");
}
/// A tool that claims Delete and Backspace (the Artboard tool's active artboard) leaves them to a
/// clicked Layers row.
#[test]
+41 -11
View File
@@ -1,9 +1,12 @@
//! Character Styles and Paragraph Styles panels (one implementation, two kinds).
//!
//! Click a style to apply it to the selected text (or the Type tool's selected characters);
//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a
//! "+" when the text overrides some of its attributes. New makes a style from the selected text;
//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`).
//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its
//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row
//! opens its Paragraph Style Options, the rename dialog the panel menu opens. The style of the
//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes
//! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`,
//! `ui.newParaStyle`).
use std::cell::RefCell;
@@ -225,6 +228,19 @@ fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Opt
(fill, is_cur && in_highlight)
}
/// Opens `name`'s options: Character Style Options for a character style, or the Paragraph Style
/// Options the Paragraph Styles panel menu opens, which rename the style (`ui.paramDialog` runs
/// `paraStyle.rename`).
fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) {
if kind == Kind::Char {
app.run("ui.charStyleOptions", json!({ "name": name })).ok();
} else {
let label = format!("{} Style Options", kind.what());
app.run("ui.paramDialog", json!({ "command": format!("{}.rename", kind.prefix()), "label": label, "params": { "name": name, "to": name } }))
.ok();
}
}
/// The style list and its New and Delete buttons. Shift-click highlights every style from the
/// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both
/// leave the text as it is. Delete deletes every highlighted style but the Normal style.
@@ -235,6 +251,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
let normal = all.first().map(|s| s.0.clone()).unwrap_or_default();
let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone()));
let mut clicked: Option<(String, egui::Modifiers)> = None;
// A double-clicked Character Style's row, whose options open after the list is laid out.
let mut options: Option<String> = None;
// Set on a double-click's second click, which must not apply the style again.
let mut double_click = false;
let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect();
// A highlighted style that is no longer listed (renamed in Style Options, deleted by a command
// or undone) is no longer highlighted.
@@ -279,6 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
if resp.clicked() {
clicked = Some((name.clone(), ui.input(|i| i.modifiers)));
}
// A double-click on a user character style opens its Character Style Options, as the
// panel menu's item does; with Shift, Cmd/Ctrl or Alt held it only follows the click
// rules. The second click never applies the style again, also on the Normal style,
// which has no options to open.
if resp.double_clicked() {
double_click = true;
let m = ui.input(|i| i.modifiers);
if *name != normal && !(m.shift || m.command || m.alt) {
options = Some(name.clone());
}
}
}
});
if scroll_to.is_some() {
@@ -287,12 +318,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
let has_text = cur.is_some();
if let Some((name, m)) = clicked {
sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m);
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel.
if has_text && !(m.shift || m.command) {
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second
// click of a double-click opens the options instead of applying the style again.
if has_text && !(m.shift || m.command) && options.is_none() && !double_click {
let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
app.run(&format!("{}.apply", kind.prefix()), p).ok();
}
}
if let Some(name) = options {
open_style_options(app, kind, &name);
}
set_highlighted(ui.ctx(), kind, sel.clone());
let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect();
let deletable = !doomed.is_empty();
@@ -389,12 +424,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false)
&& let Some(n) = &sel
{
if kind == Kind::Char {
app.run("ui.charStyleOptions", json!({ "name": n })).ok();
} else {
let label = format!("{} Style Options", kind.what());
app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok();
}
open_style_options(app, kind, n);
}
ui.separator();
// As commands, so a file dialog shown off the UI thread loads the styles when it answers.
@@ -261,6 +261,66 @@ fn click_label(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, mut d
app.run_count.wrapping_sub(before)
}
/// Double-clicking a user character style's row opens its Character Style Options once: the first
/// click applies the style to the selected text, the second opens the dialog without applying it
/// again. A paragraph style's row opens none, and the built-in Normal style opens none either.
#[test]
fn double_clicking_a_character_style_row_opens_its_options() {
for kind in [Kind::Char, Kind::Para] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, kind);
frame_events(&ctx, &mut app, vec![], &mut draw);
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
let pos = text_rect(&texts, "Body").center();
let before = app.run_count;
click(&ctx, &mut app, pos, &mut draw);
click(&ctx, &mut app, pos, &mut draw);
let dialog = app.ui.dialog.as_ref();
if kind == Kind::Char {
let d = dialog.expect("double-clicking a Character Style's row opens its options");
assert_eq!(d.kind, "charStyleOptions");
assert_eq!(d.str("__style"), "Body");
// One apply from the first click and the dialog from the second: no second apply.
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
} else {
let d = dialog.expect("double-clicking a Paragraph Style's row opens its options");
assert_eq!(d.kind, "command");
assert_eq!(d.str("__command"), "paraStyle.rename");
assert_eq!(d.str("__label"), "Paragraph Style Options");
// One apply from the first click and the rename dialog from the second.
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
}
}
}
/// A double-click on the built-in Normal style opens no options, and its second click applies it
/// only once, as a user style's does.
#[test]
fn double_clicking_the_normal_style_opens_no_options() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, Kind::Char);
frame_events(&ctx, &mut app, vec![], &mut draw);
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
let normal = texts.iter().find(|(text, _)| text.contains("Normal")).map(|(_, r)| r.center());
let pos = normal.expect("the Normal style's row is drawn");
let before = app.run_count;
click(&ctx, &mut app, pos, &mut draw);
click(&ctx, &mut app, pos, &mut draw);
assert!(app.ui.dialog.is_none(), "the Normal style has no Character Style Options");
assert_eq!(app.run_count.wrapping_sub(before), 1, "the second click applies nothing");
}
/// The panel menu's Duplicate highlights the copy and its Delete clears the highlight, as the
/// panel's New and Delete buttons do; after a menu delete, Duplicate runs nothing.
#[test]
+24 -17
View File
@@ -511,10 +511,13 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
}
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
PrefKind::Num { min, max, unit } => {
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, label_id| {
let mut x = v.as_f64().unwrap_or(min);
let new = if sp.key == "uiScaling" {
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x)
// Without internal text, both slider controls inherit the row's name across focus changes.
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05)).labelled_by(label_id);
ui.add(egui::Label::new(tl!("Smaller ↔ Larger")).wrap_mode(egui::TextWrapMode::Extend));
response.changed().then_some(x)
} else {
widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0)
};
@@ -527,17 +530,19 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
// In the dialog's own Units choice, so a change there shows right away.
let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default();
let x = d.f64(sp.key, min);
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, _| {
if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) {
d.fields.insert(sp.key.into(), json!(x.clamp(min, max)));
}
});
}
PrefKind::Int { min, max } => {
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, label_id| {
let mut x = v.as_i64().unwrap_or(min);
let new = if sp.key == "anchorSize" {
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x)
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false)).labelled_by(label_id);
ui.add(egui::Label::new(tl!("Size")).wrap_mode(egui::TextWrapMode::Extend));
response.changed().then_some(x)
} else {
widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64)
};
@@ -569,11 +574,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
}
}
PrefKind::Color => {
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, label_id| {
let hex = v.as_str().unwrap_or("#000000");
let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
if ui.color_edit_button_srgb(&mut rgb).labelled_by(label_id).changed() {
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
@@ -581,7 +586,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
}
PrefKind::Text if sp.key == "interfaceLanguage" => {
// `auto` plus every registered language, by its own name.
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, _| {
let cur = v.as_str().unwrap_or("auto");
let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect();
let labels: Vec<&str> =
@@ -598,7 +603,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
// Plug-ins Folder: typed, or picked with Choose… where the host has a folder picker
// (not on the web).
PrefKind::Text if folder_check(sp.key).is_some() => {
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, _| {
let mut s = v.as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() {
d.fields.insert(sp.key.into(), json!(s));
@@ -609,7 +614,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
});
}
PrefKind::Text => {
labeled(ui, sp.label, |ui| {
labeled(ui, sp.label, |ui, _| {
let mut s = v.as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() {
d.fields.insert(sp.key.into(), json!(s));
@@ -644,7 +649,7 @@ fn picas_in_use(d: &Dialog) -> bool {
}
fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) {
labeled(ui, label, |ui| {
labeled(ui, label, |ui, _| {
let cur = v.as_str().unwrap_or("");
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
@@ -664,14 +669,16 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
}
}
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui, egui::Id)) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(210.0);
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate());
});
add(ui);
let label = ui
.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(210.0);
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate())
})
.inner;
ui.label_inputs_by(label.id, |ui| add(ui, label.id));
});
}
+89
View File
@@ -955,6 +955,95 @@ fn ok_refuses_a_typed_folder_that_choose_would_refuse() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Preferences form rows associate each input with its visible label for accessibility tools.
#[test]
fn preference_inputs_expose_their_visible_labels() {
use egui_kittest::kittest::Queryable;
let mut app = app();
open(&mut app, Some("General"));
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).with_accessibility_check(false).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(3);
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:");
}
/// Render the real category without the dialog's scroll viewport clipping later rows.
fn preference_category_harness(category: &'static str) -> egui_kittest::Harness<'static, (VectorcraftApp, Dialog)> {
let mut app = app();
app.services.pick_folder = Some(Box::new(|| None));
open(&mut app, Some(category));
let dialog = app.ui.dialog.take().unwrap();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 1400.0)).with_accessibility_check(false).build_ui_state(
move |ui, (app, dialog): &mut (VectorcraftApp, Dialog)| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
category_fields(ui, app, dialog, category);
},
(app, dialog),
);
h.run_steps(3);
h
}
#[test]
fn preference_colour_well_exposes_its_row_label_and_value() {
use egui_kittest::kittest::{NodeT, Queryable};
let h = preference_category_harness("Guides & Grid");
let expected = format!("{}ff", h.state().1.str("patternTileEdgeColor").to_lowercase());
let colour = h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Tile Edge Color:");
assert_eq!(colour.accesskit_node().value().as_deref(), Some(expected.as_str()));
}
#[test]
fn preference_scale_labels_both_controls_across_focus_changes() {
use egui::accesskit::Role;
use egui_kittest::kittest::{NodeT, Queryable};
let mut h = preference_category_harness("User Interface");
let expected = h.state().1.f64("uiScaling", 1.0);
for focus in [None, Some(Role::SpinButton), Some(Role::Slider)] {
if let Some(role) = focus {
h.get_by_role_and_label(role, "Scale:").focus();
h.run_steps(3);
assert!(h.get_by_role_and_label(role, "Scale:").accesskit_node().is_focused());
}
for role in [Role::Slider, Role::SpinButton] {
let control = h.get_by_role_and_label(role, "Scale:");
assert_eq!(control.accesskit_node().numeric_value(), Some(expected));
}
}
}
#[test]
fn preference_anchor_slider_exposes_its_row_label_and_value() {
use egui::accesskit::Role;
use egui_kittest::kittest::{NodeT, Queryable};
let h = preference_category_harness("Selection & Anchor Display");
let slider = h.get_by_role_and_label(Role::Slider, "Size:");
assert_eq!(slider.accesskit_node().numeric_value(), Some(h.state().1.f64("anchorSize", 3.0)));
}
#[test]
fn preference_folder_label_preserves_the_choose_action_name() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let h = preference_category_harness("File Handling");
let _ = h.get_by_role_and_label(Role::TextInput, "Folder:");
let _ = h.get_by_role_and_label(Role::Button, "Choose…");
assert!(h.query_by_role_and_label(Role::Button, "Folder:").is_none());
}
/// Under automation roots, OK refuses a typed folder outside them before it looks at the folder.
#[test]
fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() {
+4
View File
@@ -370,6 +370,9 @@ pub struct UiState {
/// That popup's first text field is still to take the keyboard.
#[serde(skip)]
pub transient_focus: bool,
/// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs.
#[serde(skip)]
pub find_font_in: String,
pub control_bar: bool,
/// The Control bar is docked to the bottom of the window instead of the top
/// (`window.control.dock`).
@@ -974,6 +977,7 @@ impl Default for UiState {
open_panel: None,
transient_panel: None,
transient_focus: false,
find_font_in: String::new(),
control_bar: false,
control_bar_bottom: false,
toolbar: true,
+14 -7
View File
@@ -227,7 +227,7 @@ impl Window {
}
/// One headless frame of the whole window, `seconds` after the last one, with `events`.
fn after(&mut self, seconds: f64, events: Vec<egui::Event>) {
fn after(&mut self, seconds: f64, events: Vec<egui::Event>) -> egui::FullOutput {
self.time += seconds;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
let raw = egui::RawInput { screen_rect: Some(rect), time: Some(self.time), events, ..Default::default() };
@@ -237,6 +237,7 @@ impl Window {
app.ui(ui);
});
out.textures_delta.clear();
out
}
/// A press and release of Escape, `seconds` after the last frame.
@@ -336,12 +337,18 @@ fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view()
#[test]
fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() {
let mut w = Window::new();
// The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's
// row, which hosts other widgets too. egui 0.37 salts the Ui's stable `scope_id`, not the
// widget rect's unstable `parent_id`; in debug builds the id's debug source names its salt.
let widgets: Vec<egui::WidgetRect> = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect());
let combo = widgets.iter().find(|r| format!("{:?}", r.id).contains("rot-combo")).copied();
let combo = combo.expect("the status bar's angle menu");
// Find the actual combo by its accessible name, which is present in both build profiles;
// `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled.
w.ctx.enable_accesskit();
let out = w.after(0.0, vec![]);
let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree");
let combos: Vec<_> =
tree.nodes.iter().filter(|(_, node)| node.role() == egui::accesskit::Role::ComboBox && node.label() == Some("Rotation")).collect();
assert_eq!(combos.len(), 1, "the status bar's angle menu");
let combo = w
.ctx
.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == combos[0].0).copied())
.expect("the angle menu's widget");
w.click(0.0, egui::PointerButton::Primary, combo.rect.center());
w.after(0.0, vec![]);
assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open");
+1 -1
View File
@@ -126,7 +126,7 @@ the two can still win that race.
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
+5 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS and `.ai` editing data: opacity mask art without its own paint takes the paint before its dictionary; a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -50,7 +50,8 @@ private editing data. VectorCraft:
A multi-layer mask (`Mb` … `Md` … `MB`) comes in as a clipping group in each layer it covers, each
clipped by a copy of the mask's path.
An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the
editing data of a `.ai`, EPS or PDF; other art opens in a group.
editing data of a `.ai`, EPS or PDF; other art opens in a group. Art in the mask that sets no paint of its
own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change.
RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`
import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile
open with different, unconverted RGB colours than before.
@@ -305,6 +306,8 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. |
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |
+5 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -36,12 +36,14 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. |
| 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). |
| 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. |
| 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. |
| 2026-11-04 | minor | M7: Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); estimates unchanged. |
| 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. |
| 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. |
| 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. |
@@ -120,7 +122,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
@@ -181,6 +183,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged |
| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. |
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Typographer's Quotes sets a lone typed `”` as the opening quote where one opens, for the macOS Serbian Cyrillic layout, which has no straight quote, #5247; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -29,12 +29,12 @@ against Illustrator's public documentation; **estimated** unless noted.
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
| Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn |
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) |
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
| Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` |
@@ -52,7 +52,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
| Smart Punctuation, Change Case, Find & Replace | done | |
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner |
| Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. |
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
@@ -82,11 +82,14 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
| 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged |
| 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. |
| 2026-11-04 | minor | Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); scores and estimates unchanged. |
| 2026-11-04 | minor | Typographer's Quotes: a lone typed `”` (U+201D) is set like a straight `"` where a quote opens (at the start, after a space), taking the document's opening quote, and stays `”` where one closes: the macOS Serbian Cyrillic layout has no straight quote and types `”` on Shift+2, which now gives „…” (#5247). `“ „` and the single quotes `‘ ’ ‚` stay as typed (that layout's apostrophe key types `’`, and `’90s` keeps its apostrophe); pasted text keeps its quotes; with the option off `”` stays as typed. Evidence: `docsetup::a_typed_closing_double_quote_opens_where_a_quote_opens`; scores unchanged |
| 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. |
| 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. |
+7 -3
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) |
| 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged |
@@ -131,7 +132,7 @@ tools, never checked side by side) isn't captured row by row.
| Click the first anchor to close; the drag shapes the closing curve | done | |
| Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | |
| Alt over a handle, anchor or segment acts as the Anchor Point tool | done | |
| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980) |
| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980); anchors or segments the lent Direct Selection picks are what Copy and Cut take, as with Direct Selection chosen, while Copy with the Pen's own last anchor selected takes the whole path (#6123) |
| Click an open path's end to continue it; click another path's end to join | done | #776 |
| Rubber Band preview | done | Preferences option |
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
@@ -191,7 +192,7 @@ tools, never checked side by side) isn't captured row by row.
| Alt with Direct Selection makes a marquee Group Selection's for the drag: it selects every path it touches, whole (Shift toggles them), and the Group Selection pointer stays until the release | done | #4890 |
| Direct Selection: a click on a selected object's centre point (Attributes › Show Center) selects the object whole, and a drag from it moves the object in one undo step | done | #4891 |
| Escape cancels a Direct Selection drag | done | #1803 (#1743) |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239), and with every corner selected a Shift-click on a widget picks that corner alone instead of deselecting it (#5188); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
| Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) |
| Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing |
| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` |
@@ -436,6 +437,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Color Effects add-on (VectorCraft addition): Object › Plug-ins › Color Effects › Find Nearest Swatch… picks a Library (Document Swatches, a built-in library, or a library or colour book the user saved or loaded) and Fill and Stroke, lists each colour of the selection with its three closest swatches (chip, name, ΔE 2000), and Apply gives each colour its nearest swatch (global and spot swatches linked; a library's swatches added to the document first), one undo step | done | #4923; `addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, `crates/engine/src/cmd/addons/color/nearest.rs`, `crates/ui-egui/src/addons/color/nearest.rs` and their tests; not a feature of the reference app, so it is not scored |
| Selection Tools add-on (VectorCraft addition): Object › Plug-ins › Selection Tools › Select by Artboards… lists every artboard (number, name, width × height in the document's units), filters them by name, chooses several (click, Shift-click for a range, Cmd/Ctrl-click to toggle; Select All / None act on the rows shown; a count of those chosen), fits a row's artboard in the window on double-click and makes it active, and on OK selects the objects overlapping, fully inside or fully outside the chosen artboards (Overlapping is All on Active Artboard's test) | done | #5525, #5548, #5549; `addon.select.byArtboards`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select.rs` and their tests; not a feature of the reference app |
| Selection Tools add-on (VectorCraft addition): Select Linked, Select Embedded, Select Missing and Select Modified in Object › Plug-ins › Selection Tools and the Links panel menu (off when no image has that status; Shift adds to the selection) select the images and placed documents with that link status, leaving out hidden, locked, template-layer and outside-isolation ones, which the status bar counts (e.g. "Selected 9 missing images; 3 more are hidden or locked"); Select by Link Status… lists them with name, status and format, searches by name, filters by status, picks with checkboxes and All / None, and selects with Zoom to Selection on by default | done | #5526; `addon.select.byLinkStatus`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select/link_status.rs` and their tests; not a feature of the reference app |
| Style panels: a double-click on a user character style's row opens its Character Style Options, and on a paragraph style's row its Paragraph Style Options (rename), and the Character Style Options dialog keeps a fixed width, its sections scrolling when the window is shorter so OK and Cancel stay on screen | done | `ui.charStyleOptions`, `ui.paramDialog` (`paraStyle.rename`); `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
## Look and feel
@@ -480,6 +482,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged |
| 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged |
| 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged |
| 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged |