Merge main into fix/hu-missing-message-rows

This commit is contained in:
dotCooCoo
2026-11-14 01:50:10 -08:00
15 changed files with 5265 additions and 84 deletions
+6
View File
@@ -18,6 +18,8 @@ gate: all six core workflows (path drawing and editing, paint and style, type, l
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
## Headline numbers
| | Value | Kind |
@@ -96,6 +98,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
| Spanish | `es` | full | 100% |
| Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner |
| Gujarati | `gu` | 4,673 contributor-reviewed catalog rows; 11 corrections applied to the 387 additions | menus and dialogs; status/error messages partial |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | full | 100% |
@@ -218,6 +221,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. |
@@ -226,6 +230,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. |
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; 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-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) |
| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback; messages not yet translated; native review pending |
| 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-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
+8
View File
@@ -53,6 +53,7 @@ pub(super) fn script_families(c: char) -> &'static [&'static str] {
"Lohit Devanagari",
"Mangal",
],
Script::Gujarati => &["Gujarati Sangam MN", "GujaratiMT", "Nirmala UI", "Shruti", "Noto Sans Gujarati", "Noto Sans Gujarati UI"],
_ => &[],
}
}
@@ -164,6 +165,13 @@ mod tests {
assert!(script_families('a').is_empty());
}
/// Gujarati takes a font that draws it (macOS Gujarati Sangam MN, Windows Nirmala UI or Shruti).
#[test]
fn gujarati_prefers_fonts_made_for_it() {
assert_eq!(script_families('ગ').first(), Some(&"Gujarati Sangam MN"));
assert!(script_families('ગ').contains(&"Nirmala UI"));
}
#[test]
fn a_name_table_of_many_records_is_cut_short() {
let many: Vec<(u16, &str)> = (0..2000).map(|_| (1, "Short")).collect();
+27 -5
View File
@@ -97,7 +97,7 @@ fn plural_polish(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 28] = [
pub static LANGUAGES: [LangInfo; 29] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Hindi shares the integer zero/one rule with French. Desktop UI fonts are loaded lazily
// from installed faces by ui_fonts; the web needs a Devanagari font in its font set.
@@ -109,6 +109,18 @@ pub static LANGUAGES: [LangInfo; 28] = [
complete_menus: true,
catalog: OnceLock::new(),
},
// Gujarati: menus and dialogs, with partial status/error message coverage and loanwords
// transliterated into Gujarati script; zero/one singular plurals. Gujarati text is drawn with an installed system font
// (Gujarati Sangam MN, Nirmala UI, Shruti) or a craft-fonts face tagged `Gujr`. Web selection
// requires a bundled Gujarati interface face; otherwise `gu-*` locales use the fallback.
LangInfo {
code: "gu",
name: "ગુજરાતી",
source: include_str!("i18n/gu.tsv"),
plural: plural_french,
complete_menus: true,
catalog: OnceLock::new(),
},
// Greek: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
LangInfo {
@@ -355,6 +367,16 @@ pub static LANGUAGES: [LangInfo; 28] = [
];
impl LangInfo {
fn available(&self) -> bool {
self.available_with_fonts(cfg!(target_arch = "wasm32"), vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Gujr")))
}
fn available_with_fonts(&self, web: bool, gujarati_font: bool) -> bool {
// Browsers cannot use the native installed-font fallback. Do not offer a language
// whose text would be missing-glyph boxes, including when restoring a preference.
self.code != "gu" || !web || gujarati_font
}
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| Catalog::parse(self.source))
}
@@ -387,7 +409,7 @@ impl Lang {
/// A language by its exact code.
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code) && l.available()).map(Lang)
}
/// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
@@ -396,12 +418,12 @@ impl Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
/// Every language available in this build.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
LANGUAGES.iter().filter(|l| l.available()).map(Lang)
}
/// Every registered language in the order a picker lists them: English first (the source
/// Every available language in the order a picker lists them: English first (the source
/// language and the fallback), then the rest by their own name with accents folded, so
/// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in
/// code point order: Greek, Cyrillic, Arabic, then the CJK languages.
+4 -4
View File
@@ -7577,7 +7577,7 @@ breakText Glyphs Glyfy
@msg `alignOn` must be a string `alignOn` musí být řetězec
@msg `align` is start, center or end `align` je start, center nebo end
@msg `align` must be a string `align` musí být řetězec
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` musí být pole dvojic [dílčí cesta, kotevní bod]
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` musí být pole dvojic [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {v}
@msg `answer` for this {kind} dialog must be one of: {_1} `answer` pro tento dialog {kind} musí být jedna z hodnot: {_1}
@@ -7642,7 +7642,7 @@ breakText Glyphs Glyfy
@msg `fidelity` must be from {lo} to {hi} pt `fidelity` musí být od {lo} do {hi} pt
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` musí být 1 až 1000 cest k souborům písem, které našlo poslední hledání text.findFontFiles
@msg `focal` and `radius` cannot be used together `focal` a `radius` nelze použít společně
@msg `focal` must be an array of [subpath, anchor] pairs `focal` musí být pole dvojic [dílčí cesta, kotevní bod]
@msg `focal` must be an array of [subpath, anchor] pairs `focal` musí být pole dvojic [subpath, anchor]
@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` uvádí [{si}, {ai}], což není kotevní bod cesty
@msg `folder` must be an absolute folder path `folder` musí být absolutní cesta ke složce
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` musí být objekt se značkami os a hodnotami, například {"wght"}
@@ -8307,7 +8307,7 @@ breakText Glyphs Glyfy
@msg instruction {pc} reached with stack depths {seen} and {d} instrukce {pc} byla dosažena s hloubkami zásobníku {seen} a {d}
@msg invalid `color` neplatné `color`
@msg invalid anchor index {v}: expected a non-negative integer neplatný index kotevního bodu {v}: očekáváno nezáporné celé číslo
@msg invalid anchor reference {v}: expected [subpath, anchor] neplatný odkaz na kotevní bod {v}: očekáváno [dílčí cesta, kotevní bod]
@msg invalid anchor reference {v}: expected [subpath, anchor] neplatný odkaz na kotevní bod {v}: očekáváno [subpath, anchor]
@msg invalid angle neplatný úhel
@msg invalid array length neplatná délka pole
@msg invalid brush: {e} neplatný štětec: {e}
@@ -8416,7 +8416,7 @@ breakText Glyphs Glyfy
@msg missing `{k}` chybí `{k}`
@msg missing anchor chybí kotevní bod
@msg missing anchors chybí kotevní body
@msg missing anchors [[subpath, anchor]…] chybí kotevní body [[dílčí cesta, kotevní bod]…]
@msg missing anchors [[subpath, anchor]…] chybí kotevní body [[subpath, anchor]…]
@msg missing array `{key}` chybí pole `{key}`
@msg missing case chybí case
@msg missing color chybí barva
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff
+32
View File
@@ -3,6 +3,20 @@ use super::*;
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
#[test]
fn gujarati_resolves_locales_and_takes_the_singular_for_zero_and_one() {
// The catalog is present even when this web build cannot display it.
let gu = Lang(LANGUAGES.iter().find(|l| l.code == "gu").expect("gu registered"));
let selectable = gu.0.available().then_some(gu);
assert_eq!(Lang::from_code("gu"), selectable);
for tag in ["gu", "gu-IN", "gu_IN.UTF-8", "GU-in", "gu-Gujr-IN"] {
assert_eq!(lang_from_tag(tag), selectable, "{tag}");
}
assert_eq!(gu.name(), "ગુજરાતી");
assert_eq!([0, 1, 2, 21].map(gu.0.plural), [0, 0, 1, 1]);
assert_eq!(tr(gu, "Save"), "સાચવો");
}
#[test]
fn hindi_resolves_locales_and_uses_zero_one_plurals() {
let hi = Lang::from_code("hi").expect("hi registered");
@@ -209,6 +223,17 @@ fn tags_map_to_languages() {
assert_eq!(lang_from_tag("_"), None);
}
#[test]
fn gujarati_web_selection_requires_a_bundled_interface_font() {
let gu = LANGUAGES.iter().find(|l| l.code == "gu").expect("Gujarati catalog");
assert!(gu.available_with_fonts(false, false), "native installed-font fallback");
assert!(!gu.available_with_fonts(true, false), "web must not offer missing glyphs");
assert!(gu.available_with_fonts(true, true));
assert!(LANGUAGES[0].available_with_fonts(true, false), "English fallback");
assert_eq!(Lang::from_code("gu").is_some(), gu.available());
assert_eq!(Lang::for_picker().iter().any(|l| l.code() == "gu"), gu.available());
}
/// The Bosnian, Georgian, Ukrainian, Hungarian, Korean, Vietnamese, Swedish and Catalan tags of
/// [`tags_map_to_languages`].
fn other_tags_map_to_languages() {
@@ -823,6 +848,11 @@ fn is_devanagari(c: char) -> bool {
matches!(c as u32, 0x0900..=0x097F | 0x1CD0..=0x1CFF | 0xA8E0..=0xA8FF)
}
#[cfg(feature = "interface-fonts")]
fn is_gujarati(c: char) -> bool {
matches!(c as u32, 0x0A80..=0x0AFF)
}
/// The UI fonts draw every character the catalogs use, in every family the UI draws text with
/// (the web build has no system fonts to fall back to). Chinese and Japanese characters come from
/// the craft-fonts build input, which is optional: without it they are left to the installed
@@ -836,6 +866,7 @@ fn bundled_fonts_cover_the_catalogs() {
let japanese_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese());
let georgian_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_georgian());
let devanagari_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Deva"));
let gujarati_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Gujr"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
@@ -859,6 +890,7 @@ fn bundled_fonts_cover_the_catalogs() {
&& (cjk_checked || !is_cjk(*c))
&& (georgian_ui_font || !is_georgian(*c))
&& (devanagari_ui_font || !is_devanagari(*c))
&& (gujarati_ui_font || !is_gujarati(*c))
})
.collect();
for family in &families {
+103 -10
View File
@@ -7,20 +7,40 @@ use serde_json::json;
use crate::theme::Tokens;
use crate::{VectorcraftApp, menus};
/// Engine commands the palette leaves out, each with the UI command of the same name that runs it
/// the way the panels and menus do: (engine id, UI command id). The Layers panel's Delete
/// Selection asks before deleting a layer that holds art, and the other UI commands open the
/// command's dialog or file chooser. Scripts and the control channel still run the engine ids.
const WRAPPED: &[(&str, &str)] = &[
("layer.delete", "ui.layerDelete"),
("brush.new", "ui.newBrush"),
("brush.options", "ui.brushOptions"),
("symbol.options", "ui.symbolOptions"),
("svgFilter.import", "ui.importSvgFilter"),
("text.convertListToText", "type.convertListToText"),
];
/// Everything the palette searches: (label, command id or `tool:<id>`, shortcut).
pub fn items() -> Vec<(String, String, String)> {
let mut items = vec![];
// Aliases kept for older scripts run a command that is listed already.
// Aliases kept for older scripts run a command that is listed already, and a wrapped engine
// command is listed as its UI command.
let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs();
for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) {
let path = c.menu.join(" › ");
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
let listed = |id: &str| !vectorcraft_engine::cmd::is_alias(id) && !WRAPPED.iter().any(|(engine, _)| *engine == id);
let labeled = |menu: &[&str], label: &str| {
let path = menu.join(" › ");
if path.is_empty() { label.to_string() } else { format!("{path} › {label}") }
};
for c in vectorcraft_engine::command_specs().iter().filter(|c| listed(c.id)).chain(plugin_commands) {
items.push((labeled(c.menu, c.label), c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
}
// `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. A script of
// the Scripts folder runs from File › Scripts, by its path.
// the Scripts folder runs from File › Scripts, by its path. A UI command that wraps an engine
// command takes that command's menu path, so a search for the path still finds it.
for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog" && c.0 != crate::scripts_menu::COMMAND) {
items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
let engine = WRAPPED.iter().find(|(_, ui)| *ui == c.0).and_then(|(engine, _)| vectorcraft_engine::find_command(engine));
let label = engine.map_or_else(|| c.1.to_string(), |e| labeled(e.menu, c.1));
items.push((label, c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
}
for tool in vectorcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
@@ -40,8 +60,9 @@ pub(crate) struct Entry {
shortcut: String,
/// The label in the UI language.
shown: String,
/// The English label (what agents document), the shown label and the id, lowercased, one per
/// line: a query word (which has no line break) matches within one of them.
/// The English label (what agents document), the shown label and the id (and the id of the
/// engine command a UI command wraps), lowercased, one per line: a query word (which has no
/// line break) matches within one of them.
haystack: String,
}
@@ -71,7 +92,12 @@ fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
/// item (`plugin:<id>`), whose name is not translated.
fn build(lang: crate::i18n::Lang) -> Vec<Entry> {
let entry = |label: String, shown: String, id: String, shortcut: String| {
let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
let mut haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
// A UI command that wraps an engine command is also found by the engine command's id.
if let Some((engine, _)) = WRAPPED.iter().find(|(_, ui)| *ui == id) {
haystack.push('\n');
haystack.push_str(&engine.to_lowercase());
}
Entry { id, shortcut, shown, haystack }
};
let mut out: Vec<Entry> = items()
@@ -250,6 +276,73 @@ mod tests {
assert_eq!((st.selection.len(), st.selection.key), (2, None), "both objects stay selected, without a key");
}
/// An engine command that a UI command of the same name runs is listed once, as the UI command:
/// the Layers panel's Delete Selection, New Brush…, Brush Options…, Symbol Options…, Import SVG
/// Filter… and Convert to Text each have one result.
#[test]
fn a_wrapped_engine_command_is_listed_once_as_its_ui_command() {
let items = items();
for (engine, ui) in WRAPPED {
let spec = vectorcraft_engine::command_specs().iter().find(|c| c.id == *engine).unwrap_or_else(|| panic!("{engine} is a command"));
let wrapper = menus::UI_COMMANDS.iter().find(|c| c.0 == *ui).unwrap_or_else(|| panic!("{ui} is a UI command"));
assert_eq!(spec.label, wrapper.1, "{engine} and {ui} share a name");
assert!(items.iter().all(|(_, id, _)| id != engine), "{engine} is left out");
let listed: Vec<&str> = items.iter().filter(|(_, id, _)| id == ui).map(|(label, ..)| label.as_str()).collect();
let path: String = spec.menu.iter().map(|m| format!("{m} › ")).collect();
assert_eq!(listed, [format!("{path}{}", wrapper.1)], "{ui} is listed once, with {engine}'s menu path");
}
// A search for Delete Selection, or for its menu path, finds the Layers panel's, and not the
// engine command that deletes without asking; the path translates.
let entries = build(Lang::EN);
for (engine, ui) in WRAPPED {
assert!(entries.iter().any(|e| e.id == *ui && e.haystack.contains(&engine.to_lowercase())), "a search for {engine} finds {ui}");
}
for query in ["delete selection", "layers delete", "layer.delete"] {
let found: Vec<&str> =
entries.iter().filter(|e| query.split_whitespace().all(|w| e.haystack.contains(w))).map(|e| e.id.as_str()).collect();
assert!(found.contains(&"ui.layerDelete") && !found.contains(&"layer.delete"), "{query}: {found:?}");
}
let zh = Lang::from_code("zh-hant").unwrap();
let shown = build(zh).into_iter().find(|e| e.id == "ui.layerDelete").map(|e| e.shown).unwrap();
let want = ["Window", "Layers", "Delete Selection"].map(|s| crate::i18n::tr(zh, s)).join(" › ");
assert_eq!(shown, want);
}
/// Every result a search for Delete Selection gives asks before deleting a layer that holds art:
/// Cancel keeps it, and OK deletes it in one undo step. A script's `layer.delete` still deletes
/// at once.
#[test]
fn a_pick_of_delete_selection_asks_before_deleting_a_layer_with_art() {
let query = "delete selection";
let picks: Vec<String> =
build(Lang::EN).into_iter().filter(|e| query.split_whitespace().all(|w| e.haystack.contains(w))).map(|e| e.id).collect();
assert!(!picks.is_empty(), "the search finds Delete Selection");
let node = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap());
let exists = |app: &VectorcraftApp, v: &serde_json::Value| app.session.active().unwrap().doc.node(node(v)).is_some();
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let asked = |app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::confirm::KIND);
for pick in &picks {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let layer = app.run("layer.new", json!({})).unwrap()["id"].clone();
let art = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.none", json!({})).unwrap();
let before = undo(&app);
run_pick(&mut app, pick);
assert!(asked(&app), "{pick} asks first: {}", app.ui.status);
assert!(exists(&app, &layer) && exists(&app, &art) && undo(&app) == before, "{pick}: nothing is deleted yet");
crate::dialogs::cancel(&mut app);
assert!(exists(&app, &layer) && exists(&app, &art) && undo(&app) == before, "{pick}: Cancel keeps the layer");
run_pick(&mut app, pick);
crate::dialogs::confirm(&mut app).unwrap();
assert!(!exists(&app, &layer) && !exists(&app, &art), "{pick}: OK deletes the layer and its art");
assert_eq!(undo(&app), before + 1, "{pick}: one undo step");
app.run("edit.undo", json!({})).unwrap();
app.run("layer.delete", json!({ "ids": [layer] })).unwrap();
assert!(app.ui.dialog.is_none() && !exists(&app, &layer), "a script deletes at once");
}
}
/// #4864: a pick of an Asset Export command, which the palette runs with no params, opens the
/// Asset Export panel instead of failing on the missing params or exporting nowhere.
#[test]
+180 -60
View File
@@ -268,6 +268,66 @@ fn disclosure(ui: &Ui, view: &View, n: &Node, (r, x): (egui::Rect, f32), open: b
}
}
/// The row's name label or inline rename field. Returns (name_end, name_rect).
fn row_name(ui: &mut Ui, view: &View, item: &Row, r: egui::Rect, x: f32, out: &mut Out) -> (f32, egui::Rect) {
let n = &*item.node;
let doc = view.doc;
let t = &view.t;
let xml_id = view.identify_xml_id;
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
let name_end = match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let field = rename_id().with("field");
let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child
.add(egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font));
// Focus the field as it opens. Asking every frame took the focus back from Enter,
// Escape or a click elsewhere, so the rename never ended.
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
}
} else {
// Shift+Enter renames it and keeps the field open.
if kept && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()})));
}
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
}
name_rect.right()
}
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
// XML IDs stay as ids (not translated).
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string()
} else if xml_id {
name
} else {
painted_name(n, &n.display_name(), crate::i18n::current())
};
let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n));
// A clipping path's name is underlined, a masked object's with a dashed line.
if item.clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.extras().mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
text.right()
}
};
(name_end, name_rect)
}
/// The widgets of row `item`.
pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
if let Some(i) = item.guide {
@@ -277,8 +337,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
return spine_row(ui, view, item, out);
}
let n = &*item.node;
let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour);
let doc = view.doc;
let (depth, has_children, open, color) = (item.depth, item.opens, item.open, item.colour);
let t = &view.t;
let h = view.h;
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
@@ -309,60 +368,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
th
});
// Name (Preferences › Units › Identify Objects By: Object Name or XML ID).
let xml_id = view.identify_xml_id;
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
let name_end;
match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let field = rename_id().with("field");
let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child.add(
egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font.clone()),
);
// Focus the field as it opens. Asking every frame took the focus back from Enter,
// Escape or a click elsewhere, so the rename never ended.
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
}
} else {
// Shift+Enter renames it and keeps the field open.
if kept && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()})));
}
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
}
name_end = name_rect.right();
}
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
// XML IDs stay as ids (not translated).
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string()
} else if xml_id {
name.clone()
} else {
painted_name(n, &n.display_name(), crate::i18n::current())
};
let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n));
name_end = text.right();
// A clipping path's name is underlined, a masked object's with a dashed line.
if clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.extras().mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
}
}
let (name_end, name_rect) = row_name(ui, view, item, r, x, out);
// Target circle and selection square.
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
@@ -404,12 +410,18 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
}
}
// Row menu: Collect for Export on this row (or every highlighted row when this is one of them).
// Row menu: context actions on this row (or every highlighted row when this is one of them).
let collect_ids: Vec<u64> = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] };
// Right-clicking an unselected row selects it; a highlighted row keeps multiselection.
if resp.secondary_clicked() && !view.rows.contains(&n.id) {
out.actions.push(("layer.setCurrent".into(), json!({"id": n.id.0})));
out.rows_after_click = Some(vec![n.id]);
}
if resp.secondary_clicked() {
crate::panels::set_row_keys(ui.ctx(), ROW_KEYS, true);
}
resp.context_menu(|ui| {
collect_for_export_menu(ui, true, |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
});
row_context_menu(ui, view, n, &collect_ids, out);
});
// Dragging rows: the highlighted rows when this is one of them, else this row. Only the primary
// button drags; the secondary button opens the row menu, and a drag with it moves nothing.
@@ -428,6 +440,114 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
}
/// The right-click context menu of row `n`.
fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out: &mut Out) {
let is_multi = collect_ids.len() > 1;
// "Layer Options…" / "Options for Selection…" / "Options…"
let options_label = if is_multi {
"Options for Selection…"
} else if n.is_layer() {
"Layer Options…"
} else {
"Options…"
};
if widgets::menu_item(ui, options_label, true, false) {
out.actions.push(("ui.layerOptions".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Duplicate "<name>"" or "Duplicate Selection"
let display = {
let raw = painted_name(n, &n.display_name(), crate::i18n::current());
if raw.chars().count() > 20 {
let truncated: String = raw.chars().take(20).collect();
format!("{truncated}…")
} else {
raw
}
};
let dup_label = if is_multi { tl!("Duplicate Selection").to_string() } else { format!("{} \"{display}\"", tl!("Duplicate")) };
if widgets::menu_item_name(ui, &dup_label, true, false) {
out.actions.push(("layer.duplicate".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Delete "<name>"" or "Delete Selection"
let del_label = if is_multi { tl!("Delete Selection").to_string() } else { format!("{} \"{display}\"", tl!("Delete")) };
let del_enabled = {
let listed: std::collections::HashSet<NodeId> = collect_ids.iter().map(|&id| NodeId(id)).collect();
!view.doc.layers.iter().all(|l| listed.contains(&l.id))
};
if widgets::menu_item_name(ui, &del_label, del_enabled, false) {
out.actions.push(("layer.delete".into(), json!({ "ids": collect_ids })));
ui.close();
}
ui.separator();
// Lock Others / Unlock All Layers
{
let (lock_cmd, lock_params, lock_enabled) = multi_others_action(view.doc, collect_ids, false);
let lock_label = if lock_cmd == "layer.lockOthers" { "Lock Others" } else { "Unlock All Layers" };
if widgets::menu_item(ui, lock_label, lock_enabled, false) {
out.actions.push((lock_cmd, lock_params));
ui.close();
}
}
// Hide Others / Show All Layers
{
let (hide_cmd, hide_params, hide_enabled) = multi_others_action(view.doc, collect_ids, true);
let hide_label = if hide_cmd == "layer.hideOthers" { "Hide Others" } else { "Show All Layers" };
if widgets::menu_item(ui, hide_label, hide_enabled, false) {
out.actions.push((hide_cmd, hide_params));
ui.close();
}
}
ui.separator();
// Locate Object
if widgets::menu_item(ui, "Locate Object", true, false) {
out.actions.push(("layer.locate".into(), json!({ "id": n.id.0 })));
ui.close();
}
ui.separator();
// Collect for Export
let ids: Vec<u64> = collect_ids.to_vec();
collect_for_export_menu(ui, true, move |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": ids.clone(), "multiple": multiple })));
});
}
/// Context menu Others action for `ids`: Lock Others / Unlock All Layers or Hide Others / Show All Layers.
/// When any other layer is unlocked (visible), returns `lockOthers` (`hideOthers`) preserving all layers holding
/// `ids`. When all other layers are locked (hidden) already, returns `unlockAll` (`showAll`).
pub(crate) fn multi_others_action(doc: &Document, ids: &[u64], eye: bool) -> (String, Value, bool) {
let own_layers: std::collections::HashSet<NodeId> = ids.iter().filter_map(|&id| doc.layer_of(NodeId(id))).collect();
let has_other_active = if eye {
doc.layers.iter().any(|l| !own_layers.contains(&l.id) && l.visible)
} else {
doc.layers.iter().any(|l| !own_layers.contains(&l.id) && !l.locked)
};
let cmd = match (eye, has_other_active) {
(true, true) => "layer.hideOthers",
(true, false) => "layer.showAll",
(false, true) => "layer.lockOthers",
(false, false) => "layer.unlockAll",
};
let enabled = match (eye, has_other_active) {
(true, true) | (false, true) => true,
(true, false) => any_layer_state(doc, |n| !n.visible),
(false, false) => any_layer_state(doc, |n| n.locked),
};
let params = if cmd.ends_with("Others") { json!({ "ids": ids }) } else { json!({}) };
(cmd.into(), params, enabled)
}
fn any_layer_state(doc: &Document, pred: impl Fn(&Node) -> bool) -> bool {
let mut found = false;
doc.walk(|n| {
if !found && n.is_layer() && pred(n) {
found = true;
}
});
found
}
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock
/// All Layers). Each is one undo step.
+192
View File
@@ -2440,3 +2440,195 @@ fn guide_rows_list_by_index_whatever_the_node_order() {
let indexes: Vec<usize> = rows.iter().filter_map(|r| r.guide).collect();
assert_eq!(indexes, [2, 1, 0]);
}
/// Right-clicking a row that is not highlighted selects it.
#[test]
fn context_menu_right_click_selects_unselected_row() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.highlight", &json!({"ids": [], "mode": "set"})).unwrap();
let ctx = egui::Context::default();
// Rows top-first: Layer 2, Layer 1. Neither is highlighted.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 2);
assert!(rows_of(&app).is_empty());
// Secondary-click Layer 1's row (bottom row).
let pos = circles[1] - vec2(100.0, 0.0); // name area
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// The row is now highlighted.
let highlighted = rows_of(&app);
let layer1 = app.session.active().unwrap().doc.layers[0].id;
assert!(highlighted.contains(&layer1), "right-click selected the unselected row");
assert!(crate::panels::has_row_keys(&ctx, super::ROW_KEYS), "secondary click claims row keys");
}
/// Right-clicking a row that IS highlighted preserves the multiselection.
#[test]
fn context_menu_right_click_preserves_multiselection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
let ids: Vec<u64> = doc.layers.iter().map(|l| l.id.0).collect();
// Highlight all 3 layers.
app.session.execute("layer.highlight", &json!({"ids": ids, "mode": "set"})).unwrap();
let ctx = egui::Context::default();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
// Secondary-click the middle row.
let pos = circles[1] - vec2(100.0, 0.0);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// All 3 must still be highlighted.
assert_eq!(rows_of(&app).len(), 3, "multiselection preserved after right-click on a highlighted row");
}
/// layer.delete removes the specified layer.
#[test]
fn context_menu_delete_dispatches_layer_delete() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer2_id = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let before = app.session.active().unwrap().doc.layers.len();
// Dispatch layer.delete with explicit ids (what the context menu item does).
app.session.execute("layer.delete", &json!({"ids": [layer2_id]})).unwrap();
let after = app.session.active().unwrap().doc.layers.len();
assert_eq!(after, before - 1, "layer.delete removes the layer");
}
/// layer.duplicate adds a copy.
#[test]
fn context_menu_duplicate_dispatches_layer_duplicate() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer_id = app.session.active().unwrap().doc.layers[0].id.0;
let before_layers = app.session.active().unwrap().doc.layers.len();
let before_undo = app.session.active().unwrap().history.undo.len();
app.session.execute("layer.duplicate", &json!({"ids": [layer_id]})).unwrap();
let after = &app.session.active().unwrap();
assert_eq!(after.doc.layers.len(), before_layers + 1, "layer.duplicate adds a layer");
assert_eq!(after.history.undo.len(), before_undo + 1, "one undo step");
}
/// layer.locate locates a specified object id without requiring a prior canvas selection.
#[test]
fn context_menu_locate_object_locates_by_id() {
let (mut app, _, [a, _]) = two_rects();
app.session.execute("select.none", &json!({})).unwrap();
assert!(app.session.active().unwrap().selection.objects.is_empty());
// Context menu passes the node's id directly to layer.locate.
let result = app.session.execute("layer.locate", &json!({"id": a.0}));
assert!(result.is_ok(), "layer.locate with id succeeds without selection: {:?}", result.err());
assert_eq!(app.session.active().unwrap().highlighted_rows(), vec![a]);
}
/// In a 1-layer document, the Delete action in the context menu is disabled when targeting that layer.
#[test]
fn context_menu_delete_disabled_when_every_layer_targeted() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
let layer_id = doc.layers[0].id;
// Targeting every layer disables delete.
let listed: std::collections::HashSet<NodeId> = [layer_id].into_iter().collect();
let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id));
assert!(!del_enabled, "Delete must be disabled when every layer is targeted");
// Add a second layer: delete is now enabled for one layer.
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 2);
let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id));
assert!(del_enabled, "Delete is enabled when at least one layer remains");
}
/// Others actions (Hide Others / Lock Others) retain all multiselected layers.
#[test]
fn context_menu_others_actions_preserve_multiselected_layers() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 3);
let l1 = doc.layers[0].id.0;
let l2 = doc.layers[1].id.0;
// Hide Others with multiselection [l1, l2] hides only l3.
let (hide_cmd, hide_params, hide_enabled) = super::rows::multi_others_action(doc, &[l1, l2], true);
assert_eq!(hide_cmd, "layer.hideOthers");
assert!(hide_enabled);
assert_eq!(hide_params["ids"], json!([l1, l2]));
// Lock Others with multiselection [l1, l2] locks only l3.
let (lock_cmd, lock_params, lock_enabled) = super::rows::multi_others_action(doc, &[l1, l2], false);
assert_eq!(lock_cmd, "layer.lockOthers");
assert!(lock_enabled);
assert_eq!(lock_params["ids"], json!([l1, l2]));
}
/// Context menu Others action resolves an object row to its holding top-level layer.
#[test]
fn context_menu_others_actions_object_row_resolves_to_holding_layer() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Layer 1 (current) holding a rectangle.
let rect = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let rect_id = rect["id"].as_u64().unwrap();
// Create Layer 2 and hide it.
let l2 = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
app.session.execute("layer.setProps", &json!({"ids": [l2], "visible": false})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 2);
// Right-clicking the object in Layer 1: Layer 1 is visible, Layer 2 is hidden.
// There are no other visible layers, so the action should be Show All Layers, enabled.
let (hide_cmd, _hide_params, hide_enabled) = super::rows::multi_others_action(doc, &[rect_id], true);
assert_eq!(hide_cmd, "layer.showAll");
assert!(hide_enabled, "Show All Layers is enabled because Layer 2 is hidden");
// Now make Layer 2 visible, but lock it.
app.session.execute("layer.setProps", &json!({"ids": [l2], "visible": true, "locked": true})).unwrap();
let doc = &app.session.active().unwrap().doc;
// Right-clicking the object in Layer 1: Layer 1 is unlocked, Layer 2 is locked.
// There are no other unlocked layers, so the action should be Unlock All Layers, enabled.
let (lock_cmd, _lock_params, lock_enabled) = super::rows::multi_others_action(doc, &[rect_id], false);
assert_eq!(lock_cmd, "layer.unlockAll");
assert!(lock_enabled, "Unlock All Layers is enabled because Layer 2 is locked");
}
/// Context menu Show/Unlock All Layers are enabled when sublayers are hidden or locked.
#[test]
fn context_menu_others_actions_sublayer_state_enables_all_commands() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
let l1 = doc.layers[0].id.0;
// Create a sublayer in Layer 1.
let sub = app.session.execute("layer.newSublayer", &json!({"parent": l1})).unwrap()["id"].as_u64().unwrap();
// Case 1: Sublayer is hidden while top layer is visible.
app.session.execute("layer.setProps", &json!({"ids": [sub], "visible": false})).unwrap();
let doc = &app.session.active().unwrap().doc;
let (hide_cmd, _, hide_enabled) = super::rows::multi_others_action(doc, &[l1], true);
assert_eq!(hide_cmd, "layer.showAll");
assert!(hide_enabled, "hidden sublayer enables Show All Layers");
// Case 2: Sublayer is visible, but locked while top layer is unlocked.
app.session.execute("layer.setProps", &json!({"ids": [sub], "visible": true, "locked": true})).unwrap();
let doc = &app.session.active().unwrap().doc;
let (lock_cmd, _, lock_enabled) = super::rows::multi_others_action(doc, &[l1], false);
assert_eq!(lock_cmd, "layer.unlockAll");
assert!(lock_enabled, "locked sublayer enables Unlock All Layers");
}
+5 -1
View File
@@ -571,7 +571,10 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
fn add_craft_fonts(fonts: &mut FontDefinitions) {
// The name of each face's copy in each family.
let mut copies: std::collections::HashMap<(FontFamily, String), String> = std::collections::HashMap::new();
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva")) {
for f in vectorcraft_text::CRAFT_FONTS
.iter()
.filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva") || f.scripts.contains(&"Gujr"))
{
let name = craft_font_name(f);
for (copy, data, families) in crate::ui_fonts::baseline_copies(fonts, &name, &FontData::from_static(f.bytes)) {
for family in families {
@@ -589,6 +592,7 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) {
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(copy));
stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(copy));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(copy));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Gujr")).map(copy));
}
}
+3 -2
View File
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. |
| 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups `<g>`, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
@@ -499,12 +500,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
- **Done:** Hyphenation › Default Language is the language of new type, unless Document Setup chose one, and each
run hyphenates in its own language (Character panel). English and eleven Indian languages have break
points; other languages stay whole apart from the Exceptions list (#394, #4947).
- **Done (2026-11-14):** Layers panel row context menu: right-clicking a layer, group, or object row shows a context menu (Options…, Duplicate, Delete, Lock Others / Unlock All, Hide Others / Show All, Locate Object, Collect for Export) with proper selection behavior and Undo/Redo tracking. Tests in `crates/ui-egui/src/panels/layers/tests.rs`.
- **Missing:** 2 Preferences options stored but not acted on (#394), each waiting on a subsystem of its own:
Plug-ins & Scratch Disks › Primary and Secondary (documents are kept in memory; nothing pages to
disk), and Clipboard Handling › Legacy vector clipboard with its Preserve Paths / Preserve Appearance and
Overprints choice (Illustrator's own clipboard flavour, which has no public specification). Also Properties
panel per context, context menus in the panels that have none, Layers row menu commands beyond Collect for
Export, and menus on guide and blend spine rows in the Layers panel.
panel per context, context menus in the panels that have none, and menus on guide and blend spine rows in the Layers panel.
- **Impact:** settings that silently do nothing erode trust.
- **Estimate:** 13–20 h.
+7 -1
View File
@@ -20,7 +20,9 @@ English) in-app help, and the docs are English.
**Native review:** every catalog was written clean-room from the English meaning, most of it by agents
(`bflatastic`), with community contributors adding or correcting (German by `dwetscher`, Japanese and Traditional
Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker; the other catalog headers ask for one.
Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded as reviewed by native speakers; the contributor approved all 387 later Gujarati additions on 2026-11-14.
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
## The twelve key languages
@@ -30,6 +32,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,439 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 40 missing messages for it) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
| Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font |
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) |
| Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) |
@@ -97,6 +100,9 @@ font for the web build.
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) |
| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback and a glyph-coverage filter; status and error messages not yet translated; native review pending |
| 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-10-30 | minor | Bosnian (`bs`, Latin, Ijekavian): complete catalog of 5,841 rows, every `bs-*` locale, one/few/other plurals; AI-assisted, not yet native-reviewed |
| 2026-10-29 | minor | Vietnamese text: letters with two marks (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms, and a font lacking the marks falls back for the whole letter (#4491); scores unchanged |
+7
View File
@@ -12,6 +12,8 @@
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
## Feature submission evidence
M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged.
@@ -128,6 +130,8 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented;
native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md).
Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule are implemented. The original 4,286 rows remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and a bundled Gujarati web font remains to be supplied.
## Current focus
1. **Interaction-fidelity pass** (G1) by tool, from [ui-parity.md](ui-parity.md): Pen and Curvature, Direct
@@ -169,6 +173,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
| M14 follow-up | Gujarati interface (#6613) | 🟡 Zero/one singular plurals and desktop font fallback; 4,673 contributor-reviewed rows, including 387 approved additions with 11 wording corrections; status/error messages remain partial and pinned fonts lack a Gujarati face | Included above |
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in
@@ -202,6 +207,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged |
| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. |
| 2026-11-12 | minor | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. |
| 2026-11-12 | minor | M14: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects all (#6659, follow-up to #1326); estimates unchanged. |
+6
View File
@@ -14,6 +14,8 @@
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
## Feature submission evidence
EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md).
@@ -887,6 +889,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- A toolbar made with New Toolbar docks in a lane along the window's left or right edge when its title bar is dropped on that edge, or with Dock to Left and Dock to Right in the title bar's context menu. Toolbars docked at one edge stack from the top, each with its share of the lane's height, and the lane is as wide as its widest toolbar; a lane too short for its stack wraps it into more columns, up to half the window's width, and past that the toolbars show only their title bars, which the mouse wheel scrolls through when even they do not fit. Dragging the title bar out of the lane, or Float Toolbar, floats it again, and Escape during the drag puts it back. Hiding and renaming keep its place, and the workspace keeps it. New toolbars still start floating. The lanes and the stacking are VectorCraft's own rules (#1759; `window.toolbar.dock`, `window.toolbar.float`; `toolbars_dock_and_float_by_their_commands`, `docked_toolbars_stack_in_lanes_along_the_window_edges`, `a_title_bar_dropped_on_an_edge_docks_the_toolbar`).
- Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`).
- Hindi (2026-11-06): 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. [Terminology and limitations](localization-hi.md).
- Gujarati (2026-11-14, #6613): `gu` has 4,673 catalog rows, zero/one singular plurals, installed desktop font fallback and support for optional `Gujr` craft-font faces. The original 4,286 rows remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial; the pinned fonts and web startup list have no Gujarati face, so web selection remains unavailable until one is bundled. Current-head validation is recorded in the PR; scores and estimates unchanged.
- Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged.
- Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged.
- Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged.
@@ -1183,6 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Filter button at the right of Search All keeps the rows that are of an object kind ticked under By Object (paths, shapes, text, images, effects, groups, linked files, guides) and in a state ticked under By Layer (locked, hidden), with the rows that hold them; a section with nothing ticked takes every row. The filter combines with the search, the button is lit while a filter is on, and Clear All in its popup turns the filter off (`ui.layersFilter`). The filter lasts the session.
The panel menu's Save Selection submenu has Save Selection…, Update Selection and Edit Selection…, enabled and run as in the Select menu, and lists the document's saved selections: choosing one selects its objects again (`select.recall`).
Deleting rows from the Layers panel (the trash button, rows dragged onto it, the panel menu's Delete Selection, and Delete or Backspace on highlighted rows) asks "Delete this layer and its artwork?" when a layer or sublayer among them holds art, hidden, locked and nested art included, and "Delete the selected items and their artwork?" when several rows are deleted; OK deletes the rows it asked about in one undo step, and Cancel keeps them (`ui.layerDelete`). The help page's shortcut list names the Layers panel as the place where deleting asks for confirmation ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). An empty layer, a layer with only guides, and groups or objects alone are deleted without a question, and `layer.delete` itself does not ask (`panels::layers` tests `deleting_a_layer_that_holds_art_asks_first`, `delete_selection_asks_only_for_layers_with_art`).
The Command Palette lists Window › Layers › Delete Selection once, as the Layers panel's `ui.layerDelete`, so a pick asks the same question; it does not list `layer.delete`, which scripts run without a question. New Brush…, Brush Options…, Symbol Options…, Import SVG Filter… and Convert to Text are also listed once each, as the UI command that opens the dialog or file chooser (`palette` tests `a_wrapped_engine_command_is_listed_once_as_its_ui_command`, `a_pick_of_delete_selection_asks_before_deleting_a_layer_with_art`).
`object.setProps` with `visible: false` or `locked: true` deselects every selected object that is then hidden or locked, the key object included, as the eye and lock columns do.
Ctrl/Cmd+Alt-clicking an eye switches the other layers to Outline and the row's layer to Preview, or switches all layers back to Preview when the others are in Outline (`layer.outlineOthers`, `layer.previewAll`).
Ctrl/Cmd+Alt-clicking New Layer adds a layer directly below the current layer, at its level (`layer.new {below: true}`), and Ctrl/Cmd+Alt-clicking New Sublayer adds a sublayer under the contents of the layer it goes into (`layer.newSublayer {bottom: true}`), each in one undo step. The reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists these clicks for placing a layer below the selected layer and a new sublayer at the bottom of the layer list.
@@ -2355,6 +2359,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged |
| 2026-11-13 | minor | File formats: in a `.ai` or EPS opened from its editing data whose layers don't draw as the page even with the drawn art of every live envelope put back, and are used all the same (trusted, their objects that differ coming in as the page's art, or opened alone), art that differs away from an envelope of type no longer costs it its editable type: it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, and where a clip, mask, group appearance, another envelope, a repeat or a blend holds it (#7141); evidence updated; scores and hours unchanged. |
| 2026-11-13 | minor | Character panel: the Set Font Height Reference dropdown shows an icon of the height each reference measures before its name, in the open list and in the closed field (#6304). Scores and estimates unchanged. |
| 2026-11-13 | minor | Envelopes: a top object's side whose pieces, from corner to corner, are all one cubic curve split at anchors (each within 0.02 pt of that curve's piece) follows that curve's parameter instead of sharing the content out by the pieces' control polygons, so Add Anchor Points on such a side leaves how it shares out the content as it was, and a top object whose `.ai` mesh saves its sides' curves in pieces draws as the file's drawn art (`a_split_curve_keeps_the_top_object_patch`, `a_top_object_side_of_many_arcs_stays_on_them` and three more in `live::tests`, `envelope_top_object_keeps_its_look_when_anchors_are_added`, `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`). Existing top-object envelopes with such sides redraw (#7097). Scores and estimates unchanged. |
+3 -1
View File
@@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. |
| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. |
@@ -435,7 +436,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork | done | #3426; `object.pathfinder.options`, kept in the preferences; defaults (0.01 pt, both off) keep the earlier results; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) |
| Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Panel operations, direct `object.pathfinder.*` commands and `command.batch` steps (Actions playback) set history; `action.batch`, Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) |
| Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; blends, envelopes and warped objects are members as they draw; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: question marks for mixed modes, hit-testing by the area |
| Canvas context menu | done | Right-clicking type opens the canvas menu, with Bullets and Numbering and Spelling. Layer, sublayer, group and object rows in the Layers panel offer Collect for Export (As Single Asset, As Multiple Assets) for the row, or for every highlighted row when it is one of them; artboard rows, library groups and items, the rulers and floating toolbar grips have menus too. Gaps: the Layers row menu has no other commands, guide and blend spine rows have no menu, and other panels have none |
| Canvas context menu | done | Right-clicking type opens the canvas menu, with Bullets and Numbering and Spelling. Artboard rows, library groups and items, the rulers and floating toolbar grips have menus too. Gaps: guide and blend spine rows have no menu, and other panels have none |
| Layers panel row context menu | done | Right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. |
| SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` |
| Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior. On-canvas Appearance and Easing widgets set Shift and Ramp; a blend without a spine shows the Appearance widget alone. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) |