mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
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:
committed by
moppeeer
co-authored by
Claude Opus 5.5
parent
84a919d57a
commit
8b0bc54bdb
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ®ular(&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
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 | ✓ | |
|
||||
|
||||
Reference in New Issue
Block a user