diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs index e7971bb888..fae45a190e 100644 --- a/crates/ui-egui/src/addons/shapes.rs +++ b/crates/ui-egui/src/addons/shapes.rs @@ -42,7 +42,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } match active_shape.as_str() { @@ -106,7 +106,7 @@ fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, se if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -164,7 +164,7 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option) { @@ -225,7 +225,7 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -255,7 +255,7 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -298,7 +298,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn mode_str(m: EllipseMode) -> &'static str { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 855d542d08..ddf0040a2c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -18,6 +18,7 @@ mod hints; mod status_bar; mod tabs; +pub(crate) use control_bar::group_separator; pub use control_bar::{ AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement, read_hidden_groups, diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 9b862cf636..7cb577c812 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -202,7 +202,7 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 { /// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it. fn group_end(ui: &mut Ui) { ui.add_space(GROUP_GAP); - separator(ui); + boundary(ui, separator); } fn group_end_width(ui: &Ui) -> f32 { @@ -218,6 +218,66 @@ fn separator_width(ui: &Ui) -> f32 { SEPARATOR + ui.spacing().item_spacing.x } +/// A separator between two groups of the Control bar's controls, drawn in the bar's row `ui`: the +/// pass that drew it, the range of the paint list that holds it (between two empty shapes), the +/// row's next widget id just after it, and whether it reaches the end of the row, leaving no room +/// for a control after it. +#[derive(Clone, Copy)] +struct Boundary { + pass: u64, + shapes: (usize, usize), + after: egui::Id, + at_end: bool, +} + +/// The key of the row `ui`'s last [`Boundary`]. +fn boundary_key(ui: &Ui) -> egui::Id { + ui.unique_id().with("cb-group-boundary") +} + +/// A separator between two groups of the Control bar's controls, drawn with `draw`. It stays only +/// while a control follows it in the row: [`erase_trailing`] erases it at the end of the row when no +/// control does or none has room, and the next separator erases it when no control comes between +/// them. The separator keeps its room either way. +fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) { + erase_trailing(ui); + let start = ui.painter().add(egui::Shape::Noop); + draw(ui); + let end = ui.painter().add(egui::Shape::Noop); + let at_end = ui.cursor().min.x >= ui.max_rect().right(); + let b = Boundary { pass: ui.ctx().cumulative_pass_nr(), shapes: (start.0, end.0), after: ui.next_auto_id(), at_end }; + let key = boundary_key(ui); + ui.data_mut(|d| d.insert_temp(key, b)); +} + +/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws +/// one, and left out when no control follows it ([`boundary`]). +pub(crate) fn group_separator(ui: &mut Ui) { + boundary(ui, |ui| { + ui.separator(); + }); +} + +/// Erases the row `ui`'s last group separator when no control was drawn after it in this pass, or +/// none had room in the row. The end of the Control bar's controls row calls it too. +fn erase_trailing(ui: &mut Ui) { + let key = boundary_key(ui); + let Some(b) = ui.data_mut(|d| { + let b = d.get_temp::(key); + d.remove::(key); + b + }) else { + return; + }; + if b.pass != ui.ctx().cumulative_pass_nr() || (b.after != ui.next_auto_id() && !b.at_end) { + return; + } + let painter = ui.painter(); + for i in b.shapes.0 + 1..b.shapes.1 { + painter.set(egui::layers::ShapeIdx(i), egui::Shape::Noop); + } +} + /// The room the controls at the end of the Control bar need: the Transform link, or Document Setup /// and Preferences with nothing selected, or none while the panel menu has that group off. The /// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons, @@ -276,7 +336,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize } } if transform { - separator(ui); + boundary(ui, separator); } } transform @@ -292,7 +352,7 @@ fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) { if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) { return; } - separator(ui); + boundary(ui, separator); crate::panels::align::align_buttons(app, ui, BUTTON); if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x { crate::panels::align::align_to_menu(app, ui, BUTTON); @@ -479,7 +539,7 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { } /// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight -/// and the width profile, and the separator after them. +/// and the width profile, and the separator after them while a control follows ([`group_separator`]). fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); @@ -494,12 +554,12 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); - ui.separator(); + group_separator(ui); } /// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something /// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while -/// the panel menu has it on, and the separator after them. +/// the panel menu has it on, and the separator after them while a control follows. fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) { let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected); if opacity_on { @@ -529,7 +589,7 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: global_edit_options(app, ui); } if opacity_on || style_on { - ui.separator(); + group_separator(ui); } } @@ -680,6 +740,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { artboard_align_group(app, ui); + erase_trailing(ui); return; } if anchors != AnchorControls::None && group_on(app, "anchors") { @@ -713,6 +774,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::character::control_bar(app, ui); } if !groups_before_transform(app, ui, sel.len()) { + erase_trailing(ui); return; } // The Transform link opens the whole Transform panel (reference point, rotate, @@ -750,6 +812,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); } } + erase_trailing(ui); }); // Controls that keep their size can still run into the button's room in a narrow window; // the button is left out then. A floating bar clips them, so its button always shows. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 626ff8c66e..346dcb9f05 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2845,6 +2845,96 @@ fn the_artboard_tool_s_control_bar_follows_its_two_groups() { assert!(has(&bar, "Name:") && has(&bar, "Move Artwork with Artboard"), "{bar:?}"); } +/// The separators the docked Control bar draws in window `screen` (vertical lines most of the bar's +/// height, left of the panel menu button), in order, with whether any other shape is drawn between +/// each one and the next, or after the last one for the last. +fn control_bar_separators(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Vec<(f32, bool)> { + control_bar_frame_in(app, ctx, screen, vec![]); + let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + let menu_left = + nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds()).map_or(screen.right(), |b| b.x0 as f32); + let separator = |s: &egui::Shape| match s { + egui::Shape::LineSegment { points: [a, b], .. } if (a.x - b.x).abs() < 0.5 && (a.y - b.y).abs() >= 16.0 => Some(a.x), + _ => None, + }; + let (mut lines, mut others) = (vec![], vec![]); + for cs in &out.shapes { + let r = cs.shape.visual_bounding_rect(); + if !r.is_finite() || !cs.clip_rect.intersects(r) || r.min.x >= menu_left || r.width() > screen.width() / 2.0 { + continue; + } + match separator(&cs.shape) { + Some(x) => lines.push(x), + None => others.push(r.center().x), + } + } + lines.sort_by(f32::total_cmp); + (0..lines.len()) + .map(|i| { + let next = lines.get(i + 1).copied().unwrap_or(f32::INFINITY); + (lines[i], others.iter().any(|&x| x > lines[i] && x < next)) + }) + .collect() +} + +/// A Control bar separator stands only between two groups that draw controls: none is left at the +/// end of the bar when the groups after it are off, do not apply or have no room, and none follows +/// another with nothing between them, in every window width. With every group on, the separators +/// between the groups stay. +#[test] +fn no_separator_is_left_at_the_end_of_the_control_bar() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let set = |app: &mut crate::VectorcraftApp, groups: &[&str], visible: bool| { + for group in groups { + app.run("window.control.options", json!({ "group": group, "visible": visible })).unwrap(); + } + }; + // The first width in each case where a separator has nothing after it. + let mut stray = vec![]; + let mut check = |app: &mut crate::VectorcraftApp, case: &str| { + for w in (300..=1500).step_by(50) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + let lines = control_bar_separators(app, &ctx, screen); + if lines.iter().any(|(_, followed)| !followed) { + stray.push(format!("{case}, {w} wide: {lines:?}")); + return; + } + } + }; + app.run("select.set", json!({ "ids": [id] })).unwrap(); + let wide = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1500.0, 300.0)); + assert!(control_bar_separators(&mut app, &ctx, wide).len() >= 2, "the groups keep their separators"); + check(&mut app, "every group on"); + set(&mut app, &["transform", "alignDistribute"], false); + assert!(!control_bar_separators(&mut app, &ctx, wide).is_empty(), "Fill and Stroke and Opacity keep theirs"); + check(&mut app, "Transform and Align off"); + set(&mut app, &["opacity", "graphicStyle"], false); + check(&mut app, "Fill and Stroke last"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + set(&mut app, &["opacity", "graphicStyle"], true); + set(&mut app, &["documentOptions"], false); + check(&mut app, "nothing selected, Document Options off"); + app.select_tool("puppetWarp"); + set(&mut app, &["fillStroke", "opacity"], false); + check(&mut app, "Puppet Warp options last"); + app.select_tool("ellipse"); + check(&mut app, "Ellipse options last"); + set(&mut app, &["fillStroke", "opacity", "documentOptions", "transform", "alignDistribute"], true); + app.select_tool("artboard"); + set(&mut app, &["artboardOptions"], false); + check(&mut app, "the artboard's Transform link and Align"); + set(&mut app, &["alignDistribute"], false); + check(&mut app, "the artboard's Transform link last"); + assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}"); +} + /// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps /// its Anchor Point label. #[test] diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 1513efc8f2..a73d68cd1a 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -255,7 +255,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let Some((editing, bar)) = bar_of(app) else { return }; let mut run: Option<(&str, Value)> = None; - ui.separator(); + crate::chrome::group_separator(ui); for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] { if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { run = Some(("object.envelope.editContents", json!({ "editing": contents }))); diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 774002a72d..0f7223f7e8 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -100,7 +100,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, } if transform { super::transform::link(app, ui); - ui.separator(); + crate::chrome::group_separator(ui); } if options { art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..de42febef6 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1073,7 +1073,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); size_field(app, ui, "cb-font-size", 96.0); ui.add_space(4.0); - ui.separator(); + crate::chrome::group_separator(ui); } /// The panel state key of the panel menu's Show Snap to Glyph Options. diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index ce5a5955b8..7777380180 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -361,7 +361,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() { app.run("imageTrace.expand", json!({})).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 1169422789..94ae695ca4 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -604,7 +604,7 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool crate::menus::invoke(app, cmd, json!({})); } } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b1788e1e8a..e20d3c734d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -528,7 +528,7 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("tool.setOption", json!({ "key": key, "value": value })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's @@ -564,7 +564,7 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { crate::canvas::apply_requests(app, r); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as @@ -1155,7 +1155,7 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() { app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..9620fd452d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -626,6 +626,7 @@ since 2026-10-01. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. +- **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`.