feat(ui): Lay out the font menu like InDesign's

The font menu (Control bar, Character panel, Properties, task bar, style
dialogs, Find Font, Glyphs) now has InDesign's layout:

- a search field with a clear button, and a Filters row: classification
  (All Classes, serif, slab serif, sans serif, script, monospaced,
  decorative, symbol), Show Favorites Only, Show Recently Added (fonts
  added in the last 30 days);
- the sample text (Selected Text, Sample, Aa Bb Cc, a pangram, digits)
  in three sizes, remembered with the UI state;
- Recently Used fonts at the top, then every family;
- a family of several styles shows how many and opens to list them, each
  previewed in its own face; picking a style applies it as it is;
- each family's format after its sample, and Show Similar Fonts (≈) to
  list the families of the same class nearest by PANOSE.

Find More and the activated-fonts filter are Adobe Fonts services and
are left out. The font combo is a field and a plain popup now: egui's
combo box kept its menu at the size it last had, so a family opening or
a filter clearing didn't show. The ≈ button stays while the pointer is
on it (the row under it no longer counts as hovered there). New strings
are translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-12 09:55:02 +02:00
committed by moppeeer
co-authored by Claude Opus 5.5
parent 84a919d57a
commit 8b0bc54bdb
12 changed files with 756 additions and 198 deletions
+3
View File
@@ -129,6 +129,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-11** · Font menu laid out like InDesign's: filters (classification, favourites,
recently added), families opening to their styles, the selected text as the sample in three
sizes, Recently Used fonts, Show Similar Fonts and each font's format.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
+7 -2
View File
@@ -2906,7 +2906,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
cf.set(d, "fontFamily", Value::Null);
}
if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) {
cf.set(d, "fontFamily", json!(f));
cf.set(d, "fontFamily", json!(f.family));
if let Some(style) = f.style {
cf.set(d, "fontStyle", json!(style));
}
}
});
ui.end_row();
@@ -3720,7 +3723,9 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
if let Some(f) = crate::panels::font_combo(app, ui, "toFamily", &d.s("toFamily"), 170.0) {
d.fields.insert("toFamily".into(), json!(f));
let style = crate::panels::pick_style(app, &f);
d.fields.insert("toFamily".into(), json!(f.family));
d.fields.insert("toStyle".into(), json!(style));
}
ui.end_row();
let styles = db.styles(&d.s("toFamily"));
+49
View File
@@ -2811,6 +2811,55 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]),
("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]),
("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]),
// Font menu
("Filters:", ["Filter:", "Filtres :", "Filtros:", "フィルター:", "过滤器:"]),
("All Classes", ["Alle Klassen", "Toutes les classes", "Todas las clases", "すべての分類", "所有分类"]),
("Serif", ["Serifenschrift", "Serif", "Con serifa", "セリフ", "衬线"]),
("Slab Serif", ["Egyptienne", "Slab serif", "Serifa cuadrada", "スラブセリフ", "粗衬线"]),
("Sans Serif", ["Serifenlos", "Sans serif", "Sin serifa", "サンセリフ", "无衬线"]),
("Script", ["Schreibschrift", "Script", "Manuscrita", "スクリプト", "手写体"]),
("Monospaced", ["Dicktengleich", "Chasse fixe", "Monoespaciada", "等幅", "等宽"]),
("Decorative", ["Dekorativ", "Décorative", "Decorativa", "装飾", "装饰"]),
("Symbol", ["Symbol", "Symbole", "Símbolo", "記号", "符号"]),
(
"Show Recently Added",
[
"Zuletzt hinzugefügte anzeigen",
"Afficher les polices ajoutées récemment",
"Mostrar añadidas recientemente",
"最近追加したフォントを表示",
"显示最近添加的字体",
],
),
(
"Small Sample Text Size",
["Kleine Beispieltextgröße", "Petite taille de l'exemple", "Tamaño de muestra pequeño", "サンプルテキスト(小)", "小号示例文本"],
),
(
"Medium Sample Text Size",
["Mittlere Beispieltextgröße", "Taille moyenne de l'exemple", "Tamaño de muestra mediano", "サンプルテキスト(中)", "中号示例文本"],
),
(
"Large Sample Text Size",
["Große Beispieltextgröße", "Grande taille de l'exemple", "Tamaño de muestra grande", "サンプルテキスト(大)", "大号示例文本"],
),
("Selected Text", ["Ausgewählter Text", "Texte sélectionné", "Texto seleccionado", "選択したテキスト", "所选文本"]),
("Sample", ["Beispiel", "Exemple", "Muestra", "サンプル", "示例"]),
("Similar to {family}", ["Ähnlich wie {family}", "Semblables à {family}", "Similares a {family}", "{family} に類似", "与 {family} 相似"]),
(
"No fonts match",
["Keine passenden Schriften", "Aucune police ne correspond", "Ninguna fuente coincide", "一致するフォントはありません", "没有匹配的字体"],
),
(
"Show Similar Fonts",
["Ähnliche Schriften anzeigen", "Afficher les polices semblables", "Mostrar fuentes similares", "類似フォントを表示", "显示相似字体"],
),
("Show Styles", ["Schnitte einblenden", "Afficher les styles", "Mostrar estilos", "スタイルを表示", "显示样式"]),
("Hide Styles", ["Schnitte ausblenden", "Masquer les styles", "Ocultar estilos", "スタイルを隠す", "隐藏样式"]),
("TrueType font", ["TrueType-Schrift", "Police TrueType", "Fuente TrueType", "TrueType フォント", "TrueType 字体"]),
("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]),
("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]),
("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]),
];
/// Interface direction; independent of document binding and paragraph direction.
+25
View File
@@ -1492,4 +1492,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Paragraph Rules", "Filetti paragrafo"),
("Rule Below", "Filetto sotto"),
("Tint:", "Tinta:"),
// Font menu
("Filters:", "Filtri:"),
("All Classes", "Tutte le classi"),
("Serif", "Graziato"),
("Slab Serif", "Graziato squadrato"),
("Sans Serif", "Senza grazie"),
("Script", "Calligrafico"),
("Monospaced", "Monospaziato"),
("Decorative", "Decorativo"),
("Symbol", "Simboli"),
("Show Recently Added", "Mostra aggiunti di recente"),
("Small Sample Text Size", "Testo di esempio piccolo"),
("Medium Sample Text Size", "Testo di esempio medio"),
("Large Sample Text Size", "Testo di esempio grande"),
("Selected Text", "Testo selezionato"),
("Sample", "Esempio"),
("Similar to {family}", "Simili a {family}"),
("No fonts match", "Nessun font corrisponde"),
("Show Similar Fonts", "Mostra font simili"),
("Show Styles", "Mostra stili"),
("Hide Styles", "Nascondi stili"),
("TrueType font", "Font TrueType"),
("OpenType font", "Font OpenType"),
("Variable font", "Font variabile"),
("Color font", "Font a colori"),
];
+5
View File
@@ -243,6 +243,9 @@ pub struct UiState {
pub workspace: String,
/// Window › Workspace › New Workspace: saved panel arrangements.
pub custom_workspaces: Vec<SavedWorkspace>,
/// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2).
pub font_sample: String,
pub font_preview_size: u8,
/// Transform reference point (0..8, row-major; 0 = top-left).
pub ref_point: u8,
/// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`).
@@ -340,6 +343,8 @@ impl Default for UiState {
units: Unit::Picas,
workspace: "Essentials".into(),
custom_workspaces: Vec::new(),
font_sample: "selected".into(),
font_preview_size: 1,
ref_point: 0,
align_to: "selection".into(),
text_style_tab: 0,
+549
View File
@@ -0,0 +1,549 @@
//! The Font menu, laid out like InDesign's: a search field, a Filters row (classification,
//! favourites, recently added, similar fonts), the sample text and its size, then the recently used
//! fonts and every family. A family with several styles shows how many and opens to list them,
//! each previewed in its own face; the format of each family shows after its sample.
use std::collections::HashSet;
use designcraft_fonts::{FaceKind, FamilyInfo, FontClass, FontFormat};
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::DesignApp;
use crate::theme::Tokens;
/// A font picked in the menu: the family, and the style when one of its styles was picked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontPick {
pub family: String,
pub style: Option<String>,
}
/// The sample texts the menu can preview (the first is the text selection's own).
pub const SAMPLES: [(&str, &str); 5] = [
("selected", "Selected Text"),
("sample", "Sample"),
("pairs", "Aa Bb Cc"),
("pangram", "The quick brown fox jumps over the lazy dog"),
("digits", "0123456789"),
];
/// Preview sizes: (row height, sample size in points).
const SIZES: [(f32, f32); 3] = [(22.0, 13.0), (28.0, 17.0), (40.0, 26.0)];
/// Fonts listed as recently used, and a recently added font's age.
const RECENT: usize = 10;
const ADDED_WITHIN: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60);
/// Families Show Similar Fonts lists.
const SIMILAR: usize = 30;
/// The menu's state while it is open.
#[derive(Clone, Default)]
struct MenuState {
query: String,
only_favs: bool,
only_recent: bool,
class: Option<FontClass>,
/// Show Similar Fonts: the family the list is like.
similar: Option<String>,
expanded: HashSet<String>,
/// The Filters and Sample choices, shown in the menu (a menu can't hold another popup).
show_classes: bool,
show_samples: bool,
highlight: Option<usize>,
frame: Option<u64>,
}
/// What the menu knows about a family (read once, then kept for the session).
#[derive(Clone)]
struct FamilyMeta {
styles: Vec<String>,
kind: FaceKind,
added: bool,
}
fn regular(styles: &[String]) -> String {
styles.iter().find(|s| *s == "Regular").or(styles.first()).cloned().unwrap_or_else(|| "Regular".into())
}
fn meta(ui: &Ui, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32, family: &str) -> FamilyMeta {
let key = egui::Id::new(("font_meta", family, scope));
if let Some(m) = ui.data(|d| d.get_temp::<FamilyMeta>(key)) {
return m;
}
let styles = fonts.styles(family);
let face = fonts.face(family, &regular(&styles));
let added = face.added().and_then(|t| t.elapsed().ok()).is_some_and(|age| age < ADDED_WITHIN);
let m = FamilyMeta { styles, kind: face.kind(), added };
ui.data_mut(|d| d.insert_temp(key, m.clone()));
m
}
/// One row of the list.
#[derive(Clone)]
enum Row {
/// A family; `recent` rows are the Recently Used ones at the top.
Family {
info: FamilyInfo,
recent: bool,
},
Style {
family: String,
style: String,
},
Separator,
}
/// A five-pointed star (filled, or outlined).
fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: Color32) {
let pt = |i: usize, rad: f32| {
let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
c + egui::vec2(a.cos(), a.sin()) * rad
};
let outline: Vec<egui::Pos2> = (0..10).map(|i| pt(i, if i % 2 == 0 { r } else { r * 0.42 })).collect();
if filled {
// The inner pentagon and the five points (each convex).
let inner: Vec<egui::Pos2> = (0..5).map(|i| outline[2 * i + 1]).collect();
p.add(egui::Shape::convex_polygon(inner, color, Stroke::NONE));
for i in 0..5 {
p.add(egui::Shape::convex_polygon(vec![outline[(2 * i + 9) % 10], outline[2 * i], outline[2 * i + 1]], color, Stroke::NONE));
}
} else {
p.add(egui::Shape::closed_line(outline, Stroke::new(1.0, color)));
}
}
/// The format badge after a family's sample: TT, O (PostScript outlines), a variable font's
/// slider, or a colour font's four dots.
fn paint_format(p: &egui::Painter, r: Rect, format: FontFormat, color: Color32) {
let c = r.center();
match format {
FontFormat::TrueType => {
p.text(c, Align2::CENTER_CENTER, "TT", FontId::proportional(10.0), color);
}
FontFormat::OpenType => {
p.circle_stroke(c, 5.0, Stroke::new(1.3, color));
}
FontFormat::Variable => {
p.line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], Stroke::new(1.2, color));
p.circle_filled(c + vec2(2.0, 0.0), 2.6, color);
}
FontFormat::Color => {
for (i, dot) in
[Color32::from_rgb(230, 80, 70), Color32::from_rgb(250, 190, 60), Color32::from_rgb(80, 180, 110), Color32::from_rgb(70, 140, 230)]
.into_iter()
.enumerate()
{
let (dx, dy) = ((i % 2) as f32 * 5.0 - 2.5, (i / 2) as f32 * 5.0 - 2.5);
p.circle_filled(c + vec2(dx, dy), 2.1, dot);
}
}
}
}
fn format_name(format: FontFormat) -> &'static str {
match format {
FontFormat::TrueType => "TrueType font",
FontFormat::OpenType => "OpenType font",
FontFormat::Variable => "Variable font",
FontFormat::Color => "Color font",
}
}
/// `text` in `family`'s `style`, rendered once per text, size and colour.
fn preview(
ui: &Ui,
fonts: &designcraft_fonts::ScopedFonts<'static>,
scope: u32,
family: &str,
style: &str,
text: &str,
size: f32,
color: Color32,
) -> egui::TextureHandle {
let ppp = ui.ctx().pixels_per_point();
let px = (size * ppp).round() as u32;
let key = egui::Id::new(("font_preview", family, style, scope, text, px, color.to_array()));
if let Some(t) = ui.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return t;
}
let img = designcraft_render::glyphs::text_line(fonts, family, style, text, px, color.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let t = ui.ctx().load_texture(format!("font_preview_{family}_{style}"), ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, t.clone()));
t
}
/// A small toggle in the filter row (an icon), with its tooltip.
fn toggle(ui: &mut Ui, on: bool, tip: &str, paint: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
if on {
ui.painter().rect(r, 3.0, t.well, Stroke::new(1.0, t.well_rim), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
paint(ui.painter(), r, if on { t.text_strong } else { t.icon });
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tip));
resp.on_hover_text(tip).clicked()
}
fn clock(p: &egui::Painter, r: Rect, color: Color32) {
let c = r.center();
p.circle_stroke(c, 6.5, Stroke::new(1.2, color));
p.line_segment([c, c - vec2(0.0, 4.0)], Stroke::new(1.2, color));
p.line_segment([c, c + vec2(3.0, 1.5)], Stroke::new(1.2, color));
}
/// The Font menu inside any font field's popup or combo box. The search field has the keyboard
/// while the menu is open (empty each time it opens); ↑/↓ move through the rows and Return picks
/// one. A click on a family picks it (its Regular style), on one of its styles that style; the
/// arrow opens a family, the star marks a favourite and ≈ shows similar fonts, all keeping the menu
/// open (it closes on a click outside only).
pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], current: &str, width: f32) -> Option<FontPick> {
let lang = app.ui.language.clone();
let tr = |s: &'static str| crate::i18n::tr(&lang, s);
let (fonts, scope) = (super::fonts(app), super::font_scope(app));
let english = app.session.prefs.show_font_names_in_english;
let mut favs = app.session.prefs.favorite_fonts.clone();
let state_id = egui::Id::new("font_menu_state");
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let frame = ui.ctx().cumulative_frame_nr();
if st.frame.is_none_or(|f| f.saturating_add(1) < frame) {
// Just opened: an empty search, nothing highlighted.
st.query.clear();
st.highlight = None;
}
st.frame = Some(frame);
let t = Tokens::get(ui.ctx());
ui.set_width(width.max(560.0));
let mut pick: Option<FontPick> = None;
let mut favs_changed = false;
// Search.
let search = ui
.horizontal(|ui| {
let search = ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(crate::rtl::widget(ui, tr("Search fonts")))
.desired_width(ui.available_width() - 30.0)
// Return picks the highlighted row instead of leaving the field.
.return_key(None),
);
let clear = ui.add_enabled(!st.query.is_empty(), egui::Button::new("×").frame(false)).on_hover_text(tr("Clear"));
if clear.clicked() {
st.query.clear();
}
search
})
.inner;
// Typing always goes to the search, never to the document under the menu.
if !search.has_focus() {
search.request_focus();
}
// Filters, sample text and size.
let sample_key = app.ui.font_sample.clone();
let size_index = usize::from(app.ui.font_preview_size).min(SIZES.len() - 1);
ui.horizontal(|ui| {
crate::rtl::label(ui, egui::RichText::new(tr("Filters:")).color(t.text));
let class = st.class.map_or_else(|| tr("All Classes").to_string(), |c| tr(c.label()).to_string());
if ui.selectable_label(st.show_classes, format!("{class} ▾")).clicked() {
st.show_classes = !st.show_classes;
st.show_samples = false;
}
if toggle(ui, st.only_favs, tr("Show Favorites Only"), |p, r, c| paint_star(p, r.center(), 6.5, true, c)) {
st.only_favs = !st.only_favs;
}
if toggle(ui, st.only_recent, tr("Show Recently Added"), clock) {
st.only_recent = !st.only_recent;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
for (i, (_, size)) in SIZES.iter().enumerate().rev() {
let tip = [tr("Small Sample Text Size"), tr("Medium Sample Text Size"), tr("Large Sample Text Size")][i];
if toggle(ui, size_index == i, tip, |p, r, c| {
p.text(r.center(), Align2::CENTER_CENTER, "A", FontId::proportional(8.0 + *size * 0.35), c);
}) {
app.ui.font_preview_size = i as u8;
}
}
let shown = SAMPLES.iter().find(|(k, _)| *k == sample_key).map_or(SAMPLES[0].1, |(_, l)| l);
if ui.selectable_label(st.show_samples, format!("{} ▾", tr(shown))).clicked() {
st.show_samples = !st.show_samples;
st.show_classes = false;
}
});
});
if st.show_classes {
ui.horizontal_wrapped(|ui| {
if ui.selectable_label(st.class.is_none(), tr("All Classes")).clicked() {
st.class = None;
st.show_classes = false;
}
for c in FontClass::ALL {
if ui.selectable_label(st.class == Some(c), tr(c.label())).clicked() {
st.class = Some(c);
st.show_classes = false;
}
}
});
}
if st.show_samples {
ui.horizontal_wrapped(|ui| {
for (key, label) in SAMPLES {
if ui.selectable_label(sample_key == key, tr(label)).clicked() {
app.ui.font_sample = key.to_string();
st.show_samples = false;
}
}
});
}
if let Some(like) = st.similar.clone() {
ui.horizontal(|ui| {
crate::rtl::label(ui, egui::RichText::new(tr("Similar to {family}").replace("{family}", &like)).color(t.text_strong));
if ui.small_button("×").on_hover_text(tr("Clear")).clicked() {
st.similar = None;
}
});
}
let selected = super::text_attrs(app).and_then(|a| a["selectedText"].as_str().map(str::to_string)).filter(|s| !s.trim().is_empty());
let sample = match app.ui.font_sample.as_str() {
"selected" => selected.unwrap_or_else(|| tr("Sample").to_string()),
key => tr(SAMPLES.iter().find(|(k, _)| *k == key).map_or("Sample", |(_, l)| l)).to_string(),
};
let (row_h, sample_size) = SIZES[usize::from(app.ui.font_preview_size).min(SIZES.len() - 1)];
ui.separator();
// The rows: Recently Used (only for the plain list), then the families that pass the filters.
let q = st.query.to_lowercase();
let filtered = st.class.is_some() || st.only_recent || st.similar.is_some();
let mut families: Vec<&FamilyInfo> = menu
.iter()
.filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family)))
.filter(|f| {
!filtered || {
let m = meta(ui, &fonts, scope, &f.family);
st.class.is_none_or(|c| m.kind.class == c) && (!st.only_recent || m.added)
}
})
.collect();
if let Some(like) = &st.similar {
let base = meta(ui, &fonts, scope, like).kind;
let mut near: Vec<(u32, &FamilyInfo)> = families
.iter()
.filter_map(|f| {
let k = meta(ui, &fonts, scope, &f.family).kind;
if f.family == *like {
return Some((0, *f));
}
(k.class == base.class).then(|| (designcraft_fonts::panose_distance(&base.panose, &k.panose).unwrap_or(u32::MAX / 2) + 1, *f))
})
.collect();
near.sort_by_key(|(d, _)| *d);
families = near.into_iter().take(SIMILAR).map(|(_, f)| f).collect();
}
let mut rows: Vec<Row> = Vec::new();
if q.is_empty() && !filtered && !st.only_favs {
let recent: Vec<Row> = app
.session
.prefs
.recent_fonts
.iter()
.filter_map(|r| menu.iter().find(|f| f.family == *r))
.take(RECENT)
.map(|f| Row::Family { info: f.clone(), recent: true })
.collect();
if !recent.is_empty() {
rows.extend(recent);
rows.push(Row::Separator);
}
}
let mut group = None;
for f in families {
// A separator between the Western fonts and each CJK language's.
if group.is_some_and(|g| g != f.group) && st.similar.is_none() {
rows.push(Row::Separator);
}
group = Some(f.group);
rows.push(Row::Family { info: f.clone(), recent: false });
if st.expanded.contains(&f.family) {
for s in meta(ui, &fonts, scope, &f.family).styles {
rows.push(Row::Style { family: f.family.clone(), style: s });
}
}
}
// Keyboard: ↑/↓ through the pickable rows, Return picks.
let pickable: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| !matches!(r, Row::Separator)).map(|(i, _)| i).collect();
if search.changed() {
st.highlight = if q.is_empty() { None } else { Some(0) };
}
let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
let last = pickable.len().checked_sub(1);
st.highlight = match (st.highlight, last) {
(_, None) => None,
(None, Some(_)) if down => Some(0),
(Some(h), Some(l)) if down => Some((h + 1).min(l)),
(Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)),
(h, Some(l)) => h.map(|h| h.min(l)),
};
let highlighted_row = st.highlight.and_then(|h| pickable.get(h).copied());
if enter && let Some(row) = highlighted_row.and_then(|i| rows.get(i)) {
pick = match row {
Row::Family { info, .. } => Some(FontPick { family: info.family.clone(), style: None }),
Row::Style { family, style } => Some(FontPick { family: family.clone(), style: Some(style.clone()) }),
Row::Separator => None,
};
}
let mut toggle_expand = None;
let mut toggle_fav = None;
let mut similar = None;
// A popup only offers last frame's height: ask for the rows' own (a family just opened, a
// filter just cleared), up to the list's limit.
let list_h: f32 = rows
.iter()
.map(|r| match r {
Row::Family { .. } => row_h + ui.spacing().item_spacing.y,
Row::Style { .. } => row_h.min(30.0) + ui.spacing().item_spacing.y,
Row::Separator => 8.0,
})
.sum();
egui::ScrollArea::vertical().max_height(420.0).min_scrolled_height(list_h.clamp(24.0, 420.0)).auto_shrink([false, true]).show(ui, |ui| {
if rows.is_empty() {
crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim));
}
for (i, row) in rows.iter().enumerate() {
let (family, style, recent) = match row {
Row::Separator => {
ui.separator();
continue;
}
Row::Family { info, recent } => (info, None, *recent),
Row::Style { family, style } => {
let Some(info) = menu.iter().find(|f| f.family == *family) else { continue };
(info, Some(style.as_str()), false)
}
};
let f = &family.family;
let (r, mut resp) =
ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click());
let highlighted = highlighted_row == Some(i);
if highlighted && (down || up) {
ui.scroll_to_rect(r, None);
}
if !ui.is_rect_visible(r) {
continue;
}
let label = match style {
Some(s) => s.to_string(),
None => family.label(english).to_string(),
};
let is_current = style.is_none() && f == current;
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label));
if style.is_none() && label != *f {
resp = resp.on_hover_text(f);
}
// Over the row or one of its buttons (the star, the arrow, ≈ are on top of it).
let hovered = resp.contains_pointer();
if is_current {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if highlighted || hovered {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let p = ui.painter_at(r);
let m = meta(ui, &fonts, scope, f);
let cy = r.center().y;
let mut handled = false;
if style.is_none() {
// Favourite star.
let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0));
let fav = favs.contains(f);
let sr = ui.interact(star, ui.id().with(("fav", f, recent)), Sense::click());
paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
if sr.clicked() {
toggle_fav = Some(f.clone());
handled = true;
}
// The arrow opens a family of several styles.
if m.styles.len() > 1 && !recent {
let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0));
let ar = ui.interact(arrow, ui.id().with(("open", f)), Sense::click());
ar.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Button,
true,
tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" }),
)
});
let open = st.expanded.contains(f);
crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon);
if ar.clicked() {
toggle_expand = Some(f.clone());
handled = true;
}
}
}
// Name (and how many styles), then the sample in the face, then the format.
let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 };
let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() };
p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text);
let preview_x = r.min.x + (r.width() * 0.42).max(190.0);
let icons_x = r.max.x - 52.0;
let face_style = style.map_or_else(|| regular(&m.styles), str::to_string);
let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text);
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size);
let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y));
ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
// Show Similar Fonts (on hover), and the format.
if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) {
let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0));
let sr = ui.interact(sim, ui.id().with(("similar", f, recent)), Sense::click());
sr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tr("Show Similar Fonts")));
p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon });
if sr.on_hover_text(tr("Show Similar Fonts")).clicked() {
similar = Some(f.clone());
handled = true;
}
}
if style.is_none() {
let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0));
paint_format(&p, badge, m.kind.format, t.text_dim);
ui.interact(badge, ui.id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format)));
}
if resp.clicked() && !handled {
pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) });
}
}
});
if let Some(f) = toggle_expand
&& !st.expanded.remove(&f)
{
st.expanded.insert(f);
}
if let Some(f) = toggle_fav {
if favs.contains(&f) {
favs.retain(|x| *x != f);
} else {
favs.push(f);
}
favs_changed = true;
}
if let Some(f) = similar {
st.similar = if st.similar.as_deref() == Some(f.as_str()) { None } else { Some(f) };
st.query.clear();
}
ui.data_mut(|d| d.insert_temp(state_id, st));
if favs_changed {
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
}
if let Some(p) = &pick {
// Recently used, newest first.
let mut recent = app.session.prefs.recent_fonts.clone();
recent.retain(|f| *f != p.family);
recent.insert(0, p.family.clone());
recent.truncate(RECENT);
let _ = app.run("prefs.set", json!({ "recentFonts": recent }));
ui.close();
}
pick
}
+2 -2
View File
@@ -34,8 +34,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let (db, scope) = (super::fonts(app), super::font_scope(app));
ui.horizontal(|ui| {
if let Some(f) = super::font_combo(app, ui, "glyph_family", &st.family, 140.0) {
st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into());
st.family = f;
st.style = f.style.clone().or_else(|| db.styles(&f.family).into_iter().next()).unwrap_or_else(|| "Regular".into());
st.family = f.family;
}
egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| {
for s in db.styles(&st.family) {
+112 -190
View File
@@ -1,6 +1,8 @@
//! Panels and shared panel helpers.
pub mod conditions;
mod font_menu;
pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body};
pub mod datamerge;
pub mod glyphs;
pub mod interactive;
@@ -219,220 +221,57 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current:
}
}
/// A five-pointed star (filled, or outlined).
fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egui::Color32) {
let pt = |i: usize, rad: f32| {
let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
c + egui::vec2(a.cos(), a.sin()) * rad
};
let outline: Vec<egui::Pos2> = (0..10).map(|i| pt(i, if i % 2 == 0 { r } else { r * 0.42 })).collect();
if filled {
// The inner pentagon and the five points (each convex).
let inner: Vec<egui::Pos2> = (0..5).map(|i| outline[2 * i + 1]).collect();
p.add(egui::Shape::convex_polygon(inner, color, egui::Stroke::NONE));
for i in 0..5 {
p.add(egui::Shape::convex_polygon(vec![outline[(2 * i + 9) % 10], outline[2 * i], outline[2 * i + 1]], color, egui::Stroke::NONE));
}
} else {
p.add(egui::Shape::closed_line(outline, egui::Stroke::new(1.0, color)));
}
}
/// The font menu's state while it is open: the search, Show Favorites Only, the match ↑/↓ highlight
/// and the frame it was last shown (a menu not shown the frame before has just opened).
#[derive(Clone, Default)]
struct FontMenuState {
query: String,
only_favs: bool,
highlight: Option<usize>,
frame: Option<u64>,
}
/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard
/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites
/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move
/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog
/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the
/// stars keep it open (the popup closes on a click outside only).
pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> {
let (fonts, scope) = (fonts(app), font_scope(app));
let english = app.session.prefs.show_font_names_in_english;
let mut favs = app.session.prefs.favorite_fonts.clone();
let mut pick = None;
let mut favs_changed = false;
let state_id = egui::Id::new("font_menu_state");
let mut st: FontMenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let frame = ui.ctx().cumulative_frame_nr();
if st.frame.is_none_or(|f| f.saturating_add(1) < frame) {
st.query.clear();
st.highlight = None;
}
st.frame = Some(frame);
let t = crate::theme::Tokens::get(ui.ctx());
ui.set_width(width.max(300.0));
let search = ui
.horizontal(|ui| {
let search = ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts")))
.desired_width(200.0)
// Return picks the highlighted match instead of leaving the field.
.return_key(None),
);
ui.toggle_value(&mut st.only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only"));
});
search
})
.inner;
// Typing always goes to the search, never to the document under the menu.
if !search.has_focus() {
search.request_focus();
}
let q = st.query.to_lowercase();
let shown: Vec<&designcraft_fonts::FamilyInfo> = menu.iter().filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))).collect();
if search.changed() {
st.highlight = if q.is_empty() { None } else { Some(0) };
}
// The menu owns these keys while it is open: a dialog under it must not take Return as OK or
// Escape as Cancel.
let (down, up, enter, escape) = ui.input_mut(|i| {
let mut key = |k| i.consume_key(egui::Modifiers::NONE, k);
(key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape))
});
let last = shown.len().checked_sub(1);
st.highlight = match (st.highlight, last) {
(_, None) => None,
(None, Some(_)) if down => Some(0),
(Some(h), Some(l)) if down => Some((h + 1).min(l)),
(Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)),
(h, Some(l)) => h.map(|h| h.min(l)),
};
if enter && let Some(f) = st.highlight.and_then(|h| shown.get(h)) {
pick = Some(f.family.clone());
}
let ppp = ui.ctx().pixels_per_point();
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
let mut group = None;
for (k, info) in shown.iter().enumerate() {
// A separator between the Western fonts and each CJK language's.
if group.is_some_and(|g| g != info.group) {
ui.separator();
}
group = Some(info.group);
let f = &info.family;
let label = info.label(english);
let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
let highlighted = st.highlight == Some(k);
if highlighted && (down || up) {
ui.scroll_to_rect(row, None);
}
if !ui.is_rect_visible(row) {
continue;
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, f == current, label));
if label != f {
resp = resp.on_hover_text(f);
}
if f == current {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if highlighted || resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
// Favourite star.
let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0));
let fav = favs.contains(f);
let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click());
paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
if sr.clicked() {
if fav {
favs.retain(|x| x != f);
} else {
favs.push(f.clone());
}
favs_changed = true;
}
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text);
// The name in its own face (rendered once per family and scale).
let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array()));
let tex: Option<egui::TextureHandle> = ui.data(|d| d.get_temp(key));
let tex = tex.unwrap_or_else(|| {
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, h.clone()));
h
});
let size = tex.size_vec2() / ppp;
let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0);
ui.painter().image(
tex.id(),
egui::Rect::from_min_size(at, size),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
if resp.clicked() && !sr.clicked() {
pick = Some(f.clone());
}
}
});
ui.data_mut(|d| d.insert_temp(state_id, st));
if favs_changed {
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
}
if pick.is_some() || escape {
ui.close();
}
pick
}
/// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu.
/// Returns the family picked.
/// Returns the font picked.
pub fn font_combo(
app: &mut DesignApp,
ui: &mut egui::Ui,
id_salt: impl std::hash::Hash + std::fmt::Debug,
current: &str,
width: f32,
) -> Option<String> {
) -> Option<FontPick> {
let menu = font_menu(app);
let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) };
egui::ComboBox::from_id_salt(id_salt)
.selected_text(shown)
.width(width)
.height(440.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| font_menu_body(app, ui, &menu, current, width))
.inner
.flatten()
}
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family
/// picked.
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
// Screen readers (and tests) see the field as the combo box it looks like.
// A field and a plain popup rather than egui's combo box: its own scroll area would keep the
// menu at the size it last had (a family opening, a filter clearing, wouldn't show).
let field = ui.push_id(id_salt, |ui| crate::widgets::dropdown(ui, &shown, width)).inner;
field.widget_info(|| {
let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox);
info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) });
info.enabled = true;
info.current_text_value = Some(shown.clone());
info
});
font_popup(app, &field, &menu, current)
}
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the font
/// picked.
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<FontPick> {
egui::Popup::menu(field)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
.and_then(|r| r.inner)
}
/// Set the text's font to `family`, in its Regular style (else its first).
pub fn apply_font_family(app: &mut DesignApp, family: &str) {
let styles = fonts(app).styles(family);
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
let _ = app.run("type.char", json!({"attrs": {"fontFamily": family, "fontStyle": style}}));
/// The style a pick sets: the one picked, else the family's Regular (else its first).
pub fn pick_style(app: &DesignApp, pick: &FontPick) -> String {
if let Some(s) = &pick.style {
return s.clone();
}
let styles = fonts(app).styles(&pick.family);
if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }
}
/// Set the text's font to the one picked (a family's Regular style unless a style was picked).
pub fn apply_font(app: &mut DesignApp, pick: &FontPick) {
let style = pick_style(app, pick);
let _ = app.run("type.char", json!({"attrs": {"fontFamily": pick.family, "fontStyle": style}}));
}
/// The text's font family field: [`font_combo`], applied to the text.
pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
if let Some(f) = font_combo(app, ui, "font_family", current, width) {
apply_font_family(app, &f);
apply_font(app, &f);
}
}
@@ -560,6 +399,89 @@ mod font_menu_ui_tests {
h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fontFamily"].as_str().unwrap().to_string()
}
/// Open the Control bar's font menu (showing `family`) and search for `query`.
fn open_menu(h: &mut Harness<'static, Window>, family: &str, query: &str) {
let at = field(h, family);
click_at(h, at.center());
if !query.is_empty() {
h.event(egui::Event::Text(query.into()));
}
h.run_steps(4);
}
#[test]
fn a_family_opens_to_its_styles_and_a_style_is_picked() {
let (mut h, _) = typing();
let serif = designcraft_fonts::DEFAULT_FAMILY;
open_menu(&mut h, serif, "serif 4");
// The arrow before the name opens the family; the menu stays open.
{
let at = h.get_by_label("Show Styles").rect().center();
click_at(&mut h, at);
}
assert!(h.query_by_label("Bold Italic").is_some(), "its styles are listed");
{
let at = h.get_by_label("Bold Italic").rect().center();
click_at(&mut h, at);
}
let attrs = h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap();
assert_eq!((attrs["chars"]["fontFamily"].as_str(), attrs["chars"]["fontStyle"].as_str()), (Some(serif), Some("Bold Italic")));
assert!(!h.ctx.text_edit_focused(), "picking closes the menu");
}
#[test]
fn picked_fonts_come_first_as_recently_used() {
let (mut h, _) = typing();
open_menu(&mut h, designcraft_fonts::DEFAULT_FAMILY, "source sans");
{
let at = h.get_by_label("Source Sans 3").rect().center();
click_at(&mut h, at);
}
assert_eq!(h.state().app.session.prefs.recent_fonts, ["Source Sans 3"]);
// Reopened without a search: Source Sans 3 is listed at the top, and again in its place.
open_menu(&mut h, "Source Sans 3", "");
let recent = h.get_by_label("Source Sans 3").rect();
let search = h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).rect();
assert!(recent.min.y > search.max.y && recent.min.y - search.max.y < 80.0, "the first row, under the filters: {recent:?} vs {search:?}");
}
#[test]
fn the_class_filter_and_similar_fonts_narrow_the_list() {
let (mut h, _) = typing();
open_menu(&mut h, designcraft_fonts::DEFAULT_FAMILY, "");
{
let at = h.get_by(|n| n.label().is_some_and(|l| l.starts_with("All Classes"))).rect().center();
click_at(&mut h, at);
}
{
let at = h.get_by_label("Monospaced").rect().center();
click_at(&mut h, at);
}
assert!(h.query_by_label("JetBrains Mono").is_some(), "a monospaced family is listed");
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "a serif isn't");
// Back to all classes, then fonts like the serif: it leads, other serifs follow, no sans.
{
let at = h.get_by(|n| n.label().is_some_and(|l| l.starts_with("Monospaced ▾"))).rect().center();
click_at(&mut h, at);
}
{
let at = h.get_by_label("All Classes").rect().center();
click_at(&mut h, at);
}
h.event(egui::Event::Text("serif 4".into()));
h.run_steps(4);
let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect();
h.hover_at(row.center());
h.run_steps(2);
{
let at = h.get_by_label("Show Similar Fonts").rect().center();
click_at(&mut h, at);
}
assert!(h.query_by_label(&format!("Similar to {}", designcraft_fonts::DEFAULT_FAMILY)).is_some());
assert!(h.query_by_label("Source Sans 3").is_none() && h.query_by_label("JetBrains Mono").is_none(), "other classes aren't similar");
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_some());
}
#[test]
fn typing_in_the_font_menu_searches_and_never_reaches_the_story() {
let (mut h, story) = typing();
+1 -1
View File
@@ -998,7 +998,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
t.text,
);
if let Some(f) = super::font_popup(app, &resp, &menu, &fam) {
super::apply_font_family(app, &f);
super::apply_font(app, &f);
}
ui.add_space(1.0);
let styles = fonts.styles(&fam);
+1 -1
View File
@@ -146,7 +146,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) };
let field = widgets::dropdown(ui, &shown, 150.0);
if let Some(f) = panels::font_popup(app, &field, &menu, &fam) {
panels::apply_font_family(app, &f);
panels::apply_font(app, &f);
}
let styles = fonts.styles(&fam);
let cur = styles.iter().position(|s| *s == sty);
+1 -1
View File
@@ -323,7 +323,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Font menu laid out like InDesign's: search, a Filters row (classification: serif, slab serif, sans serif, script, monospaced, decorative, symbol, read from PANOSE, the OS/2 family class, the name, else the shape of the "l"; Show Favorites Only; Show Recently Added, fonts added in the last 30 days), the sample text (the selected text, Sample, pangrams) in three sizes, Recently Used fonts at the top (`recentFonts` preference), favourites (stars, `favoriteFonts`), and each family previewed in its own face with its format (TrueType, OpenType, variable, colour). A family of several styles shows how many and opens to list them, each previewed in its own face, and a style picked is applied as it is; Show Similar Fonts (≈) lists the families of the same class nearest by PANOSE. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
+1 -1
View File
@@ -40,7 +40,7 @@ basics of editing fail for too many users on Windows and Linux.
| Copy / cut / paste objects and text, Paste Into / in Place | ✓ | ✓ on macOS | ~ | **failing on Windows/Linux** (#351, #348, #312, #185, #164, #201, #212) |
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
| IME text input on the canvas | ✓ | ✗ | ✗ | #322 |
| Font menu: search, preview, favourites, type-ahead | ✓ | ✓ | ~ | search field focus on macOS (#335, #86) |
| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope |
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | |
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |