mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #36: the font menus list the installed fonts, with a search field
Type > Font, the Character, Properties and Glyphs panels and Find Font list every installed font by default, as the reference app does, instead of the four bundled families. The family dropdowns get a search field that filters the list as you type (Enter picks the first match) and open on the current font. Show System Fonts, which no longer means anything, becomes Refresh Font List (text.rescanFonts): it picks up fonts installed since the app started and redraws type set in them without editing the document. text.fontList lets MCP agents list the families and a family's styles. Root cause: the font lists came from FontDb::families(), which only held the bundled fonts until something scanned the system fonts (fixed with #130). The Type > Font menu, rebuilt every frame, now builds its items once per font change, and renderers lay type out again when the fonts change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
49e8413ebe
commit
f12428264c
16 files changed
+380
-48
No files matched your search
+1
-1
@@ -143,7 +143,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font.
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
|
||||
//! available (the bundled and installed ones) and rescanning the installed fonts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -38,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
cmd!(
|
||||
query "text.fontList",
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
cmd!(
|
||||
"text.rescanFonts",
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -166,6 +185,53 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
Some(family) => {
|
||||
if !db.has_family(family) {
|
||||
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
|
||||
}
|
||||
// The name as the font spells it.
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0;
|
||||
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
||||
// fonts change), and the bounds follow; that isn't an edit to save.
|
||||
for d in &mut s.docs {
|
||||
let mut texts = vec![];
|
||||
d.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
texts.push(n.id);
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1172,6 +1172,8 @@ mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fontlist;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The fonts available (`text.fontList`), the installed ones listed without asking for them (#36),
|
||||
//! and Refresh Font List (`text.rescanFonts`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_text::{FontDb, system_font_dirs};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(v: &Value) -> Vec<String> {
|
||||
serde_json::from_value(v.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_list_names_every_available_family_with_its_styles() {
|
||||
let mut s = Session::new();
|
||||
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
|
||||
assert_eq!(fams, FontDb::global().families());
|
||||
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
||||
assert!(fams.iter().any(|x| x == f), "{f}");
|
||||
}
|
||||
// Every installed family, though nothing asked for system fonts.
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
|
||||
assert_eq!(r["family"], "Source Sans 3");
|
||||
let styles = names(&r["styles"]);
|
||||
assert!(["Regular", "Semibold", "Bold", "Italic"].iter().all(|x| styles.iter().any(|s| s == x)), "{styles:?}");
|
||||
assert!(s.execute("text.fontList", &json!({"family": "No Such Font"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font"})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert_eq!(r["families"], json!(FontDb::global().families().len()));
|
||||
assert!(r["faces"].is_u64());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.revision > revision, "the canvas redraws");
|
||||
assert!(!d.is_dirty(), "nothing to save");
|
||||
}
|
||||
@@ -274,6 +274,9 @@ type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
|
||||
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
|
||||
pub struct Renderer {
|
||||
texts: PtrMap<usize, (Arc<Node>, Arc<TextGeom>)>,
|
||||
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
|
||||
/// again when fonts are added or rescanned.
|
||||
text_fonts: u64,
|
||||
/// Clip regions per clipping path (see [`Self::clip_of`]).
|
||||
clips: PtrMap<usize, ClipEntry>,
|
||||
/// Context reused when rendering single-threaded (`threads == 0`).
|
||||
@@ -376,6 +379,7 @@ impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
texts: PtrMap::default(),
|
||||
text_fonts: 0,
|
||||
clips: PtrMap::default(),
|
||||
ctx_st: None,
|
||||
threads: default_threads(),
|
||||
@@ -1142,6 +1146,11 @@ impl Renderer {
|
||||
|
||||
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
|
||||
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
if fonts != self.text_fonts {
|
||||
self.texts.clear();
|
||||
self.text_fonts = fonts;
|
||||
}
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some((node, g)) = self.texts.get(&key)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
@@ -1528,6 +1537,8 @@ mod tests_cmykblend;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
#[cfg(test)]
|
||||
mod tests_fontchange;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_isolation;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Type redraws in a font that becomes available while it is on screen (fonts added, Refresh Font
|
||||
//! List).
|
||||
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bundled Source Serif 4 renamed `family` (as long as the original name).
|
||||
fn serif_as(family: &str) -> Vec<u8> {
|
||||
let mut data = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Serif 4"), utf16(family)), (b"Source Serif 4".to_vec(), family.as_bytes().to_vec())] {
|
||||
assert_eq!(from.len(), to.len());
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_redraws_when_its_font_becomes_available() {
|
||||
const FAMILY: &str = "Arrival Serif4";
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
@@ -75,12 +75,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Replace With Font");
|
||||
let families = vectorcraft_text::FontDb::global().families();
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
let names: Vec<&str> = families.iter().map(String::as_str).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, "ff-family", &fam, &names, 220.0) {
|
||||
d.fields.insert("family".into(), json!(families[i]));
|
||||
if let Some(f) = widgets::font_dropdown(ui, "ff-family", &fam, 220.0) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(""));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
|
||||
@@ -50,6 +50,8 @@ mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_home;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
|
||||
+28
-18
@@ -2223,25 +2223,35 @@ fn insert_items(list: &[(&'static str, &'static str)]) -> Vec<Item> {
|
||||
list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect()
|
||||
}
|
||||
|
||||
/// Type → Font: one item per installed family (labels interned once so the per-frame menu build
|
||||
/// doesn't allocate forever).
|
||||
/// Type → Font: one item per available family, the installed fonts included. Built again only when
|
||||
/// the fonts change (the menu tree is built every frame); labels are interned once each, so
|
||||
/// rebuilding doesn't allocate forever.
|
||||
fn font_items() -> Vec<Item> {
|
||||
static NAMES: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
||||
let fams = vectorcraft_text::FontDb::global().families();
|
||||
let Ok(mut names) = NAMES.lock() else { return vec![] };
|
||||
fams.iter()
|
||||
.map(|f| {
|
||||
let label: &'static str = match names.iter().find(|n| **n == f.as_str()) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n: &'static str = Box::leak(f.clone().into_boxed_str());
|
||||
names.push(n);
|
||||
n
|
||||
}
|
||||
};
|
||||
cp(label, "text.setStyle", json!({ "font": label }))
|
||||
})
|
||||
.collect()
|
||||
static NAMES: std::sync::Mutex<std::collections::BTreeSet<&'static str>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
|
||||
static ITEMS: std::sync::Mutex<(u64, Vec<Item>)> = std::sync::Mutex::new((u64::MAX, Vec::new()));
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Read first: fonts that load meanwhile make the next frame build the list again.
|
||||
let generation = db.generation();
|
||||
let fams = db.family_list();
|
||||
let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] };
|
||||
if items.0 != generation {
|
||||
let list = fams
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let label: &'static str = match names.get(f.as_str()) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n: &'static str = Box::leak(f.clone().into_boxed_str());
|
||||
names.insert(n);
|
||||
n
|
||||
}
|
||||
};
|
||||
cp(label, "text.setStyle", json!({ "font": label }))
|
||||
})
|
||||
.collect();
|
||||
*items = (generation, list);
|
||||
}
|
||||
items.1.clone()
|
||||
}
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
|
||||
@@ -240,11 +240,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::empty_state(ui, "type", "No text selected", "Select a text object to edit its character attributes.");
|
||||
return;
|
||||
};
|
||||
let fams = vectorcraft_text::FontDb::global().families();
|
||||
let names: Vec<&str> = fams.iter().map(String::as_str).collect();
|
||||
let w = ui.available_width();
|
||||
if let Some(i) = widgets::dropdown(ui, "ch-font", &s.font_family, &names, w - 4.0) {
|
||||
style(app, json!({"font": names[i]}));
|
||||
if let Some(f) = widgets::font_dropdown(ui, "ch-font", &s.font_family, w - 4.0) {
|
||||
style(app, json!({ "font": f }));
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&s.font_family);
|
||||
let snames: Vec<&str> = styles.iter().map(String::as_str).collect();
|
||||
@@ -403,10 +401,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
menu_item(ui, l, false, l == "Fractional Widths");
|
||||
}
|
||||
ui.separator();
|
||||
// The installed fonts are always listed; this picks up fonts installed since the app started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if menu_item(ui, "Show System Fonts", true, vectorcraft_text::FontDb::global().families().len() > 4) {
|
||||
let n = vectorcraft_text::FontDb::global().load_system_fonts();
|
||||
app.ui.status = format!("{n} system font faces available");
|
||||
if menu_item(ui, "Refresh Font List", true, false)
|
||||
&& let Ok(r) = app.run("text.rescanFonts", json!({}))
|
||||
{
|
||||
app.status(format!("{} font families available", r["families"]));
|
||||
}
|
||||
if menu_item(ui, "Reset Panel", has, false) {
|
||||
style(app, json!({"tracking": 0, "leading": "auto"}));
|
||||
|
||||
@@ -193,12 +193,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
let fams = db.families();
|
||||
let fnames: Vec<&str> = fams.iter().map(String::as_str).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "gl-family", &family, &fnames, (w * 0.6).max(80.0)) {
|
||||
let st = db.face(fnames[i], "Regular").map_or_else(|| "Regular".into(), |f| f.style.clone());
|
||||
set_pstate(ui.ctx(), "gl-font", Some((fnames[i].to_string(), st)));
|
||||
if let Some(f) = widgets::font_dropdown(ui, "gl-family", &family, (w * 0.6).max(80.0)) {
|
||||
let st = db.face(&f, "Regular").map_or_else(|| "Regular".into(), |face| face.style.clone());
|
||||
set_pstate(ui.ctx(), "gl-font", Some((f, st)));
|
||||
}
|
||||
let styles = db.styles(&family);
|
||||
let snames: Vec<&str> = styles.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -382,10 +382,8 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
let s = tx.first_style();
|
||||
section_header(ui, "Character");
|
||||
let fams = vectorcraft_text::FontDb::global().families();
|
||||
let names: Vec<&str> = fams.iter().map(String::as_str).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, "font", &s.font_family, &names, ui.available_width() - 4.0) {
|
||||
app.run("text.setStyle", json!({"font": names[i]})).ok();
|
||||
if let Some(f) = widgets::font_dropdown(ui, "font", &s.font_family, ui.available_width() - 4.0) {
|
||||
app.run("text.setStyle", json!({ "font": f })).ok();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, "Size");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! The font menus list the installed fonts without asking for them (#36), and the font dropdown
|
||||
//! searches them.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_text::{FontDb, system_font_dirs};
|
||||
|
||||
use crate::tests_labels::shapes_text;
|
||||
use crate::{VectorcraftApp, menus, widgets};
|
||||
|
||||
#[test]
|
||||
fn type_font_menu_lists_every_available_family() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let fonts = |app: &VectorcraftApp| -> Vec<String> {
|
||||
menus::menu_entries(app).into_iter().filter(|e| e.path == ["Type", "Font"]).map(|e| e.label).collect()
|
||||
};
|
||||
let labels = fonts(&app);
|
||||
assert_eq!(labels, FontDb::global().families());
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !labels.contains(f)).collect();
|
||||
assert!(missing.is_empty(), "installed but not in Type › Font: {missing:?}");
|
||||
// The menu is built every frame, from a list built once.
|
||||
assert_eq!(fonts(&app), labels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_dropdown_searches_the_families_and_enter_picks_the_first_match() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Each frame: what it painted, where the dropdown is and what it picked.
|
||||
let frame = |events: Vec<egui::Event>| {
|
||||
let input =
|
||||
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
|
||||
let (mut at, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
at = ui.next_widget_position();
|
||||
picked = widgets::font_dropdown(ui, "test-font", "Inter", 220.0);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(shapes_text(&out), at, picked)
|
||||
};
|
||||
let (closed, at, _) = frame(vec![]);
|
||||
assert_eq!(closed.trim(), "Inter");
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(vec![button(false)]);
|
||||
// The popup sizes itself invisibly in its first frame, then the list scrolls (animated).
|
||||
for _ in 0..30 {
|
||||
frame(vec![]);
|
||||
}
|
||||
// The families painted in the list (the button shows the current one, Inter).
|
||||
let families = FontDb::global().families();
|
||||
let listed = |text: &str| -> Vec<String> { text.lines().skip(1).filter(|l| families.iter().any(|f| f == l)).map(str::to_string).collect() };
|
||||
let (open, ..) = frame(vec![]);
|
||||
assert!(listed(&open).len() >= families.len().min(5), "{open}");
|
||||
assert!(listed(&open).iter().any(|f| f == "Inter"), "the current font in view: {open}");
|
||||
// The search field has the focus: typing filters the list.
|
||||
frame(vec![egui::Event::Text("source serif".into())]);
|
||||
let (filtered, ..) = frame(vec![]);
|
||||
let shown = listed(&filtered);
|
||||
assert!(shown.iter().any(|f| f == "Source Serif 4"), "{filtered}");
|
||||
assert!(shown.iter().all(|f| f.to_lowercase().contains("source serif")), "{shown:?}");
|
||||
let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
let (_, _, picked) = frame(vec![enter(true), enter(false)]);
|
||||
assert_eq!(picked.as_deref(), Some("Source Serif 4"));
|
||||
let (closed, ..) = frame(vec![]);
|
||||
assert_eq!(closed.trim(), "Inter", "the list closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_character_panel_menu_refreshes_the_font_list() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
// The installed fonts are always listed: the menu offers to look for new ones instead.
|
||||
let text = crate::tests_labels::painted_text(&mut app, crate::panels::character::menu);
|
||||
assert!(text.contains("Refresh Font List") && !text.contains("System Fonts"), "{text}");
|
||||
}
|
||||
@@ -16,6 +16,16 @@ fn app() -> VectorcraftApp {
|
||||
/// Every string painted by `f` in a headless frame (app fonts installed), one per line. The second
|
||||
/// of two frames: windows size themselves invisibly in their first.
|
||||
pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui));
|
||||
out.textures_delta.clear();
|
||||
shapes_text(&out)
|
||||
}
|
||||
|
||||
/// Every string a frame painted, one per line.
|
||||
pub(crate) fn shapes_text(out: &egui::FullOutput) -> String {
|
||||
fn collect(s: &Shape, out: &mut String) {
|
||||
match s {
|
||||
Shape::Text(t) => {
|
||||
@@ -26,11 +36,6 @@ pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut Vect
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut text = String::new();
|
||||
for c in &out.shapes {
|
||||
collect(&c.shape, &mut text);
|
||||
|
||||
@@ -409,7 +409,7 @@ pub fn dropdown_with(
|
||||
width: f32,
|
||||
enabled: impl Fn(usize) -> bool,
|
||||
) -> Option<usize> {
|
||||
combo(ui, id, current, width, |ui| {
|
||||
combo(ui, id, current, width, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (i, o) in options.iter().enumerate() {
|
||||
if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, *o)).clicked() {
|
||||
@@ -421,12 +421,14 @@ pub fn dropdown_with(
|
||||
}
|
||||
|
||||
/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns
|
||||
/// the chosen one.
|
||||
/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open
|
||||
/// while it is clicked: it closes itself when an option is chosen.
|
||||
fn combo<R>(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
current: &str,
|
||||
width: f32,
|
||||
searchable: bool,
|
||||
list: impl FnOnce(&mut Ui) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -438,6 +440,8 @@ fn combo<R>(
|
||||
egui::ComboBox::from_id_salt(ui.id().with(id))
|
||||
.selected_text(egui::RichText::new(current).size(12.0))
|
||||
.width(width - 4.0)
|
||||
.close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick })
|
||||
.height(if searchable { f32::INFINITY } else { ui.spacing().combo_height })
|
||||
.show_ui(ui, list)
|
||||
.inner
|
||||
.flatten()
|
||||
@@ -445,6 +449,61 @@ fn combo<R>(
|
||||
.inner
|
||||
}
|
||||
|
||||
/// The font family dropdown: every family available (the installed fonts included), with a search
|
||||
/// field over the list that filters it as you type (Enter picks the first match). Returns the
|
||||
/// chosen family.
|
||||
pub fn font_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32) -> Option<String> {
|
||||
let state = ui.id().with(&id).with("font-search");
|
||||
combo(ui, id, current, width, true, |ui| {
|
||||
// Each time the list opens: unfiltered, with the current font in view. Typing goes to the
|
||||
// search field.
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
// (the last pass the list was drawn in, the pass it opened in)
|
||||
let (last, opened) = ui.data(|d| d.get_temp::<(u64, u64)>(state)).unwrap_or_default();
|
||||
let opened = if last + 1 < pass || last == 0 { pass } else { opened };
|
||||
let field = state.with("query");
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(state, (pass, opened));
|
||||
if opened == pass {
|
||||
d.insert_temp(field, String::new());
|
||||
}
|
||||
});
|
||||
// Its first two passes: a popup sizes itself in the first, invisibly.
|
||||
let opening = pass <= opened + 1;
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||||
}
|
||||
let query = search_field(ui, field, "Search").to_lowercase();
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let families = vectorcraft_text::FontDb::global().family_list();
|
||||
let (mut chosen, mut first) = (None, None);
|
||||
// The search field stays put over the list as it scrolls.
|
||||
menu_scroll(ui, |ui| {
|
||||
for f in families.iter().filter(|f| query.is_empty() || f.to_lowercase().contains(&query)) {
|
||||
first.get_or_insert(f);
|
||||
let r = ui.add(egui::Button::selectable(f == current, f.as_str()));
|
||||
if opening && f == current {
|
||||
r.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
if r.clicked() {
|
||||
chosen = Some(f.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
match first {
|
||||
None => {
|
||||
dim_label(ui, "No matching fonts");
|
||||
}
|
||||
Some(f) if enter && chosen.is_none() => chosen = Some(f.clone()),
|
||||
_ => {}
|
||||
}
|
||||
if chosen.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
chosen
|
||||
})
|
||||
}
|
||||
|
||||
/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and
|
||||
/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on
|
||||
/// small windows.
|
||||
@@ -474,7 +533,7 @@ pub fn blend_separator_before(i: usize) -> bool {
|
||||
/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion
|
||||
/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode.
|
||||
pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option<BlendMode>, width: f32) -> Option<BlendMode> {
|
||||
combo(ui, id, current.map_or("", BlendMode::label), width, |ui| {
|
||||
combo(ui, id, current.map_or("", BlendMode::label), width, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (i, m) in BlendMode::ALL.into_iter().enumerate() {
|
||||
if blend_separator_before(i) {
|
||||
|
||||
+16
@@ -1713,3 +1713,19 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.vectorHalftone","params":{"shape":"circle","frequency":25,"angle":45,"mode":"cmyk"}}}
|
||||
```
|
||||
|
||||
## Fonts
|
||||
|
||||
Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web).
|
||||
The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
|
||||
by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList`
|
||||
lists every family available, the installed ones included; with `family` it gives that family's styles (upright
|
||||
by weight, then italics) and fails when the family isn't available. `text.rescanFonts` (Character panel menu ›
|
||||
Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a
|
||||
font that became available redraws in it, without editing the document.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.fontList","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"text.fontList","params":{"family":"Source Serif 4"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}}
|
||||
```
|
||||
Reference in new issue
Block a user