T: Glyphs panel — font grid rendered in-house, search, click to insert, recently used

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 16:16:49 -04:00
parent 6383871c05
commit 20ef6c2c4e
8 changed files with 204 additions and 4 deletions

View File

@ -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).

View File

@ -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");
}
}

View File

@ -24,6 +24,7 @@ pub use vello_cpu;
pub mod damage;
mod fx;
pub mod glyphs;
pub mod images;
mod text;

View File

@ -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),

View File

@ -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",

View File

@ -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<char>,
}
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<char> = 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<char> = 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::<egui::TextureHandle>(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));
}

View File

@ -1,5 +1,6 @@
//! Panels and shared panel helpers.
pub mod glyphs;
pub mod layers;
pub mod pages;
pub mod properties;

View File

@ -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.
<!-- SUMMARY -->
**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 |