From e89f917f5eb3bf6da75707df3c1304207116ecc8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:30:25 -0800 Subject: [PATCH] Panels: long swatch, preset and Add Effect popups scroll inside a short window The gradient swatches menu, the Image Trace button's list of presets, the Appearance panel's swatch picker and the top level of the Add New Effect popup (Appearance panel, Properties) had no length limit, so rows past the bottom of a short window could not be reached. Each now uses menu_scroll. Items, order and commands do not change, and a list that fits does not scroll. --- crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/panels/appearance.rs | 24 +-- crates/ui-egui/src/panels/appearance/tests.rs | 81 ++++++++++ crates/ui-egui/src/panels/gradient.rs | 40 ++--- crates/ui-egui/src/panels/gradient/tests.rs | 19 +++ crates/ui-egui/src/panels/image_trace.rs | 2 +- .../ui-egui/src/panels/image_trace/tests.rs | 25 +++ crates/ui-egui/src/panels/properties/tests.rs | 14 ++ .../ui-egui/src/panels/tests_long_popups.rs | 147 ++++++++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/panels/tests_long_popups.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b91c563f2..41f1901ba 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -661,6 +661,8 @@ mod tests_freeform; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_long_popups; +#[cfg(test)] mod tests_maskview; #[cfg(test)] mod tests_stroke; diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index ff05f1c7e..1cb2ede41 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1332,7 +1332,7 @@ fn swatch_button(app: &mut VectorcraftApp, ui: &mut Ui, r: Rect, paint: &Paint, open_popup(app, ui.ctx(), "color"); } egui::Popup::menu(&resp).open_memory((resp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { - swatch_picker(app, ui, target); + widgets::menu_scroll(ui, |ui| swatch_picker(app, ui, target)); }); } @@ -1608,18 +1608,20 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { None => groups.push((g, vec![(id.clone(), label.to_string())])), } } - for (g, items) in groups { - ui.menu_button(tl!(&g), |ui| { - crate::widgets::menu_scroll(ui, |ui| { - for (id, label) in items { - if ui.button(format!("{label}…")).clicked() { - app.run("effect.dialog", json!({"effect": id})).ok(); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (g, items) in groups { + ui.menu_button(tl!(&g), |ui| { + crate::widgets::menu_scroll(ui, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } } - } + }); }); - }); - } + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 82042a9a3..12fd3155e 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -1,5 +1,86 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll, fx_menu_ends}; use super::*; +/// A selected rectangle, for the fill row's swatch picker and the Add Effect button. +fn app_with_a_selected_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({ "ids": [id] })).unwrap(); + app +} + +/// The swatch of a fill row at the top of the window, with the size of its click target. +fn swatch_button_at_the_top() -> (impl FnMut(&mut VectorcraftApp, &mut Ui), egui::Vec2) { + let row = Rect::from_min_size(pos2(0.0, 8.0), vec2(400.0, 22.0)); + (move |app: &mut VectorcraftApp, ui: &mut Ui| swatch_button(app, ui, row, &Paint::None, Painted::Item(0)), vec2(22.0, 22.0)) +} + +/// One tile of the swatch picker. +const TILE: egui::Vec2 = vec2(15.5, 15.5); + +/// The picker has one tile per swatch: with enough of them it stops inside a short window and +/// scrolls, its rows stay 12 tiles wide, and a click on the last tile paints with the last swatch. +#[test] +fn a_long_swatch_picker_scrolls_inside_a_short_window() { + const HEIGHT: f32 = 200.0; + let mut app = app_with_a_selected_rectangle(); + for n in 0..150 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "color": format!("#{:06x}", n + 1)})).unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let last_swatch = doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|group| &group.swatches)).last().unwrap().paint.clone(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, HEIGHT, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles.len() > 150, "one tile per swatch: {}", tiles.len()); + assert_eq!(tiles[11].top(), tiles[0].top(), "12 tiles in a row"); + assert!(tiles[12].top() > tiles[0].top(), "the 13th tile starts the next row"); + assert!(tiles[tiles.len() - 1].bottom() > HEIGHT, "the picker opens at the top of its list"); + window.wheel_down(&mut app, tiles[0].center()); + let tiles = window.clickable(TILE); + let last = tiles[tiles.len() - 1]; + assert!(last.bottom() <= HEIGHT, "the last tile at {last:?} is inside the window"); + window.click(&mut app, last.center()); + let node = first_subject(&app).expect("the rectangle"); + let (Paint::Solid { color, .. }, Paint::Solid { color: last_color, .. }) = (node.appearance.items[0].paint(), &last_swatch) else { + panic!("solid paints") + }; + assert_eq!(color, last_color); +} + +/// A picker with few swatches fits its tiles and the wheel does not move them. +#[test] +fn a_short_swatch_picker_does_not_scroll() { + let mut app = app_with_a_selected_rectangle(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, 900.0, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles[tiles.len() - 1].bottom() <= 900.0, "every tile is inside the window"); + window.wheel_down(&mut app, tiles[0].center()); + assert_eq!(window.clickable(TILE), tiles, "a list that fits does not scroll"); +} + +/// The Add New Effect button: the third of the bottom bar's 24 pt buttons. +fn fx_button(window: &PopupWindow) -> egui::Pos2 { + window.clickable(vec2(24.0, 24.0))[2].center() +} + +fn draw_bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let node = first_subject(app); + bottom(app, ui, node.as_ref(), Sel::None); +} + +/// The Add New Effect popup has one row per effect group, plug-in groups included. +#[test] +fn the_add_effect_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + assert_last_row_scrolls_into_view(&mut app_with_a_selected_rectangle(), 150.0, draw_bottom_bar, fx_button, &first, &last); + assert_short_list_does_not_scroll(&mut app_with_a_selected_rectangle(), draw_bottom_bar, fx_button, &first, &last); +} + /// Duplicating rows highlights the copies, each right above its original, as the Layers panel /// does: the active row's copy becomes active, the anchor's copy the anchor. #[test] diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 631d38ab7..07744bf1b 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -547,26 +547,28 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon }); let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { - ui.set_min_width(200.0); - let mut chosen = None; - let swatches = gradient_swatches(app); - if swatches.is_empty() { - widgets::dim_label(ui, tl!("No gradient swatches")); - } - for (name, grad) in &swatches { - // The gradient swatch the gradient was applied from is lit. - if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { - chosen = Some(name.clone()); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + let mut chosen = None; + let swatches = gradient_swatches(app); + if swatches.is_empty() { + widgets::dim_label(ui, tl!("No gradient swatches")); } - } - ui.separator(); - if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { - save_to_swatches(app, g); - } - if let Some(name) = chosen { - super::apply_click(app, ui, json!({ "swatch": name })); - ui.close(); - } + for (name, grad) in &swatches { + // The gradient swatch the gradient was applied from is lit. + if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { + chosen = Some(name.clone()); + } + } + ui.separator(); + if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { + save_to_swatches(app, g); + } + if let Some(name) = chosen { + super::apply_click(app, ui, json!({ "swatch": name })); + ui.close(); + } + }); }); } diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 65602079b..e27da3cfd 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -1,8 +1,27 @@ use egui::{Event, Modifiers, PointerButton}; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; +/// The gradient swatches menu has one row per gradient swatch: with enough of them it stops inside a +/// short window and the wheel reaches Save to Swatches. With few of them it does not scroll. +#[test] +fn a_long_gradient_swatch_menu_scrolls_inside_a_short_window() { + let stops = json!([{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); + let paint = current(&app).expect("a gradient fill"); + let short = paint.clone(); + let arrow = |window: &PopupWindow| window.clickable(vec2(14.0, 40.0))[0].center(); + let first = gradient_swatches(&app)[0].0.clone(); + assert_short_list_does_not_scroll(&mut app, move |app, ui| thumbnail(app, ui, &short, true), arrow, &first, "Save to Swatches"); + for n in 0..30 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "gradient": {"stops": stops}})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, move |app, ui| thumbnail(app, ui, &paint, true), arrow, &first, "Save to Swatches"); +} + #[test] fn offsets_and_json() { assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 3c878633f..73a1b8986 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -314,7 +314,7 @@ pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() { trace_with(app, "Default"); } - if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) { + if let Some(p) = egui::Popup::menu(&arrow).show(|ui| widgets::menu_scroll(ui, |ui| preset_list(app, ui, ""))).and_then(|r| r.inner) { trace_with(app, &p); } } diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index d946d4bdc..56de0cdb0 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1,3 +1,4 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; use crate::panels::library_panel; use egui::{Event, PointerButton, Pos2, Rect, vec2}; @@ -41,6 +42,30 @@ fn traced_app_with_ink(ink: u8) -> VectorcraftApp { app } +/// The Tracing Presets arrow at the right end of the Image Trace button. +fn tracing_presets_arrow(window: &PopupWindow) -> Pos2 { + let label = window.text("Image Trace").expect("the Image Trace button"); + Pos2::new(TRACE_BUTTON_W - 4.0, label.center().y) +} + +/// The Image Trace button's list of presets grows with saved presets: with enough of them it stops +/// inside a short window and the wheel reaches the last one. With only the built-in presets it does +/// not scroll. +#[test] +fn a_long_tracing_preset_list_scrolls_inside_a_short_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| trace_button(app, ui, TRACE_BUTTON_W); + let built_in = app.session.execute("imageTrace.presets", &json!({})).unwrap(); + let name = |p: &Value| p["name"].as_str().unwrap().to_string(); + let built_in = built_in["presets"].as_array().unwrap(); + let (first, last) = (name(&built_in[0]), name(&built_in[built_in.len() - 1])); + assert_short_list_does_not_scroll(&mut app, draw, tracing_presets_arrow, &first, &last); + for n in 0..30 { + app.session.execute("imageTrace.presets.save", &json!({"name": format!("Saved {n}"), "preset": "Default"})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, draw, tracing_presets_arrow, &first, "Saved 29"); +} + #[test] fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5e82d51f1..ea1a60095 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -2,6 +2,7 @@ use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::Value; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, fx_menu_ends}; use super::*; /// One frame of the panel with `events` → the texts painted, with their rects. @@ -30,6 +31,19 @@ fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)] frame(app, ctx, vec![b(false)]); } +/// The Appearance section's fx button opens the Add Effect popup, with one row per effect group, +/// plug-in groups included: in a short window the wheel reaches its last row. +#[test] +fn the_fx_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let fx = |window: &PopupWindow| window.text("fx").expect("the fx button").center(); + assert_last_row_scrolls_into_view(&mut app, 190.0, appearance_section, fx, &first, &last); +} + /// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel, /// under its Clip Group heading and before every section, and they select its clipping path or /// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs new file mode 100644 index 000000000..8597ea995 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -0,0 +1,147 @@ +//! A headless window for checking that a panel's popup with no fixed length stays inside the window. + +use egui::{Event, PointerButton, Pos2, Rect, Ui, vec2}; + +use super::VectorcraftApp; + +/// The frames that opening a popup takes. +const OPEN_FRAMES: usize = 8; +/// The frames that finishing a wheel's scrolling takes. +const SCROLL_FRAMES: usize = 40; +/// The window's width, wider than every popup. +const WIDTH: f32 = 500.0; +/// A window tall enough for every short list. +const TALL: f32 = 900.0; + +/// What a window draws each frame. +type Draw<'a> = Box; + +/// A window `height` pt tall with its own clock, drawing `draw` each frame. +pub(super) struct PopupWindow<'a> { + ctx: egui::Context, + time: f64, + height: f32, + draw: Draw<'a>, + texts: Vec<(String, Rect)>, +} + +impl<'a> PopupWindow<'a> { + pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut window = Self { ctx, time: 0.0, height, draw: Box::new(draw), texts: vec![] }; + window.frames(app, 2); + window + } + + /// One frame with `events`. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) { + self.time += 1.0 / 60.0; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, self.height))); + let input = egui::RawInput { time: Some(self.time), screen_rect, events, ..Default::default() }; + let draw = &mut self.draw; + let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + } + + fn frames(&mut self, app: &mut VectorcraftApp, count: usize) { + for _ in 0..count { + self.frame(app, vec![]); + } + } + + /// Where the last frame drew the text `wanted`: the last one drawn, so a popup's row comes + /// before a panel's text of the same name. + pub(super) fn text(&self, wanted: &str) -> Option { + self.texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) + } + + /// The rects of the last frame's clickable widgets `size` across, in the order they were drawn. + pub(super) fn clickable(&self, size: egui::Vec2) -> Vec { + self.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.size() == size) + .map(|w| w.rect) + .collect() + }) + } + + /// A click at `at`, and the frames a popup takes to open. + pub(super) fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + self.frame(app, vec![Event::PointerMoved(at), button(true)]); + self.frame(app, vec![button(false)]); + self.frames(app, OPEN_FRAMES); + } + + /// Turns the wheel far down with the pointer at `over`, and lets the scrolling finish. + pub(super) fn wheel_down(&mut self, app: &mut VectorcraftApp, over: Pos2) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + self.frame(app, vec![Event::PointerMoved(over), wheel]); + self.frames(app, SCROLL_FRAMES); + } +} + +/// The Add Effect popup's first and last rows with no plug-ins: the effect catalog's groups, in order. +pub(super) fn fx_menu_ends() -> (String, String) { + let mut groups: Vec<&str> = vec![]; + let catalog = vectorcraft_effects::effect_catalog(); + for group in catalog.iter().map(|effect| effect.menu.get(1).copied().unwrap_or("Other")) { + if !groups.contains(&group) { + groups.push(group); + } + } + (groups[0].to_string(), groups[groups.len() - 1].to_string()) +} + +/// In a window `height` pt tall, the popup of the button `pick` finds opens at its row `first`, and +/// the wheel brings its row `last` into the window. +pub(super) fn assert_last_row_scrolls_into_view( + app: &mut VectorcraftApp, + height: f32, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, height, draw); + let button = pick(&window); + window.click(app, button); + let over = window.text(first).unwrap_or_else(|| panic!("the popup's first row, {first}")).center(); + assert!(window.text(last).is_none_or(|row| row.bottom() > height), "the popup opens at the top of its list, with {last} out of view"); + window.wheel_down(app, over); + let row = window.text(last).unwrap_or_else(|| panic!("the wheel brings {last} into the {height} pt window")); + assert!(row.bottom() <= height, "{last} at {row:?} is inside the {height} pt window"); +} + +/// In a tall window the popup of the button `pick` finds shows its rows `first` to `last` without +/// scrolling, and the wheel moves neither. +pub(super) fn assert_short_list_does_not_scroll( + app: &mut VectorcraftApp, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, TALL, draw); + let button = pick(&window); + window.click(app, button); + let shown = |window: &PopupWindow| { + let (top, end) = (window.text(first).expect("the first row"), window.text(last).expect("the last row")); + assert!(end.bottom() <= TALL, "{last} at {end:?} is inside the window"); + (top, end) + }; + let before = shown(&window); + window.wheel_down(app, before.0.center()); + assert_eq!(shown(&window), before, "a list that fits does not scroll"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b91ffb976..1ea7d3bc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1152,6 +1152,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. + The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.