Brushes: dragging a brush reorders it within its type

The reference app reorders a brush that is dragged to a new place in
its Brushes panel, among the brushes of its type only. VectorCraft's
Brushes panel could not reorder brushes, and no command moved one.

brush.move {name | names, to?, type?} moves brushes to index to among
the brushes of their type, in brush.list order, as one undo step. A
move that keeps the order records no step. Brushes of different types,
or a type other than theirs, are refused with "brushes move only within
their type". In the Brushes panel, a brush dragged onto another brush
of its type goes before or after it by the half of the tile or row
under the pointer, and a bar marks the slot while it is held there. A
brush dropped on a brush of another type stays where it is, and the
status bar shows the refusal.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 23:14:33 +07:00
committed by bflatastic
parent b4fef5dc98
commit 7aa25698d2
30 changed files with 285 additions and 6 deletions
+58 -1
View File
@@ -17,7 +17,7 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::names_param;
use super::swatch::{anchor, names_param};
use super::*;
use crate::EngineError;
@@ -76,6 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_delete
),
cmd!(
"brush.move",
"Move Brush",
[],
None,
"{name? | names?: [brush names of one type, in order], to?: index among the brushes of that type in brush.list order (they go before the brush of that type now there that isn't moving; default: after the last one), type?: calligraphic|scatter|art|pattern|bristle (the type they go into; default: theirs)} reorder brushes within their type, as one undo step (none when the order stays as it was); brushes of different types, or a type that isn't theirs, are refused (brushes move only within their type) → {moved}",
has_doc,
brush_move
),
cmd!(
"brush.duplicate",
"Duplicate Brush",
@@ -466,6 +475,54 @@ fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their
/// type in library order: before the first brush of that type at or after `to` that isn't moving,
/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type`
/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step.
fn brush_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.move";
const WITHIN: &str = "brushes move only within their type";
let names = names_param(p, C)?;
check_numbers(p, &["to"], C)?;
let to = match p.get("to").filter(|v| !v.is_null()) {
Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?),
None => None,
};
let old = brush::library(&s.doc()?.doc);
let mut lib = old.clone();
// `names_param` refuses an empty list, so the loop sets `kind`.
let mut kind = "";
for name in &names {
let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
if !kind.is_empty() && kind != b.kind.type_id() {
return Err(bad(C, WITHIN));
}
kind = b.kind.type_id();
}
if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) {
return Err(bad(C, WITHIN));
}
let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect();
let before = anchor(&same, to, &names, |b| &b.name);
let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len());
let mut items = vec![];
for name in &names {
if let Some(i) = lib.iter().position(|b| &b.name == name) {
items.push(lib.remove(i));
}
}
let at = match before {
Some(a) => lib.iter().position(|b| b.name == a),
None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1),
};
let at = at.unwrap_or(first).min(lib.len());
lib.splice(at..at, items);
if lib != old {
save_library(s, "Move Brush", lib, &[])?;
}
Ok(json!({ "moved": names.len() }))
}
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
/// without `names`, names the copy.
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -351,7 +351,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
/// The name of the item that items moving to index `to` of `list` go before: the first one at or
/// after `to` that isn't moving (`None`: the end).
fn anchor<T>(list: &[T], to: Option<usize>, moving: &[String], name: impl Fn(&T) -> &str) -> Option<String> {
pub(super) fn anchor<T>(list: &[T], to: Option<usize>, moving: &[String], name: impl Fn(&T) -> &str) -> Option<String> {
list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string)
}
+56
View File
@@ -140,6 +140,62 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// `brush.move`, which the Brushes panel runs when a brush is dragged, reorders brushes among those of
/// their type in one undo step and refuses to move one into another type.
#[test]
fn brush_move_reorders_brushes_within_their_type() {
let mut s = session();
let names = |s: &mut Session, ty: &str| -> Vec<String> {
let list = s.execute("brush.list", &json!({})).unwrap();
let of_type = list["brushes"].as_array().unwrap().iter().filter(|b| ty.is_empty() || b["type"] == ty);
of_type.map(|b| b["name"].as_str().unwrap().to_string()).collect()
};
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = names(&mut s, "");
assert_eq!(names(&mut s, "calligraphic"), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]);
let steps = undo(&s);
// To index 0 of the calligraphic brushes: before the first; the other types stay where they are.
let r = s.execute("brush.move", &json!({"name": "10 pt. Oval", "to": 0})).unwrap();
assert_eq!(r["moved"], json!(1));
assert_eq!(names(&mut s, "")[..3], ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]);
assert_eq!(names(&mut s, "")[3..], before[3..]);
assert_eq!(undo(&s), steps + 1);
// Several go in the order given, as one step.
s.execute("brush.move", &json!({"names": ["6 pt. Flat", "3 pt. Round"], "to": 0, "type": "calligraphic"})).unwrap();
assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "3 pt. Round", "10 pt. Oval"]);
assert_eq!(undo(&s), steps + 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(names(&mut s, ""), before);
// Into the scatter brushes, with a scatter brush, or with a `to` that isn't a whole number: an
// error, and nothing moves.
for (p, why) in [
(json!({"name": "3 pt. Round", "type": "scatter"}), "brushes move only within their type"),
(json!({"names": ["3 pt. Round", "Dots"], "to": 0}), "brushes move only within their type"),
(json!({"name": "3 pt. Round", "to": "1"}), "`to` must be a number, not \"1\""),
(json!({"name": "3 pt. Round", "to": 1.5}), "`to` must be a whole number"),
(json!({"name": "Nope", "to": 0}), "no brush named `Nope`"),
] {
let r = s.execute("brush.move", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == why), "{p}: {r:?}");
}
assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps));
// A move that keeps the order records no step.
s.execute("brush.move", &json!({"name": "3 pt. Round", "to": 0})).unwrap();
assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps));
// Without `to`, or past the last one: after the last brush of the type, even one listed after
// brushes of other types.
s.execute("brush.new", &json!({"type": "calligraphic", "name": "Quill"})).unwrap();
s.execute("brush.move", &json!({"name": "3 pt. Round"})).unwrap();
assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "10 pt. Oval", "Quill", "3 pt. Round"]);
assert_eq!(names(&mut s, "").last().map(String::as_str), Some("3 pt. Round"));
s.execute("brush.move", &json!({"name": "6 pt. Flat", "to": 99})).unwrap();
assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "Quill", "3 pt. Round", "6 pt. Flat"]);
s.execute("brush.move", &json!({"name": "Leaves", "to": 1})).unwrap();
assert_eq!(names(&mut s, "scatter"), ["Dots", "Leaves", "Confetti"]);
}
/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options
/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name
/// gives the name the command gives when there is none.
+2
View File
@@ -1826,6 +1826,7 @@
Move Slices Преместване на резени
Move Spine Point Преместване на точка от оста
Reshape Spine Segment Промяна на формата на сегмент от оста
Move Brush Преместване на четка
Move Swatch Преместване на образец
Move Up Преместване нагоре
Move… Преместване…
@@ -3918,6 +3919,7 @@ axis Both И двете
@msg no bounds няма граници
@msg no brush named `{name}` няма четка с име `{name}`
@msg no brush selected няма избрана четка
@msg brushes move only within their type четките се преместват само в рамките на своя тип
@msg no catalog няма каталог
@msg no changes since the last save няма промени от последното запазване
@msg no clock to keep a heartbeat by няма часовник за поддържане на ритъм
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3558,6 +3558,7 @@ theme Appearance Motiv
Delete Swatch Odstranit položku vzorníku
Duplicate Swatch Duplikovat položku vzorníku
Merge Swatches Sloučit položky vzorníku
Move Brush Přesunout štětec
Move Swatch Přesunout položku vzorníku
New Color Group Nová skupina barev
New Swatch Nová položka vzorníku
@@ -4308,6 +4309,7 @@ imageTrace Outline Obrys
@msg no bounds žádné hranice
@msg no brush named `{name}` štětec s názvem `{name}` neexistuje
@msg no brush selected není vybrán žádný štětec
@msg brushes move only within their type štětce lze přesouvat jen v rámci jejich typu
@msg no catalog chybí katalog
@msg no changes since the last save od posledního uložení nejsou žádné změny
@msg no clock to keep a heartbeat by chybí hodiny, podle kterých by šlo udržovat signál aktivity
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3309,6 +3309,7 @@ theme Appearance Udseende
Delete Swatch Slet farveprøve
Duplicate Swatch Duplikér farveprøve
Merge Swatches Flet farveprøver
Move Brush Flyt pensel
Move Swatch Flyt farveprøve
New Color Group Ny farvegruppe
New Swatch Ny farveprøve
@@ -4029,6 +4030,7 @@ imageTrace Outline Kontur
@msg no bounds ingen grænser
@msg no brush named `{name}` ingen pensel med navnet `{name}`
@msg no brush selected ingen pensel er markeret
@msg brushes move only within their type pensler kan kun flyttes inden for deres egen type
@msg no catalog intet katalog
@msg no changes since the last save ingen ændringer siden sidste gemning
@msg no clock to keep a heartbeat by intet ur at holde hjerteslag efter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1883,6 +1883,7 @@ file-filter Templates Vorlagen
Move Slices Slices verschieben
Move Spine Point Achsenpunkt verschieben
Reshape Spine Segment Achsensegment umformen
Move Brush Pinsel verschieben
Move Swatch Farbfeld verschieben
Move Up Nach oben verschieben
Move… Verschieben…
@@ -3944,6 +3945,7 @@ theme Appearance Erscheinungsbild
@msg no bounds keine Begrenzung
@msg no brush named `{name}` kein Pinsel namens `{name}`
@msg no brush selected kein Pinsel ausgewählt
@msg brushes move only within their type Pinsel lassen sich nur innerhalb ihrer Art verschieben
@msg this kind of brush has no options dialog yet für diese Pinselart gibt es noch kein Optionsdialogfeld
@msg no catalog kein Katalog
@msg no changes since the last save keine Änderungen seit dem letzten Speichern
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2581,6 +2581,7 @@ theme Appearance Εμφάνιση
Move Slices Μετακίνηση τομών
Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης
Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης
Move Brush Μετακίνηση πινέλου
Move Swatch Μετακίνηση δείγματος χρώματος
Move Up Μετακίνηση επάνω
My Themes Τα θέματά μου
@@ -3974,6 +3975,7 @@ theme Appearance Εμφάνιση
@msg no bounds δεν υπάρχουν όρια
@msg no brush named `{name}` δεν υπάρχει πινέλο με όνομα `{name}`
@msg no brush selected δεν έχει επιλεγεί πινέλο
@msg brushes move only within their type τα πινέλα μετακινούνται μόνο μέσα στον τύπο τους
@msg no catalog δεν υπάρχει κατάλογος
@msg no changes since the last save δεν υπάρχουν αλλαγές μετά την τελευταία αποθήκευση
@msg no clock to keep a heartbeat by δεν υπάρχει ρολόι για τον παλμό διατήρησης σύνδεσης
Can't render this file because it contains an unexpected character in line 2021 and column 19.
+2
View File
@@ -1874,6 +1874,7 @@
Move Slices Mover sectores
Move Spine Point Mover punto del eje
Reshape Spine Segment Remodelar segmento del eje
Move Brush Mover pincel
Move Swatch Mover muestra
Move Up Subir
Move… Mover…
@@ -4006,6 +4007,7 @@ axis Both Ambos
@msg no bounds sin límites
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
@msg no brush selected no hay ningún pincel seleccionado
@msg brushes move only within their type los pinceles solo se pueden mover dentro de su tipo
@msg no catalog no hay catálogo
@msg no changes since the last save no hay cambios desde la última vez que se guardó
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3486,6 +3486,7 @@ theme Appearance Ulkoasu
Delete Swatch Poista värimalli
Duplicate Swatch Monista värimalli
Merge Swatches Yhdistä värimallit
Move Brush Siirrä sivellintä
Move Swatch Siirrä värimallia
New Color Group Uusi väriryhmä
New Swatch Uusi värimalli
@@ -4223,6 +4224,7 @@ imageTrace Outline Ääriviiva
@msg no bounds ei rajoja
@msg no brush named `{name}` ei sivellintä nimeltä `{name}`
@msg no brush selected sivellintä ei ole valittu
@msg brushes move only within their type siveltimiä voi siirtää vain oman tyyppinsä sisällä
@msg no catalog ei luetteloa
@msg no changes since the last save ei muutoksia viimeisen tallennuksen jälkeen
@msg no clock to keep a heartbeat by ei kelloa sykkeen ylläpitoon
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1873,6 +1873,7 @@
Move Slices Déplacer les tranches
Move Spine Point Déplacer le point de la dorsale
Reshape Spine Segment Modeler le segment de la dorsale
Move Brush Déplacer la forme
Move Swatch Déplacer la nuance
Move Up Monter
Move… Déplacer…
@@ -4003,6 +4004,7 @@ axis Both Les deux
@msg no bounds aucune limite
@msg no brush named `{name}` aucune forme nommée `{name}`
@msg no brush selected aucune forme sélectionnée
@msg brushes move only within their type les formes ne se déplacent qu’à l’intérieur de leur type
@msg no catalog aucun catalogue
@msg no changes since the last save aucune modification depuis le dernier enregistrement
@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2366,6 +2366,7 @@ file-filter Templates Sablonok
Move Rows Sorok áthelyezése
Move Slices Szeletek áthelyezése
Move Spine Point Gerincpont áthelyezése
Move Brush Ecset áthelyezése
Move Swatch Színminta áthelyezése
Move to Group Áthelyezés csoportba
Move Up Feljebb
@@ -4643,6 +4644,7 @@ imageTrace Outline Körvonal
@msg no bounds nincsenek határok
@msg no brush named `{name}` nincs ilyen nevű ecset: `{name}`
@msg no brush selected nincs kijelölt ecset
@msg brushes move only within their type az ecsetek csak a saját típusukon belül helyezhetők át
@msg no catalog nincs katalógus
@msg no changes since the last save a legutóbbi mentés óta nincs módosítás
@msg no clock to keep a heartbeat by nincs óra az életjel időzítéséhez
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3309,6 +3309,7 @@ theme Appearance Tampilan
Delete Swatch Hapus sampel
Duplicate Swatch Duplikatkan sampel
Merge Swatches Gabungkan sampel
Move Brush Pindahkan kuas
Move Swatch Pindahkan sampel
New Color Group Grup warna baru
New Swatch Sampel baru
@@ -4029,6 +4030,7 @@ imageTrace Outline Kontur
@msg no bounds tidak ada batas
@msg no brush named `{name}` tidak ada kuas bernama `{name}`
@msg no brush selected tidak ada kuas yang dipilih
@msg brushes move only within their type kuas hanya dapat dipindahkan di dalam jenisnya sendiri
@msg no catalog tidak ada katalog
@msg no changes since the last save tidak ada perubahan sejak penyimpanan terakhir
@msg no clock to keep a heartbeat by tidak ada jam untuk menjaga detak
Can't render this file because it contains an unexpected character in line 3782 and column 19.
+2
View File
@@ -1901,6 +1901,7 @@ file-filter Templates Modelli
Move Slices Sposta sezioni
Move Spine Point Sposta punto dorsale
Reshape Spine Segment Rimodella segmento della dorsale
Move Brush Sposta pennello
Move Swatch Sposta campione
Move Up Sposta su
Move… Sposta…
@@ -4046,6 +4047,7 @@ axis Both Entrambi
@msg no bounds nessun limite
@msg no brush named `{name}` nessun pennello denominato `{name}`
@msg no brush selected nessun pennello selezionato
@msg brushes move only within their type i pennelli si possono spostare solo all'interno del loro tipo
@msg no catalog nessun catalogo
@msg no changes since the last save nessuna modifica dall'ultimo salvataggio
@msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2023,6 +2023,7 @@
Move Slices スライスを移動
Move Spine Point スパインポイントを移動
Reshape Spine Segment スパインセグメントをシェイプ変形
Move Brush ブラシを移動
Move Swatch スウォッチを移動
Move Up 上へ移動
Move in Perspective 遠近で移動
@@ -3862,6 +3863,7 @@ swatch Bright Violet ビビッドバイオレット
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
@msg no brush selected ブラシが選択されていません
@msg brushes move only within their type ブラシは同じ種類の中でのみ移動できます
@msg no symbol instances selected シンボルインスタンスが選択されていません
@msg select a symbol instance シンボルインスタンスを選択してください
@msg the art has no bounds アートに境界がありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3113,6 +3113,7 @@ theme Appearance იერსახე
Delete Swatch ფერის ნიმუშის წაშლა
Duplicate Swatch ფერის ნიმუშის დუბლირება
Merge Swatches ფერის ნიმუშების შერწყმა
Move Brush ფუნჯის გადატანა
Move Swatch ფერის ნიმუშის გადატანა
New Color Group ფერების ახალი ჯგუფი
New Swatch ფერის ახალი ნიმუში
@@ -3787,6 +3788,7 @@ effect Regular ჩვეულებრივი
@msg no bounds საზღვრები არ არის
@msg no brush named `{name}` ფუნჯი სახელით `{name}` არ არის
@msg no brush selected ფუნჯი მონიშნული არ არის
@msg brushes move only within their type ფუნჯების გადატანა მხოლოდ საკუთარი ტიპის ფარგლებში შეიძლება
@msg no catalog კატალოგი არ არის
@msg no changes since the last save ბოლო შენახვის შემდეგ ცვლილებები არ არის
@msg no clock to keep a heartbeat by პულსის შესანარჩუნებელი საათი არ არის
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2018,6 +2018,7 @@
Move Slices 슬라이스 이동
Move Spine Point 스파인 포인트 이동
Reshape Spine Segment 스파인 세그먼트 모양 변경
Move Brush 브러시 이동
Move Swatch 견본 이동
Move Up 위로 이동
Move in Perspective 원근 이동
@@ -3794,6 +3795,7 @@ theme Appearance 외관
@msg Expand: no brush strokes selected 확장: 선택한 브러시 선이 없습니다
@msg no brush named `{name}` `{name}` 브러시가 없습니다
@msg no brush selected 선택한 브러시가 없습니다
@msg brushes move only within their type 브러시는 같은 유형 안에서만 이동할 수 있습니다
@msg no symbol instances selected 선택한 심볼 인스턴스가 없습니다
@msg select a symbol instance 심볼 인스턴스를 선택하세요
@msg the art has no bounds 아트웍에 경계가 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3323,6 +3323,7 @@ theme Appearance Vormgeving
Delete Swatch Kleurstaal verwijderen
Duplicate Swatch Kleurstaal dupliceren
Merge Swatches Kleurstalen samenvoegen
Move Brush Kwast verplaatsen
Move Swatch Kleurstaal verplaatsen
New Color Group Nieuwe kleurgroep
New Swatch Nieuw kleurstaal
@@ -4041,6 +4042,7 @@ imageTrace Outline Contour
@msg no bounds geen grenzen
@msg no brush named `{name}` geen penseel met de naam `{name}`
@msg no brush selected geen penseel geselecteerd
@msg brushes move only within their type penselen kunnen alleen binnen hun eigen type worden verplaatst
@msg no catalog geen catalogus
@msg no changes since the last save geen wijzigingen sinds de laatste keer opslaan
@msg no clock to keep a heartbeat by geen klok om een hartslag bij te houden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3416,6 +3416,7 @@ theme Appearance Wygląd
Delete Swatch Usuń próbkę
Duplicate Swatch Powiel próbkę
Merge Swatches Scal próbki
Move Brush Przesuń pędzel
Move Swatch Przesuń próbkę
New Color Group Nowa grupa kolorów
New Swatch Nowa próbka
@@ -4130,6 +4131,7 @@ imageTrace Outline Kontur
@msg no bounds brak granic
@msg no brush named `{name}` brak pędzla o nazwie `{name}`
@msg no brush selected nie zaznaczono pędzla
@msg brushes move only within their type pędzle można przesuwać tylko w obrębie ich typu
@msg no catalog brak katalogu
@msg no changes since the last save brak zmian od ostatniego zapisu
@msg no clock to keep a heartbeat by brak zegara do podtrzymywania sygnału aktywności
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1572,6 +1572,7 @@
Move Slices Mover Fatias
Move Spine Point Mover Ponto de Coluna
Reshape Spine Segment Remodelar Segmento da Coluna
Move Brush Mover Pincel
Move Swatch Mover Amostra
Move Up Mover para Cima
Move in Perspective Mover em Perspectiva
@@ -3586,6 +3587,7 @@ axis Both Ambos
@msg no bounds sem limites
@msg no brush named `{name}` nenhum pincel chamado `{name}`
@msg no brush selected nenhum pincel selecionado
@msg brushes move only within their type os pincéis só podem ser movidos dentro do seu tipo
@msg no catalog sem catálogo
@msg no changes since the last save nenhuma alteração desde o último salvamento
@msg no clock to keep a heartbeat by sem relógio para manter o sinal de atividade
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1899,6 +1899,7 @@ file-filter Templates Шаблоны
Move Slices Переместить фрагменты
Move Spine Point Переместить точку остова
Reshape Spine Segment Изменить форму сегмента остова
Move Brush Переместить кисть
Move Swatch Переместить образец
Move Up Переместить вверх
Move… Переместить…
@@ -4042,6 +4043,7 @@ axis Both Оба
@msg no bounds нет границ
@msg no brush named `{name}` нет кисти с именем `{name}`
@msg no brush selected кисть не выбрана
@msg brushes move only within their type кисти можно перемещать только в пределах их типа
@msg no catalog нет каталога
@msg no changes since the last save нет изменений с момента последнего сохранения
@msg no clock to keep a heartbeat by нет часов для поддержания сигнала
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1826,6 +1826,7 @@
Move Slices Flytta skivor
Move Spine Point Flytta ryggradspunkt
Reshape Spine Segment Omforma ryggradssegment
Move Brush Flytta pensel
Move Swatch Flytta färgruta
Move Up Flytta upp
Move… Flytta…
@@ -3923,6 +3924,7 @@ axis Both Båda
@msg no bounds inga gränser
@msg no brush named `{name}` ingen pensel med namnet `{name}`
@msg no brush selected ingen pensel är markerad
@msg brushes move only within their type penslar kan bara flyttas inom sin egen typ
@msg no catalog ingen katalog
@msg no changes since the last save inga ändringar sedan senaste sparningen
@msg no clock to keep a heartbeat by ingen klocka att styra hjärtslagen efter
Can't render this file because it contains an unexpected character in line 5 and column 25.
+16
View File
@@ -1696,6 +1696,22 @@ fn align_reflect_and_shear_param_errors_are_translated() {
}
}
/// The error of `brush.move` for a brush dragged onto a brush of another type in the Brushes panel,
/// whose text the scan above doesn't read, is translated whole in each language of
/// [`COMPLETE_MESSAGES`] and in Bulgarian, Danish, Indonesian and Dutch.
#[test]
fn brush_move_param_error_is_translated() {
use serde_json::json;
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({})).unwrap();
let e = s.execute("brush.move", &json!({"name": "3 pt. Round", "type": "scatter"})).unwrap_err();
let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") };
for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) {
let shown = message(Lang::from_code(code).unwrap(), &e.to_string());
assert!(!shown.contains(msg.as_str()) && !shown.contains("invalid parameters") && shown.contains("`brush.move`"), "{code}: {shown}");
}
}
/// The warning that a data set with no value for a bound variable gives, which the status bar
/// shows and the scan above doesn't read, is translated in each language of [`COMPLETE_MESSAGES`],
/// with the data set's name kept.
+2
View File
@@ -1895,6 +1895,7 @@ file-filter Templates Шаблони
Move Slices Перемістити фрагменти
Move Spine Point Перемістити точку осі
Reshape Spine Segment Змінити форму сегмента осі
Move Brush Перемістити пензель
Move Swatch Перемістити зразок
Move Up Перемістити вгору
Move… Перемістити…
@@ -4030,6 +4031,7 @@ axis Both Обидві
@msg no bounds немає кордонів
@msg no brush named `{name}` немає пензля з ім’ям `{name}`
@msg no brush selected пензель не вибрано
@msg brushes move only within their type пензлі можна переміщувати лише в межах їхнього типу
@msg no catalog немає каталогу
@msg no changes since the last save немає змін з моменту останнього збереження
@msg no clock to keep a heartbeat by немає годинника для підтримки сигналу
Can't render this file because it contains an unexpected character in line 726 and column 40.
+2
View File
@@ -2176,6 +2176,7 @@ file-filter Templates Mẫu
Move Rows Di chuyển hàng
Move Slices Di chuyển lát cắt
Move Spine Point Di chuyển điểm trục
Move Brush Di chuyển cọ
Move Swatch Di chuyển mẫu màu
Move to Group Di chuyển vào nhóm
Move Up Di chuyển lên
@@ -4290,6 +4291,7 @@ theme Appearance Giao diện
@msg no bounds không có giới hạn
@msg no brush named `{name}` không có cọ tên `{name}`
@msg no brush selected chưa chọn cọ
@msg brushes move only within their type chỉ có thể di chuyển cọ trong cùng loại của nó
@msg no catalog không có danh mục
@msg no changes since the last save không có thay đổi nào kể từ lần lưu cuối
@msg no clock to keep a heartbeat by không có đồng hồ để duy trì nhịp tim
Can't render this file because it contains an unexpected character in line 3982 and column 10.
+2
View File
@@ -1794,6 +1794,7 @@ file-filter Templates 模板
@msg x and y must be finite x 和 y 必须是有限值
@msg t, dx and dy must be finite t、dx 和 dy 必须是有限值
@msg handle must be in or out handle 必须是 in 或 out
Move Brush 移动画笔
Move Swatch 移动色板
Move Up 上移
Move… 移动…
@@ -3619,6 +3620,7 @@ imageTrace Outline 轮廓
@msg Expand: no brush strokes selected 扩展:未选择画笔描边
@msg no brush named `{name}` 没有名为 `{name}` 的画笔
@msg no brush selected 未选择画笔
@msg brushes move only within their type 画笔只能在同一类型内移动
@msg no symbol instances selected 未选择符号实例
@msg select a symbol instance 请选择一个符号实例
@msg the art has no bounds 图稿没有边界
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1622,6 +1622,7 @@ file-filter Templates 範本
@msg Expand: no brush strokes selected 展開:未選取筆刷筆畫
@msg no brush named `{name}` 沒有名為 `{name}` 的筆刷
@msg no brush selected 未選取筆刷
@msg brushes move only within their type 筆刷只能在同一類型內移動
@msg no symbol instances selected 未選取符號實體
@msg select a symbol instance 請選取符號實體
@msg the art has no bounds 圖稿沒有邊界
@@ -2641,6 +2642,7 @@ file-filter Templates 範本
@msg x and y must be finite x 和 y 必須是有限值
@msg t, dx and dy must be finite t、dx 和 dy 必須是有限值
@msg handle must be in or out handle 必須是 in 或 out
Move Brush 移動筆刷
Move Swatch 移動色票
Move Up 上移
Move… 移動…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+102 -4
View File
@@ -122,6 +122,43 @@ fn shown<'a>(list: &'a [(String, String)], hidden: &[String]) -> Vec<&'a str> {
kinds.flat_map(|(ty, _)| list.iter().filter(move |b| b.1 == *ty).map(|b| b.0.as_str())).collect()
}
/// A brush dragged over the tile or row `resp` of brush `name`, the `i`th brush of type `ty`
/// (`list`: rows stack downwards), lands before or after it by the pointer's half; a bar marks the
/// slot when the dragged brush is of type `ty` too (`types`: the (name, type) of every brush).
/// Released there, it sets `moved` to the `brush.move` params, which refuse a brush of another type.
fn reorder_drop(
ui: &Ui,
resp: &egui::Response,
types: &[(String, String)],
(name, ty, i): (&str, &str, usize),
list: bool,
moved: &mut Option<Value>,
) {
let Some(drag) = resp.dnd_hover_payload::<PanelDrag>() else { return };
let PanelDrag::Brush { name: moving, .. } = &*drag else { return };
if moving == name {
// Dropped on itself: it stays where it is.
resp.dnd_release_payload::<PanelDrag>();
return;
}
let r = resp.rect;
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
let after = if list { at.y > r.center().y } else { at.x > r.center().x };
if types.iter().any(|b| &b.0 == moving && b.1 == ty) {
let accent = egui::Stroke::new(2.0, Tokens::get(ui.ctx()).accent);
if list {
let y = if after { r.bottom() } else { r.top() };
ui.painter().line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], accent);
} else {
let x = if after { r.right() + 1.5 } else { r.left() - 1.5 };
ui.painter().line_segment([egui::pos2(x, r.top()), egui::pos2(x, r.bottom())], accent);
}
}
if resp.dnd_release_payload::<PanelDrag>().is_some() {
*moved = Some(json!({ "name": moving, "to": i + usize::from(after), "type": ty }));
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (list, current) = brushes(app);
@@ -133,6 +170,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
let mut clicked: Option<(String, egui::Modifiers)> = None;
let mut options: Option<String> = None;
let mut moved: Option<Value> = None;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
@@ -190,13 +228,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
};
if list_view {
for (name, _) in group {
pick(row(ui, name, vec2(ui.available_width(), 26.0), true), name);
for (i, (name, _)) in group.into_iter().enumerate() {
let resp = row(ui, name, vec2(ui.available_width(), 26.0), true);
reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved);
pick(resp, name);
}
} else {
ui.horizontal_wrapped(|ui| {
for (name, _) in group {
pick(row(ui, name, vec2(64.0, 28.0), false), name);
for (i, (name, _)) in group.into_iter().enumerate() {
let resp = row(ui, name, vec2(64.0, 28.0), false);
reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved);
pick(resp, name);
}
});
}
@@ -211,6 +253,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
{
app.status(e);
}
// A brush dragged within the list moves among the brushes of its type.
if let Some(p) = moved
&& let Err(e) = app.run("brush.move", p)
{
app.status(e);
}
let highlighted = sel_brush.clone().or(current.clone());
match clicked {
// Shift and Cmd/Ctrl only select, as in the Swatches panel: they start from the
@@ -636,4 +684,54 @@ mod tests {
let drag = egui::DragAndDrop::payload::<PanelDrag>(&ctx);
assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}");
}
/// A brush dragged onto the left half of another brush of its type goes before it, with a bar
/// marking the slot while it is held there, as one undo step; dropped on a brush of another
/// type, it stays where it is and the status bar says why.
#[test]
fn a_brush_dragged_onto_another_of_its_type_moves_before_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show);
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect();
let (list, _) = brushes(&mut app);
let order: Vec<String> = shown(&list, &[]).into_iter().map(str::to_string).collect();
assert_eq!(order[..4], ["3 pt. Round", "6 pt. Flat", "10 pt. Oval", "Dots"]);
let accent = Tokens::get(&ctx).accent;
let bars = |out: &egui::FullOutput| -> Vec<f32> {
let lines = out.shapes.iter().filter_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke } if stroke.color == accent => Some(points[0].x),
_ => None,
});
lines.collect()
};
// Press on a tile, drag it to `to`, hold it there for a frame (its output returned) and release.
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2, time: f64| -> egui::FullOutput {
frame_with(&ctx, app, vec![egui::Event::PointerMoved(from), button(from, true)], egui::Modifiers::NONE, time, show);
frame_with(&ctx, app, vec![egui::Event::PointerMoved(from + vec2(20.0, 0.0))], egui::Modifiers::NONE, time + 0.1, show);
frame_with(&ctx, app, vec![egui::Event::PointerMoved(to)], egui::Modifiers::NONE, time + 0.2, show);
let held = frame_with(&ctx, app, vec![], egui::Modifiers::NONE, time + 0.3, show);
frame_with(&ctx, app, vec![button(to, false)], egui::Modifiers::NONE, time + 0.4, show);
held
};
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let steps = undo(&app);
let calligraphic =
|app: &mut VectorcraftApp| -> Vec<String> { brushes(app).0.into_iter().filter(|b| b.1 == "calligraphic").map(|b| b.0).collect() };
// "10 pt. Oval" onto the left half of "3 pt. Round".
let held = drag(&mut app, tiles[2].center(), tiles[0].center() - vec2(16.0, 0.0), 1.0);
assert_eq!(bars(&held), [tiles[0].left() - 1.5]);
assert_eq!(calligraphic(&mut app), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]);
assert_eq!(undo(&app), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(calligraphic(&mut app), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]);
// "3 pt. Round" onto "Dots", a scatter brush: no bar, and nothing moves.
let held = drag(&mut app, tiles[0].center(), tiles[3].center(), 3.0);
assert!(bars(&held).is_empty(), "{:?}", bars(&held));
assert_eq!(brushes(&mut app).0, list);
assert_eq!(undo(&app), steps);
assert!(app.ui.status.contains("brushes move only within their type"), "{}", app.ui.status);
}
}
+5
View File
@@ -446,6 +446,11 @@ in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `i
The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the
names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and
`swatch.sortByKind`.
`brush.move {name | names, to?, type?}` reorders brushes among the brushes of their type, as dragging one onto
another of its type in the Brushes panel does. `to` is an index among the brushes of that type in `brush.list`
order: the brushes go before the first brush of that type at or after `to` that is not moving, and after the last
brush of that type when there is none or `to` is left out. A move that keeps the order records no undo step.
Brushes of different types, or a `type` other than theirs, are refused with "brushes move only within their type".
Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB
colors applied to it (`paint.setFill`/`setStroke` colors, gradient stops and freeform points, `paint.editGradient`
stops, `paint.freeform.addPoint`/`setPoint`, `paint.sampleColor`, `object.mesh.setPointColor`/`addLine`, the
+1
View File
@@ -588,6 +588,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°.
- **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
In the Brushes panel, a brush dragged onto another brush of its type goes before or after it, by the half of the tile or row the pointer is over, and a bar marks the slot while the brush is held there (`brush.move`); the move is one undo step. Brushes move only within their type, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page states ("You can move brushes only within their type"): a brush dropped on a brush of another type stays where it is, and the status bar shows "brushes move only within their type".
In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too; Move opens on the offset of the last drag or Move and with the checkboxes it was last confirmed with, so Enter, Enter repeats a move or a copy at the same spacing, as [an Adobe Community thread](https://community.adobe.com/questions-652/move-tool-771221) describes), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, Keep selected, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open.