From 7aa25698d2f493c5f8c44e8902880a02222f39cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 01:28:20 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/brushsym.rs | 59 ++++++++++++++- crates/engine/src/cmd/swatch.rs | 2 +- crates/engine/src/tests_brushsym.rs | 56 ++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tests.rs | 16 ++++ crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/brushes.rs | 106 ++++++++++++++++++++++++++- docs/mcp.md | 5 ++ docs/target-app-parity.md | 1 + 30 files changed, 285 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8287d087f..28f853f09 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -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 { 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 { 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 { + 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 { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index e78dea380..e52e64849 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -351,7 +351,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { /// 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(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { +pub(super) fn anchor(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string) } diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index 82bbefbd8..b8e85dc4e 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -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 { + 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. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3588e692d..068435e26 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 няма часовник за поддържане на ритъм diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fbf4634be..5766a2018 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 649f44dfe..d6de95b14 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8dbaad832..f379e6c18 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index df991d872..dedcf1a5d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 δεν υπάρχει ρολόι για τον παλμό διατήρησης σύνδεσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a17b216e5..a153fd473 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 54b6da79b..def49808d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 21d33d135..3b03e2c86 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c0449477d..25dccc606 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b7790cb6a..f43186e39 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bd6a3afba..cdc95fd44 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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à diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7b8e1b4b7..207b1139b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 アートに境界がありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index aec92e005..c1fad3c6f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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 პულსის შესანარჩუნებელი საათი არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ebe3fe101..8257d6faf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 아트웍에 경계가 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d744129b8..fa5e55dd6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aa846c0d4..f46b75f29 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8e899c16f..fb65c0f5e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee51cd2c6..d70bc2228 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 нет часов для поддержания сигнала diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index db5a1f4e4..24bea6a6a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fab0cb10b..1a981ddba 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 30c81f989..86f844da7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 немає годинника для підтримки сигналу diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a6a04b6de..dae6c9400 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d68c02bf9..c0e84c8cb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 图稿没有边界 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f7b197e7e..a0200aba9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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… 移動… diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index b51c7fe18..7b43daf60 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -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, +) { + let Some(drag) = resp.dnd_hover_payload::() 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::(); + 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::().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 = None; + let mut moved: Option = 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::(&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 = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + let (list, _) = brushes(&mut app); + let order: Vec = 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 { + 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 { 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); + } } diff --git a/docs/mcp.md b/docs/mcp.md index e24a33202..03a3e5a04 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 799851d82..cf5d5efef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.