diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 9b3b14456..2a8d48fab 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -99,10 +99,18 @@ macro_rules! style_cmds { concat!($p, ".delete"), concat!("Delete ", $what, " Style"), &["Window", "Type", $menu], - "{name} (text keeps its look)", + "{name} | {names: [name…]} delete styles in one undo step (text keeps its look; never the Normal style)", |s, p| delete(s, p, $kind), true, ), + spec( + concat!($p, ".unused"), + "Select All Unused", + &["Window", "Type", $menu], + "{} → {names: [the styles no text uses, in panel order; never the Normal style]} (the panel highlights them)", + |s, _| unused(s, $kind), + false, + ), ] }; } @@ -291,21 +299,33 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb } } -/// How many runs (char) or paragraphs (para) use `name`. -fn uses(d: &Document, kind: Kind, name: &str) -> usize { - let target = (name != kind.normal()).then_some(name); - let mut n = 0; - d.walk(|node| { - if let NodeKind::Text(t) = &node.kind { - n += match kind { - Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(), - Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(), - }; - } - }); +/// How many runs (char) or paragraphs (para) use each style, by style name (`None`: the Normal +/// style). Type in the art (opacity masks included), symbols, graph designs, pattern tiles and the +/// art of brushes counts. +fn uses(d: &Document, kind: Kind) -> std::collections::HashMap, usize> { + let mut n = std::collections::HashMap::new(); + let mut count = |name: &Option| *n.entry(name.clone()).or_insert(0) += 1; + let brushes = vectorcraft_brush::library(d); + let trees = d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(d.graph_designs.iter().map(|g| &*g.art)); + let trees = trees.chain(d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a))).chain(brushes.iter().flat_map(|b| b.art())); + for tree in trees { + vectorcraft_doc::swatches::walk_with_masks(tree, &mut |node| { + if let NodeKind::Text(t) = &node.kind { + match kind { + Kind::Char => t.runs.iter().for_each(|r| count(&r.style.style_name)), + Kind::Para => (0..t.paragraph_count()).for_each(|i| count(&t.para_at(i).style_name)), + } + } + }); + } n } +/// How many runs (char) or paragraphs (para) use `name` (see [`uses`]). +fn uses_of(uses: &std::collections::HashMap, usize>, kind: Kind, name: &str) -> usize { + uses.get(&(name != kind.normal()).then(|| name.to_string())).copied().unwrap_or(0) +} + /// Update every user of `name` from `old` to `new` attributes (overrides kept). fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map, new: &Map) { let target = (name != kind.normal()).then_some(name.to_string()); @@ -434,7 +454,8 @@ fn list(s: &mut Session, kind: Kind) -> Result { let d = &s.doc()?.doc; let mut names = vec![kind.normal().to_string()]; names.extend(kind.defs(d).iter().map(|s| s.name.clone()).filter(|n| n != kind.normal())); - let styles: Vec = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses(d, kind, n) })).collect(); + let uses = uses(d, kind); + let styles: Vec = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses_of(&uses, kind, n) })).collect(); Ok(json!({ "styles": styles })) } @@ -616,16 +637,26 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { let c = kind.cmd("delete"); - let name = name_param(p, kind, "delete")?.to_string(); - if name == kind.normal() || !kind.defs(&s.doc()?.doc).iter().any(|s| s.name == name) { + let names: Vec = match p.get("names") { + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::>>()) + .ok_or_else(|| bad(&c, "`names` must be an array of style names"))?, + None => vec![name_param(p, kind, "delete")?.to_string()], + }; + if names.is_empty() { + return Err(bad(&c, "missing `name`")); + } + let defs = kind.defs(&s.doc()?.doc); + if let Some(name) = names.iter().find(|n| *n == kind.normal() || !defs.iter().any(|s| s.name == **n)) { return Err(bad(&c, format!("`{name}` can't be deleted"))); } - s.edit("Delete Style", |d, _| { - kind.defs_mut(d).retain(|s| s.name != name); + s.edit(if names.len() > 1 { "Delete Styles" } else { "Delete Style" }, |d, _| { + kind.defs_mut(d).retain(|s| !names.contains(&s.name)); for id in all_text(d) { let Some(t) = text_mut(d, id) else { continue }; let fix = |n: &mut Option| { - if n.as_deref() == Some(name.as_str()) { + if n.as_ref().is_some_and(|n| names.contains(n)) { *n = None; } }; @@ -639,5 +670,14 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { ok() } +/// The styles no text uses (see [`uses`]), in the order the panel lists them, without the Normal +/// style. +fn unused(s: &mut Session, kind: Kind) -> Result { + let d = &s.doc()?.doc; + let uses = uses(d, kind); + let names: Vec<&str> = kind.defs(d).iter().map(|s| s.name.as_str()).filter(|n| *n != kind.normal() && uses_of(&uses, kind, n) == 0).collect(); + Ok(json!({ "names": names })) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index e322e814a..2cdcaf77a 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -122,6 +122,100 @@ fn new_styles_trim_their_names_as_a_rename_does() { assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); } +fn unused(s: &mut Session, p: &str) -> Vec { + serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap() +} + +fn listed(s: &mut Session, p: &str) -> Vec { + let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); + list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).map(str::to_string).collect() +} + +/// Select All Unused: `charStyle.unused` and `paraStyle.unused` list, in panel order and without an +/// undo step, the styles no text uses, never the Normal style. Type in a symbol, an opacity mask, a +/// graph design, a pattern tile or a brush's art counts, as type in the layers does. +#[test] +fn unused_styles_are_those_no_text_uses_wherever_the_text_is() { + let (mut s, id) = session_with_text("Used"); + let names = ["Spare", "Used", "Symbol", "Mask", "Design", "Pattern", "Brush", "Also spare"]; + for p in ["charStyle", "paraStyle"] { + for n in names { + s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": {}})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "Used", "id": id})).unwrap(); + } + // Type in style `name`, selected. + let styled = |s: &mut Session, name: &str| { + let t = s.execute("text.create", &json!({"x": 10, "y": 150, "text": name})).unwrap()["id"].as_u64().unwrap(); + for p in ["charStyle", "paraStyle"] { + s.execute(&format!("{p}.apply"), &json!({"name": name, "id": t})).unwrap(); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + NodeId(t) + }; + let clear = |s: &mut Session, id: NodeId| { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + }; + styled(&mut s, "Symbol"); + let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); + let masked = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = styled(&mut s, "Mask"); + s.execute("select.set", &json!({"ids": [masked, mask.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let design = styled(&mut s, "Design"); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + clear(&mut s, design); + let tile = styled(&mut s, "Pattern"); + s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); + if s.doc().unwrap().doc.node(tile).is_some() { + clear(&mut s, tile); + } + let art = styled(&mut s, "Brush"); + s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap(); + clear(&mut s, art); + let undo = s.doc().unwrap().history.undo.len(); + for p in ["charStyle", "paraStyle"] { + assert_eq!(unused(&mut s, p), ["Spare", "Also spare"], "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let list = s.execute("charStyle.list", &json!({})).unwrap(); + let uses = |name: &str| list["styles"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap()["uses"].as_u64(); + for name in ["Symbol", "Mask", "Design", "Pattern", "Brush"] { + assert_eq!(uses(name), Some(1), "{name}"); + } +} + +/// `charStyle.delete` and `paraStyle.delete` take `names` and delete them in one undo step; text +/// that used them keeps its look. A name that is not a style, the Normal style, or `names` that is +/// not an array of strings deletes none. +#[test] +fn several_styles_are_deleted_in_one_step() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"size": 40})).unwrap(); + for p in ["charStyle", "paraStyle"] { + for n in ["A", "B", "C"] { + s.execute(&format!("{p}.new"), &json!({"name": n})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "B", "id": id})).unwrap(); + let before = listed(&mut s, p); + let undo = s.doc().unwrap().history.undo.len(); + let normal = before[0].clone(); + for bad in [json!(["A", "Nope"]), json!(["A", normal]), json!([]), json!(["A", 7]), json!("A")] { + assert!(s.execute(&format!("{p}.delete"), &json!({"names": bad})).is_err(), "{p} {bad}"); + } + assert_eq!((listed(&mut s, p), s.doc().unwrap().history.undo.len()), (before.clone(), undo), "{p}"); + s.execute(&format!("{p}.delete"), &json!({"names": ["A", "B"]})).unwrap(); + assert_eq!(listed(&mut s, p), [normal.as_str(), "C"], "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Styles", "{p}"); + assert_eq!(listed(&mut s, p), before, "{p}"); + } + s.execute("charStyle.delete", &json!({"names": ["B"]})).unwrap(); + assert_eq!((runs(&s, id)[0].style.style_name.as_deref(), runs(&s, id)[0].style.size), (None, 40.0)); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a75e6068a..3a715a29d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1380,6 +1380,7 @@ font-technology Variable Променливи Delete Guides Изтриване на водачи Delete Slice Изтриване на отрязък Delete Style Изтриване на стил + Delete Styles Изтриване на стилове Delete Variables Изтриване на променливи Delete View Изтриване на изглед Done Editing Pattern Край на редактирането на мостра diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dfd5d5a2a..9ecbd9ec5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1042,6 +1042,7 @@ Delete Spine Point Suprimeix el punt de l’espina Delete Stop Suprimeix la parada Delete Style Suprimeix l’estil + Delete Styles Suprimeix els estils Delete Swatch Suprimeix la mostra Delete Symbol Suprimeix el símbol Delete Tab Suprimeix la tabulació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f4262a357..5e5e459ea 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1593,6 +1593,7 @@ lighting Light Světlo Delete Guides Odstranit vodítka Delete Slice Odstranit řez Delete Style Odstranit styl + Delete Styles Odstranit styly Delete Variables Odstranit proměnné Delete View Odstranit zobrazení Done Editing Pattern Dokončit úpravy vzorku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 381031efa..52eac9d03 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5330,6 +5330,7 @@ swatch Bright Violet Kraftig violet Delete Guides Slet hjælpelinjer Delete Slice Slet udsnit Delete Style Slet typografi + Delete Styles Slet typografier Delete Variables Slet variabler Delete View Slet visning Done Editing Pattern Redigering af mønster udført diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3c24d2825..74bfb8ad1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1406,6 +1406,7 @@ file-filter Templates Vorlagen Delete Guides Hilfslinien löschen Delete Slice Slice löschen Delete Style Format löschen + Delete Styles Formate löschen Delete Variables Variablen löschen Delete View Ansicht löschen Done Editing Pattern Musterbearbeitung beenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1bd9af486..fc31f0850 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1344,6 +1344,7 @@ font-technology Variable Μεταβλητές Delete Guides Διαγραφή οδηγών Delete Slice Διαγραφή τομής Delete Style Διαγραφή στυλ + Delete Styles Διαγραφή στυλ Delete Variables Διαγραφή μεταβλητών Delete View Διαγραφή προβολής Done Editing Pattern Ολοκλήρωση επεξεργασίας μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 314a86f6a..0214dd9c6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1418,6 +1418,7 @@ font-technology Variable Variables Delete Guides Eliminar guías Delete Slice Eliminar sector Delete Style Eliminar estilo + Delete Styles Eliminar estilos Delete Variables Eliminar variables Delete View Eliminar vista Done Editing Pattern Terminar edición de motivo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 52e389442..b9be325c6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1567,6 +1567,7 @@ lighting Light Valo Delete Guides Poista apuviivat Delete Slice Poista siivu Delete Style Poista tyyli + Delete Styles Poista tyylit Delete Variables Poista muuttujat Delete View Poista näkymä Done Editing Pattern Lopeta kuvion muokkaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 90c1fc733..ad57031ed 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1417,6 +1417,7 @@ font-technology Variable Variables Delete Guides Supprimer les repères Delete Slice Supprimer la tranche Delete Style Supprimer le style + Delete Styles Supprimer les styles Delete Variables Supprimer les variables Delete View Supprimer la vue Done Editing Pattern Terminer la modification du motif diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a9fce7ddb..38e97c940 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1806,6 +1806,7 @@ file-filter Templates Sablonok Delete Guides Segédvonalak törlése Delete Slice Szelet törlése Delete Style Stílus törlése + Delete Styles Stílusok törlése Delete Variables Változók törlése Delete View Nézet törlése Done Editing Pattern Mintázatszerkesztés befejezése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index fc6f7bef6..8d9fb5cfb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5330,6 +5330,7 @@ swatch Bright Violet Violet cerah Delete Guides Hapus semua panduan Delete Slice Hapus slice Delete Style Hapus gaya + Delete Styles Hapus gaya Delete Variables Hapus variabel Delete View Hapus tampilan Done Editing Pattern Selesai mengedit pola diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d13f80295..0baee70c0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1430,6 +1430,7 @@ file-filter Templates Modelli Delete Guides Elimina guide Delete Slice Elimina sezione Delete Style Elimina stile + Delete Styles Elimina stili Delete Variables Elimina variabili Delete View Elimina vista Done Editing Pattern Termina modifica motivo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 831a6ab67..583b7ae42 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1713,6 +1713,7 @@ font-technology Variable バリアブル Delete Guides ガイドを削除 Delete Slice スライスを削除 Delete Style スタイルを削除 + Delete Styles スタイルを削除 Delete Variables 変数を削除 Delete View 表示を削除 Done Editing Pattern パターン編集を完了 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 29a0bd974..5cd927a0f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5185,6 +5185,7 @@ imageTrace Outline კონტური Delete Light სინათლის წაშლა Delete Slice ნაჭრის წაშლა Delete Style სტილის წაშლა + Delete Styles სტილების წაშლა Delete Variables ცვლადების წაშლა Delete View ხედის წაშლა Done Editing Pattern პატერნის რედაქტირების დასრულება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b5a1958fa..53bc38d4a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1701,6 +1701,7 @@ font-technology Variable 가변 Delete Guides 안내선 삭제 Delete Slice 분할 삭제 Delete Style 스타일 삭제 + Delete Styles 스타일 삭제 Delete Variables 변수 삭제 Delete View 보기 삭제 Done Editing Pattern 패턴 편집 완료 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index de6dcd584..535a5d6be 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5341,6 +5341,7 @@ swatch Bright Violet Felviolet Delete Guides Hulplijnen verwijderen Delete Slice Slice verwijderen Delete Style Stijl verwijderen + Delete Styles Stijlen verwijderen Delete Variables Variabelen verwijderen Delete View Weergave verwijderen Done Editing Pattern Patroon bewerken voltooid diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2bd746e1d..99f362151 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1508,6 +1508,7 @@ lighting Light Światło Delete Guides Usuń linie pomocnicze Delete Slice Usuń plasterek Delete Style Usuń styl + Delete Styles Usuń style Delete Variables Usuń zmienne Delete View Usuń widok Done Editing Pattern Zakończ edycję wzorka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2e4aed474..dc816dd38 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1224,6 +1224,7 @@ Delete Guides Excluir Guias Delete Slice Excluir Fatia Delete Style Excluir Estilo + Delete Styles Excluir Estilos Delete Variables Excluir Variáveis Delete View Excluir Exibição Done Editing Pattern Concluir Edição de Padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2583931c4..79d270ba7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1428,6 +1428,7 @@ file-filter Templates Шаблоны Delete Guides Удалить направляющие Delete Slice Удалить фрагмент Delete Style Удалить стиль + Delete Styles Удалить стили Delete Variables Удалить переменные Delete View Удалить вид Done Editing Pattern Завершить редактирование узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2c33ce33f..24bded7f4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1382,6 +1382,7 @@ font-technology Variable Variabla Delete Guides Radera stödlinjer Delete Slice Radera skiva Delete Style Radera stil + Delete Styles Radera stilar Delete Variables Radera variabler Delete View Radera vy Done Editing Pattern Avsluta mönsterredigering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 82c531a05..b94cb137e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5147,6 +5147,7 @@ theme Appearance Görünüm Delete Guides Kılavuzları Sil Delete Slice Dilimi Sil Delete Style Stili Sil + Delete Styles Stilleri Sil Delete Variables Değişkenleri Sil Delete View Görünümü Sil Done Editing Pattern Desen Düzenlemeyi Bitir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b83c2bc94..4882ba94c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1424,6 +1424,7 @@ file-filter Templates Шаблони Delete Guides Видалити напрямні Delete Slice Видалити фрагмент Delete Style Видалити стиль + Delete Styles Видалити стилі Delete Variables Видалити змінні Delete View Видалити вигляд Done Editing Pattern Завершити редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 533d6996d..311008cab 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1637,6 +1637,7 @@ file-filter Templates Mẫu Delete Guides Xóa các đường căn Delete Slice Xóa lát cắt Delete Style Xóa kiểu + Delete Styles Xóa kiểu Delete Variables Xóa biến số Delete View Xóa chế độ xem Done Editing Pattern Hoàn tất chỉnh sửa họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 88e4d4c99..a8eb60373 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1362,6 +1362,7 @@ file-filter Templates 模板 Delete Guides 删除参考线 Delete Slice 删除切片 Delete Style 删除样式 + Delete Styles 删除样式 Delete Variables 删除变量 Delete View 删除视图 Done Editing Pattern 完成图案编辑 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2c42fbd63..3c19fbd98 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1395,6 +1395,7 @@ file-filter Templates 範本 Delete Guides 刪除參考線 Delete Slice 刪除切片 Delete Style 刪除樣式 + Delete Styles 刪除樣式 Delete Variables 刪除變數 Delete View 刪除檢視 Done Editing Pattern 完成圖樣編輯 diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 049ebac48..7c65cf93d 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -5,11 +5,15 @@ //! "+" when the text overrides some of its attributes. New makes a style from the selected text; //! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`). -use egui::{Sense, Ui, vec2}; +use std::cell::RefCell; + +use egui::{Color32, Sense, Ui, vec2}; use serde_json::{Map, Value, json}; +use vectorcraft_doc::Document; use vectorcraft_engine::cmd::textstyles; use super::character::{end_typing, text_editing, text_style}; +use super::memo::Memo; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -35,28 +39,93 @@ impl Kind { fn key(self) -> &'static str { if self == Kind::Char { "char-style-sel" } else { "para-style-sel" } } + /// Panel state holding the styles highlighted together, with the style [`Kind::key`] held + /// when they were highlighted. + fn many_key(self) -> &'static str { + if self == Kind::Char { "char-style-many" } else { "para-style-many" } + } + /// Panel state holding the style a Shift-click extends the highlight from. + fn anchor_key(self) -> &'static str { + if self == Kind::Char { "char-style-anchor" } else { "para-style-anchor" } + } /// The style row the panel scrolls into view the next time it draws. fn scroll_key(self) -> &'static str { if self == Kind::Char { "char-style-scroll" } else { "para-style-scroll" } } + /// Panel state holding the `uid` of the document the highlight was made in. + fn document_key(self) -> &'static str { + if self == Kind::Char { "char-style-document" } else { "para-style-document" } + } } -/// Highlights a style the panel just made and scrolls its row into view the next time it draws. -fn highlight_made(ctx: &egui::Context, kind: Kind, name: Option) { - set_pstate(ctx, kind.scroll_key(), name.clone()); - set_pstate(ctx, kind.key(), name); +/// The styles highlighted in the panel, in the order they were picked, among the styles `listed`: +/// the styles a Shift-click, a Cmd/Ctrl-click or Select All Unused highlighted together while +/// [`Kind::key`] still holds the style it held then, else the one style in [`Kind::key`]. +fn highlighted(app: &VectorcraftApp, ctx: &egui::Context, kind: Kind, listed: &[&str]) -> Vec { + let document = app.session.active().map(|st| st.uid); + if pstate::>(ctx, kind.document_key()) != document { + set_pstate(ctx, kind.document_key(), document); + set_highlighted(ctx, kind, vec![]); + set_pstate(ctx, kind.anchor_key(), String::new()); + set_pstate::>(ctx, kind.scroll_key(), None); + } + let one: Option = pstate(ctx, kind.key()); + let (with, many): (Option, Vec) = pstate(ctx, kind.many_key()); + let mut names = if with == one && !many.is_empty() { many } else { one.into_iter().collect() }; + names.retain(|n| listed.contains(&n.as_str())); + names +} + +/// Highlights `names` in the panel: [`Kind::key`] holds the first of them. +fn set_highlighted(ctx: &egui::Context, kind: Kind, names: Vec) { + let one = names.first().cloned(); + set_pstate(ctx, kind.key(), one.clone()); + set_pstate(ctx, kind.many_key(), (one, names)); +} + +/// Highlights the styles the panel just made and scrolls the first one's row into view the next +/// time it draws. +fn highlight_made(ctx: &egui::Context, kind: Kind, names: Vec) { + set_pstate(ctx, kind.scroll_key(), names.first().cloned()); + set_highlighted(ctx, kind, names); +} + +/// Duplicates the styles `names` (`charStyle.duplicate`, `paraStyle.duplicate`) in one undo step +/// and highlights the copies. +fn duplicate(app: &mut VectorcraftApp, ctx: &egui::Context, kind: Kind, names: &[String]) { + let p = kind.prefix(); + let made = if let [name] = names { + app.run(&format!("{p}.duplicate"), json!({ "name": name })).map(|r| vec![r]) + } else { + let commands: Vec = names.iter().map(|n| json!({ "command": format!("{p}.duplicate"), "params": { "name": n } })).collect(); + let label = if kind == Kind::Char { "New Character Style" } else { "New Paragraph Style" }; + app.run("command.batch", json!({ "label": label, "commands": commands })).map(|r| r["results"].as_array().cloned().unwrap_or_default()) + }; + if let Ok(made) = made { + highlight_made(ctx, kind, made.iter().filter_map(|r| r["name"].as_str().map(str::to_string)).collect()); + } } /// A row dragged in the list: the style's kind and name. Dropped on Create New Style it is /// duplicated (`charStyle.duplicate`, `paraStyle.duplicate`); dropped on Delete it is deleted -/// (`charStyle.delete`, `paraStyle.delete`) unless it is Normal. Each panel's buttons accept only rows of -/// their own kind. +/// (`charStyle.delete`, `paraStyle.delete`) unless it is Normal. A highlighted row carries every +/// highlighted style with it. Each panel's buttons accept only rows of their own kind. #[derive(Clone)] struct RowDrag { kind: Kind, name: String, } +/// The styles a row dragged from the list carries: every highlighted style (`sel`) in the order +/// the panel lists them when the row is one of them, else the row's style alone. +fn dragged(sel: &[String], listed: &[&str], name: &str) -> Vec { + if sel.iter().any(|s| s == name) { + listed.iter().filter(|n| sel.iter().any(|s| s == *n)).map(|n| n.to_string()).collect() + } else { + vec![name.to_string()] + } +} + /// The style row of `kind` released on `button` this frame, if any; `button` is outlined while one /// is held over it. `take`: whether a row with that name may be dropped there. fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str) -> bool) -> Option { @@ -65,9 +134,18 @@ fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str) button.dnd_release_payload::().map(|_| held.name.clone()) } -/// (name, attrs, uses) for every style, Normal first. +/// (name, attrs, uses) for every style, Normal first, listed once per document version. fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map, u64)> { - let v = app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default(); + thread_local! { + static MEMO: RefCell<[Memo; 2]> = RefCell::new([Memo::default(), Memo::default()]); + } + let Some((doc, revision)) = app.session.active().map(|st| (st.doc.clone(), st.revision)) else { return vec![] }; + let list = || { + #[cfg(test)] + LISTED.with(|n| n.set(n.get() + 1)); + app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default() + }; + let v = MEMO.with(|m| m.borrow_mut()[kind as usize].get(&doc, revision, list)); v["styles"] .as_array() .map(|a| { @@ -84,6 +162,12 @@ fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map = const { std::cell::Cell::new(0) }; +} + /// The selection's style name (Normal when none) and every one of its attributes, written as a /// style holds them, if text is selected. fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option, Map)> { @@ -116,14 +200,33 @@ pub(crate) fn open_new(app: &mut VectorcraftApp, kind: Kind) -> Result (Option, bool) { + let fill = if is_cur { + Some(t.row_selected) + } else if in_highlight || hovered { + Some(t.hover) + } else { + None + }; + (fill, is_cur && in_highlight) +} + +/// The style list and its New and Delete buttons. Shift-click highlights every style from the +/// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both +/// leave the text as it is. Delete deletes every highlighted style but the Normal style. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let t = Tokens::get(ui.ctx()); let all = styles(app, kind); let cur = current(app, kind); let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); - let mut clicked: Option<(String, bool)> = None; - let mut sel: Option = pstate(ui.ctx(), kind.key()); + let mut clicked: Option<(String, egui::Modifiers)> = None; + let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); + // A highlighted style that is no longer listed (renamed in Style Options, deleted by a command + // or undone) is no longer highlighted. + let mut sel = highlighted(app, ui.ctx(), kind, &listed); let scroll_to: Option = pstate(ui.ctx(), kind.scroll_key()); widgets::list_box(ui, |ui| { for (name, attrs, _) in &all { @@ -137,10 +240,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if scroll_to.as_deref() == Some(name.as_str()) { ui.scroll_to_rect(r, None); } - if is_cur || sel.as_deref() == Some(name.as_str()) { - ui.painter().rect_filled(r, 0.0, if is_cur { t.row_selected } else { t.hover }); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); + let (fill, outline) = row_look(is_cur, sel.contains(name), resp.hovered(), &t); + if let Some(fill) = fill { + ui.painter().rect_filled(r, 0.0, fill); + } + if outline { + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } // The built-in "[Normal … Style]" (listed first) is translated where painted; other names // are user data, brackets or not. @@ -148,7 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let label = if overridden { format!("{shown}+") } else { shown.to_string() }; ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); if resp.clicked() { - clicked = Some((name.clone(), ui.input(|i| i.modifiers.alt))); + clicked = Some((name.clone(), ui.input(|i| i.modifiers))); } } }); @@ -156,20 +261,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { set_pstate::>(ui.ctx(), kind.scroll_key(), None); } let has_text = cur.is_some(); - if let Some((name, clear)) = clicked { - sel = Some(name.clone()); - if has_text { - let p = target(app, kind, json!({ "name": name, "clearOverrides": clear })); + if let Some((name, m)) = clicked { + sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); + // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. + if has_text && !(m.shift || m.command) { + let p = target(app, kind, json!({ "name": name, "clearOverrides": m.alt })); app.run(&format!("{}.apply", kind.prefix()), p).ok(); } } - // A highlighted style that is no longer listed (renamed in Style Options, deleted by a command - // or undone) is no longer highlighted. - if sel.as_ref().is_some_and(|s| !all.iter().any(|(n, ..)| n == s)) { - sel = None; - } - set_pstate(ui.ctx(), kind.key(), sel.clone()); - let deletable = sel.as_ref().is_some_and(|s| *s != normal); + set_highlighted(ui.ctx(), kind, sel.clone()); + let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect(); + let deletable = !doomed.is_empty(); widgets::bottom_bar(ui, |ui| { ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); let new = widgets::icon_button_enabled( @@ -181,15 +283,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { 24.0, ); if let Some(name) = row_dropped(ui, &new, kind, |_| true) { - if let Ok(r) = app.run(&format!("{}.duplicate", kind.prefix()), json!({ "name": name })) { - highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string)); - } + duplicate(app, ui.ctx(), kind, &dragged(&sel, &listed, &name)); } else if new.clicked() { end_typing(app); if alt_held(ui) { app.run(if kind == Kind::Char { "ui.newCharStyle" } else { "ui.newParaStyle" }, json!({})).ok(); } else if let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) { - highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string)); + highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string).into_iter().collect()); } } let delete = widgets::icon_button_enabled( @@ -200,23 +300,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { deletable, 24.0, ); - if let Some(name) = row_dropped(ui, &delete, kind, |n| n != normal) { - if app.run(&format!("{}.delete", kind.prefix()), json!({ "name": name })).is_ok() && sel.as_ref() == Some(&name) { - set_pstate::>(ui.ctx(), kind.key(), None); + let drop_names = |n: &str| dragged(&sel, &listed, n).into_iter().filter(|s| *s != normal).collect::>(); + if let Some(name) = row_dropped(ui, &delete, kind, |n| !drop_names(n).is_empty()) { + // Deleting the highlighted styles clears the highlight; a row dropped from outside it + // leaves it as it is. + if app.run(&format!("{}.delete", kind.prefix()), json!({ "names": drop_names(&name) })).is_ok() && sel.contains(&name) { + set_highlighted(ui.ctx(), kind, vec![]); } - } else if delete.clicked() - && let Some(n) = &sel - && app.run(&format!("{}.delete", kind.prefix()), json!({ "name": n })).is_ok() - { - set_pstate::>(ui.ctx(), kind.key(), None); + } else if delete.clicked() && app.run(&format!("{}.delete", kind.prefix()), json!({ "names": doomed })).is_ok() { + set_highlighted(ui.ctx(), kind, vec![]); } }); } +/// The panel menu. Select All Unused highlights the styles no text uses (`charStyle.unused`, +/// `paraStyle.unused`), Duplicate copies every highlighted style and highlights the copies, and +/// Delete deletes every highlighted style but the Normal style. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { - let sel: Option = pstate(ui.ctx(), kind.key()); + let all = styles(app, kind); + let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); + let many = highlighted(app, ui.ctx(), kind, &listed); + // The items for one style act on the highlighted style while it is the only one. + let sel: Option = if many.len() == 1 { many.first().cloned() } else { None }; let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" }; let user = sel.as_ref().is_some_and(|s| s != normal); + let doomed: Vec<&String> = many.iter().filter(|s| *s != normal).collect(); let has_text = current(app, kind).is_some() || selection_len(app) > 0; let p = kind.prefix(); if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { @@ -224,17 +332,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { end_typing(app); app.run(&format!("{p}.new"), target(app, kind, json!({}))).ok(); } - if menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), sel.is_some(), false) - && let Some(n) = &sel - && let Ok(r) = app.run(&format!("{p}.duplicate"), json!({ "name": n })) - { - highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string)); + if menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), !many.is_empty(), false) { + let names: Vec = listed.iter().filter(|n| many.iter().any(|s| s == *n)).map(|n| n.to_string()).collect(); + duplicate(app, ui.ctx(), kind, &names); } - if menu_item(ui, &crate::i18n::fmt(tl!("Delete {kind} Style"), &[("kind", kind.what_label())]), user, false) - && let Some(n) = &sel - && app.run(&format!("{p}.delete"), json!({ "name": n })).is_ok() + if menu_item(ui, &crate::i18n::fmt(tl!("Delete {kind} Style"), &[("kind", kind.what_label())]), !doomed.is_empty(), false) + && app.run(&format!("{p}.delete"), json!({ "names": doomed })).is_ok() { - set_pstate::>(ui.ctx(), kind.key(), None); + set_highlighted(ui.ctx(), kind, vec![]); + } + if menu_item(ui, tl!("Select All Unused"), app.session.active().is_some(), false) + && let Ok(r) = app.run(&format!("{p}.unused"), json!({})) + { + set_highlighted(ui.ctx(), kind, serde_json::from_value(r["names"].clone()).unwrap_or_default()); } ui.separator(); if menu_item(ui, tl!("Clear Overrides"), sel.is_some() && has_text, false) diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index a5e9c31a6..465baeac2 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -3,6 +3,16 @@ use vectorcraft_doc::{NodeId, NodeKind}; use super::super::tests_appearance::{click, frame_events, frame_raw, text_rect}; +#[test] +fn current_style_highlight_has_a_separate_indicator() { + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + assert_eq!(row_look(true, false, false, &t), (Some(t.row_selected), false)); + assert_eq!(row_look(true, true, false, &t), (Some(t.row_selected), true)); + assert_eq!(row_look(false, true, false, &t), (Some(t.hover), false)); + assert_eq!(row_look(false, false, false, &t), (None, false)); +} + #[test] fn panels_draw_and_apply_headless() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -309,7 +319,7 @@ fn a_style_made_from_the_panel_is_scrolled_into_view() { } else { // The New button's path: the style it made, highlighted with `highlight_made`. let name = app.run(&format!("{}.new", kind.prefix()), json!({})).unwrap()["name"].as_str().unwrap_or_default().to_string(); - highlight_made(&ctx, kind, Some(name.clone())); + highlight_made(&ctx, kind, vec![name.clone()]); name }; assert_eq!(pstate::>(&ctx, kind.key()).as_deref(), Some(made.as_str()), "{what}: the new style is highlighted"); @@ -493,6 +503,243 @@ fn character_style_range_apply_from_panel_control() { assert_eq!(app.session.doc().unwrap().doc, after); } +/// A document with selected type and both kinds' styles A, B, C and D, the type using B. +fn several_app() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 18})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + for n in ["A", "B", "C", "D"] { + app.run(&format!("{}.new", kind.prefix()), json!({"name": n, "attrs": {}})).unwrap(); + } + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "B"})).unwrap(); + } + (app, id) +} + +/// Click the text `label` that `draw` paints, with `modifiers` held. +fn click_text_held( + ctx: &egui::Context, + app: &mut VectorcraftApp, + label: &str, + modifiers: egui::Modifiers, + draw: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) { + click_where(ctx, app, |texts| text_rect(texts, label).center(), modifiers, draw); +} + +/// Click where `at` finds in the texts `draw` paints, with `modifiers` held, over three frames +/// (hover, press, release) after two frames that lay them out. +fn click_where( + ctx: &egui::Context, + app: &mut VectorcraftApp, + at: impl FnOnce(&[(String, egui::Rect)]) -> egui::Pos2, + modifiers: egui::Modifiers, + mut draw: impl FnMut(&mut VectorcraftApp, &mut egui::Ui), +) { + let held = egui::Event::ModifiersChanged(modifiers); + frame_events(ctx, app, vec![held.clone()], &mut draw); + let pos = at(&frame_events(ctx, app, vec![held.clone()], &mut draw)); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_events(ctx, app, vec![held.clone(), e], &mut draw); + } + frame_events(ctx, app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], &mut draw); +} + +/// The styles `kind`'s panel highlights. +fn highlighted_now(app: &mut VectorcraftApp, ctx: &egui::Context, kind: Kind) -> Vec { + let all = styles(app, kind); + highlighted(app, ctx, kind, &all.iter().map(|s| s.0.as_str()).collect::>()) +} + +#[test] +fn switching_documents_clears_highlighted_styles_with_matching_names() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + click_text_held(&ctx, &mut app, "A", egui::Modifiers::NONE, rows); + click_text_held(&ctx, &mut app, "B", egui::Modifiers::COMMAND, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "B"]); + + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + for name in ["A", "B"] { + app.run(&format!("{}.new", kind.prefix()), json!({"name": name, "attrs": {}})).unwrap(); + } + assert!(highlighted_now(&mut app, &ctx, kind).is_empty(), "{} Styles: the new document has no highlighted styles", kind.what()); + assert_eq!(pstate::(&ctx, kind.anchor_key()), ""); + assert_eq!(pstate::>(&ctx, kind.key()), None); + assert_eq!(pstate::<(Option, Vec)>(&ctx, kind.many_key()).1, Vec::::new()); + } +} + +/// In both panels Shift-click highlights every style from the last one clicked without Shift +/// through the one clicked, and Cmd/Ctrl-click adds or removes one; neither applies a style to the +/// selected type. +/// Delete then deletes every highlighted style in one undo step, and the type keeps its look. +#[test] +fn shift_and_cmd_clicks_highlight_several_styles_that_delete_deletes_at_once() { + use egui::Modifiers; + for kind in [Kind::Char, Kind::Para] { + let (mut app, id) = several_app(); + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + click_text_held(&ctx, &mut app, "A", Modifiers::NONE, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A"]); + let doc = app.session.doc().unwrap().doc.clone(); + click_text_held(&ctx, &mut app, "C", Modifiers::SHIFT, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "B", "C"], "{} Styles: Shift-click", kind.what()); + click_text_held(&ctx, &mut app, "B", Modifiers::COMMAND, rows); + click_text_held(&ctx, &mut app, "D", Modifiers::COMMAND, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{} Styles: Cmd/Ctrl-click", kind.what()); + assert_eq!(app.session.doc().unwrap().doc, doc, "Shift and Cmd/Ctrl clicks apply nothing"); + let undo = app.session.doc().unwrap().history.undo.len(); + // The footer's Delete button is centered 12 points left of the panel's right edge. + let button = std::cell::Cell::new(egui::Pos2::ZERO); + let footer = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + let right = ui.max_rect().right(); + show(app, ui, kind); + button.set(egui::pos2(right - 12.0, ui.min_rect().bottom() - 12.0)); + }; + click_where(&ctx, &mut app, |_| button.get(), Modifiers::NONE, footer); + let left: Vec = styles(&mut app, kind).into_iter().skip(1).map(|s| s.0).collect(); + assert_eq!(left, ["B"], "{} Styles: Delete deletes A, C and D", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::::new()); + assert_eq!(text_runs(&app, id)[0].1, 18.0, "the type keeps its look"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, doc); + } +} + +/// The panel menu's Select All Unused highlights, in both panels, the styles no text uses, never +/// the Normal style; the menu's Delete then deletes them in one undo step. +#[test] +fn select_all_unused_highlights_the_unused_styles_for_delete() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind); + click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{} Styles", kind.what()); + let undo = app.session.doc().unwrap().history.undo.len(); + let label = format!(" Delete {} Style", kind.what()); + click_text_held(&ctx, &mut app, &label, egui::Modifiers::NONE, items); + let left: Vec = styles(&mut app, kind).into_iter().map(|s| s.0).collect(); + assert_eq!(left[1..], ["B"], "{} Styles: the unused styles are deleted", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::::new()); + // With every style used, nothing is highlighted. + click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items); + assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::::new()); + } +} + +/// With several styles highlighted, the panel menu's Duplicate copies each of them in one undo +/// step, highlights the copies and scrolls the first copy's row into view. +#[test] +fn panel_menu_duplicate_copies_every_highlighted_style() { + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind); + click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{what} Styles"); + let before = styles(&mut app, kind); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, &format!(" Duplicate {what} Style"), egui::Modifiers::NONE, items); + let names: Vec = styles(&mut app, kind).into_iter().skip(1).map(|s| s.0).collect(); + assert_eq!(names, ["A", "B", "C", "D", "A copy", "C copy", "D copy"], "{what} Styles: A, C and D are duplicated"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{what} Styles: one undo step"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A copy", "C copy", "D copy"], "{what} Styles: the copies are highlighted"); + assert_eq!(pstate::>(&ctx, kind.scroll_key()).as_deref(), Some("A copy"), "{what} Styles: the first copy scrolls into view"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(styles(&mut app, kind), before); + } +} + +/// The panel and its menu list the styles, and count their uses, once per document version: more +/// frames of an unchanged document list nothing again, and an edit lists them again. +#[test] +fn the_styles_are_listed_once_per_document_version() { + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + show(app, ui, kind); + menu(app, ui, kind); + }; + let listed = || LISTED.with(|n| n.get()); + frame_events(&ctx, &mut app, vec![], draw); + let first = listed(); + for _ in 0..4 { + frame_events(&ctx, &mut app, vec![], draw); + } + assert_eq!(listed(), first, "{what} Styles: unchanged frames list nothing again"); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "E", "attrs": {}})).unwrap(); + frame_events(&ctx, &mut app, vec![], draw); + assert_eq!(listed(), first + 1, "{what} Styles: an edit lists the styles again"); + assert_eq!(styles(&mut app, kind).last().map(|s| s.0.as_str()), Some("E"), "{what} Styles: the new style is listed"); + } +} + +/// A row dragged from the highlighted styles carries all of them: dropped on Create New Style it +/// duplicates each in one undo step and highlights the copies, and dropped on Delete it deletes each +/// but the Normal style in one undo step and clears the highlight. A row from outside the highlight +/// carries only its own style; dropped on Delete it leaves the highlight as it is. +#[test] +fn dragging_a_highlighted_style_carries_every_highlighted_style() { + use egui::Modifiers; + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + let names = |app: &mut VectorcraftApp| styles(app, kind).into_iter().skip(1).map(|s| s.0).collect::>(); + let undo_len = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, "A", Modifiers::NONE, rows); + click_text_held(&ctx, &mut app, "C", Modifiers::COMMAND, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C"], "{what} Styles"); + + let undo = undo_len(&app); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, "C", 40.0, false), "{what} Styles: New is outlined"); + assert_eq!(names(&mut app), ["A", "B", "C", "D", "A copy", "C copy"], "{what} Styles: A and C are duplicated"); + assert_eq!(undo_len(&app), undo + 1, "{what} Styles: one undo step for the copies"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A copy", "C copy"], "{what} Styles: the copies are highlighted"); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, "B", 40.0, false), "{what} Styles: New is outlined"); + assert_eq!(names(&mut app), ["A", "B", "C", "D", "A copy", "C copy", "B copy"], "{what} Styles: only B is duplicated"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), ["B copy"], "{what} Styles: the copy of B is highlighted"); + + let normal = super::super::label_or_name(&styles(&mut app, kind)[0].0, true).to_string(); + click_text_held(&ctx, &mut app, "A copy", Modifiers::NONE, rows); + click_text_held(&ctx, &mut app, "C copy", Modifiers::COMMAND, rows); + click_text_held(&ctx, &mut app, &normal, Modifiers::COMMAND, rows); + assert_eq!(highlighted_now(&mut app, &ctx, kind).len(), 3, "{what} Styles: A copy, C copy and Normal are highlighted"); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, "D", 12.0, false), "{what} Styles: Delete is outlined"); + assert_eq!(names(&mut app), ["A", "B", "C", "A copy", "C copy", "B copy"], "{what} Styles: only D is deleted"); + assert_eq!(highlighted_now(&mut app, &ctx, kind).len(), 3, "{what} Styles: the highlight stays"); + let undo = undo_len(&app); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, &normal, 12.0, false), "{what} Styles: Delete is outlined for the highlight"); + assert_eq!(names(&mut app), ["A", "B", "C", "B copy"], "{what} Styles: A copy and C copy are deleted, Normal stays"); + assert_eq!(undo_len(&app), undo + 1, "{what} Styles: one undo step for the deletes"); + assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::::new(), "{what} Styles: the highlight is cleared"); + } +} + /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing /// settlement here: the capture dispatch itself must close the live typing interaction. fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { diff --git a/docs/mcp.md b/docs/mcp.md index e3e7f8b1a..e92143b41 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5020,7 +5020,11 @@ spaceBefore, spaceAfter, hyphenate, mojikumi, burasagari, direction, leadingMode touches (a caret, `start == end`, touches its paragraph). Without a range they change every paragraph of the targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style that range of characters. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the -paragraph at `start`; `paraStyle.list` counts paragraphs as uses. A Return the Type tool types (or `text.editRange` +paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in +the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and +`paraStyle.unused` (Select All Unused in the Character Styles and Paragraph Styles panel menus) list the styles with no +uses, in panel order and never the Normal style, and the panel highlights them; `charStyle.delete` and +`paraStyle.delete` take `{names}` to delete several styles in one undo step. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as the story re-flows (a paragraph split between frames has them in both). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d50a7a2de..e6e5bd60d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -948,6 +948,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. + - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document.