From 6551b28b44b2ce1d428da280464a72d7ccbaf734 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 18:15:53 -0800 Subject: [PATCH] Control bar: in a narrow window, Fill, Stroke and Opacity stay inside the bar Short of room, the Control bar drew the Fill and Stroke chips, the Stroke link and weight field, the width profile, the Opacity field and the Graphic Style controls past the panel menu button. Docked, the bar then left the panel menu out; floating, it cut a control in half. - Each of those controls now shows only while the bar has room for all of it. The bar draws them from left to right, each only when the room left still holds it, so the Opacity link and field can still show after a Stroke link and weight field that did not fit. - The Snapping menu, the selection's label, the Convert and Anchors groups, the isolation button, Document Setup and Preferences show only while they fit, and a group separator only while it has room. Test: the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window checks widths from 150 to 1500 points, docked top and bottom and floating. --- crates/ui-egui/src/chrome/control_bar.rs | 129 ++++++++++++++++++----- crates/ui-egui/src/chrome/tests.rs | 61 +++++++++++ crates/ui-egui/src/panels/stroke.rs | 13 ++- docs/target-app-parity.md | 1 + 4 files changed, 172 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 5ac4025c24..8c37343667 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -30,7 +30,8 @@ pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { /// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]): /// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a -/// row of its own: inline in the Control bar, one under the other in a panel. +/// row of its own: inline in the Control bar, one under the other in a panel. In the Control bar's +/// row, a group shows only while the row has room for its label and all its buttons. pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) { let t = Tokens::get(ui.ctx()); let mut run = None; @@ -56,9 +57,14 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon AnchorControls::Convert => 1, AnchorControls::All => groups.len(), }; + let in_row = ui.layout().main_dir() == egui::Direction::LeftToRight; for (label, buttons) in groups.into_iter().take(shown) { + let text = egui::RichText::new(label).size(12.0).color(t.text); + if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) { + break; + } ui.horizontal(|ui| { - ui.label(egui::RichText::new(label).size(12.0).color(t.text)); + ui.label(text); for &(icon, tip, id, to) in buttons { if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to })))); @@ -78,6 +84,12 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation"; /// less, the menu is left out so those controls still fit the narrowest window. const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; +/// The width of [`snapping_menu`]'s button: its label, as a button draws it, and the button's padding. +fn snapping_width(ui: &Ui) -> f32 { + let label = egui::WidgetText::from(tl!("Snapping")).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button); + label.size().x + 2.0 * ui.spacing().button_padding.x +} + /// The Control bar's snapping quick access; every change uses its existing command, and Pixel /// Snapping Options… opens the dialog of `view.snapToPixel.options`. fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -115,9 +127,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// while a symbol's art is the isolated layer. Otherwise, with one object selected that /// [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which also isolates a /// path inside a group that Direct Selection selected. An envelope's contents get no button: the -/// envelope's Edit Envelope and Edit Contents buttons show instead. +/// envelope's Edit Envelope and Edit Contents buttons show instead. It is left out when the bar has +/// no room left for it. fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; + let Some(st) = app.session.active().filter(|_| ui.available_width() >= BUTTON) else { return }; let (icon, tip, id) = if st.isolation.is_some() { if st.doc.pattern_edit.is_some() { return; @@ -204,8 +217,27 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 { (BUTTON + ui.spacing().item_spacing.x) * n as f32 } +/// The room controls `widths` wide take side by side in the bar's row, with the spacing after each. +fn row_width(ui: &Ui, widths: &[f32]) -> f32 { + widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum() +} + +/// The size of the Control bar's Fill, Stroke and Graphic Style chips. +const PAINT_CHIP: f32 = 22.0; + +/// The width each of those chips takes with the chevron after it. +const CHIP_WIDTH: f32 = PAINT_CHIP + 16.0; + +/// The widths of the Control bar's stroke weight and Opacity fields. +const WEIGHT_FIELD_WIDTH: f32 = 100.0; +const OPACITY_FIELD_WIDTH: f32 = 56.0; + /// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it. +/// It is left out when the bar has no room left for it. fn group_end(ui: &mut Ui) { + if ui.available_width() < group_end_width(ui) { + return; + } ui.add_space(GROUP_GAP); boundary(ui, separator); } @@ -256,8 +288,11 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) { } /// 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`]). +/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it. pub(crate) fn group_separator(ui: &mut Ui) { + if ui.layout().main_dir() == egui::Direction::LeftToRight && ui.available_width() < separator_width(ui) { + return; + } boundary(ui, |ui| { ui.separator(); }); @@ -319,11 +354,12 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize group_end(ui); } if nothing { - if group_on(app, "documentOptions") { + // Each button shows only while the bar has room left for it. + if group_on(app, "documentOptions") && ui.available_width() >= DOCUMENT_SETUP_WIDTH { if widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() { app.run("file.documentSetup", json!({})).ok(); } - if widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() { + if ui.available_width() >= PREFERENCES_WIDTH && widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() { app.run("edit.preferences", json!({})).ok(); } } @@ -545,55 +581,75 @@ 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 while a control follows ([`group_separator`]). +/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight, +/// then the two chips; each shows only while the bar has room for all of it. fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { + if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) { + return; + } let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); - crate::panels::paint_chip(app, ui, false, 22.0, true); - crate::panels::paint_chip(app, ui, true, 22.0, true); + crate::panels::paint_chip(app, ui, false, PAINT_CHIP, true); + crate::panels::paint_chip(app, ui, true, PAINT_CHIP, true); // The link opens the Stroke panel as a popover under it; then the weight spinner // (with presets) and the width profile. - stroke_panel::link(app, ui, tl!("Stroke:")); - stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); - if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { - app.run("stroke.set", json!({"profile": id})).ok(); + let link = widgets::text_width(ui, stroke_panel::link_text(ui, tl!("Stroke:"))); + if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) { + stroke_panel::link(app, ui, tl!("Stroke:")); + stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH); + if ui.available_width() >= stroke_panel::PROFILE_DROPDOWN_WIDTH + && let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) + { + app.run("stroke.set", json!({"profile": id})).ok(); + } } - ui.add_space(4.0); - group_separator(ui); + group_end(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 while a control follows. +/// the panel menu has it on, and the separator after them while a control follows. Short of room, +/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its +/// chip, then the Opacity link with its field; each shows only while the bar has room for all of it. 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); + let mut drawn = false; if opacity_on { + let text = egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline(); + if ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone()), OPACITY_FIELD_WIDTH]) { + return; + } // A click opens the Transparency panel; a drag scrubs the field (#4088). - if crate::scrub::link_label(ui, egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() { + if crate::scrub::link_label(ui, text).clicked() { app.ui.open_panel = Some("transparency".into()); } - if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) + if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, OPACITY_FIELD_WIDTH) && selected { app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); } + drawn = true; } - if style_on { + let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline(); + if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) { // Style picker: the selection's graphic style; its menu applies another. - ui.add_space(4.0); - if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { + ui.add_space(GROUP_GAP); + if ui.link(text).clicked() { app.ui.open_panel = Some("graphicStyles".into()); } - let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); + let resp = + widgets::chip_button_named(ui, PAINT_CHIP, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); // Recolor Artwork, when the selection has colours to recolour. let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); - if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() { + if colored && ui.available_width() >= BUTTON && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked() { crate::menus::invoke(app, "ui.recolorDialog", json!({})); } global_edit_options(app, ui); + drawn = true; } - if opacity_on || style_on { + if drawn { group_separator(ui); } } @@ -709,11 +765,16 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, } gripper(app, ui, t, bar, dock_room); if app.session.active().is_none() { - ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); + let text = egui::RichText::new(tl!("No Document")).color(t.text_dim); + if ui.available_width() >= widgets::text_width(ui, text.clone()) { + ui.label(text); + } return; } // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. - if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) { + // Any other tool's bar shows the menu while it has room for it. + let snapping_room = if app.session.tool_id() == "artboard" { ARTBOARD_SNAPPING_WIDTH } else { snapping_width(ui) }; + if group_on(app, "snapping") && ui.available_width() >= snapping_room { snapping_menu(app, ui); } let Some(st) = app.session.active() else { return }; @@ -739,7 +800,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, _ => tl!(crate::panels::appearance::object_label(app)), }; let has_first = first.is_some(); - ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text); + if ui.available_width() >= widgets::text_width(ui, label.clone()) { + ui.label(label); + } // With the Artboard tool in use, the bar shows the active artboard's controls in place // of the object controls. let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); @@ -755,7 +819,9 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, if object_options { isolation_button(app, ui); } - ui.add_space(6.0); + if ui.available_width() >= 6.0 { + ui.add_space(6.0); + } // An image or an Image Trace object shows its own controls in place of Fill and Stroke, // and leaves Fill and Stroke out while the Image Options group is off too. let image = if group_on(app, "imageOptions") { @@ -783,7 +849,12 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, return; } // The Transform link opens the whole Transform panel (reference point, rotate, - // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. + // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The + // groups ahead of the link keep its room, but those ahead of them can still take it. + if ui.available_width() < crate::panels::transform::link_width(ui) { + erase_trailing(ui); + return; + } crate::panels::transform::link(app, ui); // The bounding box, rotated with rotated objects: its centre and its own sides. if let Some(b) = app.selection_box() diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 052c7ec230..801b2e5bd2 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -3037,6 +3037,67 @@ fn no_separator_is_left_at_the_end_of_the_control_bar() { assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}"); } +/// The shapes of the Control bar's controls drawn in window `screen` that reach past the left edge +/// of its panel menu button, or past the room a floating bar clips its controls to, or `None` when +/// the bar leaves the button out. +fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Option> { + 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())?.x0 as f32; + // The bar's frame spans the whole bar, the panel menu button included. + let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() }; + let shapes = out.shapes.iter().filter_map(|cs| { + let r = cs.shape.visual_bounding_rect(); + let control = r.is_finite() && cs.clip_rect.intersects(r) && r.width() < bar_width - 4.0 && r.min.x < menu_left - 0.5; + let clipped = cs.clip_rect.right() < menu_left && r.right() > cs.clip_rect.right() + 0.5; + (control && (r.right() > menu_left + 0.5 || clipped)).then_some(r) + }); + Some(shapes.collect()) +} + +/// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke and +/// Opacity included: every control it shows is drawn whole, left of the panel menu button, which +/// shows in every width, with the bar docked to the top or the bottom or floating. +#[test] +fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() { + use crate::tests_removeanchors::{control_frame, has}; + 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 mut faults = vec![]; + let mut check = |app: &mut crate::VectorcraftApp, case: &str| { + for (bottom, floating) in [(false, false), (true, false), (false, true)] { + (app.ui.control_bar_bottom, app.ui.control_bar_floating) = (bottom, floating); + for w in (150..=1500).step_by(9) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + match control_bar_overflow(app, &ctx, screen) { + None => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: no panel menu")), + Some(over) if !over.is_empty() => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: {over:?}")), + Some(_) => continue, + } + break; + } + } + (app.ui.control_bar_bottom, app.ui.control_bar_floating) = (false, false); + }; + app.run("select.set", json!({ "ids": [id] })).unwrap(); + let bar = control_frame(&mut app, &ctx, vec![]); + assert!(has(&bar, "Stroke:") && has(&bar, "Opacity:"), "Fill and Stroke and Opacity show in a wide window: {bar:?}"); + check(&mut app, "a rectangle selected"); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]]})).unwrap(); + check(&mut app, "an anchor selected"); + app.select_tool("selection"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + check(&mut app, "nothing selected"); + assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}"); +} + /// 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/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 894b8488f8..c667f01200 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -167,15 +167,19 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig /// "Stroke"): a click toggles the Stroke panel in a popover under it, and a drag scrubs the weight /// field after it ([`crate::scrub`]). pub(crate) fn link(app: &mut VectorcraftApp, ui: &mut Ui, label: &str) { - let t = Tokens::get(ui.ctx()); // A drag on it scrubs the weight field after it (#4088). - let resp = crate::scrub::link_label(ui, egui::RichText::new(label).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options")); + let resp = crate::scrub::link_label(ui, link_text(ui, label)).on_hover_text(tl!("Stroke options")); widgets::popover(&resp, resp.clicked(), |ui| { ui.set_width(260.0); show(app, ui); }); } +/// The text of the Stroke [`link`] labeled `label`, as it draws it and the Control bar measures it. +pub(crate) fn link_text(ui: &Ui, label: &str) -> egui::RichText { + egui::RichText::new(label).size(12.0).color(Tokens::get(ui.ctx()).text).underline() +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = current_stroke(app); @@ -491,12 +495,15 @@ pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color ui.painter().with_clip_rect(r).add(egui::Shape::mesh(mesh)); } +/// The width of the [`profile_dropdown`] button. +pub(crate) const PROFILE_DROPDOWN_WIDTH: f32 = 100.0; + /// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button /// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the /// id or name picked. pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option { let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(PROFILE_DROPDOWN_WIDTH, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 19b9c8d319..7786b5bb27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -638,6 +638,7 @@ since 2026-10-01. - **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`. +- **Fill and Stroke and Opacity stay inside a narrow Control bar:** the Control bar leaves out a Fill and Stroke, Opacity or Graphic Style control it has no room for, whole, instead of drawing it past the panel menu button or under it. The bar draws its controls from left to right, each only when the room left still holds it, so a narrower control can still show after a wider one that was left out: the Opacity link with its field can show when the Stroke link with its weight field did not fit. The Snapping menu, the selection's label, the Convert and Anchors groups, the isolation button, Document Setup and Preferences also show only while they fit, and a group separator only while it has room. This is VectorCraft's own rule. Regression test: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window` 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`.