diff --git a/ROADMAP.md b/ROADMAP.md index dc901ac9..54f3b548 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23โ€“37 | diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 5403a914..424c274f 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -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 { 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 { Ok(json!({ "count": n })) } +fn font_list(_: &mut Session, p: &Value) -> Result { + 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 { + 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::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5b9f9160..0872cd56 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_fontlist.rs b/crates/engine/src/tests_fontlist.rs new file mode 100644 index 00000000..01aaecfd --- /dev/null +++ b/crates/engine/src/tests_fontlist.rs @@ -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 { + 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 = 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"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 74e25e36..1dce3dc7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -274,6 +274,9 @@ type ClipPaintEntry = (Arc, Option>, Option>); /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { texts: PtrMap, Arc)>, + /// 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, /// 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, t: &TextObject) -> Arc { + 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; diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs new file mode 100644 index 00000000..551ad3bf --- /dev/null +++ b/crates/render/src/tests_fontchange.rs @@ -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 { + 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::>(); + 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"); +} diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index a1dc4fc5..6d6758cb 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -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")); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index efba0180..00314226 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c1743c07..9003c03f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2223,25 +2223,35 @@ fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { 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 { - static NAMES: std::sync::Mutex> = 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::sync::Mutex::new(std::collections::BTreeSet::new()); + static ITEMS: std::sync::Mutex<(u64, Vec)> = 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. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 7f17962a..829d3da2 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -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"})); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7c43a2a8..9cbbca69 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3a61bc0a..c54b4f3b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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"); diff --git a/crates/ui-egui/src/tests_fonts.rs b/crates/ui-egui/src/tests_fonts.rs new file mode 100644 index 00000000..aa59143f --- /dev/null +++ b/crates/ui-egui/src/tests_fonts.rs @@ -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 { + 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 = 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| { + 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 { 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}"); +} diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index a34535fb..49167f6c 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -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); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c1fd7ea2..4e7e1d49 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -409,7 +409,7 @@ pub fn dropdown_with( width: f32, enabled: impl Fn(usize) -> bool, ) -> Option { - 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( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32, + searchable: bool, list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); @@ -438,6 +440,8 @@ fn combo( 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( .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 { + 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, width: f32) -> Option { - 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) { diff --git a/docs/mcp.md b/docs/mcp.md index 25ebdc5a..a85a2ae0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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":{}}} +```