diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 0014047657..486cfd682f 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -6,9 +6,12 @@ //! back. The Glyphs panel and Find Font only pick a font ([`picked`]): nothing is previewed on the //! document. A star marks a favourite family, and the ★ filter lists only those. The Font //! Technology filter's Variable lists only variable font families. Preferences › Type › Enable -//! in-menu font previews and Font Preview Size turn the samples off or size the rows. Recently -//! used families appear first, in the order they were last applied, within the same search and -//! filters as the remaining families. In the Character and Properties panels and the Control bar, +//! in-menu font previews and Font Preview Size turn the samples off or size the rows. The menu lays +//! out its filters on the same four rows wherever it opens. However narrow its field, it opens at +//! least wide enough for those rows and for a family name up to 220 points wide with its sample +//! beside it, up to 60% of the window; each sample fills its row's height. Recently used families +//! appear first, in the order they were last applied, within the same search and filters as the +//! remaining families. In the Character and Properties panels and the Control bar, //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. @@ -132,10 +135,29 @@ impl<'a> MenuLook<'a> { const ROW: f32 = 24.0; /// Width of the star at the end of a family's row. const STAR: f32 = 18.0; -/// The share of a row the family's name takes; the sample fills the rest. +/// The least share of a row the family's name takes when the row is too narrow for the widest +/// name and [`SAMPLE_W`]; the sample fills the rest. const NAME_SHARE: f32 = 0.5; +/// Size of a family's name in its row. +const NAME_SIZE: f32 = 12.0; +/// The widest name the menu widens itself for. A longer name takes more of a row only in a menu +/// wider than that. +pub(crate) const NAME_MAX: f32 = 220.0; +/// Space between a family's name and its sample. +const NAME_GAP: f32 = 8.0; +/// How much wider than the sum of its characters' advances a name may lay out once shaped +/// (kerning, ligatures, contextual forms), as a share of that sum. +const SHAPING: f32 = 0.1; +/// The room the menu keeps for a sample beside the widest name. +const SAMPLE_W: f32 = 150.0; +/// The greatest share of the window's width the menu takes. +const WINDOW_SHARE: f32 = 0.6; +/// Size of the filters' labels. +const FILTER_SIZE: f32 = 11.0; /// Width of the disclosure triangle of a family with several styles. const DISCLOSURE: f32 = 14.0; +/// How far a family's style rows are indented past the triangle. +const STYLE_INDENT: f32 = 12.0; /// The list's greatest height, under the search field and filters. const LIST_HEIGHT: f32 = 600.0; /// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI. @@ -289,6 +311,8 @@ struct MenuState { rows_key: Option, /// The height of the search field and filters last frame. header: f32, + /// The width of the widest row of filters without wrapping, last frame. + filters: f32, /// The families the wheel steps through, listed at its first notch over the closed field and /// kept for that text selection while the pointer stays on it: each step makes its family a recent one, which would /// otherwise move it in the order. @@ -321,7 +345,11 @@ pub fn font_menu( // of families. let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { menu.height(height).show_rows_with_header( - |ui| header(ui, state_id, state, current, look), + |ui| { + // Wider than a narrow field, so the filters keep their rows and long names show. + ui.set_min_width(menu_width(ui, state, look)); + header(ui, state_id, state, current, look) + }, look.row, |h| h.rows.len(), |ui, range, h| rows(ui, range, h, sample, look), @@ -957,42 +985,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & ui.memory_mut(|m| m.request_focus(field.with("edit"))); } let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); - // The filters: script, then kind, then font technology. - let mut refilter = false; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - for i in 0..SCRIPTS { - if ui.selectable_label(st.script == i, egui::RichText::new(script_label(i)).size(11.0)).clicked() { - st.script = i; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - if ui.selectable_label(st.favorites_only, egui::RichText::new("★").size(11.0)).on_hover_text(tl!("Favorites")).clicked() { - st.favorites_only = !st.favorites_only; - refilter = true; - } - if ui.selectable_label(st.class.is_none(), egui::RichText::new(tl!("Any Kind")).size(11.0)).clicked() { - st.class = None; - refilter = true; - } - for c in FontClass::ALL { - if ui.selectable_label(st.class == Some(c), egui::RichText::new(class_label(c)).size(11.0)).clicked() { - st.class = if st.class == Some(c) { None } else { Some(c) }; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - ui.label(egui::RichText::new(tl!("Font Technology")).size(11.0).color(t.text_dim)); - if ui.selectable_label(st.variable_only, egui::RichText::new(variable_label()).size(11.0)).clicked() { - st.variable_only = !st.variable_only; - refilter = true; - } - }); + let refilter = filters(ui, st, &t); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), @@ -1064,6 +1057,149 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & Listed { st, rows, current: current.to_string(), enter, keyed, opening } } +/// One of the filters' chips. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Chip { + /// A script ([`SCRIPTS`]). + Script(usize), + Favorites, + AnyKind, + Class(FontClass), + /// The Font Technology label, which filters nothing. + Technology, + Variable, +} + +/// The filters, row by row, the same wherever the menu opens: the scripts; favorites, any kind +/// and the first three kinds; the other kinds; font technology. +fn filter_rows() -> [Vec; 4] { + let (first, rest) = FontClass::ALL.split_at(3); + [ + (0..SCRIPTS).map(Chip::Script).collect(), + [Chip::Favorites, Chip::AnyKind].into_iter().chain(first.iter().copied().map(Chip::Class)).collect(), + rest.iter().copied().map(Chip::Class).collect(), + vec![Chip::Technology, Chip::Variable], + ] +} + +/// The filters on their rows ([`filter_rows`]): a row wraps only where the menu is narrower than +/// it. Records in `st` the width of the widest row unwrapped; whether a filter changed. +fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool { + let mut refilter = false; + let mut widest = 0.0_f32; + for row in filter_rows() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + let gap = ui.spacing().item_spacing.x; + let text = |s: &str| egui::RichText::new(s).size(FILTER_SIZE); + let mut width = -gap; + for chip in row { + let resp = match chip { + Chip::Script(i) => ui.selectable_label(st.script == i, text(script_label(i))), + Chip::Favorites => ui.selectable_label(st.favorites_only, text("★")).on_hover_text(tl!("Favorites")), + Chip::AnyKind => ui.selectable_label(st.class.is_none(), text(tl!("Any Kind"))), + Chip::Class(c) => ui.selectable_label(st.class == Some(c), text(class_label(c))), + Chip::Technology => ui.label(text(tl!("Font Technology")).color(t.text_dim)), + Chip::Variable => ui.selectable_label(st.variable_only, text(variable_label())), + }; + width += resp.rect.width() + gap; + if !resp.clicked() { + continue; + } + match chip { + Chip::Script(i) => st.script = i, + Chip::Favorites => st.favorites_only = !st.favorites_only, + Chip::AnyKind => st.class = None, + Chip::Class(c) => st.class = if st.class == Some(c) { None } else { Some(c) }, + Chip::Technology => continue, + Chip::Variable => st.variable_only = !st.variable_only, + } + refilter = true; + } + widest = widest.max(width); + }); + } + st.filters = widest; + refilter +} + +/// The width the open menu takes at least: the widest row of filters last frame, and the widest +/// name up to [`NAME_MAX`] with room for its sample, the star and the scroll bar beside it, within +/// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field. +fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 { + let sample = if look.samples { SAMPLE_W } else { 0.0 }; + let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width(); + // Rounding in the chips' widths must not wrap the widest row. + let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 }; + filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE) +} + +/// The width of the widest name the menu lists after the disclosure triangle: the family names as +/// Show Font Names in English shows them, and the style names among `rows` after their indent. +fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x; + let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max); + family_name_width(ui, english).max(styles) +} + +/// What the widest family name was measured for: the font list's generation, Show Font Names in +/// English, and the number of fonts the interface draws with, which grows as faces for other +/// scripts are added to it. +fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) { + let faces = ui.ctx().fonts(|f| f.definitions().font_data.len()); + (vectorcraft_text::FontDb::global().generation(), english, faces) +} + +/// The id under which the widest family name's width is kept with its [`family_name_key`]. +fn family_name_id() -> egui::Id { + egui::Id::unique("font-menu-name-width") +} + +/// The width of the widest family name the menu lists, labeled as Show Font Names in English +/// (`english`) labels it; worked out again only when its [`family_name_key`] changes. +fn family_name_width(ui: &Ui, english: bool) -> f32 { + let key = family_name_key(ui, english); + if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id())) + && k == key + { + return w; + } + let db = vectorcraft_text::FontDb::global(); + let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect()); + ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w))); + w +} + +/// The width of the widest of `labels` laid out at the names' size. Each label's characters' +/// advances are added up, and the labels are laid out widest sum first, until no sum left could +/// reach the widest layout, allowing [`SHAPING`] for shaping. +fn widest_label(ui: &Ui, labels: Vec) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let mut sums: Vec<(f32, String)> = + ui.ctx().fonts_mut(|f| labels.into_iter().map(|l| (l.chars().map(|c| f.glyph_width(&font, c)).sum(), l)).collect()); + sums.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut w = 0.0_f32; + for (sum, label) in sums { + if sum * (1.0 + SHAPING) + 1.0 <= w { + break; + } + w = w.max(ui.painter().layout_no_wrap(label, font.clone(), Color32::WHITE).size().x); + } + w +} + +/// Where a row `width` wide puts its sample, after the name column: room for the widest name +/// (`widest`) and a gap, as far as that leaves [`SAMPLE_W`] and the star for the sample or +/// [`NAME_SHARE`] of the row for the name. Without samples the name runs to the star. +fn name_column(width: f32, widest: f32, samples: bool) -> f32 { + if !samples { + return width - STAR; + } + (DISCLOSURE + widest + NAME_GAP).min((width - STAR - 2.0 - SAMPLE_W).max(width * NAME_SHARE)) +} + /// The disclosure triangle of a family with several styles; whether the pointer is on it. fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool { p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim); @@ -1101,6 +1237,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio let t = Tokens::get(ui.ctx()); let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); + let widest = name_width(ui, look.english_names, &rows); if rows.is_empty() { // A filter or a font rescan can remove the row whose font was previewed. // Do not leave that font applied while the list has no candidate to highlight. @@ -1166,8 +1303,8 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio starred = Some(f.clone()); } } - let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE }; - let name_w = rect.width() * NAME_SHARE; + let indent = if row.style().is_some() { DISCLOSURE + STYLE_INDENT } else { DISCLOSURE }; + let name_w = name_column(rect.width(), widest, look.samples); let name_rect = egui::Rect::from_min_max(rect.min + vec2(indent, 0.0), egui::pos2(rect.min.x + name_w - 4.0, rect.max.y)); // Family rows follow Show Font Names in English; style rows keep the style name (#394). let name = match row { @@ -1175,8 +1312,9 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio Row::Family(f) => db.family_display_name(f, look.english_names), Row::Heading(_) => continue, }; - ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); - let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0)); + ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text); + // The sample takes the row's whole height. + let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0)); if !look.samples { } else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) { paint_sample(ui, sample_rect, &tex, t.text); @@ -1376,6 +1514,21 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio render_sample(family, "Regular", text, px) } +/// Waits for the samples rendering on this thread's behalf and keeps them, so the next frame +/// draws them. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn wait_for_samples(ctx: &egui::Context) { + SAMPLES.with(|s| { + let mut s = s.borrow_mut(); + let pending: Vec<_> = s.pending.drain().collect(); + for (key, rx) in pending { + let tex = + rx.recv().ok().flatten().map(|img| ctx.load_texture(format!("font-sample-{}-{}", key.0, key.3), img, egui::TextureOptions::LINEAR)); + s.ready.insert(key, tex); + } + }); +} + // ---------- applying a pick ---------- /// Is a font previewed on the text (an interaction is open for it)? diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 259408711c..69063e8e87 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -58,6 +58,103 @@ fn the_scroll_bar_leaves_the_stars_clear() { } } +/// In a wide row the sample starts after the widest name and a gap, not halfway along, even for a +/// name wider than the menu widens itself for; the sample keeps its room, and a row too narrow for +/// both gives the name half of it. Without samples the name runs to the star. +#[test] +fn the_sample_starts_after_the_widest_name() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR, name_column}; + assert_eq!(name_column(500.0, 100.0, true), DISCLOSURE + 100.0 + NAME_GAP); + assert_eq!(name_column(600.0, NAME_MAX + 80.0, true), DISCLOSURE + NAME_MAX + 80.0 + NAME_GAP); + assert_eq!(name_column(600.0, 1000.0, true), 600.0 - STAR - 2.0 - SAMPLE_W); + assert_eq!(name_column(200.0, 100.0, true), 100.0); + assert_eq!(name_column(500.0, 100.0, false), 500.0 - STAR); +} + +/// The widest label is the widest one laid out, though it has fewer characters than others, or +/// characters that are narrow on their own. +#[test] +fn the_widest_label_is_the_widest_laid_out() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut labels: Vec = ["WWWWWWWW", "Mm", "", "ffi ffl fi fl", "Source Serif 4 Display Semibold"].map(str::to_string).into(); + labels.push("i".repeat(40)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let font = egui::FontId::proportional(super::NAME_SIZE); + let laid_out = |l: &String| ui.painter().layout_no_wrap(l.clone(), font.clone(), egui::Color32::WHITE).size().x; + let widest = labels.iter().map(laid_out).fold(0.0, f32::max); + assert_eq!(super::widest_label(ui, labels.clone()), widest); + for l in &labels { + assert_eq!(super::widest_label(ui, vec![l.clone()]), laid_out(l), "{l}"); + } + assert_eq!(super::widest_label(ui, vec![]), 0.0); + }); + out.textures_delta.clear(); +} + +/// The widest family name is measured again when the interface's fonts change, as they do when +/// faces for another script are added to it, not only when the font list does. +#[test] +fn the_widest_name_is_measured_again_when_the_interface_fonts_change() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |f: &mut dyn FnMut(&mut egui::Ui)| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(ui)); + out.textures_delta.clear(); + }; + // A width no name has, kept as if measured for the fonts as they are. + frame(&mut |ui| { + let key = super::family_name_key(ui, true); + ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32))); + }); + frame(&mut |ui| { + let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font"); + ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] }); + }); + frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again")); +} + +/// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample, +/// the star and the scroll bar; without samples it is the sample's room narrower. +#[test] +fn the_menu_makes_room_for_the_widest_name_and_its_sample() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let st = super::MenuState::default(); + let look = super::MenuLook::default(); + let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + assert!(names > 0.0, "a family is listed"); + let width = super::menu_width(ui, &st, look); + assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide"); + let plain = super::menu_width(ui, &st, super::MenuLook { samples: false, ..look }); + assert!((width - plain - SAMPLE_W).abs() < 0.01, "{width} with samples, {plain} without"); + }); + out.textures_delta.clear(); +} + +/// The style names of the families expanded in the listed rows count toward the names' width; +/// those of a family expanded earlier but not listed now do not. +#[test] +fn only_listed_style_names_widen_the_names() { + use super::{Row, STYLE_INDENT}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let families = super::name_width(ui, true, &[]); + let font = egui::FontId::proportional(super::NAME_SIZE); + let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x; + assert!(style > families, "the style name is wider than every family name"); + let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())]; + assert_eq!(super::name_width(ui, true, &listed), style); + assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured"); + }); + out.textures_delta.clear(); +} + use super::{Row, remap_highlight}; fn family(name: &str) -> Row { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ea79c1d5cc..109122308f 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -959,3 +959,142 @@ fn a_preview_stays_put_while_the_menu_is_still() { assert_eq!(picked, Some(FontPick::EndPreview)); assert_eq!(font(&app), "Inter"); } + +/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of +/// the samples, and the menu's rectangle. +struct Drawn { + texts: Vec<(String, egui::Rect, egui::Rect)>, + samples: Vec, + menu: egui::Rect, +} + +impl Drawn { + fn text(&self, label: &str) -> egui::Rect { + self.texts.iter().find(|(t, ..)| t == label).map(|(_, r, _)| *r).unwrap_or_else(|| panic!("{label} is drawn")) + } + + /// The filters' rows, each by the centers of its first and last chips, in the UI's language. + fn filter_rows(&self) -> Vec<(f32, f32)> { + use crate::i18n::{current, t, tr_ctx}; + let variable = tr_ctx(current(), "font-technology", "Variable"); + [(t("All Scripts"), t("Thai")), ("★", t("Rounded")), (t("Script / Brush"), t("Decorative")), (t("Font Technology"), variable)] + .map(|(first, last)| (self.text(first).center().y, self.text(last).center().y)) + .to_vec() + } + + /// The filters lie on their four rows, none wrapped. + fn assert_filter_rows(&self) { + let rows = self.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "filter row {i} wraps: its first chip at {first}, its last at {last}"); + } + assert!(rows.windows(2).all(|w| w[1].0 > w[0].0 + 4.0), "four rows of filters, in order: {rows:?}"); + } +} + +/// Opens the font menu of a field `width` wide whose left edge is `left` points into a window of +/// size `screen`, draws it until it settles and its samples are rendered, and returns what the +/// last frame drew. +fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0)); + let frame = |events: Vec| { + let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| { + font_menu(ui, id, "Inter", width, None, MenuLook::default()); + }); + }); + out.textures_delta.clear(); + out.shapes + }; + let click = field.min + egui::vec2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![]); + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + for _ in 0..5 { + frame(vec![]); + } + crate::font_menu::wait_for_samples(&ctx); + let shapes = frame(vec![]); + let mut texts = vec![]; + let mut samples = vec![]; + for s in &shapes { + match &s.shape { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)), + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()), + _ => {} + } + } + let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING }; + let filter = drawn.text(crate::i18n::t("Any Kind")).center(); + drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open"); + drawn +} + +/// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their +/// rows and for the families' names, each drawn whole beside its sample. +#[test] +fn a_narrow_field_opens_a_menu_wide_enough_for_the_filters_and_names() { + let field = 115.0; + let d = open_menu("test-narrow", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + assert!(d.menu.width() > 2.0 * field, "the menu is {} wide", d.menu.width()); + let families = FontDb::global().menu_family_list(); + // The field shows the current family too: only the names in the menu's rows. + let names: Vec<_> = d.texts.iter().filter(|(t, r, _)| r.top() > d.text("Variable").bottom() && families.iter().any(|f| f == t)).collect(); + assert!(names.iter().any(|(_, r, _)| r.width() > field / 2.0), "a name wider than half the field is listed: {names:?}"); + // The menu widens itself for names up to its limit; a longer name is cut. + for (name, r, clip) in names.iter().filter(|(_, r, _)| r.width() <= crate::font_menu::NAME_MAX) { + assert!(r.right() <= clip.right() + 0.5, "{name} ends at {} and is cut at {}", r.right(), clip.right()); + } +} + +/// In every UI language the filters keep their four rows unwrapped when the window has room for +/// them: the longest translated labels set the menu's width. +#[test] +fn the_filter_rows_stay_whole_in_every_language() { + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let d = open_menu(&format!("test-lang-{}", lang.code()), 20.0, 115.0, egui::vec2(2400.0, 900.0)); + let rows = d.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "{}: filter row {i} wraps in a menu {} wide", lang.code(), d.menu.width()); + } + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A menu opened near the window's right edge is wider than its field and stays in the window. +#[test] +fn a_menu_wider_than_its_field_stays_in_the_window() { + let screen = egui::vec2(700.0, 800.0); + let d = open_menu("test-edge", 600.0, 90.0, screen); + d.assert_filter_rows(); + assert!(d.menu.width() > 200.0, "the menu is {} wide", d.menu.width()); + assert!(d.menu.left() >= -0.5 && d.menu.right() <= screen.x + 0.5, "the menu at {:?} leaves the window", d.menu); +} + +/// A docked panel's field is as wide as the panel: its menu takes that width, with the filters on +/// the same rows as a narrow field's menu. +#[test] +fn a_wide_fields_menu_takes_its_width_with_the_same_filter_rows() { + let field = 480.0; + let d = open_menu("test-docked", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + // The field is drawn 4 points narrower than its width; the menu's frame adds its margins. + assert!(d.menu.width() >= field - 4.0 && d.menu.width() < field + 40.0, "the menu is {} wide", d.menu.width()); +} + +/// Each sample takes its row's whole height. +#[test] +fn samples_fill_their_rows_height() { + let d = open_menu("test-samples", 20.0, 260.0, egui::vec2(1200.0, 800.0)); + assert!(!d.samples.is_empty(), "samples are drawn"); + let row = MenuLook::default().row; + for s in &d.samples { + assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height()); + } +} diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..c67e915d56 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` |