1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-31 05:40:03 -04:00

Merge branch 'main' into ime-preedit-visuals

This commit is contained in:
Umaĵo
2026-05-27 07:48:06 +08:00
committed by GitHub
230 changed files with 5317 additions and 4687 deletions

View File

@@ -5,6 +5,10 @@ This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.34.2 - 2026-05-04
* Fix text layout bugs in wrapped texts [#8137](https://github.com/emilk/egui/pull/8137) by [@lucasmerlin](https://github.com/lucasmerlin)
## 0.34.1 - 2026-03-27
Nothing new

View File

@@ -1,3 +1,4 @@
use ecolor::linear_f32_from_linear_u8;
use emath::Vec2;
use crate::{Color32, textures::TextureOptions};
@@ -346,22 +347,37 @@ impl std::fmt::Debug for ColorImage {
// ----------------------------------------------------------------------------
/// How to convert font coverage values into alpha and color values.
//
// This whole thing is less than rigorous.
// Ideally we should do this in a shader instead, and use different computations
// for different text colors.
// See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis.
///
/// epaint stores all glyphs in the font atlas as white (with varying opacity),
/// so that egui can reuse the same glyph for different text colors
/// (with a simple color multiplication in the shader).
///
/// Because of this simplification, we need to apply a non-linear
/// ramp to the glyph colors before writing them into the font atlas,
/// as a way to compensate.
///
/// This whole thing is less than rigorous.
///
/// It would be better to either render all text colors into the font atlas
/// (which would require more atlas space, but would allow for more accurate rendering of colored text and emojis),
/// or do the color compensation in the shader, based on the active text color.
///
/// When experimenting, use <https://fonts.google.com/specimen/Ubuntu> to compare to a ground truth.
///
/// See <https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html> for related analysis.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum AlphaFromCoverage {
/// `alpha = coverage`.
pub enum FontColorTransferFunction {
/// Use the raw RGBA values from the font rasterizer, without any conversion.
///
/// Looks good for black-on-white text, i.e. light mode.
/// This is the required mode for colored emojis etc.
///
/// Same as [`Self::Gamma`]`(1.0)`, but more efficient.
Linear,
/// This mode looks good for black-on-white text, i.e. light mode.
Off,
/// `alpha = coverage^gamma`.
///
/// Gamma=1 looks good for black-on-white text, i.e. light mode.
Gamma(f32),
/// `alpha = 2 * coverage - coverage^2`
@@ -374,29 +390,59 @@ pub enum AlphaFromCoverage {
TwoCoverageMinusCoverageSq,
}
impl AlphaFromCoverage {
impl FontColorTransferFunction {
/// A good-looking default for light mode (black-on-white text).
pub const LIGHT_MODE_DEFAULT: Self = Self::Linear;
pub const LIGHT_MODE_DEFAULT: Self = Self::Off;
/// A good-looking default for dark mode (white-on-black text).
pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq;
/// How to convert a white color written by the font rasterizer
/// into a color to be written into the font atlas.
#[inline(always)]
pub fn to_atlas_color(self, input_color: Color32) -> Color32 {
match self {
Self::Off | Self::Gamma(1.0) => input_color,
Self::Gamma(gamma) => {
let coverage = linear_f32_from_linear_u8(input_color.a());
let alpha = coverage.powf(gamma);
Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha))
}
Self::TwoCoverageMinusCoverageSq => {
let coverage = linear_f32_from_linear_u8(input_color.a());
let alpha = 2.0 * coverage - coverage * coverage;
Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha))
}
}
}
/// Convert coverage to alpha.
#[inline(always)]
pub fn alpha_from_coverage(&self, coverage: f32) -> f32 {
pub fn alpha_from_coverage(self, coverage: f32) -> f32 {
let coverage = coverage.clamp(0.0, 1.0);
match self {
Self::Linear => coverage,
Self::Gamma(gamma) => coverage.powf(*gamma),
Self::Off | Self::Gamma(1.0) => coverage,
Self::Gamma(gamma) => coverage.powf(gamma),
Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage,
}
}
#[inline(always)]
pub fn color_from_coverage(&self, coverage: f32) -> Color32 {
pub fn color_from_coverage(self, coverage: f32) -> Color32 {
let alpha = self.alpha_from_coverage(coverage);
Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha))
}
/// Convert this into the closest gamma exponent
pub fn to_gamma(self) -> f32 {
match self {
Self::Off => 1.0,
Self::Gamma(gamma) => gamma,
Self::TwoCoverageMinusCoverageSq => 0.5, // approximately the same
}
}
}
// ----------------------------------------------------------------------------

View File

@@ -52,7 +52,7 @@ pub use self::{
corner_radius::CornerRadius,
corner_radius_f32::CornerRadiusF32,
direction::Direction,
image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta},
image::{ColorImage, FontColorTransferFunction, ImageData, ImageDelta},
margin::Margin,
margin_f32::*,
mesh::{Mesh, Mesh16, Vertex},
@@ -71,15 +71,9 @@ pub use self::{
viewport::ViewportInPixels,
};
#[deprecated = "Renamed to CornerRadius"]
pub type Rounding = CornerRadius;
pub use ecolor::{Color32, Hsva, HsvaGamma, Rgba};
pub use emath::{Pos2, Rect, Vec2, pos2, vec2};
#[deprecated = "Use the ahash crate directly."]
pub use ahash;
pub use ecolor;
pub use emath;

View File

@@ -17,9 +17,6 @@ pub struct MarginF32 {
pub bottom: f32,
}
#[deprecated = "Renamed to MarginF32"]
pub type Marginf = MarginF32;
impl From<Margin> for MarginF32 {
#[inline]
fn from(margin: Margin) -> Self {
@@ -97,18 +94,6 @@ impl MarginF32 {
pub fn is_same(&self) -> bool {
self.left == self.right && self.left == self.top && self.left == self.bottom
}
#[deprecated = "Use `rect + margin` instead"]
#[inline]
pub fn expand_rect(&self, rect: Rect) -> Rect {
Rect::from_min_max(rect.min - self.left_top(), rect.max + self.right_bottom())
}
#[deprecated = "Use `rect - margin` instead"]
#[inline]
pub fn shrink_rect(&self, rect: Rect) -> Rect {
Rect::from_min_max(rect.min + self.left_top(), rect.max - self.right_bottom())
}
}
impl From<f32> for MarginF32 {

View File

@@ -357,12 +357,6 @@ impl Shape {
.into()
}
#[inline]
#[deprecated = "Use `Shape::galley` or `Shape::galley_with_override_text_color` instead"]
pub fn galley_with_color(pos: Pos2, galley: Arc<Galley>, text_color: Color32) -> Self {
Self::galley_with_override_text_color(pos, galley, text_color)
}
#[inline]
pub fn mesh(mesh: impl Into<Arc<Mesh>>) -> Self {
let mesh = mesh.into();

View File

@@ -22,9 +22,9 @@ impl Stroke {
};
#[inline]
pub fn new(width: impl Into<f32>, color: impl Into<Color32>) -> Self {
pub fn new(width: f32, color: impl Into<Color32>) -> Self {
Self {
width: width.into(),
width,
color: color.into(),
}
}
@@ -136,9 +136,9 @@ impl PathStroke {
};
#[inline]
pub fn new(width: impl Into<f32>, color: impl Into<Color32>) -> Self {
pub fn new(width: f32, color: impl Into<Color32>) -> Self {
Self {
width: width.into(),
width,
color: ColorMode::Solid(color.into()),
kind: StrokeKind::Middle,
}
@@ -149,11 +149,11 @@ impl PathStroke {
/// The bounding box passed to the callback will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`)
#[inline]
pub fn new_uv(
width: impl Into<f32>,
width: f32,
callback: impl Fn(Rect, Pos2) -> Color32 + Send + Sync + 'static,
) -> Self {
Self {
width: width.into(),
width,
color: ColorMode::UV(Arc::new(callback)),
kind: StrokeKind::Middle,
}

View File

@@ -1703,16 +1703,6 @@ impl Tessellator {
.stroke_open(self.feathering, &stroke.into(), out);
}
#[deprecated = "Use `tessellate_line_segment` instead"]
pub fn tessellate_line(
&mut self,
points: [Pos2; 2],
stroke: impl Into<Stroke>,
out: &mut Mesh,
) {
self.tessellate_line_segment(points, stroke, out);
}
/// Tessellate a single [`PathShape`] into a [`Mesh`].
///
/// * `path_shape`: the path to tessellate.

View File

@@ -1,5 +1,6 @@
#![expect(clippy::mem_forget)]
use ecolor::Color32;
use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2};
use self_cell::self_cell;
use skrifa::{GlyphId, MetadataProvider as _};
@@ -58,6 +59,41 @@ impl GlyphInfo {
};
}
/// Result of resolving a `char` to a [`GlyphId`] within a single [`FontFace`].
///
/// Location-independent: only depends on the font's charmap and `FontTweak`,
/// not on variable-font variation coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum GlyphIdResolution {
/// A real, visible glyph.
Glyph(GlyphId),
/// A valid char, but rendered as zero-width (control chars, joiners, …).
Invisible,
}
/// A precomputed hash of a [`skrifa::instance::Location`].
///
/// Used as a cache key so that we don't have to re-hash the coordinate list
/// for every glyph lookup. Compute once per text run and reuse for every glyph
/// in the run.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub(crate) struct LocationHash(u64);
impl nohash_hasher::IsEnabled for LocationHash {}
impl LocationHash {
#[inline]
pub fn new(location: &skrifa::instance::Location) -> Self {
if location.coords().is_empty() {
// Fast path for the (common) default-coords case.
Self(0)
} else {
Self(crate::util::hash(location))
}
}
}
// Subpixel binning, taken from cosmic-text:
// https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs
@@ -131,10 +167,12 @@ struct GlyphCacheKey(u64);
impl nohash_hasher::IsEnabled for GlyphCacheKey {}
impl GlyphCacheKey {
#[inline]
fn new(glyph_id: GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self {
let StyledMetrics {
pixels_per_point,
px_scale_factor,
location_hash,
..
} = *metrics;
debug_assert!(
@@ -150,6 +188,7 @@ impl GlyphCacheKey {
pixels_per_point.to_bits(),
px_scale_factor.to_bits(),
bin,
location_hash,
)))
}
}
@@ -161,7 +200,6 @@ struct DependentFontData<'a> {
charmap: skrifa::charmap::Charmap<'a>,
outline_glyphs: skrifa::outline::OutlineGlyphCollection<'a>,
metrics: skrifa::metrics::Metrics,
glyph_metrics: skrifa::metrics::GlyphMetrics<'a>,
hinting_instance: Option<skrifa::outline::HintingInstance>,
}
@@ -204,7 +242,9 @@ impl FontCell {
if let Some(hinting_instance) = &mut font_data.hinting_instance {
let size = skrifa::instance::Size::new(metrics.scale);
if hinting_instance.size() != size {
if hinting_instance.size() != size
|| hinting_instance.location().coords() != location.coords()
{
hinting_instance
.reconfigure(
&font_data.outline_glyphs,
@@ -235,25 +275,31 @@ impl FontCell {
let width = bounds.width() as u16;
let height = bounds.height() as u16;
let mut ctx = vello_cpu::RenderContext::new(width, height);
ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0)));
ctx.set_paint(color::OpaqueColor::<color::Srgb>::WHITE);
ctx.fill_path(&path);
let mut dest = vello_cpu::Pixmap::new(width, height);
ctx.render_to_pixmap(&mut dest);
let uv_rect = if width == 0 || height == 0 {
UvRect::default()
} else {
let mut ctx = vello_cpu::RenderContext::new(width, height);
ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0)));
ctx.set_paint(color::OpaqueColor::<color::Srgb>::WHITE);
ctx.fill_path(&path);
let mut dest = vello_cpu::Pixmap::new(width, height);
let mut resources = vello_cpu::Resources::new();
ctx.render_to_pixmap(&mut resources, &mut dest);
let glyph_pos = {
let alpha_from_coverage = atlas.options().alpha_from_coverage;
let color_transfer_function = atlas.options().color_transfer_function;
let (glyph_pos, image) = atlas.allocate((width as usize, height as usize));
let pixels = dest.data_as_u8_slice();
for y in 0..height as usize {
for x in 0..width as usize {
image[(x + glyph_pos.0, y + glyph_pos.1)] = alpha_from_coverage
.color_from_coverage(
pixels[((y * width as usize) + x) * 4 + 3] as f32 / 255.0,
);
let pixel_offset = 4 * ((y * width as usize) + x);
image[(x + glyph_pos.0, y + glyph_pos.1)] = color_transfer_function
.to_atlas_color(Color32::from_rgba_premultiplied(
pixels[pixel_offset],
pixels[pixel_offset + 1],
pixels[pixel_offset + 2],
pixels[pixel_offset + 3],
));
}
}
glyph_pos
@@ -322,7 +368,18 @@ pub struct FontFace {
/// `ShaperData` is `Copy` — lives outside the `self_cell`.
shaper_data: harfrust::ShaperData,
glyph_info_cache: ahash::HashMap<char, GlyphInfo>,
/// Location-independent: `char → GlyphId | Invisible`.
///
/// Only depends on the font's charmap + `FontTweak`. A miss means the char
/// is not in this face's repertoire and the fallback chain should be tried.
glyph_id_cache: ahash::HashMap<char, GlyphIdResolution>,
/// Location-dependent: `(char, LocationHash) → unscaled advance width`.
///
/// Variable fonts can vary advance widths per axis (HVAR table), so this
/// must be re-keyed per resolved [`skrifa::instance::Location`].
advance_width_cache: ahash::HashMap<(char, LocationHash), OrderedFloat<f32>>,
glyph_alloc_cache: ahash::HashMap<GlyphCacheKey, GlyphAllocation>,
}
@@ -344,14 +401,11 @@ impl FontFace {
// Note: We use default location here during initialization because
// the actual weight will be applied via the stored location during rendering.
// The metrics won't be significantly different at this unscaled size.
// TODO(emilk): heed location for vertical metrics too (HVAR/MVAR).
let metrics = skrifa_font.metrics(
skrifa::instance::Size::unscaled(),
skrifa::instance::LocationRef::default(),
);
let glyph_metrics = skrifa_font.glyph_metrics(
skrifa::instance::Size::unscaled(),
skrifa::instance::LocationRef::default(),
);
let hinting_enabled = tweak.hinting.unwrap_or(options.font_hinting);
let hinting_instance = hinting_enabled
@@ -373,7 +427,6 @@ impl FontFace {
charmap,
outline_glyphs: glyphs,
metrics,
glyph_metrics,
hinting_instance,
})
})?;
@@ -388,7 +441,8 @@ impl FontFace {
tweak,
subpixel_binning,
shaper_data,
glyph_info_cache: Default::default(),
glyph_id_cache: Default::default(),
advance_width_cache: Default::default(),
glyph_alloc_cache: Default::default(),
})
}
@@ -422,65 +476,86 @@ impl FontFace {
.filter_map(|(chr, _)| char::from_u32(chr).filter(|c| !self.ignore_character(*c)))
}
/// `\n` will result in `None`
pub(super) fn glyph_info(&mut self, c: char) -> Option<GlyphInfo> {
if let Some(glyph_info) = self.glyph_info_cache.get(&c) {
return Some(*glyph_info);
/// Resolve a `char` to a [`GlyphId`] within this face.
///
/// Location-independent. Returns `None` when this face cannot represent
/// the char (the caller should try the fallback chain).
///
/// `\t` and thin spaces share `' '`s glyph id (they just have a custom advance).
pub(super) fn glyph_id_resolution(&mut self, c: char) -> Option<GlyphIdResolution> {
if let Some(resolution) = self.glyph_id_cache.get(&c) {
return Some(*resolution);
}
if self.ignore_character(c) {
return None; // these will result in the replacement character when rendering
}
if c == '\t'
&& let Some(space) = self.glyph_info(' ')
{
let glyph_info = GlyphInfo {
advance_width_unscaled: (self.tweak.tab_size * space.advance_width_unscaled.0)
.into(),
..space
};
self.glyph_info_cache.insert(c, glyph_info);
return Some(glyph_info);
}
if (c == '\u{2009}' || c == '\u{202F}')
&& let Some(space) = self.glyph_info(' ')
{
// Thin space (U+2009) and narrow no-break space (U+202F),
// often used as thousands separator: 1234567890
let advance_width = self.tweak.thin_space_width * space.advance_width_unscaled.0;
let glyph_info = GlyphInfo {
advance_width_unscaled: advance_width.into(),
..space
};
self.glyph_info_cache.insert(c, glyph_info);
return Some(glyph_info);
}
if invisible_char(c) {
let glyph_info = GlyphInfo::INVISIBLE;
self.glyph_info_cache.insert(c, glyph_info);
return Some(glyph_info);
}
let font_data = self.font.borrow_dependent();
// Add new character:
let glyph_id = font_data
.charmap
.map(c)
.filter(|id| *id != GlyphId::NOTDEF)?;
let glyph_info = GlyphInfo {
id: Some(glyph_id),
advance_width_unscaled: font_data
.glyph_metrics
.advance_width(glyph_id)
.unwrap_or_default()
.into(),
let resolution = if c == '\t' || c == '\u{2009}' || c == '\u{202F}' {
// `\t` and thin spaces are rendered as a space glyph with a custom advance.
self.glyph_id_resolution(' ')?
} else if invisible_char(c) {
GlyphIdResolution::Invisible
} else {
let glyph_id = self
.font
.borrow_dependent()
.charmap
.map(c)
.filter(|id| *id != GlyphId::NOTDEF)?;
GlyphIdResolution::Glyph(glyph_id)
};
self.glyph_id_cache.insert(c, resolution);
Some(resolution)
}
/// Unscaled advance width for `c` at the given variation location.
///
/// Location-dependent (variable fonts can vary advances via HVAR).
/// Cached per `(char, LocationHash)`.
fn advance_width_unscaled(&mut self, c: char, metrics: &StyledMetrics) -> f32 {
let cache_key = (c, metrics.location_hash);
if let Some(advance) = self.advance_width_cache.get(&cache_key) {
return advance.0;
}
let advance = match c {
'\t' => self.tweak.tab_size * self.advance_width_unscaled(' ', metrics),
'\u{2009}' | '\u{202F}' => {
// Thin space (U+2009) and narrow no-break space (U+202F),
// often used as thousands separator.
self.tweak.thin_space_width * self.advance_width_unscaled(' ', metrics)
}
_ => {
let Some(GlyphIdResolution::Glyph(glyph_id)) = self.glyph_id_resolution(c) else {
return 0.0;
};
let font_data = self.font.borrow_dependent();
let glyph_metrics = font_data
.skrifa
.glyph_metrics(skrifa::instance::Size::unscaled(), &metrics.location);
glyph_metrics.advance_width(glyph_id).unwrap_or_default()
}
};
self.advance_width_cache.insert(cache_key, advance.into());
advance
}
/// `\n` will result in `None`.
///
/// Caller must pass [`StyledMetrics`] resolved against *this* face so that
/// variable-font advance widths are looked up at the correct location.
pub(super) fn glyph_info(&mut self, c: char, metrics: &StyledMetrics) -> Option<GlyphInfo> {
let resolution = self.glyph_id_resolution(c)?;
let glyph_info = match resolution {
GlyphIdResolution::Invisible => GlyphInfo::INVISIBLE,
GlyphIdResolution::Glyph(glyph_id) => GlyphInfo {
id: Some(glyph_id),
advance_width_unscaled: self.advance_width_unscaled(c, metrics).into(),
},
};
self.glyph_info_cache.insert(c, glyph_info);
Some(glyph_info)
}
@@ -507,13 +582,9 @@ impl FontFace {
let axes = font_data.skrifa.axes();
// Override the default coordinates with ones specified via FontTweak, then the ones specified directly via the
// argument (probably from TextFormat).
let settings = self
.tweak
.coords
.as_ref()
.iter()
.chain(coords.as_ref().iter());
let settings = std::iter::chain(self.tweak.coords.as_ref(), coords.as_ref());
let location = axes.location(settings);
let location_hash = LocationHash::new(&location);
StyledMetrics {
pixels_per_point,
@@ -523,6 +594,7 @@ impl FontFace {
ascent,
row_height: ascent - descent + line_gap,
location,
location_hash,
}
}
@@ -598,7 +670,7 @@ pub struct Font<'a> {
impl Font<'_> {
pub fn preload_characters(&mut self, s: &str) {
for c in s.chars() {
self.glyph_info(c);
self.resolve_face(c);
}
}
@@ -630,19 +702,23 @@ impl Font<'_> {
.unwrap_or_default()
}
/// Width of this character in points.
/// Width of this character in points, at the font's default variation location.
pub fn glyph_width(&mut self, c: char, font_size: f32) -> f32 {
let (key, glyph_info) = self.glyph_info(c);
if let Some(font) = &self.fonts_by_id.get(&key) {
glyph_info.advance_width_unscaled.0 * font.font.px_scale_factor(font_size)
} else {
0.0
}
let face_key = self.resolve_face(c);
let Some(font_face) = self.fonts_by_id.get_mut(&face_key) else {
return 0.0;
};
let metrics = font_face.styled_metrics(1.0, font_size, &VariationCoords::default());
let Some(glyph_info) = font_face.glyph_info(c, &metrics) else {
return 0.0;
};
glyph_info.advance_width_unscaled.0 * font_face.font.px_scale_factor(font_size)
}
/// Can we display this glyph?
pub fn has_glyph(&mut self, c: char) -> bool {
self.glyph_info(c) != self.cached_family.replacement_glyph // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️
// TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️
self.resolve_face(c) != self.cached_family.replacement_face_key
}
/// Can we display all the glyphs in this text?
@@ -650,21 +726,52 @@ impl Font<'_> {
s.chars().all(|c| self.has_glyph(c))
}
/// `\n` will (intentionally) show up as the replacement character.
pub(crate) fn glyph_info(&mut self, c: char) -> (FontFaceKey, GlyphInfo) {
if let Some(font_index_glyph_info) = self.cached_family.glyph_info_cache.get(&c) {
return *font_index_glyph_info;
/// Find which face in the fallback chain owns `c`.
///
/// Location-independent — fallback choice depends only on charmap support.
/// Falls back to the replacement-glyph face when no fallback face has `c`.
#[inline]
pub(crate) fn resolve_face(&mut self, c: char) -> FontFaceKey {
if let Some(font_key) = self.cached_family.face_cache.get(&c) {
return *font_key;
}
self.resolve_face_slow(c)
}
let font_index_glyph_info = self
#[cold]
fn resolve_face_slow(&mut self, c: char) -> FontFaceKey {
let font_key = self
.cached_family
.glyph_info_no_cache_or_fallback(c, self.fonts_by_id);
let font_index_glyph_info =
font_index_glyph_info.unwrap_or(self.cached_family.replacement_glyph);
self.cached_family
.glyph_info_cache
.insert(c, font_index_glyph_info);
font_index_glyph_info
.find_face_for_char(c, self.fonts_by_id)
.unwrap_or(self.cached_family.replacement_face_key);
self.cached_family.face_cache.insert(c, font_key);
font_key
}
/// Resolve `c` to its (face, [`GlyphInfo`]) at the given face's location.
///
/// `\n` will (intentionally) show up as the replacement character.
///
/// `metrics` must be the resolved [`StyledMetrics`] for the face that ends
/// up owning `c`. Most callers pass the metrics of their text run's primary
/// face — that is correct as long as `c` is in that face. For correct
/// fallback-face advances, resolve the face first with [`Self::resolve_face`]
/// and build metrics for that face.
pub(crate) fn glyph_info(
&mut self,
c: char,
metrics: &StyledMetrics,
) -> (FontFaceKey, GlyphInfo) {
let face_key = self.resolve_face(c);
let Some(face) = self.fonts_by_id.get_mut(&face_key) else {
return (face_key, GlyphInfo::INVISIBLE);
};
let glyph_info = face.glyph_info(c, metrics).unwrap_or_else(|| {
// `c` is in no face — render the replacement character instead.
face.glyph_info(self.cached_family.replacement_char, metrics)
.unwrap_or(GlyphInfo::INVISIBLE)
});
(face_key, glyph_info)
}
}
@@ -697,6 +804,12 @@ pub struct StyledMetrics {
/// Resolved variation coordinates.
pub location: skrifa::instance::Location,
/// Precomputed hash of [`Self::location`].
///
/// Hashed once per run of text so per-glyph cache lookups don't have to
/// re-hash the full coordinate list.
pub(crate) location_hash: LocationHash,
}
/// Code points that will always be invisible (zero width).

View File

@@ -3,7 +3,7 @@ use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
atomic::{AtomicUsize, Ordering},
},
};
@@ -11,7 +11,7 @@ use crate::{
TextureAtlas,
text::{
Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords,
font::{Font, FontFace, GlyphInfo},
font::{Font, FontFace},
},
};
use emath::{NumExt as _, OrderedFloat};
@@ -439,7 +439,7 @@ impl FontFaceKey {
pub const INVALID: Self = Self(0);
fn new() -> Self {
static KEY_COUNTER: AtomicU64 = AtomicU64::new(1);
static KEY_COUNTER: AtomicUsize = AtomicUsize::new(1);
Self(crate::util::hash(
KEY_COUNTER.fetch_add(1, Ordering::Relaxed),
))
@@ -457,9 +457,20 @@ pub(super) struct CachedFamily {
/// Lazily calculated.
pub characters: Option<BTreeMap<char, Vec<String>>>,
pub replacement_glyph: (FontFaceKey, GlyphInfo),
/// The face used when no face in [`Self::fonts`] supports a char.
pub replacement_face_key: FontFaceKey,
pub glyph_info_cache: ahash::HashMap<char, (FontFaceKey, GlyphInfo)>,
/// The char that [`Self::replacement_face_key`] actually contains.
///
/// When the user asks about a char that no fallback face supports we
/// render this char in its place.
pub replacement_char: char,
/// Cache: `char → which face in the fallback chain owns this char`.
///
/// Location-independent (fallback choice depends only on charmap support,
/// not on variation coordinates).
pub face_cache: ahash::HashMap<char, FontFaceKey>,
}
impl CachedFamily {
@@ -467,49 +478,59 @@ impl CachedFamily {
fonts: Vec<FontFaceKey>,
fonts_by_id: &mut nohash_hasher::IntMap<FontFaceKey, FontFace>,
) -> Self {
const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square
const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback
if fonts.is_empty() {
return Self {
fonts,
characters: None,
replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE),
glyph_info_cache: Default::default(),
replacement_face_key: FontFaceKey::INVALID,
replacement_char: PRIMARY_REPLACEMENT_CHAR,
face_cache: Default::default(),
};
}
let mut slf = Self {
fonts,
characters: None,
replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE),
glyph_info_cache: Default::default(),
replacement_face_key: FontFaceKey::INVALID,
replacement_char: PRIMARY_REPLACEMENT_CHAR,
face_cache: Default::default(),
};
const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square
const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback
let replacement_glyph = slf
.glyph_info_no_cache_or_fallback(PRIMARY_REPLACEMENT_CHAR, fonts_by_id)
.or_else(|| slf.glyph_info_no_cache_or_fallback(FALLBACK_REPLACEMENT_CHAR, fonts_by_id))
let (replacement_face_key, replacement_char) = slf
.find_face_for_char(PRIMARY_REPLACEMENT_CHAR, fonts_by_id)
.map(|key| (key, PRIMARY_REPLACEMENT_CHAR))
.or_else(|| {
slf.find_face_for_char(FALLBACK_REPLACEMENT_CHAR, fonts_by_id)
.map(|key| (key, FALLBACK_REPLACEMENT_CHAR))
})
.unwrap_or_else(|| {
log::warn!(
"Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph."
);
(FontFaceKey::INVALID, GlyphInfo::INVISIBLE)
(FontFaceKey::INVALID, PRIMARY_REPLACEMENT_CHAR)
});
slf.replacement_glyph = replacement_glyph;
slf.replacement_face_key = replacement_face_key;
slf.replacement_char = replacement_char;
slf
}
pub(crate) fn glyph_info_no_cache_or_fallback(
&mut self,
/// Walk the fallback chain and return the first face whose charmap supports `c`.
///
/// Pure — does not touch any cache. Callers that want memoisation should
/// insert into [`Self::face_cache`] themselves.
pub(crate) fn find_face_for_char(
&self,
c: char,
fonts_by_id: &mut nohash_hasher::IntMap<FontFaceKey, FontFace>,
) -> Option<(FontFaceKey, GlyphInfo)> {
) -> Option<FontFaceKey> {
for font_key in &self.fonts {
let font_face = fonts_by_id.get_mut(font_key).expect("Nonexistent font ID");
if let Some(glyph_info) = font_face.glyph_info(c) {
self.glyph_info_cache.insert(c, (*font_key, glyph_info));
return Some((*font_key, glyph_info));
if font_face.glyph_id_resolution(c).is_some() {
return Some(*font_key);
}
}
None
@@ -1035,6 +1056,7 @@ impl GalleyCache {
0.0
},
round_output_to_gui: job.round_output_to_gui,
keep_trailing_whitespace: job.keep_trailing_whitespace,
};
// Add overlapping sections:

View File

@@ -25,8 +25,8 @@ pub struct TextOptions {
/// Maximum size of the font texture.
pub max_texture_side: usize,
/// Controls how to convert glyph coverage to alpha.
pub alpha_from_coverage: crate::AlphaFromCoverage,
/// Controls how to convert glyph colors when writing to the font atlas.
pub color_transfer_function: crate::FontColorTransferFunction,
/// Whether to enable font hinting
///
@@ -54,7 +54,7 @@ impl Default for TextOptions {
fn default() -> Self {
Self {
max_texture_side: 2048, // Small but portable
alpha_from_coverage: crate::AlphaFromCoverage::default(),
color_transfer_function: crate::FontColorTransferFunction::default(),
font_hinting: true,
subpixel_binning: true,
}

View File

@@ -1,6 +1,7 @@
#![expect(clippy::unwrap_used)] // TODO(emilk): remove unwraps
use std::sync::Arc;
use std::{iter, ops::Range};
use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2};
@@ -161,6 +162,7 @@ pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc<LayoutJob>)
job.halign,
job.wrap.max_width,
justify_row,
job.keep_trailing_whitespace,
);
}
}
@@ -217,6 +219,11 @@ struct TextRun {
}
/// Emit shaped glyphs from a [`harfrust::GlyphBuffer`] into a [`Paragraph`].
///
/// When a cluster maps multiple characters to fewer glyphs (e.g. flag emojis,
/// ligatures), zero-width "continuation" glyphs are emitted for the extra
/// characters so that `glyphs.len() == char_count` — an invariant that all
/// cursor and selection code relies on.
fn layout_shaped_run(
font: &mut Font<'_>,
run: &TextRun,
@@ -232,11 +239,12 @@ fn layout_shaped_run(
// so they are not comparable across runs.
ctx.prev_cluster = None;
for (info, pos) in glyph_buffer
.glyph_infos()
.iter()
.zip(glyph_buffer.glyph_positions())
{
// Track how many glyphs we emit per cluster so we can add zero-width
// continuation glyphs when a cluster has more chars than glyphs.
let mut cluster_start_byte: usize = 0;
let mut cluster_glyph_count: usize = 0;
for (info, pos) in iter::zip(glyph_buffer.glyph_infos(), glyph_buffer.glyph_positions()) {
let glyph_id = skrifa::GlyphId::new(info.glyph_id);
let cluster = info.cluster;
let mut advance_width_px = pos.x_advance as f32 * px_scale;
@@ -253,7 +261,7 @@ fn layout_shaped_run(
if chr == '\t' {
let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak());
let tab_size = tweak.map_or(4.0, |t| t.tab_size);
let (_, space_info) = font.glyph_info(' ');
let (_, space_info) = font.glyph_info(' ', face_metrics);
let space_width_px = space_info.advance_width_unscaled.0 * px_scale;
advance_width_px = tab_size * space_width_px;
}
@@ -263,7 +271,7 @@ fn layout_shaped_run(
if chr == '\u{2009}' || chr == '\u{202F}' {
let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak());
let thin_space_width = tweak.map_or(0.5, |t| t.thin_space_width);
let (_, space_info) = font.glyph_info(' ');
let (_, space_info) = font.glyph_info(' ', face_metrics);
let space_width_px = space_info.advance_width_unscaled.0 * px_scale;
advance_width_px = thin_space_width * space_width_px;
}
@@ -271,10 +279,22 @@ fn layout_shaped_run(
// Apply extra_letter_spacing only at cluster boundaries,
// never between glyphs within the same cluster (e.g. base + mark).
let is_new_cluster = ctx.prev_cluster.is_none_or(|pc| pc != cluster);
if !ctx.is_first_glyph_in_section && is_new_cluster {
paragraph.cursor_x_px += ctx.extra_letter_spacing * ctx.pixels_per_point;
}
if is_new_cluster {
if ctx.prev_cluster.is_some() {
emit_continuation_glyphs(
ctx,
paragraph,
run_text,
cluster_start_byte..cluster as usize,
cluster_glyph_count,
face_metrics,
);
}
if !ctx.is_first_glyph_in_section {
paragraph.cursor_x_px += ctx.extra_letter_spacing * ctx.pixels_per_point;
}
cluster_start_byte = cluster as usize;
cluster_glyph_count = 0;
ctx.is_first_glyph_in_section = false;
}
ctx.prev_cluster = Some(cluster);
@@ -288,7 +308,7 @@ fn layout_shaped_run(
}
// Use the fallback font face (not run.font_key which returned NOTDEF).
let (fallback_key, glyph_info) = font.glyph_info(chr);
let fallback_key = font.resolve_face(chr);
let fallback_metrics = font
.fonts_by_id
.get(&fallback_key)
@@ -296,6 +316,7 @@ fn layout_shaped_run(
ff.styled_metrics(ctx.pixels_per_point, ctx.font_size, &Default::default())
})
.unwrap_or_default();
let (_, glyph_info) = font.glyph_info(chr, &fallback_metrics);
let advance_width_px =
glyph_info.advance_width_unscaled.0 * fallback_metrics.px_scale_factor;
let (glyph_alloc, physical_x) =
@@ -353,6 +374,50 @@ fn layout_shaped_run(
)
};
paragraph.glyphs.push(glyph);
cluster_glyph_count += 1;
}
// Emit continuation glyphs for the last cluster in the run.
if ctx.prev_cluster.is_some() {
emit_continuation_glyphs(
ctx,
paragraph,
run_text,
cluster_start_byte..run_text.len(),
cluster_glyph_count,
face_metrics,
);
}
}
/// Emit zero-width continuation glyphs when a cluster has more characters than
/// shaped glyphs.
///
/// This preserves the invariant `glyphs.len() == char_count` that all cursor
/// and text-selection code depends on. Continuation glyphs have
/// [`UvRect::default()`] so [`tessellate_glyphs`] skips them entirely.
fn emit_continuation_glyphs(
ctx: &ShapingContext,
paragraph: &mut Paragraph,
run_text: &str,
cluster_bytes: Range<usize>,
cluster_glyph_count: usize,
face_metrics: &StyledMetrics,
) {
let Some(cluster_text) = run_text.get(cluster_bytes) else {
return;
};
let char_count = cluster_text.chars().count();
if char_count <= cluster_glyph_count {
return;
}
let physical_x = paragraph.cursor_x_px.round() as i32;
for chr in cluster_text.chars().skip(cluster_glyph_count) {
paragraph
.glyphs
.push(ctx.glyph(chr, physical_x, 0.0, face_metrics, UvRect::default()));
}
}
@@ -457,7 +522,7 @@ fn layout_section(
/// Avoids `Box<dyn Iterator>` and `Vec<&str>` allocation.
enum SplitOrWhole<'a> {
Split(std::str::Split<'a, char>),
Whole(std::iter::Once<&'a str>),
Whole(iter::Once<&'a str>),
}
impl<'a> SplitOrWhole<'a> {
@@ -465,7 +530,7 @@ impl<'a> SplitOrWhole<'a> {
if split {
Self::Split(text.split('\n'))
} else {
Self::Whole(std::iter::once(text))
Self::Whole(iter::once(text))
}
}
}
@@ -658,18 +723,19 @@ fn line_break(
if job.wrap.max_rows <= out_rows.len() {
*elided = true; // can't fit another row
} else {
let paragraph_min_x = paragraph.glyphs[row_start_idx].pos.x - row_start_x;
let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x() - row_start_x;
let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..]
.iter()
.copied()
.map(|mut glyph| {
glyph.pos.x -= row_start_x;
glyph.pos.x -= row_start_x + paragraph_min_x;
glyph
})
.collect();
let section_index_at_start = glyphs[0].section_index;
let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(PlacedRow {
pos: pos2(paragraph_min_x, 0.0),
@@ -709,12 +775,14 @@ fn replace_last_glyph_with_overflow_character(
let mut font = fonts.font(&section.format.font_id.family);
let font_size = section.format.font_id.size;
let (font_id, glyph_info) = font.glyph_info(overflow_character);
let mut font_face = font.fonts_by_id.get_mut(&font_id);
let font_face_metrics = font_face
.as_mut()
let font_id = font.resolve_face(overflow_character);
let font_face_metrics = font
.fonts_by_id
.get(&font_id)
.map(|f| f.styled_metrics(pixels_per_point, font_size, &section.format.coords))
.unwrap_or_default();
let (_, glyph_info) = font.glyph_info(overflow_character, &font_face_metrics);
let mut font_face = font.fonts_by_id.get_mut(&font_id);
let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() {
prev_glyph.max_x() + extra_letter_spacing
@@ -788,6 +856,7 @@ fn halign_and_justify_row(
halign: Align,
wrap_width: f32,
justify: bool,
keep_trailing_whitespace: bool,
) {
#![expect(clippy::useless_let_if_seq)] // False positive
@@ -806,6 +875,8 @@ fn halign_and_justify_row(
let glyph_range = if num_leading_spaces == row.glyphs.len() {
// There is only whitespace
(0, row.glyphs.len())
} else if keep_trailing_whitespace {
(num_leading_spaces, row.glyphs.len())
} else {
let num_trailing_spaces = row
.glyphs
@@ -1303,7 +1374,7 @@ fn segment_into_runs(font: &mut Font<'_>, text: &str, out: &mut Vec<TextRun>) {
let byte_end = byte_offset + grapheme_str.len();
let base_char = grapheme_str.chars().next().unwrap_or(' ');
let (font_key, _) = font.glyph_info(base_char);
let font_key = font.resolve_face(base_char);
if let Some(last_run) = out.last_mut()
&& last_run.font_key == font_key
@@ -1334,11 +1405,7 @@ fn shape_text(
let tweak = font_face.tweak();
// Build shaper with variable font instance if variation coordinates are set.
let variations: Vec<harfrust::Variation> = tweak
.coords
.as_ref()
.iter()
.chain(coords.as_ref().iter())
let variations: Vec<harfrust::Variation> = iter::chain(tweak.coords.as_ref(), coords.as_ref())
.map(|&(tag, value)| harfrust::Variation { tag, value })
.collect();
@@ -1367,8 +1434,10 @@ fn shape_text(
#[cfg(test)]
mod tests {
use std::iter;
use super::{super::*, *};
use crate::text::cursor::CCursor;
#[test]
fn test_zero_max_width() {
@@ -1502,10 +1571,11 @@ mod tests {
&mut fonts,
pixels_per_point,
Arc::new(LayoutJob::single_section(
(0..elided_galley.rows[0].char_count_excluding_newline())
.map(|_| ch)
.chain(std::iter::once('…'))
.collect::<String>(),
iter::chain(
(0..elided_galley.rows[0].char_count_excluding_newline()).map(|_| ch),
iter::once('…'),
)
.collect::<String>(),
TextFormat::default(),
)),
);
@@ -1738,6 +1808,113 @@ mod tests {
}
}
/// Regression test for <https://github.com/emilk/egui/issues/8087>.
///
/// Multi-codepoint grapheme clusters (flag emojis, combining marks) must
/// produce exactly as many glyphs as characters so that cursor positioning
/// and text selection remain correct.
#[test]
fn test_grapheme_cluster_glyph_count() {
let pixels_per_point = 1.0;
let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default());
let font_id = FontId::default();
// Each test case: (input text, expected char count)
let cases: &[(&str, usize)] = &[
// Flag emoji: two Regional Indicator codepoints → one visual glyph
("\u{1F1EF}\u{1F1F5}", 2), // 🇯🇵
// Flag surrounded by ASCII
("A\u{1F1EB}\u{1F1F7}B", 4), // A🇫🇷B
// Base char + combining acute accent
("e\u{0301}", 2), // é as decomposed
// Multiple combining marks
("o\u{0302}\u{0323}", 3), // ộ
// Plain ASCII (sanity check)
("Hello", 5),
];
for &(text, expected_chars) in cases {
let job = LayoutJob::simple(
text.to_owned(),
font_id.clone(),
Color32::WHITE,
f32::INFINITY,
);
let galley = layout(&mut fonts, pixels_per_point, job.into());
let total_glyphs: usize = galley.rows.iter().map(|r| r.row.glyphs.len()).sum();
assert_eq!(
total_glyphs,
expected_chars,
"Glyph count mismatch for {text:?}: \
expected {expected_chars} glyphs (one per char), got {total_glyphs}. \
Glyphs: {:?}",
galley.rows[0]
.row
.glyphs
.iter()
.map(|g| (g.chr, g.advance_width))
.collect::<Vec<_>>(),
);
// Verify that Row::text() reconstructs the input text.
let row_text: String = galley.rows.iter().map(|r| r.text()).collect();
assert_eq!(row_text, text, "Row::text() mismatch for {text:?}",);
// Verify cursor round-trip: end cursor index == char count.
assert_eq!(
galley.end().index,
expected_chars,
"Galley::end().index mismatch for {text:?}",
);
}
}
/// Verify that cursor positioning round-trips correctly for text
/// containing multi-codepoint grapheme clusters (regression test for #8087).
#[test]
fn test_grapheme_cluster_cursor_roundtrip() {
let pixels_per_point = 1.0;
let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default());
let font_id = FontId::default();
// "A" + flag emoji (2 codepoints) + "B" = 4 chars
let text = "A\u{1F1EF}\u{1F1F5}B";
let job = LayoutJob::simple(
text.to_owned(),
font_id.clone(),
Color32::WHITE,
f32::INFINITY,
);
let galley = layout(&mut fonts, pixels_per_point, job.into());
// Walking through every cursor index should produce valid positions.
for i in 0..=galley.end().index {
let cursor = CCursor {
index: i,
prefer_next_row: false,
};
let rect = galley.pos_from_cursor(cursor);
assert!(
rect.is_finite(),
"pos_from_cursor returned non-finite rect for index {i}",
);
// Round-trip: position → cursor → position should be stable.
let cursor2 = galley.cursor_from_pos(Vec2::new(rect.center().x, rect.center().y));
let rect2 = galley.pos_from_cursor(cursor2);
assert!(
(rect.min.x - rect2.min.x).abs() < 1.0,
"Cursor round-trip unstable at index {i}: \
first={}, second={}, cursor2.index={}",
rect.min.x,
rect2.min.x,
cursor2.index,
);
}
}
fn measure_text(
fonts: &mut FontsImpl,
text: &str,

View File

@@ -79,6 +79,14 @@ pub struct LayoutJob {
/// Round output sizes using [`emath::GuiRounding`], to avoid rounding errors in layout code.
pub round_output_to_gui: bool,
/// If `false` (default), trailing whitespace is ignored when computing
/// horizontal alignment ([`Self::halign`]).
/// This is desirable for labels so that e.g. "Hello " centers the same as "Hello".
///
/// If `true`, trailing whitespace is included in the row width used for alignment.
/// This is desirable for text editors where the user expects to see their spaces.
pub keep_trailing_whitespace: bool,
}
impl Default for LayoutJob {
@@ -93,6 +101,7 @@ impl Default for LayoutJob {
halign: Align::LEFT,
justify: false,
round_output_to_gui: true,
keep_trailing_whitespace: false,
}
}
}
@@ -216,6 +225,7 @@ impl std::hash::Hash for LayoutJob {
halign,
justify,
round_output_to_gui,
keep_trailing_whitespace,
} = self;
text.hash(state);
@@ -226,6 +236,7 @@ impl std::hash::Hash for LayoutJob {
halign.hash(state);
justify.hash(state);
round_output_to_gui.hash(state);
keep_trailing_whitespace.hash(state);
}
}
@@ -696,9 +707,12 @@ impl PlacedRow {
/// Same as [`Self::rect`] but excluding the `LayoutSection::leading_space`.
pub fn rect_without_leading_space(&self) -> Rect {
let x = self.glyphs.first().map_or(self.pos.x, |g| g.pos.x);
let size_x = self.size.x - x;
Rect::from_min_size(Pos2::new(x, self.pos.y), Vec2::new(size_x, self.size.y))
let x = self.pos.x + self.glyphs.first().map_or(0.0, |g| g.pos.x);
let right = self.pos.x + self.size.x;
Rect::from_min_max(
Pos2::new(x, self.pos.y),
Pos2::new(right, self.pos.y + self.size.y),
)
}
}
@@ -1244,7 +1258,8 @@ impl Galley {
let new_layout_cursor = {
// keep same X coord
let column = self.rows[new_row].char_at(h_pos);
// char_at is Row-relative, so subtract the row's position
let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x);
LayoutCursor {
row: new_row,
column,
@@ -1266,7 +1281,8 @@ impl Galley {
let new_layout_cursor = {
// keep same X coord
let column = self.rows[new_row].char_at(h_pos);
// char_at is Row-relative, so subtract the row's position
let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x);
LayoutCursor {
row: new_row,
column,

View File

@@ -120,8 +120,9 @@ impl TextureAtlas {
let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt();
let coverage =
remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0);
image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] =
options.alpha_from_coverage.color_from_coverage(coverage);
image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = options
.color_transfer_function
.color_from_coverage(coverage);
}
}
atlas.discs.push(PrerasterizedDisc {