Files
vectorcraft/crates/ui-egui/src/font_menu.rs
T
Ayberk A. f14f8d8eb7 Font menu: the scroll bar no longer covers the favourite stars (#2277)
The family list scrolled with egui's floating scroll bar, which is drawn
over the rows' right ends, where each family's star is: with the pointer
there the bar widened over the star and a click went to the bar. The list
now takes the scroll bar the menus got for the same reason (#2086), slim
and in a column of its own; `widgets::list_scroll_style` holds it for both.
2026-10-16 14:57:19 +07:00

849 lines
36 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. Preferences ›
//! Type › Enable in-menu font previews and Font Preview Size turn the samples off or size the
//! rows.
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],
}
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: &[] }
}
}
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,
}
}
}
/// 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 {
1 => tl!("Japanese"),
2 => tl!("Latin"),
3 => tl!("Thai"),
_ => tl!("All Scripts"),
}
}
/// 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 {
Family(String),
Style(String, String),
}
impl Row {
fn family(&self) -> &str {
match self {
Row::Family(f) | Row::Style(f, _) => f,
}
}
fn style(&self) -> Option<&str> {
match self {
Row::Family(_) => None,
Row::Style(_, s) => Some(s),
}
}
fn pick(&self) -> (String, Option<String>) {
(self.family().to_string(), self.style().map(str::to_string))
}
}
/// 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 and favourites filters, Show Font
/// Names in English, the expanded families, the favourites and the font list's generation.
type RowsKey = (String, usize, Option<FontClass>, bool, bool, 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, and
/// favourites only.
script: usize,
class: Option<FontClass>,
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.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.
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;
}
}
});
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.favorites_only, look.english_names, st.expanded.clone(), look.favorites.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() {
return true;
}
let traits = db.family_traits(f);
script_matches(st.script, traits) && st.class.is_none_or(|c| c == traits.class)
};
let mut rows: Vec<Row> = vec![];
for f in db.menu_family_list().iter().filter(|f| wanted(f)) {
rows.push(Row::Family(f.clone()));
if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) {
rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
}
}
// 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 last = rows.len() - 1;
st.highlight = Some(match (st.highlight, down) {
(None, _) => 0,
(Some(i), true) => (i + 1).min(last),
(Some(i), false) => i.saturating_sub(1),
});
keyed = true;
}
// → opens the highlighted family's styles, ← closes them (from the family or one of them).
if let Some(row) = st.highlight.and_then(|i| rows.get(i)).cloned().filter(|_| left || right) {
let f = row.family().to_lowercase();
let open = st.expanded.contains(&f);
if right && !open && db.styles(row.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(row.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));
// The highlight in view: centred as the menu opens, scrolled just enough as the keys move it.
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 };
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),
};
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(), row.family(), row.style(), sample, sample_rect.height()) {
paint_sample(ui, sample_rect, &tex, t.text);
}
if resp.clicked() && !on_triangle && !on_star {
chosen = Some(row.pick());
}
}
});
st.scrolled = shown.state.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)).or(rows.first()).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)).map(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::new("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");
}
/// #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::new("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 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);
}
}