diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index ea7afa520..e69e97c46 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,11 +1,12 @@ //! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own //! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the -//! selected text objects). +//! selected text objects). The character selected with the Type tool is highlighted in the grid. use std::collections::HashMap; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; +use vectorcraft_doc::NodeKind; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -144,6 +145,28 @@ fn glyph_texture(ctx: &egui::Context, face: &vectorcraft_text::FontFace, gid: u3 tex } +/// The character the grid highlights: the first one of the Type tool's selected range, else the +/// one before its caret. Public behaviour: the Glyphs panel shows the selected character's glyph +/// (the overview page cited at [`CELL_STEPS`]). Walks the runs, not the characters, so it stays +/// cheap on every frame. +fn selected_char(app: &VectorcraftApp) -> Option { + let (id, a, b) = super::character::text_editing(app)?; + let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; + let (at, range) = (a.min(b), a != b); + let mut start = 0; + for run in &t.runs { + let end = start + run.text.len(); + if range && (start..end).contains(&at) { + return run.text.get(at - start..)?.chars().next(); + } + if !range && at > start && at <= end { + return run.text.get(..at - start)?.chars().next_back(); + } + start = end; + } + None +} + /// Insert `ch` at the Type tool's caret, else append it to the selected text. fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { let s = ch.to_string(); @@ -193,17 +216,35 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; let list_h = (ui.available_height() - 64.0).max(cell * 3.0); let hover_id = egui::Id::unique("gl-hover"); - egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| { - for r in range { + let selected = selected_char(app); + // The selected character and the rows shown last frame: the grid scrolls to the selected glyph + // only when the selection moves to another character, so it still scrolls freely meanwhile. + let follow_id = egui::Id::unique("gl-follow"); + let (followed, shown): (Option, std::ops::Range) = ui.ctx().data(|d| d.get_temp(follow_id)).unwrap_or_default(); + let mut area = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]); + if selected != followed + && let Some(c) = selected + && let Ok(i) = chars.binary_search_by_key(&c, |x| x.0) + && !shown.contains(&(i / cols)) + { + let row_h = cell + ui.spacing().item_spacing.y; + area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5); + } + let shown = area.show_rows(ui, cell, rows, |ui, range| { + for r in range.clone() { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for &(c, gid) in chars.iter().skip(r * cols).take(cols) { let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); + let chosen = selected == Some(c); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); + } else if chosen { + ui.painter().rect_filled(rect, 2, t.row_selected); } - ui.painter().rect_stroke(rect.shrink(0.5), 0, egui::Stroke::new(0.5, t.border), egui::StrokeKind::Inside); + let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; + ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); let tex = glyph_texture(ui.ctx(), &face, gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert"))); @@ -213,7 +254,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); } + range }); + ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner))); ui.add_space(4.0); let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id)); ui.label( @@ -295,14 +338,46 @@ mod tests { use super::*; - fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> (Vec<(String, Pos2)>, Vec) { + fn shapes(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> Vec { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); - let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> (Vec<(String, Pos2)>, Vec) { + let shapes = shapes(ctx, app, events); (text_anchors(&shapes), square_sides(&shapes)) } + /// The Type tool editing `text` in a new document. + fn editing(text: &str) -> (VectorcraftApp, serde_json::Value) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": text})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + (app, id) + } + + fn select(app: &mut VectorcraftApp, start: usize, end: usize) { + app.session.set_tool_option("select", &json!({"start": start, "end": end})); + } + + /// Centres of the highlighted (selected-character) cells. + fn highlighted(shapes: &[Shape], t: &Tokens) -> Vec { + fn walk(s: &Shape, fill: Color32, out: &mut Vec) { + match s { + Shape::Rect(r) if r.fill == fill => out.push(r.rect.center()), + Shape::Vec(v) => v.iter().for_each(|s| walk(s, fill, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, t.row_selected, &mut out)); + out + } + fn text_anchors(shapes: &[Shape]) -> Vec<(String, Pos2)> { fn walk(s: &Shape, out: &mut Vec<(String, Pos2)>) { match s { @@ -350,6 +425,67 @@ mod tests { frame(ctx, app, vec![button(false)]); } + #[test] + fn the_selected_character_is_the_range_start_or_the_one_before_the_caret() { + let (mut app, id) = editing("aHé"); + select(&mut app, 1, 2); + assert_eq!(selected_char(&app), Some('H')); + select(&mut app, 2, 2); + assert_eq!(selected_char(&app), Some('H')); + select(&mut app, 4, 1); + assert_eq!(selected_char(&app), Some('H'), "a backward range starts at its lower end"); + select(&mut app, 0, 0); + assert_eq!(selected_char(&app), None, "nothing before the caret"); + // Across runs: a style on `H` alone splits the text into three runs. + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "kerning": 50})).unwrap(); + select(&mut app, 1, 1); + assert_eq!(selected_char(&app), Some('a')); + select(&mut app, 2, 2); + assert_eq!(selected_char(&app), Some('H')); + select(&mut app, 2, 4); + assert_eq!(selected_char(&app), Some('é')); + app.session.select_tool("select", Default::default()).unwrap(); + assert_eq!(selected_char(&app), None, "only the Type tool's selection counts"); + } + + #[test] + fn the_selected_glyph_is_highlighted_and_scrolled_into_view() { + let (mut app, _) = editing("A"); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let (texts, _) = frame(&ctx, &mut app, vec![]); + if texts.iter().any(|(t, _)| t.contains("No fonts are available")) { + eprintln!("glyphs highlight: no fonts installed, grid not drawn"); + return; + } + let (family, style) = super::super::character::text_style(&app).map(|(s, _)| (s.font_family, s.font_style)).unwrap(); + let face = FontDb::global().face(&family, &style).unwrap(); + let chars: Vec = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !c.is_whitespace()).collect(); + let (Some(&first), Some(&last)) = (chars.first(), chars.last()) else { return }; + if chars.len() < 200 { + eprintln!("glyphs highlight: font too small to need scrolling"); + return; + } + let (mut app, _) = editing(&format!("{first}{last}")); + let end = first.len_utf8() + last.len_utf8(); + select(&mut app, end, end); + let ctx = egui::Context::default(); + // The caret sits after the last glyph of the font, far below the first screen of the grid. + let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t); + assert_eq!(lit.len(), 1, "the grid scrolled to the selected glyph and highlighted it: {lit:?}"); + let lit = highlighted(&shapes(&ctx, &mut app, vec![]), &t); + assert_eq!(lit.len(), 1, "it stays highlighted"); + // Highlighting the first character scrolls back up to it. + select(&mut app, 0, 1); + shapes(&ctx, &mut app, vec![]); + let top = highlighted(&shapes(&ctx, &mut app, vec![]), &t); + assert_eq!(top.len(), 1, "{top:?}"); + assert!(top[0].y < lit[0].y, "the first glyph sits at the top of the grid: {top:?} vs {lit:?}"); + // The caret at the start selects nothing. + select(&mut app, 0, 0); + assert!(highlighted(&shapes(&ctx, &mut app, vec![]), &t).is_empty()); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a19e610fd..dd2f9c55a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Sort Objects, #4917; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Glyphs panel highlights the character selected with the Type tool, #4908; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -127,7 +127,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -1446,6 +1446,7 @@ Symbols area (#4938), from the public symbol documentation ([create and place sy | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Type core: the Glyphs panel highlights the character selected with the Type tool and scrolls it into view (#4908); evidence added; scores unchanged. | | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); evidence added; scores unchanged. | | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); evidence added; scores unchanged. | | 2026-11-01 | minor | Symbols panel: Select All Unused (`symbol.unused`) shows the symbols nothing places selected, and Delete Symbol deletes them all, asked once, in one undo step (`symbol.delete {names}`, #4918); scores and hours unchanged | diff --git a/docs/type-parity.md b/docs/type-parity.md index 0b7384d65..e13ad6680 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Serbian in Cyrillic and Latin and Croatian among the languages, with their quote marks; previously minor: Area Type Options › Text Flow by rows or by columns, #4978; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Glyphs panel: the character selected with the Type tool is highlighted and scrolled into view, #4908; previously minor: Serbian in Cyrillic and Latin and Croatian among the languages, with their quote marks; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges, and Alternates for Current Selection is not there yet | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -73,6 +73,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Glyphs panel (#4908): the character selected with the Type tool (a range's first, else the one before the caret) is highlighted in the grid and scrolled into view when the selection moves to another character; Show › Alternates for Current Selection not yet; scores unchanged | | 2026-11-01 | minor | Serbian: Cyrillic (`sr-Cyrl`), Serbian: Latin (`sr-Latn`) and Croatian (`hr`) in the language list, with their quote marks (Serbian „…” and ’…’ per the Pravopis srpskoga jezika, Matica srpska 2010, ISBN 978-86-7946-079-0, т. 168, and [CLDR `sr`](https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml); Croatian „…“ and ‚…‘ per [CLDR `hr`](https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml)); Serbian text gets the fonts' Serbian forms (italic б, г, д, п, т); scores unchanged | | 2026-11-01 | minor | Area Type Options › Text Flow (#4978): By Rows / By Columns radio buttons and `text.areaOptions {flow}`; area type with several rows and columns fills them across each row first or down each column first (the default, so existing documents are unchanged; saved only when by rows); scores unchanged | | 2026-11-01 | minor | Digits (#4942): the native digits of Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada and Malayalam in the Character panel menu › Digits and `{digits}`; scores unchanged |