mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
Merge PR #881
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -292,6 +292,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1379,6 +1389,125 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut segments: Vec<FeatureSegment> = 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::<usize>().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<String> = 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::<u32>().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<Value> = 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<Value> {
|
||||
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::<Result<_>>()?;
|
||||
let targets: Vec<Target> = 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<usize>, 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. \
|
||||
@@ -2559,3 +2688,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// The name each stylistic set (1 to 20) gives itself, from its feature parameters.
|
||||
pub stylistic_sets: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
/// 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::<u32>().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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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-typography"),
|
||||
("glyphs", "Glyphs", "panel-character"),
|
||||
("paragraph", "Paragraph", "panel-paragraph"),
|
||||
("textWrap", "Text Wrap", "panel-wrap"),
|
||||
@@ -252,6 +253,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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1428,9 +1428,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<String> =
|
||||
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")] {
|
||||
@@ -1465,37 +1471,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<u32> = (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) {
|
||||
|
||||
@@ -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<String, Feature>,
|
||||
sets: Vec<Set>,
|
||||
}
|
||||
|
||||
struct Set {
|
||||
number: u32,
|
||||
tag: String,
|
||||
name: Option<String>,
|
||||
feature: Feature,
|
||||
}
|
||||
|
||||
impl Offered {
|
||||
fn get(&self, tag: &str) -> Option<Feature> {
|
||||
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<Feature> {
|
||||
let found: Vec<Feature> = 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<String, bool> = 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<bool> {
|
||||
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<String, bool>) {
|
||||
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<String, bool>) {
|
||||
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<String, bool>) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user