From 20ef6c2c4e0ec5b3504a6f60df75fb4fbc3edb99 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:16:49 -0400 Subject: [PATCH] =?UTF-8?q?T:=20Glyphs=20panel=20=E2=80=94=20font=20grid?= =?UTF-8?q?=20rendered=20in-house,=20search,=20click=20to=20insert,=20rece?= =?UTF-8?q?ntly=20used?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/glyphs.rs | 60 +++++++++++++ crates/render/src/lib.rs | 1 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/glyphs.rs | 134 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + docs/parity.md | 8 +- 8 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 crates/render/src/glyphs.rs create mode 100644 crates/ui-egui/src/panels/glyphs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 47bb46e..f5eb04c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs new file mode 100644 index 0000000..1bf23be --- /dev/null +++ b/crates/render/src/glyphs.rs @@ -0,0 +1,60 @@ +//! Glyph grids for the Glyphs panel: a font's characters drawn into square cells. + +use designcraft_fonts::FontDb; +use vello_cpu::{RenderContext, Resources, kurbo, peniko}; + +use crate::Rendered; + +/// Draw `chars` of `family`/`style` into a grid of `cols` columns of `cell`-pixel cells, in +/// `color` (straight RGBA) on transparency. Glyphs are scaled to the font's em and centred. +pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered { + let db = FontDb::global(); + let face = db.face(family, style); + let cols = cols.max(1); + let rows = (chars.len() as u32).div_ceil(cols).max(1); + let (w, h) = ((cols * cell).clamp(1, u16::MAX as u32) as u16, (rows * cell).clamp(1, u16::MAX as u32) as u16); + let mut ctx = RenderContext::new(w, h); + ctx.set_paint(peniko::Color::from_rgba8(color[0], color[1], color[2], color[3])); + let upem = face.units_per_em().max(1.0); + let (asc, desc) = face.vertical_metrics(); + let k = cell as f64 * 0.62 / upem; + for (i, c) in chars.iter().enumerate() { + let gid = face.glyph_for(*c); + if gid == 0 { + continue; + } + let outline = db.outline(&face, gid); + if outline.elements().is_empty() { + continue; + } + let (col, row) = (i as u32 % cols, i as u32 / cols); + let adv = face.advance(gid); + // Horizontally centred by advance; the baseline puts the em box in the middle. + let x = (col * cell) as f64 + (cell as f64 - adv * k) / 2.0; + let mid = (asc - desc.abs()) / 2.0; + let y = (row * cell) as f64 + cell as f64 / 2.0 + mid * k; + ctx.set_transform(kurbo::Affine::translate((x, y)) * kurbo::Affine::scale(k)); + ctx.fill_path(&outline); + } + ctx.flush(); + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + let mut res = Resources::new(); + ctx.render(vello_cpu::PixmapMut::new(w, h, &mut pixels).expect("buffer size"), &mut res); + Rendered { width: w as u32, height: h as u32, pixels } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_draws_each_cell() { + let img = glyph_grid(designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]); + assert_eq!((img.width, img.height), (80, 80)); + let ink = |cx: u32, cy: u32| { + (cx * 40..cx * 40 + 40).flat_map(|x| (cy * 40..cy * 40 + 40).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count() + }; + assert!(ink(0, 0) > 30 && ink(1, 0) > 30 && ink(0, 1) > 30, "letters drawn"); + assert_eq!(ink(1, 1), 0, "a space is blank"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cd7296d..fc78668 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ pub use vello_cpu; pub mod damage; mod fx; +pub mod glyphs; pub mod images; mod text; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 896e217..99d181f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,6 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), + ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), ("table", "Table", "panel-table"), @@ -152,6 +153,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "characterStyles" => panels::styles::character(app, ui), "stroke" => panels::properties::stroke_panel(app, ui), "character" => panels::properties::character_panel(app, ui), + "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7daad0..29e5d88 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -171,6 +171,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.findFontDialog", "-", "cmd:type.createOutlines", + "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "cmd:type.fillWithPlaceholder", ">Insert Special Character", ">Symbols", diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs new file mode 100644 index 0000000..60b954a --- /dev/null +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -0,0 +1,134 @@ +//! Glyphs panel: every character of a font in a grid; click to insert it at the text cursor. + +use designcraft_fonts::FontDb; +use egui::{Sense, vec2}; +use serde_json::json; + +use crate::DesignApp; +use crate::theme::Tokens; + +const CELL: f32 = 30.0; +/// Characters shown at most (the rest via the search field). +const MAX: usize = 1200; + +#[derive(Clone, Default)] +struct State { + family: String, + style: String, + query: String, + recent: Vec, +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let id = egui::Id::new("glyphs_panel"); + let mut st: State = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + // Default to the font at the text cursor. + if st.family.is_empty() { + let a = super::text_attrs(app); + st.family = a + .as_ref() + .and_then(|a| a["chars"]["fontFamily"].as_str().map(str::to_string)) + .unwrap_or_else(|| designcraft_fonts::DEFAULT_FAMILY.into()); + st.style = a.as_ref().and_then(|a| a["chars"]["fontStyle"].as_str().map(str::to_string)).unwrap_or_else(|| "Regular".into()); + } + let db = FontDb::global(); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("glyph_family").selected_text(&st.family).width(140.0).show_ui(ui, |ui| { + for f in db.families() { + if ui.selectable_label(f == st.family, &f).clicked() { + st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); + st.family = f; + } + } + }); + 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) { + if ui.selectable_label(s == st.style, &s).clicked() { + st.style = s; + } + } + }); + }); + ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search: character or U+code").desired_width(f32::INFINITY)); + let face = db.face(&st.family, &st.style); + let q = st.query.trim().to_string(); + let code = q.strip_prefix("U+").or_else(|| q.strip_prefix("u+")).and_then(|h| u32::from_str_radix(h, 16).ok()); + let mut chars: Vec = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control()).collect(); + chars.sort(); + chars.dedup(); + if !q.is_empty() { + chars.retain(|c| Some(*c as u32) == code || q.contains(*c) || format!("{:04X}", *c as u32).contains(&q.to_ascii_uppercase())); + } + chars.truncate(MAX); + let mut insert: Option = None; + if !st.recent.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("Recently Used").size(10.5).color(t.text_dim)); + for c in st.recent.clone() { + if ui.small_button(c.to_string()).on_hover_text(format!("U+{:04X}", c as u32)).clicked() { + insert = Some(c); + } + } + }); + } + let cols = ((ui.available_width() / CELL).floor() as u32).max(1); + let ppp = ui.ctx().pixels_per_point(); + let cell_px = (CELL * ppp).round() as u32; + let ink = t.text_strong; + let key = egui::Id::new(("glyph_grid", &st.family, &st.style, &q, cols, cell_px, ink.to_array())); + let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::(key)) { + Some(t) => t, + None => { + let img = designcraft_render::glyphs::glyph_grid(&st.family, &st.style, &chars, cols, cell_px, ink.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("glyph_grid", ci, egui::TextureOptions::LINEAR); + ui.data_mut(|d| d.insert_temp(key, h.clone())); + h + } + }; + let rows = (chars.len() as u32).div_ceil(cols); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + let size = vec2(cols as f32 * CELL, rows as f32 * CELL); + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + ui.painter().rect_filled(r, 0.0, t.input); + ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + for c in 1..cols { + let x = r.min.x + c as f32 * CELL; + ui.painter().line_segment([egui::pos2(x, r.min.y), egui::pos2(x, r.max.y)], egui::Stroke::new(1.0, t.border)); + } + for row in 1..rows { + let y = r.min.y + row as f32 * CELL; + ui.painter().line_segment([egui::pos2(r.min.x, y), egui::pos2(r.max.x, y)], egui::Stroke::new(1.0, t.border)); + } + let cell_at = |p: egui::Pos2| { + let (cx, cy) = (((p.x - r.min.x) / CELL) as u32, ((p.y - r.min.y) / CELL) as u32); + chars.get((cy * cols + cx.min(cols - 1)) as usize).copied() + }; + if let Some(p) = resp.hover_pos() + && let Some(c) = cell_at(p) + { + let (cx, cy) = (((p.x - r.min.x) / CELL).floor(), ((p.y - r.min.y) / CELL).floor()); + let cr = egui::Rect::from_min_size(r.min + vec2(cx * CELL, cy * CELL), vec2(CELL, CELL)); + ui.painter().rect_stroke(cr, 0.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + resp.clone().on_hover_text(format!("U+{:04X}", c as u32)); + } + if resp.clicked() + && let Some(c) = resp.interact_pointer_pos().and_then(cell_at) + { + insert = Some(c); + } + }); + if let Some(c) = insert { + match app.run("text.insert", json!({"text": c.to_string(), "raw": true})) { + Ok(_) => { + st.recent.retain(|x| *x != c); + st.recent.insert(0, c); + st.recent.truncate(12); + } + Err(e) => app.status(format!("Glyphs: {e}")), + } + } + ui.label(egui::RichText::new(format!("{} glyphs — click to insert at the text cursor", chars.len())).size(10.5).color(t.text_dim)); + ui.data_mut(|d| d.insert_temp(id, st)); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2dea8c5..3f8100f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panels and shared panel helpers. +pub mod glyphs; pub mod layers; pub mod pages; pub mod properties; diff --git a/docs/parity.md b/docs/parity.md index d25655e..4108727 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 75%** over 275 features. +**Weighted breadth parity: 76%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 49 | 23 | 25 | 62% | +| P1 | 97 | 50 | 23 | 24 | 63% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -23,7 +23,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Fill, stroke, colour | 74% | | Effects & transparency | 50% | | Placing & links | 78% | -| Type & text frames | 80% | +| Type & text frames | 82% | | Typography | 83% | | Styles | 71% | | Tables | 73% | @@ -194,7 +194,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Type & text frames | Find Font | P0 | D | | Type & text frames | Spell checking | P1 | D | | Type & text frames | Hyphenation dictionaries | P0 | D | -| Type & text frames | Glyphs panel | P1 | M | +| Type & text frames | Glyphs panel | P1 | D | | Type & text frames | Insert special chars / white space / breaks | P0 | D | | Type & text frames | Text variables | P1 | D | | Type & text frames | Conditional text | P2 | M |