diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..d8b415f922 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -67,8 +67,10 @@ pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; +#[cfg(test)] +pub(crate) use task_bar::chip_paints; use task_bar::task_bar; -pub(crate) use task_bar::{has_colors, task_bar_rect}; +pub(crate) use task_bar::{has_colors, task_bar_rect, task_bar_shows_fonts, task_bar_shows_type}; #[cfg(test)] use tool_overlays::{DIAL_RADIUS, HiddenMarks}; pub use tool_overlays::{HiddenChars, SpellingMarks}; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 5372cc083d..e908640c25 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -13,6 +13,12 @@ pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option { ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) } +/// Whether the Contextual Task Bar, where it was last drawn, held the font controls: on a canvas +/// too narrow for them it leaves them out. +pub(crate) fn task_bar_shows_fonts(ctx: &egui::Context) -> bool { + task_bar_rect(ctx).is_some() && ctx.data(|d| d.get_temp::(task_bar_id().with("fonts")).unwrap_or(false)) +} + /// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour, /// a gradient's stops or a mesh's points, which Recolor Artwork can change. pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool { @@ -30,21 +36,59 @@ fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) } +/// The Type tools whose Contextual Task Bar shows the text formatting controls. The Touch Type +/// tool has no bar: its own handles take the place of one. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// Whether every selected object is a type object, with no anchors picked out. +fn only_type_selected(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + !st.selection.is_empty() + && st.selection.anchors.is_empty() + && st.selection.objects.iter().all(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) + }) +} + +/// Whether a selection tool is in use with something selected. +fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| !st.selection.is_empty()) +} + +/// Whether a Type tool edits type, or has type selected: its bar then shows only the text +/// formatting controls. +fn type_tool_on_type(app: &VectorcraftApp) -> bool { + TYPE_TOOLS.contains(&app.session.tool_id()) && (crate::panels::character::text_editing(app).is_some() || only_type_selected(app)) +} + +/// Whether the Contextual Task Bar shows the font family, font style and Font Size controls: a +/// Type tool edits or has selected type, or a selection tool has only type selected. +pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { + type_tool_on_type(app) || (selection_tool_with_selection(app) && only_type_selected(app)) +} + /// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. /// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard /// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). /// 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. /// 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) { let t = Tokens::get(ui.ctx()); let on_artboards = app.session.tool_id() == "artboard"; - if !on_artboards - && (app.session.active().is_none_or(|st| st.selection.is_empty()) - || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) - { + // Text added with a Type tool shows its formatting controls, as the help page describes + // (). + let typing = type_tool_on_type(app); + if !(on_artboards || typing || selection_tool_with_selection(app)) { return; } + // Selected type has the font controls too, as the help page describes + // (). + let fonts = task_bar_shows_type(app); let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); @@ -93,7 +137,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); - } else { + } else if !typing { + // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines + // and the type conversions included. // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and @@ -185,22 +231,31 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. - let (fill, stroke) = crate::panels::current_paints(app); - let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); + let (fill, stroke, fill_mixed, stroke_mixed) = 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 // rotated). 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 est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let bounds = xf.rect.shrink(8.0); + // 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(); + 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()); // 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). - let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); + 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(vec2(est_w, 44.0)); - let bounds = xf.rect.shrink(8.0); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).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. @@ -255,23 +310,21 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { 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. - if !on_artboards { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Fill")).clicked() { - app.session.fill_active = true; - show_swatches = true; - } + // 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. - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Stroke")).clicked() { + if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) { app.session.fill_active = false; show_swatches = true; } @@ -316,6 +369,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } }); }); + if font_row { + 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); + }); + } }); }); if moved != Vec2::ZERO { @@ -353,6 +413,42 @@ 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 paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or +/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. +pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ctx); + if crate::panels::character::text_editing(app).is_some() + && let Some((chars, _)) = crate::panels::character::text_style(app) + { + let mixed = crate::panels::character::shared(app, |s| s.fill.clone()).is_none(); + return (chars.fill, stroke, mixed, stroke_mixed); + } + (fill, stroke, fill_mixed, stroke_mixed) +} + +/// One of the Contextual Task Bar's Fill and Stroke chips, showing `paint` ("?" when `mixed`, a +/// ring of `ring` points for the stroke), named `tip` for screen readers. Returns whether it was +/// clicked. +fn paint_chip(ui: &mut Ui, t: &Tokens, paint: &vectorcraft_color::Paint, mixed: bool, ring: Option, tip: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, paint, mixed, ring); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + resp.on_hover_text(tip).clicked() +} + /// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a /// closed path. fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 590b93a2fa..c7a74e3178 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -55,6 +55,8 @@ pub enum WheelOwner { Control, /// The Character popover attached to this Control bar link. ControlPopover(egui::Id), + /// The Contextual Task Bar's type controls. + TaskBar, } impl WheelOwner { @@ -68,6 +70,12 @@ impl WheelOwner { Self::ControlPopover(anchor) => { return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor); } + Self::TaskBar => { + return app.ui.task_bar + && app.ui.screen_mode < 3 + && crate::canvas::task_bar_shows_type(app) + && crate::canvas::task_bar_shows_fonts(ctx); + } Self::Character => "character", Self::Properties => { let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id))); @@ -106,12 +114,24 @@ pub struct MenuLook<'a> { pub wheel: bool, /// Its owner, so a field hidden before the next layout cannot capture wheel input. pub wheel_owner: Option, + /// The name screen readers give the closed field when no label stands beside it. + pub name: Option<&'a str>, } impl Default for MenuLook<'static> { /// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel. fn default() -> Self { - Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } + Self { + samples: true, + on_white: false, + english_names: true, + row: ROW, + favorites: &[], + recent: &[], + wheel: false, + wheel_owner: None, + name: None, + } } } @@ -131,6 +151,7 @@ impl<'a> MenuLook<'a> { recent: app.recent_fonts(), wheel: false, wheel_owner: None, + name: None, } } } @@ -382,7 +403,7 @@ pub fn font_menu( // The search field and filters stay in place over the rows, which scroll. The rows are all // one height (a family's styles too), so only those in view are laid out: there are thousands // of families. - let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { + let (field, shown) = widgets::combo_menu_field_named(ui, &id, look.name, current, width, |menu| { menu.height(height).show_rows_with_header( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index e744640a72..c7a3e818cd 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -929,8 +929,13 @@ fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { + size_field_named(app, ui, id, None, width); +} + +/// [`size_field`] named `name` for screen readers, where no label stands beside it. +fn size_field_named(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, name: Option<&str>, width: f32) { let size = shared_effective(app, |s| s.size); - if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { + if let Some(v) = widgets::spin_field_named(ui, id, name, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } } @@ -1043,7 +1048,7 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, "cb-font" => Some(crate::font_menu::WheelOwner::Control), _ => None, }; - font_pickers_owned(app, ui, s, ids, widths, wheel_owner); + font_pickers_owned(app, ui, s, ids, widths, wheel_owner, (None, None)); } fn font_pickers_owned( @@ -1053,16 +1058,17 @@ fn font_pickers_owned( ids: (&str, &str), widths: (f32, f32), wheel_owner: Option, + a11y: (Option<&str>, Option<&str>), ) { crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id()); let sample = crate::font_menu::sample_text(app); - let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, ..crate::font_menu::MenuLook::of(app) }; + let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, name: a11y.0, ..crate::font_menu::MenuLook::of(app) }; if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) { crate::font_menu::apply(app, ui.ctx(), pick); } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { + if let Some(name) = widgets::dropdown_names_named(ui, ids.1, a11y.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { style(app, json!({"style": name})); } } @@ -1157,6 +1163,35 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { crate::chrome::group_separator(ui); } +/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the +/// Font Size combo. +pub(crate) const TASK_BAR_WIDTHS: [f32; 3] = [140.0, 90.0, 76.0]; + +/// The smallest share of [`TASK_BAR_WIDTHS`] the Contextual Task Bar's type controls narrow to. +const TASK_BAR_MIN_SCALE: f32 = 0.4; + +/// The gaps between the Contextual Task Bar's type controls. +fn task_bar_gaps() -> f32 { + 6.0 * (TASK_BAR_WIDTHS.len() - 1) as f32 +} + +/// The narrowest room the Contextual Task Bar's type controls fit in, with their spacing. +pub(crate) fn task_bar_min_room() -> f32 { + TASK_BAR_MIN_SCALE * TASK_BAR_WIDTHS.iter().sum::() + task_bar_gaps() +} + +/// The Contextual Task Bar's type controls while type is selected or edited: the font family and +/// style menus and the Font Size combo, as the Control bar has them, named for screen readers. +/// They narrow, down to 40% of their widths, to fit in `room` points with their spacing. +pub(crate) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, room: f32) { + let Some((s, _)) = text_style(app) else { return }; + let k = ((room - task_bar_gaps()) / TASK_BAR_WIDTHS.iter().sum::()).clamp(TASK_BAR_MIN_SCALE, 1.0); + let [family, style, size] = TASK_BAR_WIDTHS.map(|w| w * k); + let owner = Some(crate::font_menu::WheelOwner::TaskBar); + font_pickers_owned(app, ui, &s, ("tb-font", "tb-font-style"), (family, style), owner, (Some(tl!("Font Family")), Some(tl!("Style")))); + size_field_named(app, ui, "tb-font-size", Some(tl!("Font Size")), size); +} + /// The panel state key of the panel menu's Show Snap to Glyph Options. const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options"; /// Set when Show Snap to Glyph Options is chosen: the next frame scrolls the panel to the options, @@ -1227,7 +1262,7 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::Wh if let Some(f) = app.character_focus { widgets::arm_field_focus(ui.ctx(), f.salt()); } - font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner)); + font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner), (None, None)); variable_font(app, ui, &s, w - 4.0); ui.add_space(2.0); let hidden = pstate::(ui.ctx(), "ch-hide-options"); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d5199e0220..68ef8a3f15 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,8 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make //! Clipping Mask, type converts between point and area type, type on a path opens its options, and -//! a basic shape shows its properties in place of Lock. +//! a basic shape shows its properties in place of Lock. Type, and the Type tools while they edit it, +//! show the font family, font style and Font Size controls. use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; @@ -535,6 +536,304 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// The Type tools whose bar shows the font and Fill controls. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// The labels drawn inside the task bar (none while it isn't shown), with the context. +fn bar_texts(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let (ctx, texts) = bar(app); + let Some(r) = canvas::task_bar_rect(&ctx) else { return (ctx, vec![]) }; + (ctx, texts.into_iter().filter(|(_, t)| r.contains(t.center())).collect()) +} + +/// Point type "Hello" at 20 pt, made with `tool` and edited by it as after a click with it. +fn edited_type(app: &mut VectorcraftApp, tool: &str) -> u64 { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(crate::panels::character::text_editing(app).is_some(), "{tool} edits the type"); + t +} + +/// The font family and style of type `t`'s first character. +fn font_of(app: &VectorcraftApp, t: u64) -> (String, String) { + match kind(app, t) { + NodeKind::Text(t) => (t.first_style().font_family, t.first_style().font_style), + k => panic!("{k:?}"), + } +} + +/// The size of each run of type `t`, with its text. +fn sizes(app: &VectorcraftApp, t: u64) -> Vec<(String, f64)> { + match kind(app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.size)).collect(), + k => panic!("{k:?}"), + } +} + +/// Each Type tool that adds or edits type shows the font family, font style and Font Size controls +/// and the Fill, without the selection tools' object actions, the Stroke chip or Lock. +#[test] +fn the_type_tools_show_font_and_fill_controls() { + for tool in TYPE_TOOLS { + let mut app = app(); + let t = edited_type(&mut app, tool); + let (family, style) = font_of(&app, t); + let (_, texts) = bar_texts(&mut app); + for label in [family.as_str(), style.as_str(), "20 pt"] { + assert!(has(&texts, label), "{tool}: {label} in {texts:?}"); + } + for label in ["Create Outlines", "Convert To Area Type", "Dynamic Text", "Duplicate"] { + assert!(!has(&texts, label), "{tool}: {label} in {texts:?}"); + } + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + assert!(named("Fill"), "{tool}: the Fill shows"); + for label in ["Stroke", "Recolor Artwork", "Lock"] { + assert!(!named(label), "{tool}: {label} is left out"); + } + } +} + +/// A Type tool shows no bar before it adds type, nor with other art selected; the Touch Type tool +/// shows none with type selected. +#[test] +fn the_type_tools_show_no_bar_without_type() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "nothing to format"); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "a rectangle is not type"); + let t = id(app.run("text.create", json!({"x": 60, "y": 180, "text": "Hello", "size": 20})).unwrap()); + app.run("tool.select", json!({"tool": "touchType"})).unwrap(); + select(&mut app, &[t]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "Touch Type has its own handles"); +} + +/// Selected type keeps its actions and gains the font controls, for one type object or several; +/// type selected with other art does not. +#[test] +fn selected_type_shows_font_controls_beside_its_actions() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + let rect = id(app.run("shape.rectangle", json!({"x": 200, "y": 40, "width": 80, "height": 60})).unwrap()); + let (family, _) = font_of(&app, a); + select(&mut app, &[a]); + let (_, texts) = bar_texts(&mut app); + for label in ["Create Outlines", "Convert To Area Type", "Duplicate", family.as_str(), "20 pt"] { + assert!(has(&texts, label), "{label} in {texts:?}"); + } + select(&mut app, &[a, b]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, family.as_str()) && has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[a, rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Group") && !has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); +} + +/// The bar's Font Size sets the characters a Type tool selects, one undo step; with type selected +/// it sets every selected type object, as the Character panel's does. +#[test] +fn the_task_bars_font_size_sets_what_the_character_panel_would() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + assert_eq!(sizes(&app, t), [("He".to_string(), 30.0), ("llo".to_string(), 20.0)]); + // Mixed sizes leave the field blank, as in the Character panel. + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "20 pt") && !has(&texts, "30 pt"), "{texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&app, t), [("Hello".to_string(), 20.0)]); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + select(&mut app, &[a, b]); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "14"); + assert_eq!(sizes(&app, a), [("Hello".to_string(), 14.0)]); + assert_eq!(sizes(&app, b), [("World".to_string(), 14.0)]); +} + +/// At a caret, with no characters selected, the bar's Font Size sets the edited type as the +/// Character panel's does, and the characters typed next take it. +#[test] +fn the_task_bars_font_size_at_a_caret_sets_what_is_typed_next() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); + assert_eq!(crate::panels::character::text_editing(&app), Some((NodeId(t), 5, 5)), "a caret after the type"); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + app.session.tool_typed_text("!", app.view_info()).unwrap(); + assert_eq!(sizes(&app, t), [("Hello!".to_string(), 30.0)]); +} + +/// The Type tool's Fill chip makes the Fill active and brings up the Swatches panel. +#[test] +fn the_type_tools_fill_chip_activates_the_fill() { + let mut app = app(); + edited_type(&mut app, "type"); + app.session.fill_active = false; + let (ctx, _) = bar_texts(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, then the Fill chip. + let p = Pos2::new(r.right() - 8.0 - 30.0 - 6.0 - 13.0, r.center().y); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(p)]); + frame(&mut app, &ctx, vec![press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert!(app.session.fill_active, "the Fill is active"); + assert!(crate::panels::character::text_editing(&app).is_some(), "the type is still edited"); +} + +/// Screen readers name the Type tool bar's controls, and the bar stays on the canvas for type at +/// its lower right corner. +#[test] +fn the_type_tools_bar_is_named_and_stays_on_the_canvas() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = app(); + edited_type(&mut app, "type"); + let tree = tree(|ui| canvas::show(&mut app, ui)); + for label in ["Font Family", "Style", "Font Size", "Fill"] { + named(&tree, label); + } + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 380, "y": 290, "text": "Corner", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // The type near the canvas's lower edge, so the bar's place under it is off the canvas. + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (vectorcraft_geom::Point::new(200.0, 20.0), 1.0); + let (ctx, _) = bar(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let canvas = app.canvas_rect.expect("the canvas is laid out"); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); +} + +/// The task bar's rect after a few frames on a `width` by 700 screen, and the canvas's rect. +fn bar_on_screen(app: &mut VectorcraftApp, width: f32) -> (Rect, Rect) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + (canvas::task_bar_rect(&ctx).expect("the task bar shows"), app.canvas_rect.expect("the canvas is laid out")) +} + +/// On a canvas narrower than one row of the bar, the font controls go on a second row, narrowed to +/// fit, and the bar stays whole on the canvas: selected type's actions, and a Type tool's Fill. +#[test] +fn selected_types_font_controls_take_a_second_row_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let t = id(app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap()); + select(&mut app, &[t]); + let (r, canvas) = bar_on_screen(&mut app, 760.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // Narrower than the font controls at their full widths too. + let (r, canvas) = bar_on_screen(&mut app, 300.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + let (r, _) = bar_on_screen(&mut app, 1000.0); + assert!(r.height() < 60.0, "one row where it fits: {r:?}"); + // The Type tool's bar has only the Fill and More Options beside the controls: one row fits. + let (r, canvas) = bar_on_screen(&mut app, 460.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "one row on a 460 px screen: {r:?} in {canvas:?}"); + // Too narrow for the controls at their narrowest: the bar leaves them out. + let (r, canvas) = bar_on_screen(&mut app, 160.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "no font controls on a 160 px screen: {r:?} in {canvas:?}"); +} + +/// 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] +fn a_task_bar_without_its_font_controls_leaves_their_wheel() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |app: &mut VectorcraftApp, width: f32| { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + }; + draw(&mut app, 1000.0); + let bar = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let viewport = egui::ViewportId::ROOT; + let points = (0..(bar.width() / 4.0) as usize).flat_map(|x| (0..(bar.height() / 4.0) as usize).map(move |y| (x, y))); + let field = points + .map(|(x, y)| bar.min + vec2(x as f32 * 4.0, y as f32 * 4.0)) + .find(|p| crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, *p).is_some()) + .expect("the bar's font family field takes the wheel"); + draw(&mut app, 160.0); + assert!(canvas::task_bar_rect(&ctx).is_some(), "the bar still shows, without its font controls"); + crate::font_menu::sync_wheel_target(&app, &ctx, viewport); + assert!(crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_none(), "the left-out field at {field:?} still takes the wheel"); + + // Hiding the bar (Window › Contextual Task Bar) releases the field before the next layout. + draw(&mut app, 1000.0); + assert!(crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_some(), "the field is back"); + app.run("window.taskBar", json!({})).unwrap(); + crate::font_menu::sync_wheel_target(&app, &ctx, viewport); + assert!( + crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_none(), + "the hidden bar's field at {field:?} still takes the wheel" + ); +} + +/// While a Type tool edits type, the Fill chip shows the selected characters' fill, and "?" where +/// their fills differ. +#[test] +fn the_type_tools_fill_chip_shows_the_edited_characters_fill() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 2, "end": 5})); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let fills: Vec<_> = match kind(&app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.fill.clone())).collect(), + k => panic!("{k:?}"), + }; + let [(he, he_fill), (llo, llo_fill)] = &fills[..] else { panic!("{fills:?}") }; + assert_eq!((he.as_str(), llo.as_str()), ("He", "llo")); + assert_ne!(he_fill, llo_fill); + let ctx = egui::Context::default(); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *llo_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *he_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(mixed, "the fills differ"); +} + /// The task bar shows the selection as it is after an edit and after Undo: Join leaves once the /// open path is closed and comes back with Undo, and Shape Properties shows a rectangle's new /// corner radius and then its old one. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 52849bc2bc..4e1bc4f6a5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,7 +45,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_icon_names, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index a2741ff1cb..1539be8700 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -196,6 +196,18 @@ pub(crate) fn combo_menu_field( current: &str, width: f32, menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, +) -> (Response, Option) { + combo_menu_field_named(ui, id, None, current, width, menu) +} + +/// [`combo_menu_field`] for a field whose visible label is not an adjacent label widget. +pub(crate) fn combo_menu_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, ) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -205,7 +217,7 @@ pub(crate) fn combo_menu_field( .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { let armed = take_armed_focus(ui, &id); - let resp = combo_field(ui, id_with(ui.scope_id(), id), label, None, current, width - 4.0); + let resp = combo_field(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0); if armed { egui::Popup::open_id(ui.ctx(), egui::Popup::default_response_id(&resp)); ui.memory_mut(|m| m.stop_text_input()); diff --git a/crates/ui-egui/tests/font_wheel.rs b/crates/ui-egui/tests/font_wheel.rs index 3cdb017d77..cfa63c31de 100644 --- a/crates/ui-egui/tests/font_wheel.rs +++ b/crates/ui-egui/tests/font_wheel.rs @@ -90,18 +90,24 @@ fn a_font_field_that_appears_under_a_still_pointer_leaves_the_wheel_until_the_po #[test] fn artboard_controls_leave_replaced_font_fields_wheel_available() { - let (ctx, mut app) = selected_type_with_control(); - let mut texts = Vec::new(); - for _ in 0..8 { - texts = frame(&mut app, &ctx, vec![]); - } - let fields: Vec<_> = texts.iter().filter(|(t, _)| t == "Inter").map(|(_, point)| *point).collect(); - assert_eq!(fields.len(), 2, "Control and Properties each show a font field"); - app.select_tool("artboard"); + let laid_out = || { + let (ctx, mut app) = selected_type_with_control(); + let mut texts = Vec::new(); + for _ in 0..8 { + texts = frame(&mut app, &ctx, vec![]); + } + let fields: Vec<_> = texts.iter().filter(|(t, _)| t == "Inter").map(|(_, point)| *point).collect(); + (ctx, app, fields) + }; + let (_, _, fields) = laid_out(); + assert_eq!(fields.len(), 3, "Control, Properties and the Contextual Task Bar each show a font field"); + // Each field gets its own app, so a wheel stream begun over one field cannot decide another. for point in fields { + let (ctx, mut app, _) = laid_out(); + app.select_tool("artboard"); let mut raw = wheel_input(point); app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the Artboard tool replaces this font field"); + assert!(raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the Artboard tool replaces the font field at {point:?}"); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89cbd988b3..27f3836683 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -833,11 +833,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `