mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6916 from dotCooCoo/feat/font-menu-wide-layout
Font menu: one wide layout wherever it opens, with larger samples
This commit is contained in:
+198
-45
@@ -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)?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| 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. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet |
|
||||
| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
|
||||
| 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` |
|
||||
|
||||
Reference in New Issue
Block a user