Font menu: one wide layout wherever it opens, with larger samples

The font menu opened at its field's width, so from the Control bar's narrow
Character field the filters wrapped one chip per line and family names were
cut off after a few letters.

- The filters keep the same four rows wherever the menu opens: the scripts;
  the star, Any Kind, Serif / Mincho, Sans Serif / Gothic and Rounded;
  Script / Brush, Monospaced and Decorative; Font Technology with Variable.
- The menu opens at least wide enough for those rows and for a family name
  up to 220 points wide with room for its sample, up to 60% of the window,
  and stays in the window. A field wider than that keeps its width.
- Where the row has room, the sample starts after the widest name instead of
  halfway along the row. It takes the row's whole height instead of 4 points
  less, so samples draw larger at every Font Preview Size.

Requested by POGO.
This commit is contained in:
dotCooCoo
2026-11-12 08:06:24 -08:00
parent 10ea235ee4
commit 2defb49002
4 changed files with 435 additions and 46 deletions
+198 -45
View File
@@ -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<RowsKey>,
/// 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<Chip>; 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<String>) -> 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<usize>, 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<usize>, 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<usize>, 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)?
+97
View File
@@ -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<String> = ["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 {
+139
View File
@@ -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<egui::Rect>,
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<egui::Event>| {
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());
}
}
+1 -1
View File
@@ -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` |