From be98657a221dac9af5c540520eb78e4f9fdfe51f Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 08:42:28 +0100 Subject: [PATCH 1/2] feat(text): Typography panel lists and sets the OpenType features of the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Typography panel (Window ▸ Show Panel, and the icon column) lists the features of the selected text's fonts, merged over those fonts, grouped as Ligatures, Alternates, Stylistic Sets (named by the font), Figure Style, Figure Width, Figure Position and Capitals. A section or choice appears only when a font offers it. Each feature is a checkbox: checked when all of the selection has it, a dash when only some of it does, empty when none does. A feature only some of the selection's fonts offer is italic. Mutually exclusive choices are checkboxes too, and choosing one turns the others off. type.openTypeFeatures reports each feature's applied and available state over the selection. type.feature turns features on or off across the selection, keeping each run's other features. The fonts crate reads GSUB and GPOS feature lists and stylistic set names. The Character panel's OpenType menu is now a button that opens or focuses the Typography panel. Co-Authored-By: Claude Haiku 5.5 --- crates/doc/src/otf.rs | 2 +- crates/engine/src/cmd/text.rs | 172 ++++++++++++++++++ crates/fonts/src/features.rs | 52 ++++++ crates/fonts/src/lib.rs | 2 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 48 ++--- crates/ui-egui/src/panels/typography.rs | 225 ++++++++++++++++++++++++ 8 files changed, 470 insertions(+), 34 deletions(-) create mode 100644 crates/fonts/src/features.rs create mode 100644 crates/ui-egui/src/panels/typography.rs diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index e2eb9408b..5e246b8bf 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -31,7 +31,7 @@ pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[ /// Features shaping turns on by itself: "off" is an explicit `-tag`. fn default_on(tag: &str) -> bool { - matches!(tag, "calt" | "locl" | "mark") + matches!(tag, "calt" | "liga" | "locl" | "mark") } pub fn is_on(list: &[String], tag: &str) -> bool { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..724354c02 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -292,6 +292,16 @@ pub fn specs() -> Vec { break_link ), cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), + cmd!(query "type.openTypeFeatures", "OpenType Features", [], None, "{} → the OpenType features and stylistic sets the selected text's fonts offer, each with its applied and available state (none, some or all of the selection)", has_doc, open_type_features), + cmd!( + "type.feature", + "Set OpenType Features", + [], + None, + "{features: {tag: true|false, …}} — turns OpenType features on or off across the selected text, keeping each run's other features", + has_doc, + set_features + ), ] } @@ -1378,6 +1388,125 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { Ok(json!({"on": on, "changed": changed})) } +/// One stretch of the selection: its length in bytes, the font it is set in and its features. +struct FeatureSegment { + len: usize, + font: (String, String), + applied: Vec, +} + +/// The OpenType features of the fonts in the selection, with how much of the selection each one is +/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, +/// `some` or `all`. +fn open_type_features(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mut segments: Vec = Vec::new(); + for Target { story: sid, cell, range: r } in format_targets(s) { + let Some(story) = st.doc.text_story(sid, cell) else { continue }; + let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { + vec![(r.start, 1, story.format_after(r.start).clone())] + } else { + story + .runs() + .filter(|(run, _)| run.start < r.end && run.end > r.start) + .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) + .collect() + }; + for (pos, len, cf) in covered { + let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; + let (_, base) = st.doc.styles.resolve_para(para); + let cp = st.doc.styles.resolve_char(&base, &cf); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + } + if segments.is_empty() { + let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); + let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + let db = designcraft_fonts::FontDb::global(); + let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); + for seg in &segments { + fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); + } + let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); + let state = |covered: usize| match covered { + 0 => "none", + n if n >= total => "all", + _ => "some", + }; + // Bytes the feature is applied to, and bytes whose font offers it. + let measure = |tag: &str| { + let (mut applied, mut available) = (0, 0); + for seg in &segments { + if designcraft_doc::otf::is_on(&seg.applied, tag) { + applied += seg.len; + } + if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { + available += seg.len; + } + } + (state(applied), state(available)) + }; + let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); + let mut features = Vec::new(); + for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { + let (applied, available) = measure(tag); + features.push(json!({"tag": tag, "applied": applied, "available": available})); + } + // A stylistic set's name is shared when every font that offers it gives it the same name. + let mut sets = Vec::new(); + for set in 1..=20u32 { + let tag = format!("ss{set:02}"); + if !tags.contains(&tag) { + continue; + } + let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); + let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None }; + let (applied, available) = measure(&tag); + sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); + } + let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); + Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) +} + +/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they +/// are on. Each run keeps its other features, whichever font it is set in, and the change is one +/// undo step. +fn set_features(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.feature"; + let changes: Vec<(String, bool)> = p + .get("features") + .and_then(Value::as_object) + .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? + .iter() + .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) + .collect::>()?; + let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select text")); + } + let styles = s.doc()?.doc.styles.clone(); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = + st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); + for (run, format) in runs { + let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; + let (_, base) = styles.resolve_para(¶); + let mut list = styles.resolve_char(&base, &format).otf_features; + for (tag, on) in &changes { + designcraft_doc::otf::set(&mut list, tag, *on); + } + let range = run.start.max(t.range.start)..run.end.min(t.range.end); + st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); + } + } + ok() + }) +} + /// Our own filler text (not Adobe's). pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ @@ -2558,3 +2687,46 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod open_type_features_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { + v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) + } + + #[test] + fn the_selected_fonts_features_are_listed_with_their_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); + assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); + assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); + } + + #[test] + fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); + let story = r["story"].clone(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); + } +} diff --git a/crates/fonts/src/features.rs b/crates/fonts/src/features.rs new file mode 100644 index 000000000..22dc80dec --- /dev/null +++ b/crates/fonts/src/features.rs @@ -0,0 +1,52 @@ +//! The OpenType features a font offers (its GSUB and GPOS feature lists) and the names it gives +//! its stylistic sets, for the Typography panel to show only what the selected fonts can do. + +use std::collections::{BTreeMap, BTreeSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::layout::FeatureParams; + +use crate::FontFace; + +/// What one font's layout tables offer. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FontFeatures { + /// The feature tags its GSUB and GPOS tables define, such as `liga` or `ss01`. + pub tags: BTreeSet, + /// The name each stylistic set (1 to 20) gives itself, from its feature parameters. + pub stylistic_sets: BTreeMap, +} + +/// The features `face` offers. A face without layout tables offers none. +pub fn font_features(face: &FontFace) -> FontFeatures { + let mut out = FontFeatures::default(); + let Some(font) = face.skrifa() else { return out }; + let mut lists = Vec::new(); + if let Ok(gsub) = font.gsub() + && let Ok(list) = gsub.feature_list() + { + lists.push(list); + } + if let Ok(gpos) = font.gpos() + && let Ok(list) = gpos.feature_list() + { + lists.push(list); + } + for list in &lists { + for record in list.feature_records() { + let tag = record.feature_tag().to_string(); + let set = tag.strip_prefix("ss").and_then(|n| n.parse::().ok()).filter(|n| (1..=20).contains(n)); + if let Some(set) = set + && !out.stylistic_sets.contains_key(&set) + && let Ok(feature) = record.feature(list.offset_data()) + && let Some(Ok(FeatureParams::StylisticSet(params))) = feature.feature_params() + && let Some(name) = font.localized_strings(params.ui_name_id()).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()) + { + out.stylistic_sets.insert(set, name); + } + out.tags.insert(tag); + } + } + out +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5ff395df8..efecc8c46 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod features; mod fontdb; mod group; mod kind; @@ -14,6 +15,7 @@ mod kind; pub mod testing; mod vertical; +pub use features::{FontFeatures, font_features}; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..e2ec45078 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"), + ("typography", "Typography", "panel-character"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), @@ -205,6 +206,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), + "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..2d2f46989 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod properties; pub mod styles; pub mod swatches; pub mod table; +pub mod typography; use designcraft_doc::{Content, StrokeAlign, WrapMode}; use designcraft_geom::Rect; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44f..8c74b62ae 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1415,9 +1415,15 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { { let _ = app.run("type.char", json!({"attrs": {"position": if sup { "normal" } else { "superscript" }}})); } - let features: Vec = - c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features)); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "OpenType"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show the Typography panel")); + }) + .clicked() + { + show_typography_panel(app); + } }); // Underline / Strikethrough Options. for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { @@ -1452,37 +1458,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// The Character panel's OpenType menu: feature toggles, figure styles and stylistic sets. -pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String]) { - use designcraft_doc::otf; - ui.set_min_width(220.0); - for (tag, label, _) in otf::TOGGLES { - let mut on = otf::is_on(features, tag); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("type.openType", json!({"feature": tag, "on": on})); - ui.close(); - } +/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a +/// flyout, so it is skipped while the Typography flyout is open. +pub fn show_typography_panel(app: &mut DesignApp) { + if app.ui.open_panel.as_deref() == Some("typography") { + return; } - ui.separator(); - let fig = otf::figures(features); - for (id, label, ..) in otf::FIGURES { - if ui.radio(fig == *id, *label).clicked() { - let _ = app.run("type.openType", json!({"figures": id})); - ui.close(); - } - } - ui.separator(); - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { - let mask = otf::stylistic_sets(features); - for n in 1..=20u32 { - let mut on = mask & (1 << (n - 1)) != 0; - if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() { - let sets: Vec = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect(); - let _ = app.run("type.openType", json!({"stylisticSets": sets})); - ui.close(); - } - } - }); + let _ = app.run("window.panel", json!({"panel": "typography"})); } pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs new file mode 100644 index 000000000..ef34a0110 --- /dev/null +++ b/crates/ui-egui/src/panels/typography.rs @@ -0,0 +1,225 @@ +//! The Typography panel: the OpenType features the fonts of the selected text offer, grouped as +//! Affinity's are. Every feature is a checkbox that shows how much of the selection it is applied to +//! (checked: all of it; a dash: some of it; empty: none). A feature only some of the selection's fonts +//! offer is italic. Changes apply to the selection through `type.feature`. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use crate::DesignApp; + +/// A feature's state over the selection: applied to none of it, some of it, or all of it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Covered { + None, + Some, + All, +} + +impl Covered { + fn parse(s: &str) -> Self { + match s { + "all" => Covered::All, + "some" => Covered::Some, + _ => Covered::None, + } + } +} + +/// A switch: its OpenType tag and menu label. +type Switch = (&'static str, &'static str); + +/// A group of mutually exclusive choices: each one's label and the tags it turns on. +type Choice = (&'static str, &'static [&'static str]); + +const LIGATURES: &[Switch] = + &[("liga", "Standard Ligatures"), ("dlig", "Discretionary Ligatures"), ("hlig", "Historical Ligatures"), ("clig", "Contextual Ligatures")]; + +const ALTERNATES: &[Switch] = &[ + ("calt", "Contextual Alternates"), + ("salt", "Stylistic Alternates"), + ("swsh", "Swash"), + ("titl", "Titling Alternates"), + ("hist", "Historical Forms"), + ("ornm", "Ornaments"), + ("zero", "Slashed Zero"), +]; + +const FIGURE_STYLE: &[Choice] = &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])]; + +const FIGURE_WIDTH: &[Choice] = &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])]; + +const FIGURE_POSITION: &[Choice] = &[ + ("Numerator", &["numr"]), + ("Denominator", &["dnom"]), + ("Superscript", &["sups"]), + ("Subscript", &["subs"]), + ("Scientific Inferiors", &["sinf"]), +]; + +const FRACTION_AND_ORDINAL: &[Switch] = &[("frac", "Fractions"), ("ordn", "Ordinal")]; + +const CAPITALS: &[Choice] = &[("Caps to Small Caps", &["c2sc"]), ("Small Caps", &["smcp"]), ("All Small Caps", &["c2sc", "smcp"])]; + +/// A feature the selection's fonts offer, and its state over the selection. +#[derive(Clone, Copy)] +struct Feature { + applied: Covered, + available: Covered, +} + +/// What the selection's fonts offer: each feature's state, and each stylistic set's name and state. +#[derive(Default)] +struct Offered { + features: BTreeMap, + sets: Vec, +} + +struct Set { + number: u32, + tag: String, + name: Option, + feature: Feature, +} + +impl Offered { + fn get(&self, tag: &str) -> Option { + self.features.get(tag).copied() + } + + /// A choice's state: all of its tags applied is all, none is none, otherwise some. Its + /// availability is the least of its tags'. None when no tag is offered. + fn choice(&self, tags: &[&str]) -> Option { + let found: Vec = tags.iter().filter_map(|t| self.get(t)).collect(); + if found.is_empty() { + return None; + } + let applied = if found.iter().all(|f| f.applied == Covered::All) { + Covered::All + } else if found.iter().all(|f| f.applied == Covered::None) { + Covered::None + } else { + Covered::Some + }; + let available = found.iter().map(|f| f.available).min().unwrap_or(Covered::None); + Some(Feature { applied, available }) + } +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if super::text_attrs(app).is_none() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); + return; + } + let offered = offered(app); + if offered.features.is_empty() && offered.sets.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "The selected fonts have no OpenType features.")); + return; + } + let lang = app.ui.language.clone(); + // Changes this frame: each feature tag to the state it is set to. + let mut changes: BTreeMap = BTreeMap::new(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Typography")).strong()); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + section(ui, &lang, "Ligatures", |ui| switches(ui, &lang, &offered, LIGATURES, &mut changes)); + section(ui, &lang, "Alternates", |ui| switches(ui, &lang, &offered, ALTERNATES, &mut changes)); + section(ui, &lang, "Stylistic Sets", |ui| stylistic_sets(ui, &lang, &offered, &mut changes)); + section(ui, &lang, "Figure Style", |ui| choices(ui, &lang, &offered, FIGURE_STYLE, &mut changes)); + section(ui, &lang, "Figure Width", |ui| choices(ui, &lang, &offered, FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Position", |ui| { + choices(ui, &lang, &offered, FIGURE_POSITION, &mut changes); + switches(ui, &lang, &offered, FRACTION_AND_ORDINAL, &mut changes); + }); + section(ui, &lang, "Capitals", |ui| choices(ui, &lang, &offered, CAPITALS, &mut changes)); + }); + if !changes.is_empty() { + let _ = app.run("type.feature", json!({"features": changes})); + } +} + +/// A collapsible section, open at first. Its body runs only when it has something to show. +fn section(ui: &mut egui::Ui, lang: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(crate::i18n::tr(lang, title)).id_salt(title).default_open(true).show(ui, body); +} + +/// A checkbox for a feature's state: checked when all of the selection has it, a dash when some of +/// it does, italic when only some of the selection's fonts offer it. Returns the state to set when +/// clicked. +fn tri_checkbox(ui: &mut egui::Ui, text: &str, feature: Feature) -> Option { + let mut on = feature.applied == Covered::All; + let mut label = egui::RichText::new(text); + if feature.available == Covered::Some { + label = label.italics(); + } + let response = ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(feature.applied == Covered::Some)); + response.clicked().then_some(feature.applied != Covered::All) +} + +/// On/off switches the fonts offer. +fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut BTreeMap) { + for (tag, label) in items { + let Some(feature) = offered.get(tag) else { continue }; + if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { + changes.insert(tag.to_string(), on); + } + } +} + +/// Mutually exclusive choices, as checkboxes: choosing one turns the others off. Choosing a checked +/// one turns it off. +fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut BTreeMap) { + for (i, (label, tags)) in items.iter().enumerate() { + let Some(feature) = offered.choice(tags) else { continue }; + let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) else { continue }; + for t in *tags { + changes.insert(t.to_string(), on); + } + if on { + for (j, (_, others)) in items.iter().enumerate() { + if j == i { + continue; + } + for t in others.iter().filter(|t| !tags.contains(t)) { + changes.insert(t.to_string(), false); + } + } + } + } +} + +/// Stylistic sets by the name their fonts give them, or the generic `Stylistic Set NN` when the +/// selection's fonts name them differently. +fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut BTreeMap) { + for set in &offered.sets { + let label = match &set.name { + Some(name) => format!("{name} ({})", set.tag), + None => format!("{} {:02}", crate::i18n::tr(lang, "Stylistic Set"), set.number), + }; + if let Some(on) = tri_checkbox(ui, &label, set.feature) { + changes.insert(set.tag.clone(), on); + } + } +} + +/// The features the selection's fonts offer, with their state over the selection. +fn offered(app: &mut DesignApp) -> Offered { + let v = app.session.execute("type.openTypeFeatures", &json!({})).unwrap_or(Value::Null); + let mut out = Offered::default(); + for f in v["features"].as_array().into_iter().flatten() { + let (Some(tag), Some(applied), Some(available)) = (f["tag"].as_str(), f["applied"].as_str(), f["available"].as_str()) else { continue }; + out.features.insert(tag.to_string(), Feature { applied: Covered::parse(applied), available: Covered::parse(available) }); + } + for s in v["stylisticSets"].as_array().into_iter().flatten() { + let (Some(number), Some(tag), Some(applied), Some(available)) = + (s["set"].as_u64(), s["tag"].as_str(), s["applied"].as_str(), s["available"].as_str()) + else { + continue; + }; + let Ok(number) = u32::try_from(number) else { continue }; + let feature = Feature { applied: Covered::parse(applied), available: Covered::parse(available) }; + out.features.insert(tag.to_string(), feature); + out.sets.push(Set { number, tag: tag.to_string(), name: s["name"].as_str().map(str::to_string), feature }); + } + out +} From fbfc1dca7620e33d992053f157a0bbde71efc923 Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 08:44:07 +0100 Subject: [PATCH 2/2] feat(ui): the Typography panel has its own OT icon in the dock The icon column's Typography entry reused the Character icon. It now uses an "OT" glyph (panel-typography), and a test checks it is neither the placeholder nor the Character icon. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/dock.rs | 2 +- crates/ui-egui/src/icons.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e2ec45078..9b19f2302 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,7 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), - ("typography", "Typography", "panel-character"), + ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..6182c4a92 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -301,6 +301,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } "panel-effects" => pen.text(10.0, 10.0, "fx", 11.0), "panel-character" => pen.text(10.0, 10.5, "A", 15.0), + "panel-typography" => pen.text(10.0, 10.5, "OT", 11.5), "panel-paragraph" => pen.text(10.0, 10.5, "¶", 15.0), "panel-pstyles" => { pen.text(8.0, 10.0, "¶", 13.0); @@ -878,6 +879,7 @@ mod tests { /// What icon `name` paints into a 20×20 rect, as the painter's shape list. fn drawing(name: &str) -> String { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); }); @@ -897,4 +899,12 @@ mod tests { assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); } + + #[test] + fn typography_panel_has_its_own_icon() { + let unknown = drawing("no-such-icon"); + let typography = drawing("panel-typography"); + assert_ne!(typography, unknown, "panel-typography falls back to the placeholder"); + assert_ne!(typography, drawing("panel-character"), "Typography draws the Character icon"); + } }