Implement luminance-based glyph dilation for macOS (#54886)

Apple's text rendering stack dilates glyph outlines for text rendered
with a light foreground color. Zed doesn't consider this nuance today;
we populate our atlas using glyphs rendered with a dark foreground
color. This means that, particularly in dark themes, text in Zed looks
thin and blurry, and doesn't match the look of native macOS
applications.

This pull request replicates the native behavior of Core Graphics. Some
reverse-engineering revealed that CG computes the foreground color
luminance using the Rec. 709 formula ($Y=0.2126R + 0.7152B + 0.0722G$)
and quantizes it into five levels (0, 0.25, 0.5, 0.75, and 1). Each
level uses a different dilation factor.

With this patch, we calculate this same luminance bucket and supply it
as the foreground color during rasterization. The correct dilation will
be applied, and we'll store this glyph in the atlas keyed by this
luminance bucket. So, we'll generate and use up to 5 different bitmaps
for each glyph based on its foreground color.

I've confirmed that text rendered by Zed now exactly matches native
applications like Safari, TextEdit, etc.

Release Notes:

- Improved text rendering clarity on macOS, particularly in dark themes.

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
John Tur 2026-04-29 09:09:11 +02:00 committed by GitHub
parent 3014170d7e
commit a38fc8c8de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 66 additions and 18 deletions

View file

@ -31,10 +31,10 @@ pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBu
use crate::{
Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds,
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene,
ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task,
ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size,
ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels,
PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams,
RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer,
SystemWindowTab, Task, ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size,
};
use anyhow::Result;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
@ -783,6 +783,10 @@ pub trait PlatformTextSystem: Send + Sync {
/// Returns the recommended text rendering mode for the given font and size.
fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels)
-> TextRenderingMode;
/// Returns the dilation level to use for a glyph painted in the given color.
fn glyph_dilation_for_color(&self, _color: Hsla) -> u8 {
0
}
}
#[expect(missing_docs)]

View file

@ -348,6 +348,11 @@ impl TextSystem {
.rasterize_glyph(params, raster_bounds)
}
/// Returns the dilation level to use for a glyph painted in the given color.
pub(crate) fn glyph_dilation_for_color(&self, color: Hsla) -> u8 {
self.platform_text_system.glyph_dilation_for_color(color)
}
/// Returns the text rendering mode recommended by the platform for the given font and size.
/// The return value will never be [`TextRenderingMode::PlatformDefault`].
pub(crate) fn recommended_rendering_mode(
@ -1007,6 +1012,7 @@ pub struct RenderGlyphParams {
pub scale_factor: f32,
pub is_emoji: bool,
pub subpixel_rendering: bool,
pub dilation: u8,
}
impl Eq for RenderGlyphParams {}
@ -1020,6 +1026,7 @@ impl Hash for RenderGlyphParams {
self.scale_factor.to_bits().hash(state);
self.is_emoji.hash(state);
self.subpixel_rendering.hash(state);
self.dilation.hash(state);
}
}

View file

@ -3591,6 +3591,7 @@ impl Window {
);
let integer_origin = quantized_origin.map(|c| ScaledPixels(c.trunc()));
let subpixel_rendering = self.should_use_subpixel_rendering(font_id, font_size);
let dilation = self.text_system().glyph_dilation_for_color(color);
let params = RenderGlyphParams {
font_id,
glyph_id,
@ -3599,6 +3600,7 @@ impl Window {
scale_factor,
is_emoji: false,
subpixel_rendering,
dilation,
};
let raster_bounds = self.text_system().raster_bounds(&params)?;
@ -3688,6 +3690,7 @@ impl Window {
scale_factor,
is_emoji: true,
subpixel_rendering: false,
dilation: 0,
};
let raster_bounds = self.text_system().raster_bounds(&params)?;

View file

@ -35,9 +35,9 @@ use font_kit::{
};
use gpui::{
Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun,
FontStyle, FontWeight, GlyphId, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams,
Result, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode,
point, px, size, swap_rgba_pa_to_bgra,
FontStyle, FontWeight, GlyphId, Hsla, LineLayout, Pixels, PlatformTextSystem,
RenderGlyphParams, Result, Rgba, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString,
Size, TextRenderingMode, point, px, size, swap_rgba_pa_to_bgra,
};
use parking_lot::{RwLock, RwLockUpgradableReadGuard};
use pathfinder_geometry::{
@ -46,7 +46,7 @@ use pathfinder_geometry::{
vector::Vector2F,
};
use smallvec::SmallVec;
use std::{borrow::Cow, char, convert::TryFrom, sync::Arc};
use std::{borrow::Cow, char, convert::TryFrom, sync::Arc, sync::OnceLock};
use crate::open_type::apply_features_and_fallbacks;
@ -214,6 +214,39 @@ impl PlatformTextSystem for MacTextSystem {
) -> TextRenderingMode {
TextRenderingMode::Grayscale
}
fn glyph_dilation_for_color(&self, color: Hsla) -> u8 {
// When font smoothing is enabled, CoreGraphics thickens glyph strokes by an amount that
// depends on the foreground color's luminance. We replicate the logic used by CoreGraphics
// to select between the different levels of dilation.
if !font_smoothing_allowed_by_user() {
return 0;
}
let rgba: Rgba = color.into();
let luminance = 0.2126 * rgba.r + 0.7152 * rgba.g + 0.0722 * rgba.b;
let level = ((4.0 * luminance) + 0.5).floor() as i32;
level.clamp(0, 4) as u8
}
}
fn font_smoothing_allowed_by_user() -> bool {
static ALLOWED: OnceLock<bool> = OnceLock::new();
*ALLOWED.get_or_init(|| {
use core_foundation_sys::preferences::{
CFPreferencesCopyAppValue, kCFPreferencesCurrentApplication,
};
let key = CFString::new("AppleFontSmoothing");
let value_ref = unsafe {
CFPreferencesCopyAppValue(key.as_concrete_TypeRef(), kCFPreferencesCurrentApplication)
};
if value_ref.is_null() {
return true;
}
let number = unsafe { CFNumber::wrap_under_create_rule(value_ref as _) };
// Only an explicit value of `0` means that font smoothing is disabled.
number.to_i64() != Some(0)
})
}
impl MacTextSystemState {
@ -361,7 +394,7 @@ impl MacTextSystemState {
fn raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
let font = &self.fonts[params.font_id.0];
let scale = Transform2F::from_scale(params.scale_factor);
let mut bounds: Bounds<DevicePixels> = bounds_from_rect_i(font.raster_bounds(
let bounds: Bounds<DevicePixels> = bounds_from_rect_i(font.raster_bounds(
params.glyph_id.0,
params.font_size.into(),
scale,
@ -369,14 +402,8 @@ impl MacTextSystemState {
font_kit::canvas::RasterizationOptions::GrayscaleAa,
)?);
// Add 3% of font size as padding, clamped between 1 and 5 pixels
// to avoid clipping of anti-aliased edges.
let pad =
((params.font_size.as_f32() * 0.03 * params.scale_factor).ceil() as i32).clamp(1, 5);
bounds.origin.x -= DevicePixels(pad);
bounds.size.width += DevicePixels(pad);
Ok(bounds)
// Expand the bounds by 1 pixel on each side to give CG room for anti-aliasing.
Ok(bounds.dilate(DevicePixels(1)))
}
fn rasterize_glyph(
@ -438,13 +465,20 @@ impl MacTextSystemState {
.subpixel_variant
.map(|v| v as f32 / SUBPIXEL_VARIANTS_X as f32);
cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill);
cx.set_gray_fill_color(0.0, 1.0);
cx.set_allows_antialiasing(true);
cx.set_should_antialias(true);
cx.set_allows_font_subpixel_positioning(true);
cx.set_should_subpixel_position_fonts(true);
cx.set_allows_font_subpixel_quantization(false);
cx.set_should_subpixel_quantize_fonts(false);
if params.dilation > 0 {
let luminance = params.dilation as f64 * 0.25;
cx.set_should_smooth_fonts(true);
cx.set_gray_fill_color(luminance, 1.0);
} else {
cx.set_gray_fill_color(0.0, 1.0);
}
self.fonts[params.font_id.0]
.native_font()
.clone_with_font_size(f32::from(params.font_size) as CGFloat)