Text styles: Select All Unused highlights the unused styles, and Delete deletes them all

Squashed from #4486 onto main.
This commit is contained in:
dotCooCoo
2026-10-30 21:16:33 +07:00
committed by bflatastic
parent 6049ddbcd8
commit 3b724a0a79
31 changed files with 591 additions and 70 deletions
+59 -19
View File
@@ -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<Option<String>, usize> {
let mut n = std::collections::HashMap::new();
let mut count = |name: &Option<String>| *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<Option<String>, 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<String, Value>, new: &Map<String, Value>) {
let target = (name != kind.normal()).then_some(name.to_string());
@@ -434,7 +454,8 @@ fn list(s: &mut Session, kind: Kind) -> Result<Value> {
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<Value> = 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<Value> = 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<Value> {
fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
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<String> = match p.get("names") {
Some(v) => v
.as_array()
.and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
.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<String>| {
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<Value> {
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<Value> {
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;
+94
View File
@@ -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<String> {
serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap()
}
fn listed(s: &mut Session, p: &str) -> Vec<String> {
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.
+1
View File
@@ -1380,6 +1380,7 @@ font-technology Variable Променливи
Delete Guides Изтриване на водачи
Delete Slice Изтриване на отрязък
Delete Style Изтриване на стил
Delete Styles Изтриване на стилове
Delete Variables Изтриване на променливи
Delete View Изтриване на изглед
Done Editing Pattern Край на редактирането на мостра
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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ó
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1344,6 +1344,7 @@ font-technology Variable Μεταβλητές
Delete Guides Διαγραφή οδηγών
Delete Slice Διαγραφή τομής
Delete Style Διαγραφή στυλ
Delete Styles Διαγραφή στυλ
Delete Variables Διαγραφή μεταβλητών
Delete View Διαγραφή προβολής
Done Editing Pattern Ολοκλήρωση επεξεργασίας μοτίβου
Can't render this file because it contains an unexpected character in line 164 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2169 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1713,6 +1713,7 @@ font-technology Variable バリアブル
Delete Guides ガイドを削除
Delete Slice スライスを削除
Delete Style スタイルを削除
Delete Styles スタイルを削除
Delete Variables 変数を削除
Delete View 表示を削除
Done Editing Pattern パターン編集を完了
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5185,6 +5185,7 @@ imageTrace Outline კონტური
Delete Light სინათლის წაშლა
Delete Slice ნაჭრის წაშლა
Delete Style სტილის წაშლა
Delete Styles სტილების წაშლა
Delete Variables ცვლადების წაშლა
Delete View ხედის წაშლა
Done Editing Pattern პატერნის რედაქტირების დასრულება
Can't render this file because it is too large.
+1
View File
@@ -1701,6 +1701,7 @@ font-technology Variable 가변
Delete Guides 안내선 삭제
Delete Slice 분할 삭제
Delete Style 스타일 삭제
Delete Styles 스타일 삭제
Delete Variables 변수 삭제
Delete View 보기 삭제
Done Editing Pattern 패턴 편집 완료
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1428,6 +1428,7 @@ file-filter Templates Шаблоны
Delete Guides Удалить направляющие
Delete Slice Удалить фрагмент
Delete Style Удалить стиль
Delete Styles Удалить стили
Delete Variables Удалить переменные
Delete View Удалить вид
Done Editing Pattern Завершить редактирование узора
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -1424,6 +1424,7 @@ file-filter Templates Шаблони
Delete Guides Видалити напрямні
Delete Slice Видалити фрагмент
Delete Style Видалити стиль
Delete Styles Видалити стилі
Delete Variables Видалити змінні
Delete View Видалити вигляд
Done Editing Pattern Завершити редагування візерунка
Can't render this file because it contains an unexpected character in line 178 and column 5.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 200 and column 5.
+1
View File
@@ -1362,6 +1362,7 @@ file-filter Templates 模板
Delete Guides 删除参考线
Delete Slice 删除切片
Delete Style 删除样式
Delete Styles 删除样式
Delete Variables 删除变量
Delete View 删除视图
Done Editing Pattern 完成图案编辑
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1395,6 +1395,7 @@ file-filter Templates 範本
Delete Guides 刪除參考線
Delete Slice 刪除切片
Delete Style 刪除樣式
Delete Styles 刪除樣式
Delete Variables 刪除變數
Delete View 刪除檢視
Done Editing Pattern 完成圖樣編輯
Can't render this file because it contains an unexpected character in line 6 and column 47.
+159 -49
View File
@@ -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<String>) {
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<String> {
let document = app.session.active().map(|st| st.uid);
if pstate::<Option<u64>>(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::<Option<String>>(ctx, kind.scroll_key(), None);
}
let one: Option<String> = pstate(ctx, kind.key());
let (with, many): (Option<String>, Vec<String>) = 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<String>) {
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<String>) {
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<Value> = 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<String> {
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<String> {
@@ -65,9 +134,18 @@ fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str)
button.dnd_release_payload::<RowDrag>().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<String, Value>, u64)> {
let v = app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default();
thread_local! {
static MEMO: RefCell<[Memo<Document, Value>; 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<String, Valu
.unwrap_or_default()
}
#[cfg(test)]
thread_local! {
/// How many times [`styles`] has run `charStyle.list` or `paraStyle.list` on this thread.
static LISTED: std::cell::Cell<u64> = 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<String>, Map<String, Value>)> {
@@ -116,14 +200,33 @@ pub(crate) fn open_new(app: &mut VectorcraftApp, kind: Kind) -> Result<Value, St
crate::dialogs::new_text_style::open(app, kind == Kind::Para, t)
}
/// A style row's fill, and whether it gets an accent outline: the current style's row is filled
/// with `row_selected` and outlined when it is also highlighted, so Cmd/Ctrl-clicking it shows.
fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Option<Color32>, 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<String> = 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<String> = 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::<Option<String>>(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::<Option<String>>(ui.ctx(), kind.key(), None);
let drop_names = |n: &str| dragged(&sel, &listed, n).into_iter().filter(|s| *s != normal).collect::<Vec<_>>();
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::<Option<String>>(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<String> = 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<String> = 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<String> = 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::<Option<String>>(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)
+248 -1
View File
@@ -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::<Option<String>>(&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<String> {
let all = styles(app, kind);
highlighted(app, ctx, kind, &all.iter().map(|s| s.0.as_str()).collect::<Vec<_>>())
}
#[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::<String>(&ctx, kind.anchor_key()), "");
assert_eq!(pstate::<Option<String>>(&ctx, kind.key()), None);
assert_eq!(pstate::<(Option<String>, Vec<String>)>(&ctx, kind.many_key()).1, Vec::<String>::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<String> = 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::<String>::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<String> = 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::<String>::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::<String>::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<String> = 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::<Option<String>>(&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::<Vec<_>>();
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::<String>::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<egui::Event>) {
+5 -1
View File
@@ -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).
+1
View File
@@ -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.