Files
vectorcraft/crates/ui-egui/src/font_menu.rs
T

1185 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The font family menu of the Character, Properties and Glyphs panels and Find Font's Replace
//! With: a search field over every family available, each row the family's name and a sample of
//! the selected text set in it. In the Character and Properties panels the row under the pointer
//! or reached with ↑/↓ previews its font on the selected text (one live interaction, nothing in
//! the history); a click or Enter applies it as one step, Escape or closing the menu puts the text
//! 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.
use std::collections::HashMap;
use std::sync::Arc;
use egui::{Color32, Sense, Ui, vec2};
use serde_json::json;
use vectorcraft_text::FontClass;
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets;
/// What happened in the menu this frame.
#[derive(Clone, Debug, PartialEq)]
pub enum FontPick {
/// A family (and one of its styles, when picked from the family's expanded list) was chosen.
Chosen(String, Option<String>),
/// The highlighted row changed: preview its family (and style).
Preview(String, Option<String>),
/// The menu closed (or came back to the current family) without a choice: undo the preview.
EndPreview,
/// The star of a family was clicked: add it to the favourites, or take it out.
Favorite(String),
}
/// How the menu looks: from the preferences and the favourites.
#[derive(Clone, Copy, Debug)]
pub struct MenuLook<'a> {
/// Show each family's sample (Preferences › Type › Enable in-menu font previews).
pub samples: bool,
/// Preferences › Type › Show Font Names in English (on by default).
pub english_names: bool,
/// Row height in points (Font Preview Size).
pub row: f32,
/// The starred families.
pub favorites: &'a [String],
/// Recently applied families, newest first, limited by Number of Recent Fonts.
pub recent: &'a [String],
}
impl Default for MenuLook<'static> {
/// Samples on, English names, medium rows, no favourites.
fn default() -> Self {
Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[] }
}
}
impl<'a> MenuLook<'a> {
pub fn of(app: &'a VectorcraftApp) -> Self {
let row = match app.session.prefs.font_preview_size.as_str() {
"small" => 20.0,
"large" => 32.0,
_ => ROW,
};
Self {
samples: app.session.prefs.font_preview,
english_names: app.session.prefs.font_names_in_english,
row,
favorites: &app.ui.favorite_fonts,
recent: app.recent_fonts(),
}
}
}
/// Height of a row, in points (Font Preview Size: Medium).
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.
const NAME_SHARE: f32 = 0.5;
/// Width of the disclosure triangle of a family with several styles.
const DISCLOSURE: f32 = 14.0;
/// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI.
const SAMPLES_PER_FRAME: usize = 4;
/// Samples rendering at once (native).
#[cfg(not(target_arch = "wasm32"))]
const MAX_PENDING: usize = 8;
/// What a sample shows when the font lacks the selected text's characters (or there is none).
const FALLBACK_SAMPLE: &str = "Sample";
/// The script filter's choices: all, Japanese, Latin, Thai.
const SCRIPTS: usize = 4;
fn script_label(i: usize) -> &'static str {
match i {
// CJK families use the `japanese` font trait; Chinese UI calls this 汉字 (#2206).
1 => crate::i18n::tr_ctx(crate::i18n::current(), "font-script", "Japanese"),
2 => tl!("Latin"),
3 => tl!("Thai"),
_ => tl!("All Scripts"),
}
}
/// The Font Technology filter's one choice: variable fonts. An adjective for fonts, not the
/// Variables panel's noun (`変数`), hence its context.
fn variable_label() -> &'static str {
crate::i18n::tr_ctx(crate::i18n::current(), "font-technology", "Variable")
}
/// Does a family of `traits` pass script filter `script` ([`SCRIPTS`])? Latin: neither Japanese
/// nor Thai.
fn script_matches(script: usize, traits: vectorcraft_text::FontTraits) -> bool {
match script {
1 => traits.japanese,
2 => !traits.japanese && !traits.thai,
3 => traits.thai,
_ => true,
}
}
fn class_label(c: FontClass) -> &'static str {
match c {
FontClass::Serif => tl!("Serif / Mincho"),
FontClass::Sans => tl!("Sans Serif / Gothic"),
FontClass::Rounded => tl!("Rounded"),
FontClass::Script => tl!("Script / Brush"),
FontClass::Monospaced => tl!("Monospaced"),
FontClass::Decorative => tl!("Decorative"),
FontClass::Other => tl!("Other"),
}
}
/// A row of the list: a family, or one of the styles of an expanded family.
#[derive(Clone, Debug, PartialEq)]
enum Row {
Heading(&'static str),
Family(String),
Style(String, String),
}
impl Row {
fn family(&self) -> Option<&str> {
match self {
Row::Heading(_) => None,
Row::Family(f) | Row::Style(f, _) => Some(f),
}
}
fn style(&self) -> Option<&str> {
match self {
Row::Heading(_) | Row::Family(_) => None,
Row::Style(_, s) => Some(s),
}
}
fn pick(&self) -> Option<(String, Option<String>)> {
Some((self.family()?.to_string(), self.style().map(str::to_string)))
}
}
/// Recent families keep their application order, once each, and only while still available and
/// passing the filters. The other families keep the database's order. Headings share the virtual
/// list's row height, but never act as fonts or take keyboard focus.
fn grouped_rows(families: &[String], recent: &[String], expanded: &[String], styles: impl Fn(&str) -> Vec<String>) -> Vec<Row> {
let mut first: Vec<&String> = vec![];
for name in recent {
if let Some(f) = families.iter().find(|f| f.eq_ignore_ascii_case(name))
&& !first.iter().any(|other| other.eq_ignore_ascii_case(f))
{
first.push(f);
}
}
let mut rows = vec![];
let append = |rows: &mut Vec<Row>, f: &String| {
rows.push(Row::Family(f.clone()));
if expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) {
rows.extend(styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
}
};
// Build the family rows before adding headings, so the same expansion logic serves both groups.
for f in &first {
append(&mut rows, f);
}
let boundary = rows.len();
for f in families.iter().filter(|f| !first.contains(f)) {
append(&mut rows, f);
}
if boundary > 0 {
if boundary < rows.len() {
rows.insert(boundary, Row::Heading("Fonts"));
}
rows.insert(0, Row::Heading("Recent Fonts"));
}
rows
}
/// Keep keyboard/pointer focus on the same font after filtering, loading new fonts or
/// expanding a family's styles. A row index on its own can now name a different font.
fn remap_highlight(before: &[Row], selected: Option<usize>, after: &[Row]) -> Option<usize> {
let row = before.get(selected?)?;
after.iter().position(|candidate| candidate == row)
}
/// What the rows were listed for: the query, the script, kind, font technology (variable only) and
/// favourites filters, Show Font Names in English, the expanded families, favourites, recent
/// history and font list generation.
type RowsKey = (String, usize, Option<FontClass>, bool, bool, bool, Vec<String>, Vec<String>, Vec<String>, u64);
/// The menu's state between frames.
#[derive(Clone, Default)]
struct MenuState {
/// The pass the list was last drawn in, and the pass it opened in.
last: u64,
opened: u64,
/// The family the text had as the menu opened. A preview shows another one in the document
/// (and the menu's caller), but this one stays the current family: hovering it ends the
/// preview (#563).
current: String,
/// The highlighted row and the query and filters it belongs to.
highlight: Option<usize>,
query: String,
/// The filters (kept from one opening to the next): script (0..[`SCRIPTS`]), kind, font
/// technology (variable fonts only) and favourites only.
script: usize,
class: Option<FontClass>,
variable_only: bool,
favorites_only: bool,
/// Families whose styles are listed (lowercase).
expanded: Vec<String>,
/// What was previewed last (None: nothing is previewed).
previewed: Option<(String, Option<String>)>,
/// The rows listed, and what for: built again only when that changes, not every frame.
rows: Arc<[Row]>,
rows_key: Option<RowsKey>,
/// The list's scroll offset last frame.
scrolled: f32,
}
/// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the
/// selected text; `None`: a generic sample).
pub fn font_menu(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
current: &str,
width: f32,
sample: Option<&str>,
look: MenuLook,
) -> Option<FontPick> {
let state_id = ui.scope_id().with(&id).with("font-menu");
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let mut drawn = false;
let picked = widgets::combo(ui, &id, current, width, true, |ui| {
drawn = true;
list(ui, state_id, &mut st, current, sample, look)
});
let mut out = picked;
if !drawn && out.is_none() && st.previewed.take().is_some() {
// Closed (Escape, a click outside) with a preview showing.
out = Some(FontPick::EndPreview);
}
if matches!(out, Some(FontPick::Chosen(..))) {
st.previewed = None;
}
ui.data_mut(|d| d.insert_temp(state_id, st));
out
}
/// The list inside the open menu.
fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, sample: Option<&str>, look: MenuLook) -> Option<FontPick> {
let row_h = look.row;
let t = Tokens::get(ui.ctx());
// Each time the menu opens: no query, the current family highlighted and in view (the
// filters stay as they were).
let pass = ui.ctx().cumulative_pass_nr();
let opening_now = st.last + 1 < pass || st.last == 0;
if opening_now {
st.opened = pass;
st.highlight = None;
st.query.clear();
st.current = current.to_string();
}
st.last = pass;
// For [`end_stale_preview`]: the menu is open, in this layer.
ui.ctx().data_mut(|d| d.insert_temp(open_id(), (pass, ui.layer_id())));
let field = state_id.with("query");
if opening_now {
ui.data_mut(|d| d.insert_temp(field, String::new()));
}
// A popup sizes itself invisibly in its first pass.
let opening = pass <= st.opened + 1;
// Arrow keys move the highlight and open or close a family's styles (taken before the
// search field sees them).
let key = |i: &mut egui::InputState, k| i.consume_key(egui::Modifiers::NONE, k);
let (up, down, left, right) =
ui.input_mut(|i| (key(i, egui::Key::ArrowUp), key(i, egui::Key::ArrowDown), key(i, egui::Key::ArrowLeft), key(i, egui::Key::ArrowRight)));
if ui.memory(|m| m.focused().is_none()) {
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 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: ヒラギノ角ゴシック),
// as it was before any preview.
let current = db.canonical(&st.current, "").0;
let current = current.as_str();
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
// The generation is read first: fonts that load meanwhile make the next frame list them.
let key: RowsKey = (
query.clone(),
st.script,
st.class,
st.variable_only,
st.favorites_only,
look.english_names,
st.expanded.clone(),
look.favorites.to_vec(),
look.recent.to_vec(),
db.generation(),
);
if st.rows_key.as_ref() != Some(&key) {
let wanted = |f: &str| {
let label = db.family_display_name(f, look.english_names);
if !(query.is_empty() || f.to_lowercase().contains(&query) || label.to_lowercase().contains(&query)) {
return false;
}
if st.favorites_only && !favorite(f) {
return false;
}
if st.script == 0 && st.class.is_none() && !st.variable_only {
return true;
}
let traits = db.family_traits(f);
script_matches(st.script, traits) && st.class.is_none_or(|c| c == traits.class) && (!st.variable_only || traits.variable)
};
let families: Vec<_> = db.menu_family_list().iter().filter(|f| wanted(f)).cloned().collect();
let rows = grouped_rows(&families, look.recent, &st.expanded, |f| db.styles(f));
// Filter changes (including un-starring a favourite) may remove rows; expanding
// another family or a background font scan can insert rows before the highlight.
// Never preview an unrelated family because the old numeric index now points to it.
st.highlight = remap_highlight(&st.rows, st.highlight, &rows);
st.rows = rows.into();
st.rows_key = Some(key);
}
let rows = st.rows.clone();
if query != st.query || refilter {
st.query = query.clone();
st.highlight = None;
}
if st.highlight.is_none() && query.is_empty() {
st.highlight = rows.iter().position(|r| matches!(r, Row::Family(f) if f.eq_ignore_ascii_case(current)));
}
let mut keyed = false;
if (up || down) && !rows.is_empty() {
let selectable = |(_, r): &(usize, &Row)| r.family().is_some();
let next = match (st.highlight, down) {
(None, _) => rows.iter().enumerate().find(selectable),
(Some(i), true) => rows.iter().enumerate().skip(i.saturating_add(1)).find(selectable),
(Some(i), false) => rows.iter().enumerate().take(i).rev().find(selectable),
};
st.highlight = next.map(|(i, _)| i).or(st.highlight);
keyed = true;
}
// → opens the highlighted family's styles, ← closes them (from the family or one of them).
if let Some(family) = st.highlight.and_then(|i| rows.get(i)).and_then(Row::family).filter(|_| left || right) {
let f = family.to_lowercase();
let open = st.expanded.contains(&f);
if right && !open && db.styles(family).len() > 1 {
st.expanded.push(f);
} else if left && open {
st.expanded.retain(|e| *e != f);
st.highlight = rows.iter().position(|r| *r == Row::Family(family.to_string()));
}
keyed = true;
}
if rows.is_empty() {
widgets::dim_label(ui, tl!("No matching fonts"));
// 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.
return st.previewed.take().map(|_| FontPick::EndPreview);
}
// The rows: only those in view are laid out (there are thousands of families). The list is
// the menu's one scrolling part, under the search field and filters (#555): it fills the
// menu's room (its max rect) below them.
let room = (ui.max_rect().bottom() - ui.next_widget_position().y).clamp(120.0, 600.0);
let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32));
// Open at the recent section even when the current family is farther down the list.
// Otherwise centre the current font; subsequent key presses keep the highlight in view.
if opening && matches!(rows.first(), Some(Row::Heading("Recent Fonts"))) {
area = area.vertical_scroll_offset(0.0);
} else if let Some(top) = st.highlight.map(|i| i as f32 * row_h) {
let target = if opening {
Some(top - (room - row_h) * 0.5)
} else if keyed && top < st.scrolled {
Some(top)
} else if keyed && top + row_h > st.scrolled + room {
Some(top + row_h - room)
} else {
None
};
if let Some(y) = target {
area = area.vertical_scroll_offset(y.max(0.0));
}
}
let mut chosen: Option<(String, Option<String>)> = None;
let mut hovered: Option<usize> = None;
let mut toggled: Option<String> = None;
let mut starred: Option<String> = None;
ui.spacing_mut().item_spacing.y = 0.0;
// The scroll bar beside the rows, not over them: a floating one covered the stars at their
// right ends, so a favourite was hard to click (#2277).
ui.spacing_mut().scroll = widgets::list_scroll_style();
let shown = area.show_rows(ui, row_h, rows.len(), |ui, range| {
for i in range {
let Some(row) = rows.get(i) else { continue };
if let Row::Heading(label) = row {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover());
// Existing translated menu labels; the heading cannot preview or apply a font.
let label = match *label {
"Recent Fonts" => tl!("Recent Fonts"),
_ => tl!("Fonts"),
};
ui.painter_at(rect).text(
rect.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(11.0),
t.text_dim,
);
continue;
}
let Some(family) = row.family() else { continue };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
if resp.hovered() && ui.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO || inp.smooth_scroll_delta != egui::Vec2::ZERO) {
hovered = Some(i);
}
let p = ui.painter_at(rect);
if st.highlight == Some(i) {
p.rect_filled(rect, 2.0, t.selection);
} else if matches!(row, Row::Family(f) if f.eq_ignore_ascii_case(current)) {
p.rect_filled(rect, 2.0, t.hover);
}
// The disclosure triangle of a family with several styles.
let tri = egui::Rect::from_min_size(rect.min, vec2(DISCLOSURE, row_h));
let mut on_triangle = false;
if let Row::Family(f) = row
&& db.styles(f).len() > 1
{
let open = st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f));
p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim);
on_triangle = resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos));
if resp.clicked() && on_triangle {
toggled = Some(f.to_lowercase());
}
}
// The star at the end of a family's row.
let star = egui::Rect::from_min_max(egui::pos2(rect.max.x - STAR, rect.min.y), rect.max);
let mut on_star = false;
if let Row::Family(f) = row {
let on = favorite(f);
let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos));
if on || hot || st.highlight == Some(i) {
let color = if on { t.accent } else { t.text_dim };
p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color);
}
on_star = resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos));
if resp.clicked() && on_star {
starred = Some(f.clone());
}
}
let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE };
let name_w = rect.width() * NAME_SHARE;
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 {
Row::Style(_, style) => style.clone(),
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));
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);
}
if resp.clicked() && !on_triangle && !on_star {
chosen = row.pick();
}
}
});
st.scrolled = shown.state.clamped_offset().y;
if let Some(f) = toggled {
if st.expanded.contains(&f) {
st.expanded.retain(|e| *e != f);
} else {
st.expanded.push(f);
}
// The rows change next frame: what the highlight points at is decided then.
return None;
}
if let Some(i) = hovered {
st.highlight = Some(i);
}
if let Some(f) = starred {
return Some(FontPick::Favorite(f));
}
if enter && chosen.is_none() {
chosen = st.highlight.and_then(|i| rows.get(i)).and_then(Row::pick).or_else(|| rows.iter().find_map(Row::pick));
}
if let Some((f, s)) = chosen {
ui.close();
return Some(FontPick::Chosen(f, s));
}
// The highlighted row is previewed; back on the current family, the preview goes.
let lit = st.highlight.and_then(|i| rows.get(i)).and_then(Row::pick);
match lit {
Some((f, None)) if f == current => st.previewed.take().map(|_| FontPick::EndPreview),
Some(pick) if st.previewed.as_ref() != Some(&pick) => {
st.previewed = Some(pick.clone());
Some(FontPick::Preview(pick.0, pick.1))
}
None => st.previewed.take().map(|_| FontPick::EndPreview),
_ => None,
}
}
// ---------- samples ----------
/// Width of the sample drawn at the end of a Type → Font menu item.
/// The room a Type → Font menu item keeps for its sample, after the family's name.
pub(crate) const MENU_SAMPLE_SIZE: egui::Vec2 = vec2(90.0, 16.0);
/// A generic sample of `family` drawn in `slot` (the room a Type → Font item of the in-window
/// menu bar keeps for it; the native macOS menu shows names only).
pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str) {
if let Some(tex) = sample_texture(ui.ctx(), family, None, None, slot.height()) {
paint_sample(ui, slot, &tex, Tokens::get(ui.ctx()).text_dim);
}
}
/// Draw sample `tex` (tinted `tint`) at the left of `area`, centred vertically, cut at its right.
fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, tint: Color32) {
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size);
let shown = at.intersect(area);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0));
ui.painter_at(area).image(tex.id(), shown, uv, tint);
}
type Key = (String, String, String, u32);
/// Samples kept before the cache starts over.
const MAX_SAMPLES: usize = 256;
#[derive(Default)]
struct Samples {
ready: HashMap<Key, Option<egui::TextureHandle>>,
#[cfg(not(target_arch = "wasm32"))]
pending: HashMap<Key, std::sync::mpsc::Receiver<Option<egui::ColorImage>>>,
/// The pass the budget is for, and the samples started in it.
pass: u64,
started: usize,
}
impl Samples {
/// Take one sample from this frame's budget ([`SAMPLES_PER_FRAME`]); `false` when it is spent
/// (another frame is asked for, to go on).
fn take_budget(&mut self, ctx: &egui::Context) -> bool {
let pass = ctx.cumulative_pass_nr();
if self.pass != pass {
(self.pass, self.started) = (pass, 0);
}
if self.started >= SAMPLES_PER_FRAME {
ctx.request_repaint();
return false;
}
self.started += 1;
true
}
}
thread_local! {
static SAMPLES: crate::graphics::TexCache<Samples> = crate::graphics::TexCache::default();
}
/// The sample of `family` for a row `height` points high: the selected text set in it (else a
/// generic sample), white with coverage as alpha (tinted when drawn). Rendered off the UI thread
/// on native (the worker asks for a frame when it is done), a few per frame; `None` until it is
/// ready.
fn sample_texture(ctx: &egui::Context, family: &str, style: Option<&str>, sample: Option<&str>, height: f32) -> Option<egui::TextureHandle> {
let px = (height * ctx.pixels_per_point()).round().clamp(8.0, 96.0) as u32;
let key: Key = (family.to_string(), style.unwrap_or("Regular").to_string(), sample.unwrap_or_default().to_string(), px);
let texture = |img: egui::ColorImage| ctx.load_texture(format!("font-sample-{family}-{px}"), img, egui::TextureOptions::LINEAR);
SAMPLES.with(|s| {
let mut s = s.borrow_mut();
if let Some(t) = s.ready.get(&key) {
return t.clone();
}
// Up to ~370 KB of texture each (96 px high, 10:1): keep a few screens' worth.
if s.ready.len() > MAX_SAMPLES {
s.ready.clear();
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(rx) = s.pending.get(&key) {
let img = match rx.try_recv() {
Ok(img) => img,
Err(std::sync::mpsc::TryRecvError::Empty) => return None,
Err(std::sync::mpsc::TryRecvError::Disconnected) => None,
};
s.pending.remove(&key);
let tex = img.map(texture);
s.ready.insert(key, tex.clone());
return tex;
}
if s.pending.len() >= MAX_PENDING || !s.take_budget(ctx) {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
let (f, style, text) = (key.0.clone(), key.1.clone(), key.2.clone());
let repaint = ctx.clone();
let spawned = std::thread::Builder::new().name("font-sample".into()).spawn(move || {
// The receiver may be gone (the cache was cleared): nothing to do then.
let _ = tx.send(render_sample(&f, &style, &text, px));
repaint.request_repaint();
});
if spawned.is_ok() {
s.pending.insert(key, rx);
} else {
s.ready.insert(key, None);
}
None
}
// The web has no threads: a few samples per frame, on this one.
#[cfg(target_arch = "wasm32")]
{
if !s.take_budget(ctx) {
return None;
}
let tex = render_sample(&key.0, &key.1, &key.2, px).map(texture);
s.ready.insert(key, tex.clone());
tex
}
})
}
/// Longest sample, in pixels per pixel of height (wider samples are cut).
const MAX_ASPECT: f32 = 10.0;
/// `text` (or [`FALLBACK_SAMPLE`] when the font lacks some of its characters) set in `family`,
/// `px` pixels high: a coverage mask as white with alpha.
fn render_sample(family: &str, style: &str, text: &str, px: u32) -> Option<egui::ColorImage> {
let db = vectorcraft_text::FontDb::global();
let face: Arc<vectorcraft_text::FontFace> = db.face(family, style)?;
let covers = |s: &str| !s.trim().is_empty() && s.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c));
let text = if covers(text) { text } else { FALLBACK_SAMPLE };
if !covers(text) && !face.family.eq_ignore_ascii_case(family) {
return None;
}
let (asc, desc) = face.vertical_metrics();
let em = (asc + desc).max(1.0) / face.units_per_em();
// The em box fills the height with a little air.
let size = px as f64 * 0.8 / em;
let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size, ..Default::default() };
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, text, style);
let layout = vectorcraft_text::layout(db, &t);
let mut path = layout.to_bezpath();
let b = layout.bounds;
let w = (b.width().ceil() as u32 + 2).clamp(1, (px as f32 * MAX_ASPECT) as u32) as usize;
let h = px as usize;
// Baseline where the ascent fits the top (with the air above it).
let baseline = px as f64 * 0.1 + asc / face.units_per_em() * size;
path.apply_affine(vectorcraft_geom::Affine::translate((1.0 - b.x0.min(0.0), baseline)));
let mask = crate::panels::glyphs::rasterize(&path, w, h);
Some(egui::ColorImage::new([w, h], mask.iter().map(|&a| Color32::from_rgba_premultiplied(a, a, a, a)).collect()))
}
#[cfg(test)]
pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Option<egui::ColorImage> {
render_sample(family, "Regular", text, px)
}
// ---------- applying a pick ----------
/// Is a font previewed on the text (an interaction is open for it)?
const PREVIEWING: &str = "font-previewing";
/// Where the open font menu was drawn last: (pass, its popup's layer).
fn open_id() -> egui::Id {
egui::Id::unique("font-menu-open")
}
/// Undo a preview whose menu is gone (its panel wasn't drawn: the text was deselected, the panel
/// closed) or is closing (Escape, a press outside the menu), before anything else sees this
/// frame's input: the canvas must not act inside the preview's interaction (a drag would be
/// rolled back with the preview). The shell calls it at the start of each frame.
pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
if !crate::panels::pstate::<bool>(ctx, PREVIEWING) {
return;
}
let drawn = ctx.data(|d| d.get_temp::<(u64, egui::LayerId)>(open_id()));
// Drawn in the last pass: still open.
let Some((_, layer)) = drawn.filter(|(pass, _)| pass + 1 >= ctx.cumulative_pass_nr()) else {
apply(app, ctx, FontPick::EndPreview);
return;
};
// (`layer_id_at` outside `input`: egui's context lock isn't re-entrant.)
let (escape, press) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.pointer.any_pressed().then(|| i.pointer.interact_pos())));
let leaving = escape || press.is_some_and(|at| at.is_none_or(|p| ctx.layer_id_at(p) != Some(layer)));
if leaving {
apply(app, ctx, FontPick::EndPreview);
}
}
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
/// text (the whole text when nothing is selected), else the selected text objects.
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
match pick {
FontPick::Preview(f, style) => {
if !previewing {
crate::panels::character::end_typing(app);
if app.session.begin_interaction("Font").is_err() {
return;
}
crate::panels::set_pstate(ctx, PREVIEWING, true);
}
let (cmd, params) = font_command(app, &f, style.as_deref());
if let Err(e) = app.session.preview(cmd, &params) {
app.ui.status = e.to_string();
}
app.sync_views();
}
FontPick::EndPreview => {
if previewing {
app.session.cancel_interaction().ok();
crate::panels::set_pstate(ctx, PREVIEWING, false);
app.sync_views();
}
}
FontPick::Favorite(f) => toggle_favorite(app, f),
FontPick::Chosen(f, style) => {
if previewing {
app.session.cancel_interaction().ok();
crate::panels::set_pstate(ctx, PREVIEWING, false);
}
crate::panels::character::end_typing(app);
let (cmd, params) = font_command(app, &f, style.as_deref());
app.run(cmd, params).ok();
}
}
}
/// Star `family`, or take its star off (any case).
fn toggle_favorite(app: &mut VectorcraftApp, family: String) {
let favs = &mut app.ui.favorite_fonts;
match favs.iter().position(|x| x.eq_ignore_ascii_case(&family)) {
Some(i) => {
favs.remove(i);
}
None => favs.push(family),
}
}
/// For a font menu that only picks a font and never changes the document (Find Font's Replace
/// With, the Glyphs panel): the family (and style) chosen, if any. A star is toggled here; the
/// highlighted row is not previewed.
pub(crate) fn picked(app: &mut VectorcraftApp, pick: Option<FontPick>) -> Option<(String, Option<String>)> {
match pick? {
FontPick::Chosen(f, style) => Some((f, style)),
FontPick::Favorite(f) => {
toggle_favorite(app, f);
None
}
FontPick::Preview(..) | FontPick::EndPreview => None,
}
}
/// The command that sets font `family` (and `style`, else the closest the family has) on what the
/// menus act on.
fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'static str, serde_json::Value) {
let mut p = json!({"font": family});
if let Some(s) = style {
p["style"] = json!(s);
}
match crate::panels::character::text_editing(app) {
Some((id, a, b)) => {
p["id"] = json!(id.0);
if b > a {
p["start"] = json!(a);
p["end"] = json!(b);
}
("text.setRangeStyle", p)
}
None => ("text.setStyle", p),
}
}
/// The text the menus sample: the Type tool's selected characters, else the first line of the
/// first selected text object, at most a dozen characters.
pub(crate) fn sample_text(app: &VectorcraftApp) -> Option<String> {
let doc = &app.session.active()?.doc;
let text = match crate::panels::character::text_editing(app) {
Some((id, a, b)) if b > a => match &doc.node(id)?.kind {
vectorcraft_doc::NodeKind::Text(t) => t.plain_text().get(a..b).map(str::to_string),
_ => None,
},
_ => app.session.active()?.selection.objects.iter().find_map(|id| match &doc.node(*id)?.kind {
vectorcraft_doc::NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
}),
}?;
let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
Some(line.chars().take(12).collect())
}
#[cfg(test)]
mod tests {
/// The script filter: Thai lists families with Thai; Latin, those with neither Thai nor
/// Japanese.
#[test]
fn the_thai_filter_lists_fonts_with_thai() {
use vectorcraft_text::FontTraits;
let (thai, japanese, latin) =
(FontTraits { thai: true, ..Default::default() }, FontTraits { japanese: true, ..Default::default() }, FontTraits::default());
let passing = |t: FontTraits| (0..super::SCRIPTS).filter(|s| super::script_matches(*s, t)).collect::<Vec<_>>();
assert_eq!(passing(thai), [0, 3]);
assert_eq!(passing(japanese), [0, 1]);
assert_eq!(passing(latin), [0, 2]);
assert_eq!(super::script_label(3), "Thai");
let zh = crate::i18n::Lang::from_code("zh-hans").unwrap();
crate::i18n::set_current(zh);
assert_eq!(super::script_label(1), crate::i18n::tr_ctx(zh, "font-script", "Japanese"));
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(super::script_label(1), "Japanese");
}
/// #2277: the list's scroll bar has a column of its own, so the stars at the rows' right ends
/// stay clear of it.
#[test]
fn the_scroll_bar_leaves_the_stars_clear() {
// Every family starred, so each row draws its star.
let all = vectorcraft_text::FontDb::global().families();
let families = all.len();
let ctx = egui::Context::default();
let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0));
let mut st = super::MenuState::default();
let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() };
let mut stars = vec![];
for _ in 0..3 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look);
});
out.textures_delta.clear();
stars = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
// The rows' stars, not the ★ filter button at the top left.
egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()),
_ => None,
})
.collect();
}
assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn");
// The rows don't fill the list without enough families to scroll, and then there is no bar.
if families as f32 * look.row > area.height() {
let bar = super::widgets::list_scroll_style();
let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin;
for right in stars {
assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar");
}
}
}
use super::{Row, remap_highlight};
fn family(name: &str) -> Row {
Row::Family(name.to_string())
}
#[test]
fn recent_fonts_are_first_available_unique_and_expandable() {
let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into();
let recent: Vec<_> = ["Charlie", "Missing", "BRAVO", "charlie"].map(str::to_string).into();
let rows = super::grouped_rows(&names, &recent, &["bravo".into()], |_| vec!["Bold".into()]);
assert_eq!(
rows,
[
Row::Heading("Recent Fonts"),
family("Charlie"),
family("Bravo"),
Row::Style("Bravo".into(), "Bold".into()),
Row::Heading("Fonts"),
family("Alpha")
]
);
// A filter removing a recent family also removes it from the recent section.
let filtered = super::grouped_rows(&names[..1], &recent, &[], |_| vec![]);
assert_eq!(filtered, [family("Alpha")]);
assert!(super::grouped_rows(&[], &recent, &[], |_| vec![]).is_empty());
}
#[test]
fn recent_section_rebuilds_and_arrows_skip_its_headings() {
let names = vectorcraft_text::FontDb::global().menu_family_list();
if names.len() < 3 {
return;
}
let recent = vec![names[2].clone(), names[1].clone()];
let ctx = egui::Context::default();
let mut st = super::MenuState::default();
let mut frame = |events, recent: &[String]| {
let mut picked = None;
let input = egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
picked = super::list(
ui,
egui::Id::unique("recent-test"),
&mut st,
&names[2],
None,
super::MenuLook { samples: false, recent, ..Default::default() },
);
});
out.textures_delta.clear();
(picked, st.rows.to_vec())
};
for _ in 0..3 {
frame(vec![], &recent);
}
let key = |key| {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec()
};
assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None)));
// Other tests add fonts to the process-wide database while this one runs, so the first
// family under the Fonts heading need not be the one this test's earlier snapshot named.
// Read it from the rows this frame built: the highlight must skip the heading and land on
// that family, not on a heading or a row from the recent section.
let (picked, rows) = frame(key(egui::Key::ArrowDown), &recent);
assert_eq!(rows.get(3), Some(&Row::Heading("Fonts")), "{rows:?}");
let (first, style) = rows.get(4).and_then(Row::pick).expect("a family below the Fonts heading");
assert_eq!(picked, Some(super::FontPick::Preview(first, style)), "skip Fonts heading");
assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None)));
// Updating recent history while the menu is open must not retarget the highlight.
let updated = vec![names[1].clone()];
let (_, rows) = frame(vec![], &updated);
assert_eq!(rows[1], family(&names[1]));
assert_eq!(frame(key(egui::Key::Enter), &updated).0, Some(super::FontPick::Chosen(names[1].clone(), None)));
}
#[test]
fn recent_fonts_obey_search_and_favorite_filters() {
let names = vectorcraft_text::FontDb::global().menu_family_list();
if names.len() < 3 {
return;
}
let recent = vec![names[1].clone(), names[0].clone()];
let favorites = vec![names[0].clone()];
let ctx = egui::Context::default();
let mut st = super::MenuState { favorites_only: true, ..Default::default() };
let mut frame = |events| {
let mut pick = None;
let mut out = ctx.run_ui(
egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))),
..Default::default()
},
|ui| {
pick = super::list(
ui,
egui::Id::unique("filtered-recent"),
&mut st,
&names[2],
None,
super::MenuLook { samples: false, recent: &recent, favorites: &favorites, ..Default::default() },
);
},
);
out.textures_delta.clear();
(pick, st.rows.to_vec())
};
for _ in 0..3 {
frame(vec![]);
}
assert_eq!(frame(vec![]).1, [Row::Heading("Recent Fonts"), family(&names[0])]);
let key = [true, false]
.map(|pressed| egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Default::default() })
.to_vec();
assert_eq!(frame(key).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Recent Fonts heading");
let (pick, rows) = frame(vec![egui::Event::Text("NoSuchFontFor3515".into())]);
assert!(rows.is_empty(), "search must filter the recent section too: {rows:?}");
assert_eq!(pick, Some(super::FontPick::EndPreview));
}
#[test]
fn opening_on_another_font_keeps_the_recent_section_visible() {
let names = vectorcraft_text::FontDb::global().menu_family_list();
let Some(current) = names.last() else { return };
let recent = vec![names[0].clone()];
let ctx = egui::Context::default();
let mut st = super::MenuState::default();
let mut visible = false;
for _ in 0..3 {
let mut out = ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() },
|ui| {
super::list(
ui,
egui::Id::unique("recent-at-top"),
&mut st,
current,
None,
super::MenuLook { samples: false, recent: &recent, ..Default::default() },
);
},
);
out.textures_delta.clear();
visible = out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Recent Fonts"));
}
assert!(visible, "opening the picker must show its recent section");
}
#[test]
fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() {
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
assert_eq!(remap_highlight(&before, Some(2), &[family("Bravo"), family("Charlie")]), Some(1));
assert_eq!(remap_highlight(&before, Some(2), &[family("Alpha"), family("New"), family("Bravo"), family("Charlie")]), Some(3));
}
#[test]
fn clears_the_highlight_when_its_family_is_filtered_out() {
let before = [family("Alpha"), family("Bravo"), family("Charlie")];
assert_eq!(remap_highlight(&before, Some(1), &[family("Alpha"), family("Charlie")]), None);
assert_eq!(remap_highlight(&before, Some(10), &before), None);
assert_eq!(remap_highlight(&before, None, &before), None);
}
#[test]
fn expanded_style_rows_do_not_retarget_the_highlight() {
let before = [family("Alpha"), family("Bravo")];
let after = [family("Alpha"), Row::Style("Alpha".into(), "Bold".into()), family("Bravo")];
assert_eq!(remap_highlight(&before, Some(1), &after), Some(2));
assert_eq!(remap_highlight(&after, Some(1), &before), None);
}
/// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun.
#[test]
fn the_variable_filter_is_named_as_fonts_are() {
use crate::i18n::{Lang, tr, tr_ctx};
let (ja, zh) = (Lang::from_code("ja").unwrap(), Lang::from_code("zh-hans").unwrap());
assert_eq!((tr_ctx(ja, "font-technology", "Variable"), tr(ja, "Variable")), ("バリアブル", "変数"));
assert_eq!((tr_ctx(zh, "font-technology", "Variable"), tr(zh, "Variable")), ("可变", "变量"));
assert_eq!(tr(ja, "Font Technology"), "フォントテクノロジー");
assert_eq!(super::variable_label(), "Variable");
}
/// Font Technology › Variable lists only variable font families, together with the other
/// filters; clicked again, every family is back.
#[test]
fn the_variable_filter_lists_only_variable_families() {
use vectorcraft_text::FontClass;
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(two_axis_font().unwrap());
let ctx = egui::Context::default();
let mut st = super::MenuState::default();
let mut favorites: Vec<String> = vec![];
let frame = |st: &mut super::MenuState, events, favorites: &[String]| {
let mut out = ctx.run_ui(
egui::RawInput {
events,
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))),
..Default::default()
},
|ui| {
super::list(
ui,
egui::Id::unique("variable-filter"),
st,
"Source Sans 3",
None,
super::MenuLook { samples: false, favorites, ..Default::default() },
);
},
);
out.textures_delta.clear();
let button = out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == "Variable" => Some(t.visual_bounding_rect().center()),
_ => None,
});
let families: Vec<String> = st
.rows
.iter()
.filter_map(|r| match r {
Row::Family(f) => Some(f.clone()),
_ => None,
})
.collect();
(families, button)
};
let has = |list: &[String], f: &str| list.iter().any(|x| x == f);
let click = |st: &mut super::MenuState, favorites: &[String]| {
let (_, at) = frame(st, vec![], favorites);
let at = at.expect("the Variable filter is drawn");
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(st, vec![egui::Event::PointerMoved(at), button(true)], favorites);
frame(st, vec![button(false)], favorites);
frame(st, vec![], favorites).0
};
for _ in 0..3 {
frame(&mut st, vec![], &favorites);
}
let all = frame(&mut st, vec![], &favorites).0;
assert!(has(&all, TWO_AXIS_FAMILY) && has(&all, "Source Sans 3") && has(&all, "JetBrains Mono"), "{all:?}");
let variable = click(&mut st, &favorites);
assert!(st.variable_only, "the click turns the filter on");
assert!(has(&variable, TWO_AXIS_FAMILY), "{variable:?}");
assert!(!has(&variable, "Source Sans 3") && !has(&variable, "Inter") && !has(&variable, "JetBrains Mono"), "static families: {variable:?}");
assert!(variable.iter().all(|f| db.family_traits(f).variable), "{variable:?}");
// With the kind, script and favourites filters: each must pass.
st.class = Some(FontClass::Sans);
assert!(has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY));
st.class = Some(FontClass::Monospaced);
assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a sans serif family under Monospaced");
st.class = None;
st.script = 3;
assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a family without Thai under Thai");
st.script = 0;
st.favorites_only = true;
favorites.push("Source Sans 3".into());
assert!(frame(&mut st, vec![], &favorites).0.is_empty(), "the one favourite is static");
favorites.push(TWO_AXIS_FAMILY.into());
assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]);
st.favorites_only = false;
// Off again: every family is listed.
let again = click(&mut st, &favorites);
assert!(!st.variable_only);
assert_eq!(again, frame(&mut st, vec![], &[]).0);
assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}");
}
}