From 7c600b3c762208b5ed6e75feecd5943a07855ed3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 24 Jun 2026 20:55:57 +0200 Subject: [PATCH] Pre-populate font variation axes in the `FontTweak` UI (#8258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `FontTweak` settings UI previously let you edit variable-font variation coordinates only via free-form tag + value entry — you had to *know* that e.g. `wght` exists and what range is valid. This PR queries the font's actual variation axes and pre-populates the UI. ### Changes - **`epaint`**: new `FontData::variation_axes() -> Vec` (skrifa-backed). Each `FontVariationAxis` exposes the axis `tag`, human-readable `name`, `min`/`default`/`max`, and `hidden`. Empty for static (non-variable) fonts. - **`egui`**: extracted the `FontTweak` body into a public `style::font_tweak_ui(ui, tweak, axes)`. When `axes` is non-empty, each axis is shown as a named **slider** pre-filled with the font's default and clamped to its valid range, with a ⟲ button to drop the override. `impl Widget for &mut FontTweak` still exists and delegates with no axes (free-form fallback, also used for unknown/manual tags). - The font settings panel (`Context::fonts_tweak_ui`) now passes `data.variation_axes()`. - UI label renamed `coords` → **Axes** (matching Google Fonts' terminology); the underlying `FontTweak.coords` field keeps the OpenType "design coordinates" name. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Example (Weight and Width): Screenshot 2026-06-24 at 11 51 33 Co-authored-by: Claude Opus 4.8 (1M context) --- crates/egui/src/context.rs | 3 +- crates/egui/src/style.rs | 234 ++++++++++++++++---------------- crates/epaint/src/text/fonts.rs | 57 +++++++- crates/epaint/src/text/mod.rs | 4 +- 4 files changed, 176 insertions(+), 122 deletions(-) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index e36448cf7..1ad4a8fb1 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -3206,7 +3206,8 @@ impl Context { for (name, data) in &mut font_definitions.font_data { ui.collapsing(name, |ui| { let mut tweak = data.tweak.clone(); - if tweak.ui(ui).changed() { + let axes = data.variation_axes(); + if crate::style::font_tweak_ui(ui, &mut tweak, &axes).changed() { Arc::make_mut(data).tweak = tweak; changed = true; } diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index a054d96d5..202d49d76 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -3,8 +3,7 @@ use emath::Align; use epaint::{ CornerRadius, FontColorTransferFunction, Shadow, Stroke, TextOptions, - mutex::Mutex, - text::{FontTweak, Tag}, + text::{FontTweak, FontVariationAxis}, }; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; @@ -2882,121 +2881,124 @@ impl Widget for &mut crate::Frame { } } +/// Show a UI for editing a [`FontTweak`]. +/// +/// `axes` are the variation axes of the font this tweak applies to, as returned by +/// [`epaint::text::FontData::variation_axes`]. When non-empty, the variation +/// coordinates are shown as named sliders pre-populated with each axis' valid range +/// and default value, so the user doesn't have to guess tags and numbers. Pass an +/// empty slice if the axes are unknown (e.g. a static font) to fall back to +/// free-form tag/value entry. +/// +/// [`Widget for &mut FontTweak`](FontTweak) calls this with no axes. +pub fn font_tweak_ui(ui: &mut Ui, tweak: &mut FontTweak, axes: &[FontVariationAxis]) -> Response { + let original: FontTweak = tweak.clone(); + + let mut response = Grid::new("font_tweak") + .num_columns(2) + .show(ui, |ui| { + let FontTweak { + scale, + y_offset_factor, + y_offset, + hinting, + coords, + thin_space_width, + tab_size, + subpixel_binning, + } = tweak; + + ui.label("Scale"); + let speed = *scale * 0.01; + ui.add(DragValue::new(scale).range(0.01..=10.0).speed(speed)); + ui.end_row(); + + ui.label("y_offset_factor"); + ui.add(DragValue::new(y_offset_factor).speed(-0.0025)); + ui.end_row(); + + ui.label("y_offset"); + ui.add(DragValue::new(y_offset).speed(-0.02)); + ui.end_row(); + + ui.label("hinting"); + ui.horizontal(|ui| { + ui.radio_value(hinting, Some(true), "on"); + ui.radio_value(hinting, Some(false), "off"); + ui.radio_value(hinting, None, "default"); + }); + ui.end_row(); + + ui.label("subpixel_binning"); + ui.horizontal(|ui| { + ui.radio_value(subpixel_binning, Some(true), "on"); + ui.radio_value(subpixel_binning, Some(false), "off"); + ui.radio_value(subpixel_binning, None, "default"); + }); + ui.end_row(); + + ui.label("thin_space_width"); + ui.horizontal(|ui| { + ui.add( + DragValue::new(thin_space_width) + .range(0.0..=1.0) + .speed(0.01), + ); + ui.label("1\u{2009}234\u{2009}567\u{2009}890"); + }); + ui.end_row(); + + ui.label("tab_size"); + ui.add(DragValue::new(tab_size).range(0.0..=16.0).speed(0.1)); + ui.end_row(); + + // Show variation axes if we have them: + for axis in axes.iter().filter(|axis| !axis.hidden) { + match &axis.name { + Some(name) => ui.label(format!("{name} ({})", axis.tag)), + None => ui.label(axis.tag.to_string()), + }; + + let existing = coords.as_ref().iter().position(|(tag, _)| *tag == axis.tag); + let mut value = existing.map_or(axis.default, |i| coords.as_ref()[i].1); + + ui.horizontal(|ui| { + if ui.add(Slider::new(&mut value, axis.range.into())).changed() { + match existing { + Some(i) => coords.as_mut()[i].1 = value, + None => coords.push(axis.tag, value), + } + } + // Let the user drop the override and fall back to the font default: + if existing.is_some() + && ui + .small_button("⟲") + .on_hover_text("Reset to the font's default value") + .clicked() + && let Some(i) = + coords.as_ref().iter().position(|(tag, _)| *tag == axis.tag) + { + coords.remove(i); + } + }); + ui.end_row(); + } + + if ui.button("Reset").clicked() { + *tweak = Default::default(); + } + }) + .response; + + if *tweak != original { + response.mark_changed(); + } + + response +} + impl Widget for &mut FontTweak { fn ui(self, ui: &mut Ui) -> Response { - let original: FontTweak = self.clone(); - - let mut response = Grid::new("font_tweak") - .num_columns(2) - .show(ui, |ui| { - let FontTweak { - scale, - y_offset_factor, - y_offset, - hinting, - coords, - thin_space_width, - tab_size, - subpixel_binning, - } = self; - - ui.label("Scale"); - let speed = *scale * 0.01; - ui.add(DragValue::new(scale).range(0.01..=10.0).speed(speed)); - ui.end_row(); - - ui.label("y_offset_factor"); - ui.add(DragValue::new(y_offset_factor).speed(-0.0025)); - ui.end_row(); - - ui.label("y_offset"); - ui.add(DragValue::new(y_offset).speed(-0.02)); - ui.end_row(); - - ui.label("hinting"); - ui.horizontal(|ui| { - ui.radio_value(hinting, Some(true), "on"); - ui.radio_value(hinting, Some(false), "off"); - ui.radio_value(hinting, None, "default"); - }); - ui.end_row(); - - ui.label("subpixel_binning"); - ui.horizontal(|ui| { - ui.radio_value(subpixel_binning, Some(true), "on"); - ui.radio_value(subpixel_binning, Some(false), "off"); - ui.radio_value(subpixel_binning, None, "default"); - }); - ui.end_row(); - - ui.label("coords"); - ui.end_row(); - let mut to_remove = None; - for (i, (tag, value)) in coords.as_mut().iter_mut().enumerate() { - let tag_text = ui.ctx().data_mut(|data| { - let tag = *tag; - Arc::clone(data.get_temp_mut_or_insert_with(ui.id().with(i), move || { - Arc::new(Mutex::new(tag.to_string())) - })) - }); - - let tag_text = &mut *tag_text.lock(); - let response = ui.text_edit_singleline(tag_text); - if response.changed() - && let Ok(new_tag) = Tag::new_checked(tag_text.as_bytes()) - { - *tag = new_tag; - } - // Reset stale text when not actively editing - // (e.g. after an item was removed and indices shifted) - if !response.has_focus() - && Tag::new_checked(tag_text.as_bytes()).ok() != Some(*tag) - { - *tag_text = tag.to_string(); - } - - ui.add(DragValue::new(value)); - if ui.small_button("🗑").clicked() { - to_remove = Some(i); - } - ui.end_row(); - } - if let Some(i) = to_remove { - coords.remove(i); - } - if ui.button("Add coord").clicked() { - coords.push(b"wght", 0.0); - } - if ui.button("Clear coords").clicked() { - coords.clear(); - } - ui.end_row(); - - ui.label("thin_space_width"); - ui.horizontal(|ui| { - ui.add( - DragValue::new(thin_space_width) - .range(0.0..=1.0) - .speed(0.01), - ); - ui.label("1\u{2009}234\u{2009}567\u{2009}890"); - }); - ui.end_row(); - - ui.label("tab_size"); - ui.add(DragValue::new(tab_size).range(0.0..=16.0).speed(0.1)); - ui.end_row(); - - if ui.button("Reset").clicked() { - *self = Default::default(); - } - }) - .response; - - if *self != original { - response.mark_changed(); - } - - response + font_tweak_ui(ui, self, &[]) } } diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 6b6090c4a..7c5f69988 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -10,11 +10,11 @@ use std::{ use crate::{ TextureAtlas, text::{ - ByteIndex, Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords, + ByteIndex, Galley, LayoutJob, LayoutSection, Tag, TextOptions, VariationCoords, font::{Font, FontFace}, }, }; -use emath::{NumExt as _, OrderedFloat}; +use emath::{NumExt as _, OrderedFloat, Rangef}; #[cfg(feature = "default_fonts")] use epaint_default_fonts::{EMOJI_ICON, HACK_REGULAR, NOTO_EMOJI_REGULAR, UBUNTU_LIGHT}; @@ -147,6 +147,57 @@ impl FontData { pub fn tweak(self, tweak: FontTweak) -> Self { Self { tweak, ..self } } + + /// The variation axes of this font, e.g. `wght` (weight) and `wdth` (width). + /// + /// Use this to discover which axes a variable font supports, and their valid + /// ranges, so a UI can offer the right knobs instead of making the user guess + /// tags and values for [`FontTweak::coords`]. + /// + /// Returns an empty list for non-variable (static) fonts, or if the font data + /// fails to parse. + pub fn variation_axes(&self) -> Vec { + use skrifa::MetadataProvider as _; + + let Ok(font) = skrifa::FontRef::from_index(self.font.as_ref(), self.index) else { + return Vec::new(); + }; + + font.axes() + .iter() + .map(|axis| FontVariationAxis { + tag: axis.tag(), + name: font + .localized_strings(axis.name_id()) + .english_or_first() + .map(|name| name.chars().collect()), + range: Rangef::new(axis.min_value(), axis.max_value()), + default: axis.default_value(), + hidden: axis.is_hidden(), + }) + .collect() + } +} + +/// A single variation axis of a variable font, e.g. weight (`wght`) or width (`wdth`). +/// +/// Obtained via [`FontData::variation_axes`]. +#[derive(Clone, Debug, PartialEq)] +pub struct FontVariationAxis { + /// The axis tag, e.g. `wght` or `wdth`. + pub tag: Tag, + + /// Human-readable axis name, if the font provides one (e.g. "Weight"). + pub name: Option, + + /// Valid range of values for this axis, `min..=max`. + pub range: Rangef, + + /// The value used when the axis is not overridden. + pub default: f32, + + /// Whether the font recommends hiding this axis from user interfaces. + pub hidden: bool, } impl AsRef<[u8]> for FontData { @@ -196,7 +247,7 @@ pub struct FontTweak { /// `None` means use the global setting in [`TextOptions::subpixel_binning`]. pub subpixel_binning: Option, - /// Override the font's default variation coordinates. + /// Override the font's default variation coordinates for its axes ("wght", etc.). pub coords: VariationCoords, /// Width of a thin space (`\u{2009}`) and narrow no-break space (`\u{202F}`), diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 1b82a9e05..90f13a4ce 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -9,8 +9,8 @@ mod text_layout_types; pub use { fonts::{ - FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, Fonts, - FontsImpl, FontsView, InsertFontFamily, + FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, + FontVariationAxis, Fonts, FontsImpl, FontsView, InsertFontFamily, }, index::{ByteIndex, ByteRange, ByteRangeExt, CharIndex, CharRange, CharRangeExt}, text_layout::*,