From 3a10022dcf56b3c1ef2325a8953f765439c9d332 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:00:44 -0800 Subject: [PATCH] Contextual Task Bar: actions wrap onto more rows on a narrow canvas On a canvas narrower than one row of the bar, the action buttons ran past its right edge: only the font controls moved to a second row, and keep_in moved the bar without shrinking it. - The actions now wrap, in order, onto rows under the row with the handle, the chips and More Options. Their widths are measured with the button's font, and the bar's place on the canvas uses the same layout from the first frame and after a resize. - An action wider than a row, or one that would need a row past the canvas's height, is listed in More Options. - When the handle, the chips and More Options don't fit one row, the chips take rows after the actions. Without a row to spare, the font controls are left out. A canvas too small for the handle and More Options shows no bar. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas/task_bar.rs | 426 +++++++++++++++++++------- crates/ui-egui/src/tests/taskbar.rs | 94 ++++++ docs/target-app-parity.md | 1 + 3 files changed, 410 insertions(+), 111 deletions(-) diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index e908640c25..d606f630d5 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -73,8 +73,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { /// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock. /// Selected type also shows the font family, font style and Font Size controls before its Fill. /// While a Type tool edits type, or has type selected, the bar shows only those controls and the -/// Fill. The font controls take a second row when one row would be wider than the canvas, and -/// narrow there to fit it. +/// Fill. When one row would be wider than the canvas, the font controls take a row of their own, +/// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]). /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { @@ -231,7 +231,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx()); + let paints = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be @@ -239,23 +239,47 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); let bounds = xf.rect.shrink(8.0); + // The canvas's width less the frame's margins and stroke. + let room = bounds.width() - BAR_FRAME; // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. - let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room(); + let fonts = fonts && room >= crate::panels::character::task_bar_min_room(); + // The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and + // Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar + // has the Fill alone. + let chips: Vec = [ + (!on_artboards, Chip::Fill), + (!on_artboards && !typing, Chip::Stroke), + (!on_artboards && !typing && colored, Chip::Recolor), + (!on_artboards && !typing, Chip::Lock), + ] + .into_iter() + .filter_map(|(shown, chip)| shown.then_some(chip)) + .collect(); + let widths: Vec = items.iter().map(|(label, ..)| action_width(ui, label)).collect(); + let font_w = fonts.then(|| { + let w = crate::panels::character::TASK_BAR_WIDTHS; + w.iter().sum::() + BAR_GAP * (w.len() - 1) as f32 + }); + let spacing = ui.spacing().item_spacing.y; + let chip_widths: Vec = chips.iter().map(|c| c.width()).collect(); + let layout = bar_layout(&widths, &chip_widths, font_w, room, bar_max_rows(bounds.height(), spacing)); + // On a canvas too small for its handle and More Options: no bar, rather than one that runs off + // the canvas. + let fonts = layout.as_ref().is_some_and(|l| l.fonts); ui.ctx().data_mut(|d| d.insert_temp(task_bar_id().with("fonts"), fonts)); - // The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips, - // Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap. - let chips: f32 = - [(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)] - .iter() - .filter(|(shown, _)| *shown) - .map(|(_, w)| w + 6.0) - .sum(); - let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width()); + let Some(layout) = layout else { return }; + let est = layout.size(spacing); // Its place under the selection, then where the handle moved it, kept on the canvas (with the - // bar's size last frame, the estimate before it first shows). + // bar's size last frame, the layout's size before it first shows and whenever the layout + // changed since, as after the canvas or the selection changed). let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0); let id = task_bar_id(); - let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est); + let unchanged = ui.ctx().data_mut(|d| { + let last = d.get_temp::(id.with("layout")); + d.insert_temp(id.with("layout"), est); + last == Some(est) + }); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).filter(|_| unchanged).unwrap_or(est); let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); let place = &mut app.ui.task_bar_place; // Just unpinned: follow the selection from the pinned spot. @@ -285,95 +309,88 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { .inner_margin(egui::Margin::symmetric(8, 6)) .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) }) .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag()); - let active = grip.hovered() || grip.dragged(); - ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border }); - if grip.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - moved = grip.drag_delta(); - } else if grip.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - for (label, icon, cmd, params) in &items { - let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); - let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside); - crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); - ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); - if resp.clicked() { - run = Some((*cmd, params.clone())); - } - } - // Type's font family, font style and Font Size, which act as the Character - // panel's do: on the characters a Type tool selects, else on the selected type. - if fonts && !font_row { - crate::panels::character::task_bar(app, ui, f32::INFINITY); - } - // The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's - // properties instead), left out with the Artboard tool. A Type tool's bar has - // the Fill alone. - if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) { - app.session.fill_active = true; - show_swatches = true; - } - if !on_artboards && !typing { - // The Stroke chip, drawn as a ring as the Control bar's is. - if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) { - app.session.fill_active = false; - show_swatches = true; - } - // Recolor Artwork, when the selection has colours to recolour. - if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() { - run = Some(("ui.recolorDialog", json!({}))); - } - if let Some(shape) = &shape - && let NodeKind::Path { live: Some(live), .. } = &shape.kind - { - // The shape's properties in a popover that stays open while its fields and - // its Corner dropdown are used. - let props = widgets::icon_button(ui, "shapes", tl!("Shape Properties"), false, 30.0); - widgets::popover(&props, props.clicked(), |ui| { - ui.set_min_width(250.0); - crate::panels::transform::live_shape_rows(app, ui, shape, live, "task-bar-shape", true); - }); - } else { - // The Lock button is off in isolation mode, as Object › Lock › Selection is. - let lockable = crate::menus::enabled(app, "object.lock"); - if widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked() { - run = Some(("object.lock", json!({}))); - } - } - } - let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(170.0); - let bar = [ - (tl!("Hide Bar"), "window.taskBar", false), - (tl!("Pin Bar Position"), "window.taskBar.pin", pinned), - (tl!("Reset Bar Position"), "window.taskBar.reset", false), - ]; - for (label, cmd, checked) in bar { - if widgets::menu_item(ui, label, true, checked) { - run = Some((cmd, json!({}))); - } - } - ui.separator(); - if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) { - run = Some(("window.panel", json!({"panel": "properties"}))); - } - }); - }); - if font_row { + // The first row holds the handle and More Options; actions that don't fit beside + // them go on the rows after it, in order. + for row in 0..layout.rows { ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - // The canvas's width less the frame's margins and stroke. - crate::panels::character::task_bar(app, ui, bounds.width() - 18.0); + ui.spacing_mut().item_spacing.x = BAR_GAP; + if row == 0 { + let (g, grip) = ui.allocate_exact_size(vec2(BAR_HANDLE, 28.0), Sense::drag()); + let active = grip.hovered() || grip.dragged(); + ui.painter().rect_filled( + g.shrink2(vec2(0.5, 4.0)), + CornerRadius::same(2), + if active { t.text_dim } else { t.button_border }, + ); + if grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + moved = grip.drag_delta(); + } else if grip.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + for ((label, icon, cmd, params), at) in items.iter().zip(&layout.actions) { + if *at == Some(row) && action_button(ui, &t, label, icon) { + run = Some((*cmd, params.clone())); + } + } + // Type's font family, font style and Font Size, which act as the Character + // panel's do: on the characters a Type tool selects, else on the selected type. + if row == 0 && fonts && !layout.font_row { + crate::panels::character::task_bar(app, ui, f32::INFINITY); + } + for (chip, at) in chips.iter().zip(&layout.chips) { + if *at != row { + continue; + } + match paint_or_object_chip(app, ui, &t, *chip, &paints, shape.as_ref()) { + Some(ChipClick::Fill(fill)) => { + app.session.fill_active = fill; + show_swatches = true; + } + Some(ChipClick::Run(cmd)) => run = Some((cmd, json!({}))), + None => {} + } + } + if row != 0 { + return; + } + let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, BAR_MORE); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(170.0); + // The actions too wide for any row of the bar. + let listed = items.iter().zip(&layout.actions).filter(|(_, at)| at.is_none()); + let mut any = false; + for ((label, _, cmd, params), _) in listed { + any = true; + if widgets::menu_item_name(ui, label, true, false) { + run = Some((*cmd, params.clone())); + } + } + if any { + ui.separator(); + } + let bar = [ + (tl!("Hide Bar"), "window.taskBar", false), + (tl!("Pin Bar Position"), "window.taskBar.pin", pinned), + (tl!("Reset Bar Position"), "window.taskBar.reset", false), + ]; + for (label, cmd, checked) in bar { + if widgets::menu_item(ui, label, true, checked) { + run = Some((cmd, json!({}))); + } + } + ui.separator(); + if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) { + run = Some(("window.panel", json!({"panel": "properties"}))); + } + }); + }); + } + if layout.font_row { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = BAR_GAP; + crate::panels::character::task_bar(app, ui, room); }); } }); @@ -413,14 +430,201 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } -/// Whether the type controls go on a second row, and the bar's estimated size before it first -/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take -/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than -/// `room` (and narrow to fit it there). -fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) { - let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum(); - let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); - if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) } +/// The width of the Contextual Task Bar's frame: its margins and stroke on both sides. +const BAR_FRAME: f32 = 18.0; +/// The gap between the bar's controls. +const BAR_GAP: f32 = 6.0; +/// The width of the bar's handle. +const BAR_HANDLE: f32 = 4.0; +/// The width of the bar's More Options button. +const BAR_MORE: f32 = 30.0; +/// The height of a row of the bar's controls. +const BAR_ROW: f32 = 30.0; + +/// The controls the Contextual Task Bar shows after its actions: the Fill and Stroke chips, +/// Recolor Artwork, and Lock or a basic shape's Shape Properties. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chip { + Fill, + Stroke, + Recolor, + Lock, +} + +impl Chip { + fn width(self) -> f32 { + match self { + Chip::Fill | Chip::Stroke => 26.0, + Chip::Recolor | Chip::Lock => 30.0, + } + } +} + +/// What a click on one of the bar's [`Chip`]s does: make the Fill (`true`) or the Stroke active and +/// bring up Swatches, or run a command. +enum ChipClick { + Fill(bool), + Run(&'static str), +} + +/// Where each of the Contextual Task Bar's controls goes. +#[derive(Debug, PartialEq)] +struct BarLayout { + /// Each action's row (0 is the row with the handle and More Options), or `None` for an action + /// wider than a row or past the rows the canvas's height allows, which More Options lists + /// instead. + actions: Vec>, + /// Each chip's row. + chips: Vec, + /// Whether the bar shows the font controls it was given. + fonts: bool, + /// Whether the font controls take a row of their own, after the others. + font_row: bool, + /// The number of rows before the font controls' row. + rows: usize, + /// The bar's width, its frame included. + width: f32, +} + +impl BarLayout { + /// The bar's size, with `spacing` points between its rows. + fn size(&self, spacing: f32) -> Vec2 { + let rows = (self.rows + usize::from(self.font_row)) as f32; + vec2(self.width, rows * BAR_ROW + (rows - 1.0) * spacing + 14.0) + } +} + +/// How many rows of the Contextual Task Bar fit in `height` points, with `spacing` points between +/// them. +fn bar_max_rows(height: f32, spacing: f32) -> usize { + ((height - 14.0 + spacing) / (BAR_ROW + spacing)).floor().max(0.0) as usize +} + +/// The Contextual Task Bar's layout in `room` points (the canvas's width less the frame) and at +/// most `max_rows` rows, for actions and chips the given widths wide, and the font controls `fonts` +/// wide when shown. One row holds everything when it fits. Otherwise the font controls take a row +/// of their own, narrowed to the room, and the actions wrap: the first row keeps the handle, the +/// chips and More Options, and each action goes on the last row when it fits there, else on a new +/// row. When the handle, the chips and More Options don't fit in one row, the chips take rows of +/// their own after the actions. An action wider than a row, or one that would need a row past +/// `max_rows`, goes in More Options; without a row to spare the font controls are left out. `None` +/// when not even the handle and More Options, and the chips' rows, fit. +fn bar_layout(actions: &[f32], chips: &[f32], fonts: Option, room: f32, max_rows: usize) -> Option { + // Puts a control `w` wide on the last row when it fits there, else on a new row. + fn place(rows: &mut Vec, w: f32, room: f32) -> usize { + match rows.last_mut() { + Some(last) if *last + BAR_GAP + w <= room => *last += BAR_GAP + w, + _ => rows.push(w), + } + rows.len() - 1 + } + let ends = BAR_HANDLE + BAR_GAP + BAR_MORE; + if room < ends || max_rows == 0 { + return None; + } + let chips_w: f32 = chips.iter().map(|w| w + BAR_GAP).sum(); + let actions_w: f32 = actions.iter().map(|w| w + BAR_GAP).sum(); + let one_row = ends + chips_w + actions_w + fonts.map_or(0.0, |w| w + BAR_GAP); + if one_row <= room { + return Some(BarLayout { + actions: vec![Some(0); actions.len()], + chips: vec![0; chips.len()], + fonts: fonts.is_some(), + font_row: false, + rows: 1, + width: one_row + BAR_FRAME, + }); + } + let chips_first = ends + chips_w <= room; + let mut chip_rows = vec![]; + let chips: Vec = chips.iter().map(|w| if chips_first { 0 } else { place(&mut chip_rows, *w, room) }).collect(); + if 1 + chip_rows.len() > max_rows { + return None; + } + let font_row = fonts.is_some() && 1 + chip_rows.len() < max_rows; + let action_rows = max_rows - chip_rows.len() - usize::from(font_row); + let mut rows = vec![if chips_first { ends + chips_w } else { ends }]; + // Once an action needs a row past the last one, it and the actions after it go in More Options, + // keeping their order. + let mut full = false; + let actions: Vec> = actions + .iter() + .map(|w| { + if *w > room || full { + return None; + } + let mut next = rows.clone(); + let row = place(&mut next, *w, room); + full = row >= action_rows; + (!full).then(|| { + rows = next; + row + }) + }) + .collect(); + let chips = chips.into_iter().map(|row| if chips_first { 0 } else { rows.len() + row }).collect(); + let font_w = if font_row { fonts.map_or(0.0, |w| w.min(room)) } else { 0.0 }; + let width = rows.iter().chain(&chip_rows).fold(font_w, |a, b| a.max(*b)) + BAR_FRAME; + Some(BarLayout { actions, chips, fonts: font_row, font_row, rows: rows.len() + chip_rows.len(), width }) +} + +/// The width of the Contextual Task Bar's action button for `label`. +fn action_width(ui: &Ui, label: &str) -> f32 { + ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), Color32::PLACEHOLDER).size().x + 38.0 +} + +/// One of the Contextual Task Bar's action buttons: `icon` and `label`, named for screen readers. +/// Returns whether it was clicked. +fn action_button(ui: &mut Ui, t: &Tokens, label: &str, icon: &str) -> bool { + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); + let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, BAR_ROW), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside); + crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); + ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); + resp.clicked() +} + +/// One of the Contextual Task Bar's chips, with the Fill and Stroke `paints` ([`chip_paints`]) and +/// the basic `shape` whose properties take Lock's place. Returns what a click on it does. +fn paint_or_object_chip( + app: &mut VectorcraftApp, + ui: &mut Ui, + t: &Tokens, + chip: Chip, + paints: &(vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool), + shape: Option<&vectorcraft_doc::Node>, +) -> Option { + let (fill, stroke, fill_mixed, stroke_mixed) = paints; + match chip { + Chip::Fill => paint_chip(ui, t, fill, *fill_mixed, None, tl!("Fill")).then_some(ChipClick::Fill(true)), + // The Stroke chip, drawn as a ring as the Control bar's is. + Chip::Stroke => paint_chip(ui, t, stroke, *stroke_mixed, Some(4.0), tl!("Stroke")).then_some(ChipClick::Fill(false)), + Chip::Recolor => { + widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked().then_some(ChipClick::Run("ui.recolorDialog")) + } + Chip::Lock => { + if let Some(shape) = shape + && let NodeKind::Path { live: Some(live), .. } = &shape.kind + { + // The shape's properties in a popover that stays open while its fields and its + // Corner dropdown are used. + let props = widgets::icon_button(ui, "shapes", tl!("Shape Properties"), false, 30.0); + widgets::popover(&props, props.clicked(), |ui| { + ui.set_min_width(250.0); + crate::panels::transform::live_shape_rows(app, ui, shape, live, "task-bar-shape", true); + }); + None + } else { + // The Lock button is off in isolation mode, as Object › Lock › Selection is. + let lockable = crate::menus::enabled(app, "object.lock"); + widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked().then_some(ChipClick::Run("object.lock")) + } + } + } } /// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 68ef8a3f15..9768813a70 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -767,6 +767,100 @@ fn selected_types_font_controls_take_a_second_row_on_a_narrow_canvas() { assert!(canvas.contains_rect(r) && r.height() < 60.0, "no font controls on a 160 px screen: {r:?} in {canvas:?}"); } +/// The labels drawn inside the task bar after `frames` frames on a screen `size` points in `ctx`, +/// with the bar's rect and the canvas's. +fn bar_texts_on_screen(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, frames: usize) -> (Vec<(String, Rect)>, Rect, Rect) { + let screen = Rect::from_min_size(Pos2::ZERO, size); + let mut texts = vec![]; + for _ in 0..frames { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + texts = shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + } + let r = canvas::task_bar_rect(ctx).expect("the task bar shows"); + (texts.into_iter().filter(|(_, t)| r.contains(t.center())).collect(), r, app.canvas_rect.expect("the canvas is laid out")) +} + +/// On a canvas narrower than one row of the bar's actions, the actions wrap onto more rows, in +/// order, and the bar stays whole on the canvas, also on the first frame after the canvas narrows. +#[test] +fn a_task_bars_actions_wrap_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap()); + select(&mut app, &[t]); + let actions = ["Create Outlines", "Type on a Path Options", "Center on Top", "Center on Bottom", "Dynamic Text", "Duplicate"]; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(1800.0, 700.0), 3); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "one row where it fits: {r:?} in {canvas:?}"); + // The first frame after the canvas narrows, then once the bar settles. + for frames in [1, 3] { + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(760.0, 700.0), frames); + assert!(canvas.contains_rect(r), "{frames} frames: {r:?} in {canvas:?}"); + let ys: Vec = actions.iter().map(|label| at(&texts, label).y).collect(); + assert!(ys.windows(2).all(|w| w[0] <= w[1]), "{frames} frames: the actions keep their order: {ys:?}"); + assert!(ys[0] < ys[ys.len() - 1], "{frames} frames: the actions take more than one row: {texts:?}"); + } + assert!(actions.iter().all(|label| has(&texts, label)), "{texts:?}"); +} + +/// On a canvas too narrow for an action's button, or too short for the rows the actions would +/// wrap onto, More Options lists those actions, and the chips take rows after the actions when they +/// don't fit beside the handle and More Options. Narrower than the handle and More Options, the bar +/// doesn't show. +#[test] +fn a_task_bar_action_wider_than_the_canvas_goes_in_more_options() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 120, "height": 100})).unwrap()); + let b = id(app.run("shape.ellipse", json!({"x": 70, "y": 60, "width": 50, "height": 50})).unwrap()); + select(&mut app, &[a, b]); + app.run("object.compoundPath.make", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(160.0, 700.0), 3); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(has(&texts, "Edit Path") && has(&texts, "Duplicate") && !has(&texts, "Release Compound Path"), "{texts:?}"); + // More Options, after the frame's margin and the handle on the first row. + let more = r.min + vec2(9.0 + 4.0 + 6.0 + 15.0, 7.0 + 15.0); + let click = |app: &mut VectorcraftApp, p: Pos2| { + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let screen = Rect::from_min_size(Pos2::ZERO, vec2(160.0, 700.0)); + let mut texts = vec![]; + for events in [vec![Event::PointerMoved(p)], vec![press(true)], vec![press(false)], vec![]] { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + texts = shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + } + texts + }; + let texts = click(&mut app, more); + // Menu items are drawn after a space for the check mark. + let item = |label: &str| texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()); + assert!(item("Hide Bar").is_some(), "More Options opens: {texts:?}"); + let release = item("Release Compound Path").unwrap_or_else(|| panic!("More Options lists the action: {texts:?}")); + click(&mut app, release); + let st = app.session.active().unwrap(); + assert_eq!(st.selection.objects.len(), 2, "the paths come apart, selected"); + assert!(st.selection.objects.iter().all(|i| matches!(st.doc.node(*i).unwrap().kind, NodeKind::Path { .. }))); + + // On a canvas too short for the rows the actions would wrap onto, More Options lists them, in + // order: the bar keeps at most the first. + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(160.0, 150.0), 3); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + let shown = ["Group", "Unite", "Duplicate"].map(|label| has(&texts, label)); + assert!(!shown[1] && !shown[2], "{texts:?}"); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(60.0, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(&mut app, ui)); + out.textures_delta.clear(); + } + assert!(canvas::task_bar_rect(&ctx).is_none(), "no room for the handle and More Options: no bar"); +} + /// When the canvas narrows so far that the bar leaves its font controls out, the font family /// field it drew before no longer takes the mouse wheel. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..494d0465bc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -845,6 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). This follows Illustrator's bar: while the Artboard tool is in use, it has options for resizing, duplicating and exporting artboards (the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, point or area type Convert To Area Type or Convert To Point Type beside Create Outlines, and type on a path, for which the overview names an item to add an effect and rearrange the text, Type on a Path Options (`type.pathOptions`; its options: ) beside Create Outlines. Evidence: `crates/ui-egui/src/tests/taskbar.rs`. - While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the contextual task bar shows the font family menu, the font style menu, the Font Size combo and the Fill chip (); the Touch Type tool shows no bar, as the same page describes. Type selected with a selection tool shows the same three font controls beside its Create Outlines, conversion and Type on a Path items (). The controls act as the Character panel's do: on the characters a Type tool selects, else on every selected type object, with the size left blank where the sizes differ. VectorCraft's own rules: a Type tool's bar leaves out the object actions, the Stroke chip, Recolor Artwork and Lock; a Type tool shows no bar before it adds type or with other art selected; type selected with other art shows no font controls; the font controls go on a second row when one row would be wider than the canvas, and narrow to fit a canvas narrower than they are; while a Type tool edits type, the Fill chip shows the edited characters' fill ("?" where it differs). Evidence: `the_type_tools_show_font_and_fill_controls`, `the_type_tools_show_no_bar_without_type`, `selected_type_shows_font_controls_beside_its_actions`, `the_task_bars_font_size_sets_what_the_character_panel_would`, `the_type_tools_fill_chip_activates_the_fill`, `the_type_tools_bar_is_named_and_stays_on_the_canvas`, `selected_types_font_controls_take_a_second_row_on_a_narrow_canvas` and `the_type_tools_fill_chip_shows_the_edited_characters_fill` in `crates/ui-egui/src/tests/taskbar.rs`. + - The help page describes the contextual task bar as one horizontal row () and gives no rule for a canvas narrower than that row. VectorCraft's own rule: the bar stays whole on the canvas. When its actions don't fit in one row, they wrap, in order, onto rows under the row with the handle, the chips and More Options; an action wider than any row, or one that would need a row past the canvas's height, is listed in More Options instead; when the chips don't fit beside the handle and More Options, they take rows after the actions; with no row to spare for the font controls, the bar leaves them out; a canvas too small for the handle and More Options shows no bar. Evidence: `a_task_bars_actions_wrap_on_a_narrow_canvas` and `a_task_bar_action_wider_than_the_canvas_goes_in_more_options` in `crates/ui-egui/src/tests/taskbar.rs`. - With a live rectangle, rounded rectangle, ellipse or polygon selected with the Selection tool, the contextual task bar shows a Shape Properties button in place of Lock. It opens a popup with the shape's rows from the Transform panel (corner radius, pie angles, or sides, angle, corner and radius), which edit it through `object.setLiveShape`. The overview describes this: when a shape drawn with a shape tool (Rectangle, Star) is selected, the bar offers to change its properties where Lock Object would be ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Object › Lock and ⌘2 still lock the shape. Not done: stars, which the app draws as plain paths. Evidence: `a_basic_shape_shows_its_properties_in_place_of_lock`, `the_shape_properties_popup_edits_the_shape` and `other_selections_keep_lock` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar has a Stroke chip beside the Fill chip, drawn with the paint the Stroke proxy shows (a "?" when the selected objects' strokes differ, as on the Fill chip); a click brings the Stroke proxy forward and shows the Swatches panel wherever it is docked, floating or closed, as a click on the Fill chip does for the Fill proxy. The reference app's bar has Fill and Stroke controls for changing an object's colors ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Evidence: `the_task_bar_shows_a_stroke_chip_beside_fill` and `the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere` in `crates/ui-egui/src/canvas/tests.rs`. - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator, whose bar has items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth).