Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 2cfee9b99b Docs: type parity has one status line again
Two branches' status lines both landed in a merge; they are one line
again, naming both changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:30:12 +07:00
BFlat 90bd91986a Merge pull request #1186 from storytold/fix/thai-caret-clusters
Type: the caret steps over whole clusters (Thai marks, accents, emoji)
2026-10-11 19:29:37 +07:00
bflatasticandClaude Opus 5.5 3fb51869ac Type: the caret steps over whole clusters (Thai marks, accents, emoji)
The Type tool's Left and Right arrows and Delete moved one code point at
a time, so the caret could stop between a Thai consonant and its vowel
or tone marks (or inside an accented letter or emoji sequence), where
typing or deleting splits the cluster. They now step over extended
grapheme clusters. Backspace still takes back one character at a time,
as Thai typists expect for a mistyped tone mark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:29:16 +07:00
BFlat 436d7ecdd3 Merge pull request #1185 from storytold/fix/thai-font-fallback
Type: Thai falls back to a font made for Thai
2026-10-11 19:28:58 +07:00
bflatasticandClaude Opus 5.5 fba3702103 Type: Thai falls back to a font made for Thai
Thai typed in a font without Thai glyphs (Source Sans 3, a Latin font)
took the first loaded face that had them, or the first installed file
that did, in path order: on one Windows machine a display face (Kanit)
rather than Leelawadee UI. Characters of Thai, Lao, Khmer and Myanmar
now try fonts made for their script first, installed ones loaded on
demand (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai,
the TLWG fonts for Thai), then the fallback as before. Latin beside
them keeps the font chosen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:42:31 +07:00
BFlat 7c7a7dcdc9 Merge pull request #1181 from storytold/fix/1163-file-list
#1163: file.list lists a folder for agents, held to the read root
2026-10-11 18:35:45 +07:00
bflatasticandClaude Opus 5.5 339b01f48b #1163: file.list lists a folder for agents, held to the read root
An agent driving VectorCraft over the confined MCP server had no way to
see which files it was given, so it guessed names. `file.list {path}`
lists a folder's files (with their sizes) and folders by name, held to
the automation read root like every read; links are listed as links and
not followed. At most 5000 entries, with `truncated` saying when there
were more.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:35:23 +07:00
BFlat 6d07135456 Merge pull request #1179 from storytold/fix/1163-atomic-batch
#1163: command_batch can be one undo step
2026-10-11 18:22:58 +07:00
21 changed files with 218 additions and 5 deletions

No files matched your search

Generated
+1
View File
@@ -4813,6 +4813,7 @@ dependencies = [
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
"windows-registry",
+1 -1
View File
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+51
View File
@@ -87,6 +87,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
load::open
),
cmd!(
query "file.list",
"List Folder",
[],
None,
"{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)",
always,
file_list
),
cmd!(
query "document.serialize",
"Serialize Document",
@@ -783,6 +792,48 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
})
}
/// The most entries `file.list` returns.
const LIST_MAX: usize = 5000;
fn file_list(_: &mut Session, p: &Value) -> Result<Value> {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?;
let (entries, truncated) = list_dir(path, LIST_MAX)?;
Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated }))
}
/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A
/// link isn't followed: its target may lie outside the automation roots.
#[cfg(not(target_arch = "wasm32"))]
fn list_dir(path: &str, max: usize) -> Result<(Vec<Value>, bool)> {
may_read(path)?;
let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut entries: Vec<(String, Value)> = vec![];
let mut truncated = false;
for e in dir.flatten() {
if entries.len() == max {
truncated = true;
break;
}
let name = e.file_name().to_string_lossy().into_owned();
let Ok(t) = e.file_type() else { continue };
let entry = if t.is_symlink() {
json!({ "name": name, "kind": "link" })
} else if t.is_dir() {
json!({ "name": name, "kind": "folder" })
} else {
json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() })
};
entries.push((name, entry));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated))
}
#[cfg(target_arch = "wasm32")]
fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
Err(EngineError::Other("no file system here".into()))
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
+29
View File
@@ -1670,3 +1670,32 @@ fn path_commands_reject_bad_anchors_before_modifying_geometry() {
s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2);
}
/// #1163: `file.list` lists a folder's files and folders by name, held to the automation read root
/// when one is in force.
#[test]
fn file_list_lists_a_folder_within_the_read_root() {
let base = std::env::temp_dir().join(format!("vc-file-list-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/b.svg"), "<svg/>").unwrap();
std::fs::write(base.join("in/a.pdf"), "1234").unwrap();
let path = |d: &str| base.join(d).to_string_lossy().into_owned();
let mut s = Session::new();
let r = s.execute("file.list", &json!({"path": path("in")})).unwrap();
assert_eq!(
r["entries"],
json!([{"name": "a.pdf", "kind": "file", "size": 4}, {"name": "b.svg", "kind": "file", "size": 6}, {"name": "sub", "kind": "folder"}])
);
assert_eq!(r["truncated"], false);
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
crate::file_access::confine(Some(&roots), || {
assert!(s.execute("file.list", &json!({"path": path("in/sub")})).is_ok());
assert!(s.execute("file.list", &json!({"path": path("outside")})).is_err(), "outside the read root");
});
assert!(s.execute("file.list", &json!({"path": path("in/b.svg")})).is_err(), "not a folder");
assert!(s.execute("file.list", &json!({})).is_err());
let _ = std::fs::remove_dir_all(base);
}
+3
View File
@@ -21,6 +21,9 @@ subsetter = { version = "0.2", default-features = false, features = ["variable-f
unicode-bidi = "0.3.18"
unicode-general-category = "1"
unicode-script = "0.5"
# Grapheme clusters (a Thai consonant with its vowel and tone marks, an accented letter, an emoji
# sequence) for caret movement; MIT OR Apache-2.0, already in the build.
unicode-segmentation = "1.13"
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }
+15
View File
@@ -190,6 +190,21 @@ pub fn next_char(s: &str, i: usize) -> usize {
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
/// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and
/// tone marks, an accented letter or an emoji sequence is passed over whole.
pub fn prev_cluster(s: &str, i: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let i = floor_char(s, i);
GraphemeCursor::new(i, s.len(), true).prev_boundary(s, 0).ok().flatten().unwrap_or(0)
}
/// The end of the grapheme cluster at `i` (Right arrow, Delete): see [`prev_cluster`].
pub fn next_cluster(s: &str, i: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let i = floor_char(s, i);
GraphemeCursor::new(i, s.len(), true).next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\'' || c == '’'
}
+64
View File
@@ -391,6 +391,36 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Fonts made for Thai, tried first (when installed) for Thai the font in use lacks: Windows
/// (Leelawadee UI; Tahoma before Windows 8), macOS (Thonburi, Sukhumvit Set) and Linux (Noto,
/// TLWG).
pub const THAI_FAMILIES: &[&str] = &[
"Leelawadee UI",
"Leelawadee",
"Tahoma",
"Thonburi",
"Sukhumvit Set",
"Noto Sans Thai",
"Noto Sans Thai UI",
"Noto Sans Thai Looped",
"Waree",
"Loma",
"Garuda",
];
/// The families tried first for character `c`, in a script whose text only fonts made for it set
/// well (Thai, Lao, Khmer, Myanmar); none for other characters.
fn script_families(c: char) -> &'static [&'static str] {
use unicode_script::{Script, UnicodeScript};
match c.script() {
Script::Thai => THAI_FAMILIES,
Script::Lao => &["Lao UI", "Leelawadee UI", "Lao Sangam MN", "Noto Sans Lao", "Noto Sans Lao UI", "Phetsarath OT"],
Script::Khmer => &["Khmer UI", "Khmer Sangam MN", "Noto Sans Khmer", "Noto Sans Khmer UI", "Khmer OS"],
Script::Myanmar => &["Myanmar Text", "Myanmar Sangam MN", "Noto Sans Myanmar", "Noto Sans Myanmar UI", "Padauk"],
_ => &[],
}
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -1917,6 +1947,18 @@ impl FontDb {
/// First face (fallback family first, then load order) that covers `c`; on native, system
/// fonts are loaded lazily the first time no loaded face covers a character.
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
// Thai (Lao, Khmer, Myanmar) takes a font made for it, when one is installed, rather than
// whichever face covering it happened to load first (a display face, say).
let prefer = script_families(c);
if let Some(f) = self.family_fallback(c, exclude, prefer) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_families(c, prefer)
&& let Some(f) = self.family_fallback(c, exclude, prefer)
{
return Some(f);
}
if let Some(f) = self.loaded_fallback(c, exclude) {
return Some(f);
}
@@ -1934,6 +1976,28 @@ impl FontDb {
self.fallback_for(c, 0)
}
/// The loaded face of the first of `families` that covers `c` (upright, nearest Regular).
fn family_fallback(&self, c: char, exclude: u32, families: &[&str]) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
families.iter().find_map(|fam| {
faces
.iter()
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
.min_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32))
.cloned()
})
}
/// Load the first of the installed `families` not loaded yet that covers `c` (with system
/// fallback on). Returns whether one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn load_families(&self, c: char, families: &[&str]) -> bool {
if families.is_empty() || !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
return false;
}
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam]).is_some())
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
+20
View File
@@ -569,3 +569,23 @@ fn family_display_name_follows_show_font_names_in_english() {
let face = db.face("ヒラギノ角ゴシック", "Regular").unwrap();
assert_eq!(face.family, "Hiragino Sans");
}
/// Thai typed in a font without Thai takes an installed font made for Thai (Leelawadee UI,
/// Thonburi, Noto Sans Thai…), whatever other face covering Thai loaded first; Latin beside it keeps
/// the font chosen. Skipped where none of those families is installed.
#[test]
fn thai_falls_back_to_a_font_made_for_thai() {
let db = FontDb::global();
let Some(thai) = crate::fontdb::THAI_FAMILIES.iter().find(|f| db.has_family(f)) else {
eprintln!("skipped: no Thai font installed");
return;
};
let st = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() };
let l = layout(db, &TextObject::point(kurbo::Point::ZERO, "Thai ภาษาไทย", st));
let family = |g: &PositionedGlyph| db.face_by_id(g.font_id).map(|f| f.family.clone()).unwrap_or_default();
let (latin, thai_glyphs) = l.glyphs.split_at(5);
assert!(latin.iter().all(|g| family(g) == FALLBACK_FAMILY), "{:?}", latin.iter().map(family).collect::<Vec<_>>());
let first = crate::fontdb::THAI_FAMILIES.iter().find(|f| thai_glyphs.iter().all(|g| family(g).eq_ignore_ascii_case(f)));
assert!(first.is_some(), "Thai drawn in {:?}; {thai} is installed", thai_glyphs.iter().map(family).collect::<Vec<_>>());
assert!(thai_glyphs.iter().all(|g| g.gid != 0), "no .notdef");
}
+14
View File
@@ -791,3 +791,17 @@ fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
}
/// The arrows pass a Thai consonant with its vowel and tone marks whole (น้ำ: no, mai tho, sara am),
/// as an accented letter written with a combining mark; plain text moves a character at a time.
#[test]
fn caret_moves_over_whole_clusters() {
let s = "กน\u{0E49}\u{0E33}e\u{0301}x";
let stops: Vec<usize> = std::iter::successors(Some(0), |&i| (i < s.len()).then(|| edit::next_cluster(s, i))).collect();
assert_eq!(stops, [0, 3, 12, 15, 16]);
let back: Vec<usize> = std::iter::successors(Some(s.len()), |&i| (i > 0).then(|| edit::prev_cluster(s, i))).collect();
assert_eq!(back, [16, 15, 12, 3, 0]);
// Inside a cluster (a caret left there by a click), the arrows go to its edges.
assert_eq!((edit::prev_cluster(s, 6), edit::next_cluster(s, 6)), (3, 12));
assert_eq!((edit::next_cluster("", 0), edit::prev_cluster("ab", 99)), (0, 1));
}
+5 -3
View File
@@ -530,8 +530,10 @@ impl Tool for TypeTool {
self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) };
self.replace(cx, "", None)
}
// Backspace takes back one character (a mistyped Thai tone mark, say); Delete, the
// Left and Right arrows a whole cluster.
ToolKey::Delete if self.caret < len => {
self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_char(&text, self.caret) };
self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_cluster(&text, self.caret) };
self.replace(cx, "", None)
}
ToolKey::Left => {
@@ -549,7 +551,7 @@ impl Tool for TypeTool {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
} else {
edit::prev_char(&text, self.caret)
edit::prev_cluster(&text, self.caret)
}
};
self.move_to(to, mods.shift)
@@ -569,7 +571,7 @@ impl Tool for TypeTool {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
} else {
edit::next_char(&text, self.caret)
edit::next_cluster(&text, self.caret)
}
};
self.move_to(to, mods.shift)
+1
View File
@@ -2101,6 +2101,7 @@
Redefine Symbol Symbol neu definieren
Redefine {kind} Style {kind}format neu definieren
Redo Wiederholen
List Folder Ordner auflisten
Begin Undo Group Rückgängig-Gruppe beginnen
End Undo Group Rückgängig-Gruppe beenden
Redo {name} Wiederholen: {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2152,6 +2152,7 @@
Redefine Symbol Redefinir símbolo
Redefine {kind} Style Redefinir estilo de {kind}
Redo Rehacer
List Folder Listar carpeta
Begin Undo Group Iniciar grupo de deshacer
End Undo Group Finalizar grupo de deshacer
Redo {name} Rehacer {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2149,6 +2149,7 @@
Redefine Symbol Redéfinir le symbole
Redefine {kind} Style Redéfinir le style de {kind}
Redo Rétablir
List Folder Lister le dossier
Begin Undo Group Commencer un groupe d’annulation
End Undo Group Terminer le groupe d’annulation
Redo {name} Rétablir {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2152,6 +2152,7 @@
Redefine Symbol Ridefinisci simbolo
Redefine {kind} Style Ridefinisci stile {kind}
Redo Ripeti
List Folder Elenca cartella
Begin Undo Group Inizia gruppo di annullamento
End Undo Group Termina gruppo di annullamento
Redo {name} Ripeti {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -101,6 +101,7 @@
Edit 編集
Undo 取り消し
Redo やり直し
List Folder フォルダーの内容を一覧表示
Begin Undo Group 取り消しグループを開始
End Undo Group 取り消しグループを終了
Cut 切り取り
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2148,6 +2148,7 @@
Redefine Symbol Переопределить символ
Redefine {kind} Style Переопределить стиль {kind}
Redo Повторить
List Folder Показать содержимое папки
Begin Undo Group Начать группу отмены
End Undo Group Завершить группу отмены
Redo {name} Повторить {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2144,6 +2144,7 @@
Redefine Symbol Перевизначити символ
Redefine {kind} Style Перевизначити стиль {kind}
Redo Повторити
List Folder Показати вміст теки
Begin Undo Group Почати групу скасування
End Undo Group Завершити групу скасування
Redo {name} Повторити {name}
Can't render this file because it contains an unexpected character in line 659 and column 40.
+1
View File
@@ -2077,6 +2077,7 @@
Redefine Symbol 重新定义符号
Redefine {kind} Style 重新定义{kind}样式
Redo 重做
List Folder 列出文件夹
Begin Undo Group 开始还原组
End Undo Group 结束还原组
Redo {name} 重做 {name}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2130,6 +2130,7 @@
Redefine Symbol 重新定義符號
Redefine {kind} Style 重新定義{kind}樣式
Redo 重做
List Folder 列出資料夾
Begin Undo Group 開始還原群組
End Undo Group 結束還原群組
Redo {name} 重做 {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -69,6 +69,7 @@ the suite) keep the agent to the folders you give it:
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
library and preset saves to a path.
- `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read root like any read, so an agent can see what it was given (#1163).
- Read and write access are separate. Give the same folder twice for both; a root left out grants none
of its access (with only `--automation-read-root`, nothing can be written to disk, though `export` and
the save commands without a path still return the bytes). Without either flag nothing changes.
+5 -1
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Proportional Metrics in vertical type, `vpal`, #966; document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **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
@@ -27,6 +27,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| 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 unused (#394) |
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
| 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 |
| Variable fonts | partial | named instances as styles; axis sliders missing |
| Colour fonts (OpenType SVG / COLR) | partial | |
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
@@ -36,6 +37,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 | |
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | |
| Touch Type tool | missing | listed in the toolbar, not implemented |
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
| Snap to Glyph | missing | menu stub |
@@ -51,6 +53,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The Type tool's arrows and Delete move by grapheme cluster, never between a Thai consonant and its vowel or tone marks; score unchanged (estimated) |
| 2026-10-11 | minor | Thai (Lao, Khmer, Myanmar) missing from the font in use falls back to an installed font made for the script instead of whichever face covering it loaded first; score unchanged (estimated) |
| 2026-10-11 | minor | Proportional Metrics done: `palt` in horizontal type, `vpal` in vertical type (#966, #1082); ruby and manual tate-chu-yoko still missing |
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |