From 6365e4339b4d4cf5754111715e2ea4e4eee74b9d Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:32:37 +0100 Subject: [PATCH] M4: Increase and decrease font weight step through the styles the family has (#1831) text.weightUp/weightDown stepped the CSS weight by 100 and stored a canonical name ("Medium", "Semi Bold", ...) whether the family had that face or not, so the first press on Arial Regular changed nothing visible and the stored style named a face no font provides. They now move to the nearest heavier or lighter style of the family with the same slant and width (Narrow stays Narrow), and stay put at either end. A family that isn't installed keeps the old 100 steps. --- crates/engine/src/cmd/text.rs | 65 ++++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/text_weight_step_tests.rs | 81 +++++++++++++++++++++ 3 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/text_weight_step_tests.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f72f215ee..c2b9043c3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -122,11 +122,70 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result { }) } +/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow"). +fn style_rest(style: &str) -> String { + const WORDS: [&str; 25] = [ + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "regular", + "normal", + "roman", + "book", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "heavy", + "semi", + "demi", + "extra", + "ultra", + "italic", + "oblique", + "plain", + "standard", + ]; + let s = style.to_ascii_lowercase().replace('-', " "); + let words = s.split_whitespace().filter(|w| !WORDS.contains(w) && !w.chars().all(|c| c.is_ascii_digit())); + words.collect::>().join(" ") +} + +fn slanted(style: &str) -> bool { + let s = style.to_ascii_lowercase(); + s.contains("italic") || s.contains("oblique") +} + +/// The next heavier (`up`) or lighter style of `styles` (a family's) after the chars' style: the +/// nearest weight with the same slant and width, as the style menu lists them. None when the family +/// has nothing further that way. +pub(crate) fn next_style(styles: &[String], st: &TextStyle, up: bool) -> Option { + let (w, italic, rest) = (i32::from(st.weight()), st.italic(), style_rest(&st.font_style)); + styles + .iter() + .filter(|x| slanted(x) == italic && style_rest(x) == rest) + .map(|x| (i32::from(figcraft_doc::style_weight(x)) - w, x)) + .filter(|(d, _)| if up { *d > 0 } else { *d < 0 }) + .min_by_key(|(d, _)| d.abs()) + .map(|(_, x)| x.clone()) +} + fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { style(s, p, |st| { - let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16; - let italic = st.italic(); - st.set_font_style(styled_name(weight_name(w), italic)); + let styles = figcraft_text::family_styles(&st.font_family); + if styles.is_empty() { + // A family that isn't installed: step the weight by 100 under its canonical name. + let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16; + let italic = st.italic(); + st.set_font_style(styled_name(weight_name(w), italic)); + } else if let Some(next) = next_style(&styles, st, up) { + st.set_font_style(next); + } }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e919444e2..621fdd8cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -575,6 +575,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod text_weight_step_tests; +#[cfg(test)] mod textfit_tests; #[cfg(test)] mod theme_pages_tests; diff --git a/crates/engine/src/text_weight_step_tests.rs b/crates/engine/src/text_weight_step_tests.rs new file mode 100644 index 000000000..f3f7fa587 --- /dev/null +++ b/crates/engine/src/text_weight_step_tests.rs @@ -0,0 +1,81 @@ +//! Increase and decrease font weight step through the styles the family has, by weight with the +//! same slant and width (#1831), not by 100 to a canonical name no face provides. + +use figcraft_doc::{NodeId, TextStyle}; +use serde_json::json; + +use crate::Session; +use crate::cmd::text::next_style; + +fn styles(names: &[&str]) -> Vec { + names.iter().map(|s| (*s).to_string()).collect() +} + +fn at(style: &str) -> TextStyle { + let mut st = TextStyle::default(); + st.set_font_style(style); + st +} + +/// Walk `from` heavier (`up`) or lighter until the family has nothing further. +fn walk(family: &[String], from: &str, up: bool) -> Vec { + let mut st = at(from); + let mut out = Vec::new(); + while let Some(next) = next_style(family, &st, up) { + st.set_font_style(next.clone()); + out.push(next); + } + out +} + +#[test] +fn the_steps_follow_the_family_with_the_same_slant_and_width() { + let arial = styles(&["Narrow", "Regular", "Italic", "Narrow Italic", "Bold", "Narrow Bold", "Bold Italic", "Narrow Bold Italic", "Black"]); + assert_eq!(walk(&arial, "Regular", true), ["Bold", "Black"]); + assert_eq!(walk(&arial, "Black", false), ["Bold", "Regular"]); + assert_eq!(walk(&arial, "Italic", true), ["Bold Italic"]); + assert_eq!(walk(&arial, "Narrow", true), ["Narrow Bold"]); + assert_eq!(walk(&arial, "Narrow Bold Italic", false), ["Narrow Italic"]); + let segoe = styles(&["Light", "Semilight", "Regular", "Italic", "Semibold", "Bold", "Black", "Bold Italic"]); + assert_eq!(walk(&segoe, "Regular", true), ["Semibold", "Bold", "Black"]); + assert_eq!(walk(&segoe, "Regular", false), ["Light"]); + // A style the family doesn't have (as the old steps stored) moves to the next real one. + assert_eq!(next_style(&segoe, &at("Medium"), true).as_deref(), Some("Semibold")); +} + +#[test] +fn a_custom_weight_axis_value_steps_from_where_it_is() { + let family = styles(&["Light", "Regular", "Medium", "Bold"]); + let mut st = at("Regular"); + st.variations.insert("wght".into(), 550.0); + assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold")); + assert_eq!(next_style(&family, &st, false).as_deref(), Some("Medium")); +} + +fn font_style(s: &Session, t: NodeId) -> String { + s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style.clone() +} + +#[test] +fn the_commands_land_on_styles_the_family_has() { + figcraft_text::scan_system_fonts(); + // Any installed family with a regular face and a heavier upright one (CI machines differ). + let upright = |x: &String| !x.to_lowercase().contains("italic") && !x.to_lowercase().contains("oblique"); + let pick = figcraft_text::family_names().into_iter().find_map(|f| { + let st = figcraft_text::family_styles(&f); + (st.iter().any(|x| x == "Regular") && st.iter().any(|x| upright(x) && figcraft_doc::style_weight(x) > 400)).then_some((f, st)) + }); + let Some((family, styles)) = pick else { return }; + let mut s = Session::new(); + let r = s.execute("node.create", &json!({"type": "text", "text": "Hi", "props": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); + let t = crate::params::id_of(&r["id"]).unwrap(); + s.execute("text.weightUp", &json!({"ids": [t]})).unwrap(); + let up = font_style(&s, t); + assert!(styles.contains(&up) && figcraft_doc::style_weight(&up) > 400, "{family}: {up} of {styles:?}"); + s.execute("text.weightDown", &json!({"ids": [t]})).unwrap(); + assert_eq!(font_style(&s, t), "Regular", "{family}: {styles:?}"); + // A family that isn't installed still steps by 100 under the canonical names. + s.execute("node.set", &json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Regular"}})).unwrap(); + s.execute("text.weightUp", &json!({"ids": [t]})).unwrap(); + assert_eq!(font_style(&s, t), "Medium"); +}